From 86ad967a40a589967451d6ed538036bcdc7c576d Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:37:51 +0100 Subject: [PATCH 001/159] fix: arithmetic frozen at the placeholder its operand had --- .../analyzer/local_inference/mod.rs | 71 ++++++++++++ .../src/compilation/analyzer/lua/stats.rs | 3 +- .../src/compilation/analyzer/mod.rs | 24 +++- .../compilation/analyzer/unresolve/resolve.rs | 8 +- .../diagnostic/test/inference_trust_test.rs | 105 ++++++++++++++++++ 5 files changed, 205 insertions(+), 6 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/local_inference/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/local_inference/mod.rs index aebb71be4..66bf0f045 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/local_inference/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/local_inference/mod.rs @@ -65,6 +65,7 @@ pub(super) fn stabilize_unknown_locals( } } candidates.sort_by_key(|(_, decl_id, _)| (decl_id.file_id, decl_id.position)); + rederive_settled_initializers(db, context, &mut candidates); let mut evidence_by_node = FxHashMap::>::default(); @@ -162,6 +163,76 @@ pub(super) fn stabilize_unknown_locals( changed_any } +/// Re-derives what each candidate's own initializer says before anything is +/// guessed from how it is used. +/// +/// Inferring from usage context is the fallback, so it only applies to a value +/// the analyzer genuinely cannot derive. The unresolve pass reaches its answer +/// in waves and retires an item after a fixed number of them, so a chain like +/// `local w = frame:GetWide()` / `local x = w - 1` can leave `x` parked at the +/// placeholder `w` had when `x` was last retried, even though `w` settled +/// afterwards. Asking the initializer again here costs one inference per +/// candidate and removes the guess entirely where the value was derivable. +/// +/// Candidates arrive in source order, and this binds as it walks, so a chain +/// settles front to back in a single pass. +fn rederive_settled_initializers( + db: &mut crate::DbIndex, + context: &mut AnalyzeContext, + candidates: &mut Vec<(crate::FileId, crate::LuaDeclId, crate::DeclReference)>, +) { + let mut roots = FxHashMap::::default(); + candidates.retain(|(file_id, decl_id, _)| { + let root = match roots.entry(*file_id) { + std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(), + std::collections::hash_map::Entry::Vacant(entry) => { + let Some(root) = db + .get_vfs() + .get_syntax_tree(file_id) + .map(|tree| tree.get_red_root()) + else { + return true; + }; + entry.insert(root) + } + }; + let Some((ret_idx, expr)) = + crate::compilation::analyzer::local_initializer_expr(db, root, *decl_id) + else { + return true; + }; + // Initializers that read through a call or index — including the + // `x = y or {}` guard — have their own reconciliation pass, which + // carries policy this cannot see. Only the operator shape is re-asked + // here, because nothing else re-derives it. + if !crate::compilation::analyzer::initializer_is_operator_expr(&expr) + || crate::compilation::analyzer::initializer_reads_through_call_or_index(&expr) + { + return true; + } + let cache = context.infer_manager.get_infer_cache(*file_id); + let Ok(typ) = infer_expr(db, cache, expr) else { + return true; + }; + let typ = match &typ { + LuaType::Variadic(multi) => match multi.get_type(ret_idx) { + Some(typ) => typ.clone(), + None => return true, + }, + _ => typ, + }; + if !crate::db_index::is_informative_type(&typ) { + return true; + } + crate::compilation::analyzer::common::bind_resolved_type( + db, + (*decl_id).into(), + crate::LuaTypeCache::InferType(typ), + ); + false + }); +} + fn contextual_type_support( db: &crate::DbIndex, candidate: &crate::LuaType, diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index afdcc36d1..2ac7ae382 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -1420,7 +1420,8 @@ fn is_call_or_index_expr(expr: &LuaExpr) -> bool { /// Whether an initializer that inferred to a type carrying no information /// has to be queued for the unresolve pass as well as committed here. fn should_retry_uninformative_initializer(expr: &LuaExpr, expr_type: &LuaType) -> bool { - is_call_or_index_expr(expr) && !crate::db_index::is_informative_type(expr_type) + crate::compilation::analyzer::initializer_may_improve_after_resolve(expr) + && !crate::db_index::is_informative_type(expr_type) } /// Whether an assignment that *would* narrow an uninformative decl cache diff --git a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs index 5d69c292b..01b4df208 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs @@ -1024,7 +1024,29 @@ pub(crate) fn initializer_reads_through_call_or_index(expr: &LuaExpr) -> bool { } } -fn local_initializer_expr( +/// Whether an initializer's uninformative result may still improve once the +/// unresolve pass settles what it reads. +/// +/// An operator expression contributes no type of its own: `w - 1` is `unknown` +/// only while `w` is, so the answer the file walk cached is a placeholder in +/// exactly the way a call or index read is, and it has to be retried on the same +/// terms. Without this it stays `unknown` forever and usage-context inference +/// guesses at it instead. +pub(crate) fn initializer_may_improve_after_resolve(expr: &LuaExpr) -> bool { + initializer_reads_through_call_or_index(expr) || initializer_is_operator_expr(expr) +} + +pub(crate) fn initializer_is_operator_expr(expr: &LuaExpr) -> bool { + match expr { + LuaExpr::BinaryExpr(_) | LuaExpr::UnaryExpr(_) => true, + LuaExpr::ParenExpr(paren) => paren + .get_expr() + .is_some_and(|inner| initializer_is_operator_expr(&inner)), + _ => false, + } +} + +pub(crate) fn local_initializer_expr( db: &DbIndex, root: &LuaSyntaxNode, decl_id: LuaDeclId, 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 63e31efc2..72c4c73bc 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs @@ -164,15 +164,15 @@ pub fn try_resolve_decl( } // Narrowing an uninformative decl cache is reserved for a right-hand side - // that reads through a call or index: that is the boundary both routes into - // this pass enforce before they queue an item - // (`should_retry_uninformative_initializer`, + // whose answer can still improve — a call or index read, or an operator over + // one: that is the boundary both routes into this pass enforce before they + // queue an item (`should_retry_uninformative_initializer`, // `should_retry_narrowing_decl_assignment`). A write that landed here only // because its right-hand side could not be inferred while its file was // walked arrives without that check, so applying the narrowing policy to it // let any shape overwrite an authoritative `any` — but only in the builds // where the inference happened to fail. - if crate::compilation::analyzer::initializer_reads_through_call_or_index(&expr) { + if crate::compilation::analyzer::initializer_may_improve_after_resolve(&expr) { bind_resolved_type(db, decl_id.into(), LuaTypeCache::InferType(expr_type)); } else { bind_type(db, decl_id.into(), LuaTypeCache::InferType(expr_type)); diff --git a/crates/glua_code_analysis/src/diagnostic/test/inference_trust_test.rs b/crates/glua_code_analysis/src/diagnostic/test/inference_trust_test.rs index cbb866676..458179b70 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/inference_trust_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/inference_trust_test.rs @@ -1082,4 +1082,109 @@ mod tests { (ws.ty("Entity"), LuaType::Number, Vec::new()) ); } + + /// A callback-slot receiver types its method call through the unresolve + /// pass, so arithmetic over that call was cached `unknown` while the + /// operand was still settling. Nothing retried the operator expression, so + /// usage-context inference guessed at a value the analyzer already knew. + #[test] + fn arithmetic_over_callback_slot_receiver_does_not_infer_from_usage_context() { + let mut ws = VirtualWorkspace::new(); + let file_id = ws.def_file( + "callback_arithmetic.lua", + r#" + ---@class Frame + ---@field GetWide fun(self: Frame): number + + ---@param n number + local function sink(n) end + + ---@param func fun(frame: Frame) + local function AddScreen(func) end + + AddScreen(function(frame) + local w = frame:GetWide() + local x = w - 1 + sink(x) + end) + "#, + ); + + let found = diagnostics_for(&mut ws, file_id, DiagnosticCode::InferUnknown); + + // The operand must still resolve, so silence above cannot come from a + // workspace where the receiver never attached. + assert_eq!( + ( + local_type(&ws, file_id, "w"), + local_type(&ws, file_id, "x"), + found, + ), + (LuaType::Number, LuaType::Number, Vec::new()) + ); + } + + /// Screen layout chains arithmetic several locals deep, so each retry has to + /// wait for the one it reads. Retiring a retry that still answered + /// `unknown` froze every value past the first link. + #[test] + fn chained_arithmetic_over_a_callback_slot_receiver_resolves_every_link() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + // The registrar lives in another file, so the slot that types `frame` + // only resolves in a later unresolve wave than the arithmetic that + // reads it — which is what the real screen files do. + ws.def_file( + "lua/registrar.lua", + r#" + ---@class Frame + ---@field GetWide fun(self: Frame): number + ---@field GetTall fun(self: Frame): number + + ---@class Registry + Registry = {} + + ---@param name string + ---@param func fun(self: Registry, frame: Frame) + function Registry:AddScreen(name, func) end + "#, + ); + let file_id = ws.def_file( + "lua/screen.lua", + r#" + ---@param w number + ---@param h number + local function setSize(w, h) end + + Registry:AddScreen("Destination", function(self, frame) + local w = frame:GetWide() + local h = frame:GetTall() + local d = 0.05 * math.min(w, h) + local panel_w = (w - 3 * d) / 2 + local panel_h = (h - 4 * d) / 3 + local elem_w = (panel_w - 5 * d) / 4 + local elem_h = (panel_h - 4 * d) / 3 + setSize(elem_w, elem_h) + end) + "#, + ); + + let found = diagnostics_for(&mut ws, file_id, DiagnosticCode::InferUnknown); + + // Each link must resolve on its own, so silence cannot come from a + // workspace where the receiver never attached. + assert_eq!( + ( + local_type(&ws, file_id, "d"), + local_type(&ws, file_id, "panel_w"), + local_type(&ws, file_id, "elem_w"), + found, + ), + ( + LuaType::Number, + LuaType::Number, + LuaType::Number, + Vec::new() + ) + ); + } } From 1e3ae6eaf014162b86a8419b2ebc6d60e71bdabe Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:09:22 +0100 Subject: [PATCH 002/159] fix: namespace guard reading its own empty table --- .../src/compilation/analyzer/lua/stats.rs | 68 +++++++++++++++++-- .../diagnostic/test/undefined_field_test.rs | 34 ++++++++++ 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index 2ac7ae382..ee4583166 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -1032,10 +1032,18 @@ pub fn analyze_assign_stat(analyzer: &mut LuaAnalyzer, assign_stat: LuaAssignSta .request_member_initializer_reinfer(*member_id); } - let expr_type = member_assignment_or_source_type(analyzer, &type_owner, expr, expr_type); + let mut expr_type = + member_assignment_or_source_type(analyzer, &type_owner, expr, expr_type); widen_existing_member_collection_type(analyzer, &var, &expr_type); - assign_merge_type_owner_and_expr_type(analyzer, type_owner, &expr_type, 0, false); + assign_merge_type_owner_and_expr_type(analyzer, type_owner.clone(), &expr_type, 0, false); + // The member is only homed onto its owner above, so the sibling guards + // this one shares a table with are not visible until here. + if let LuaTypeOwner::Member(member_id) = &type_owner + && let Some(canonical) = canonical_guarded_table_bootstrap_type(analyzer.db, *member_id) + { + expr_type = canonical; + } update_literal_index_member_owner_cache(analyzer, &var, &expr_type); } @@ -1675,10 +1683,34 @@ fn assign_merge_type_owner_and_expr_type( expr_type = bootstrap_type; } + // Where every writer of this member is a `x.y = x.y or {}` guard they all + // name one table, so there are no competing writes to merge — each writer + // resolves to the earliest one's literal and the sibling widening is + // skipped. Widening them against each other unions two literals into a bare + // `table`, which drops the members another file attached to the namespace. + let canonical_guarded_bootstrap = match &type_owner { + LuaTypeOwner::Member(member_id) => { + canonical_guarded_table_bootstrap_type(analyzer.db, *member_id) + } + _ => None, + }; + + // A repeated `x.y = x.y or {}` guard names one table, however many files + // open with it: each writer means "reuse it if it is there". Widening those + // literals against each other answers `table`, which drops whatever another + // file attached to it — so the guard has to preserve them here too, the same + // way the contribution record below already reads it. + let preserve_table_literals = preserve_table_literals + || matches!(&type_owner, LuaTypeOwner::Member(member_id) + if is_guarded_table_assignment_member(analyzer.db, *member_id)); + let dynamic_expr_key_member = is_dynamic_expr_key_member_assignment(analyzer, &type_owner); // What this write carries on its own, before any sibling merge widens it. let mut source_type = None; - if !dynamic_expr_key_member { + if let Some(canonical) = canonical_guarded_bootstrap { + expr_type = canonical; + source_type = Some(expr_type.clone()); + } else if !dynamic_expr_key_member { if let Some(widened_type) = get_widened_member_assignment_collection_type(analyzer, &type_owner, &expr_type) { @@ -2449,12 +2481,38 @@ fn guarded_table_bootstrap_member_type( member_id: LuaMemberId, empty_only: bool, ) -> Option { + let range = guarded_table_bootstrap_range(db, member_id, empty_only)?; + + Some(LuaType::TableConst(InFiled::new(member_id.file_id, range))) +} + +fn guarded_table_bootstrap_range( + db: &crate::DbIndex, + member_id: LuaMemberId, + empty_only: bool, +) -> Option { let tree = db.get_vfs().get_syntax_tree(&member_id.file_id)?; let root = tree.get_red_root(); let index_expr = LuaIndexExpr::cast(member_id.get_syntax_id().to_node_from_root(&root)?)?; - let range = guarded_table_assignment_bootstrap_range(&index_expr, empty_only)?; + guarded_table_assignment_bootstrap_range(&index_expr, empty_only) +} - Some(LuaType::TableConst(InFiled::new(member_id.file_id, range))) +/// The one table a repeated `x.y = x.y or {}` guard names. +/// +/// Every such writer means "reuse it if it is there", so at runtime they are all +/// the same table and only the first to run creates it. Giving each writer its +/// own literal instead makes a file that re-guards the namespace read its own +/// empty table and lose whatever another file attached, so they resolve to the +/// earliest writer's literal — the one that would have won at runtime. +fn canonical_guarded_table_bootstrap_type( + db: &crate::DbIndex, + member_id: LuaMemberId, +) -> Option { + let canonical = guarded_table_assignment_member_ids_for_owner_key(db, member_id)? + .into_iter() + .min_by_key(|candidate| member_id_sort_key(*candidate))?; + + guarded_table_bootstrap_member_type(db, canonical, false) } fn merge_type_owner_and_unresolve_expr( diff --git a/crates/glua_code_analysis/src/diagnostic/test/undefined_field_test.rs b/crates/glua_code_analysis/src/diagnostic/test/undefined_field_test.rs index 5975c8cb8..7fdce4888 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/undefined_field_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/undefined_field_test.rs @@ -5775,4 +5775,38 @@ owner:CompletelyMadeUpMethod() let fields = diagnostics_for_code(&mut ws, file_id, DiagnosticCode::UndefinedField); assert_eq!(fields.len(), 1, "{fields:#?}"); } + + /// Every file in a multi-file namespace opens with the same + /// `X.sub = X.sub or {}` guard. Each guard produces its own `sub` member, so + /// their types have to unify — otherwise a file that re-guards the namespace + /// resolves its reads against its own fresh empty table and loses whatever + /// another file attached. + #[test] + fn repeated_namespace_guards_share_the_fields_attached_through_an_alias() { + let mut ws = VirtualWorkspace::new(); + let file_ids = ws.def_files(vec![ + ( + "lua/01_define.lua", + r#" + MCP = MCP or {} + MCP.wp = MCP.wp or {} + local wp_ = MCP.wp + function wp_.Foo() return 1 end + "#, + ), + ( + "lua/02_read.lua", + r#" + MCP.wp = MCP.wp or {} + local wp_ = MCP.wp + local a = wp_.Foo() + return a + "#, + ), + ]); + + let found = diagnostics_for_code(&mut ws, file_ids[1], DiagnosticCode::UndefinedField); + + assert!(found.is_empty(), "{found:#?}"); + } } From 54a79b7d0f9d76c1d70c2429e6185db1c6e94dc9 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:27:03 +0100 Subject: [PATCH 003/159] fix: truthiness guard ignored on an unresolved field --- .../src/diagnostic/checker/need_check_nil.rs | 75 +++++++++++++++++++ .../diagnostic/test/need_check_nil_test.rs | 25 +++++++ 2 files changed, 100 insertions(+) diff --git a/crates/glua_code_analysis/src/diagnostic/checker/need_check_nil.rs b/crates/glua_code_analysis/src/diagnostic/checker/need_check_nil.rs index 6954d1bc0..9211296ab 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/need_check_nil.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/need_check_nil.rs @@ -315,6 +315,7 @@ fn report_unsafe_receiver( receiver, ) || is_expr_guarded_by_current_type_guard_condition(semantic_model, receiver) + || is_expr_guarded_by_current_truthiness_condition(semantic_model, receiver) }; if guarded { return false; @@ -1234,6 +1235,80 @@ fn preceding_path_sibling_nodes(if_stat: &LuaIfStat) -> Vec { nodes } +/// Whether a plain truthiness test on this very expression dominates its use. +/// +/// `if x.f then x.f:m() end` proves `x.f` is neither `nil` nor `false` inside the +/// block — that is the whole meaning of the test. A field with a declared nilable +/// type already narrows through ordinary inference; one the analyzer could not +/// resolve does not, because the read fails before narrowing runs and the caller +/// substitutes the runtime `nil`. Reading the guard off the source recovers what +/// the narrowing would have said. +fn is_expr_guarded_by_current_truthiness_condition( + semantic_model: &SemanticModel, + expr: &LuaExpr, +) -> bool { + let expr_range = expr.syntax().text_range(); + for ancestor in expr.syntax().ancestors() { + if let Some(if_stat) = LuaIfStat::cast(ancestor.clone()) { + if if_stat + .get_block() + .is_some_and(|block| range_contains(block.syntax().text_range(), expr_range)) + && if_stat + .get_condition_expr() + .is_some_and(|condition| condition_proves_expr_truthy(&condition, expr)) + && !then_block_reassigns_guarded_expr_before_access(semantic_model, &if_stat, expr) + && !loop_back_edge_reassigns_guarded_expr_after_if(semantic_model, &if_stat, expr) + { + return true; + } + + for elseif_clause in if_stat.get_else_if_clause_list() { + if elseif_clause + .get_block() + .is_some_and(|block| range_contains(block.syntax().text_range(), expr_range)) + && elseif_clause + .get_condition_expr() + .is_some_and(|condition| condition_proves_expr_truthy(&condition, expr)) + { + return true; + } + } + } + + if let Some(while_stat) = glua_parser::LuaWhileStat::cast(ancestor.clone()) + && while_stat + .get_block() + .is_some_and(|block| range_contains(block.syntax().text_range(), expr_range)) + && while_stat + .get_condition_expr() + .is_some_and(|condition| condition_proves_expr_truthy(&condition, expr)) + { + return true; + } + } + + false +} + +/// Whether evaluating `condition` truthily proves `expr` truthy. Only `and` +/// chains qualify: every operand of a truthy `and` is itself truthy, while an +/// `or` proves nothing about either side. +fn condition_proves_expr_truthy(condition: &LuaExpr, expr: &LuaExpr) -> bool { + match condition { + LuaExpr::ParenExpr(paren) => paren + .get_expr() + .is_some_and(|inner| condition_proves_expr_truthy(&inner, expr)), + LuaExpr::BinaryExpr(binary) => { + binary.get_op_token().map(|op| op.get_op()) == Some(BinaryOperator::OpAnd) + && binary.get_exprs().is_some_and(|(left, right)| { + condition_proves_expr_truthy(&left, expr) + || condition_proves_expr_truthy(&right, expr) + }) + } + _ => expr_text_matches(condition, expr), + } +} + fn condition_is_positive_type_guard_call( semantic_model: &SemanticModel, condition: &LuaExpr, diff --git a/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs b/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs index acedc73cb..3f15c404a 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs @@ -14533,4 +14533,29 @@ mod test { assert_that!(diagnostics.len(), eq(1_usize)); assert_that!(diagnostics[0].range.start.line, eq(19_u32)); } + + /// A truthiness test excludes `nil` *and* `false`, so it can never narrow + /// less than `~= nil` does. An undeclared field resolves to `unknown?`, and + /// that was the one shape where it did. + #[test] + fn truthiness_guard_narrows_an_unknown_typed_field() { + let mut ws = VirtualWorkspace::new(); + + let diagnostics = diagnostics_for_code( + &mut ws, + DiagnosticCode::UncheckedNilAccess, + r#" + ---@class snd_obj + ---@field Stop fun(self: snd_obj) + + ---@class hFire + ---@param h hFire + local function fires(h) + if h.snd then h.snd:Stop() end + end + "#, + ); + + assert_that!(diagnostics, is_empty()); + } } From fb01a6635a353f70a25e4383a1a73731e7e45b9a Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:38:18 +0100 Subject: [PATCH 004/159] fix: runtime write making a field required of a literal --- .../src/db_index/member/lua_member.rs | 11 ++++++++ .../src/diagnostic/checker/duplicate_field.rs | 7 +---- .../src/diagnostic/checker/missing_fields.rs | 10 +++++++ .../diagnostic/test/missing_fields_test.rs | 26 +++++++++++++++++++ .../diagnostic/test/param_type_check_test.rs | 24 +++++++++++++++++ .../src/semantic/type_check/generic_type.rs | 7 ++--- .../src/semantic/type_check/mod.rs | 11 ++++++++ .../src/semantic/type_check/ref_type.rs | 12 ++++++--- 8 files changed, 96 insertions(+), 12 deletions(-) diff --git a/crates/glua_code_analysis/src/db_index/member/lua_member.rs b/crates/glua_code_analysis/src/db_index/member/lua_member.rs index 7fcdd0cca..6138a31e9 100644 --- a/crates/glua_code_analysis/src/db_index/member/lua_member.rs +++ b/crates/glua_code_analysis/src/db_index/member/lua_member.rs @@ -68,6 +68,17 @@ impl LuaMember { pub fn get_global_id(&self) -> Option<&GlobalId> { self.global_id.as_ref() } + + /// Whether this member exists because something assigned to it (`v.X = 1`), + /// rather than because a declaration named it. + /// + /// An assignment adds a field to a value; only a declaration states what the + /// type requires of one. Checkers that ask "must a literal supply this?" + /// have to tell the two apart. + pub fn is_assignment_define(&self) -> bool { + self.feature == LuaMemberFeature::FileDefine + && self.member_id.get_syntax_id().get_kind() == LuaSyntaxKind::IndexExpr + } } #[derive(Debug, Eq, PartialEq, Clone, Copy, Hash, Serialize, Deserialize)] diff --git a/crates/glua_code_analysis/src/diagnostic/checker/duplicate_field.rs b/crates/glua_code_analysis/src/diagnostic/checker/duplicate_field.rs index 92c60dfc7..e75debad4 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/duplicate_field.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/duplicate_field.rs @@ -179,7 +179,7 @@ fn check_decl_duplicate_field( // 1. 检查 signature let signatures = member_infos.iter().filter(|info| { matches!(info.typ, LuaType::Signature(_)) - && !is_assignment_file_define_member(info.member) + && !LuaMember::is_assignment_define(info.member) }); if signatures.clone().count() > 1 { for signature in signatures { @@ -233,11 +233,6 @@ fn check_decl_duplicate_field( Some(()) } -fn is_assignment_file_define_member(member: &LuaMember) -> bool { - member.get_feature() == LuaMemberFeature::FileDefine - && member.get_syntax_id().get_kind() == LuaSyntaxKind::IndexExpr -} - /// 特殊处理: require("a").fun = function() end fn check_one_member( context: &mut DiagnosticContext, diff --git a/crates/glua_code_analysis/src/diagnostic/checker/missing_fields.rs b/crates/glua_code_analysis/src/diagnostic/checker/missing_fields.rs index 60b43c689..1d9d5ddd4 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/missing_fields.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/missing_fields.rs @@ -490,6 +490,16 @@ fn get_required_fields_for_types( continue; } let name = member.get_key().to_path(); + + // A field the class never declared, attached by a plain `v.X = ...` + // write somewhere, is an addition to a value — not part of the + // contract a constructor has to satisfy. Only what the class + // declares can be required of a literal; runtime writes make the + // field available to read, at most optional to write. + if member.is_assignment_define() { + type_optional_fields.insert(name); + continue; + } let decl_type = db .get_type_index() .get_type_cache(&member.get_id().into()) diff --git a/crates/glua_code_analysis/src/diagnostic/test/missing_fields_test.rs b/crates/glua_code_analysis/src/diagnostic/test/missing_fields_test.rs index 46121bdb5..61455bff9 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/missing_fields_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/missing_fields_test.rs @@ -769,4 +769,30 @@ foo({}) "#, )); } + + /// A field attached to a container element at runtime is not part of the + /// class contract, so it must never become a *required* member of it. The + /// same write at file scope already promotes nothing; sitting inside a block + /// must not change the answer. + #[test] + fn undeclared_field_written_to_a_container_element_is_not_required() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + + assert!(ws.check_code_for( + DiagnosticCode::MissingFields, + r#" + ---@class Fires + ---@field ID string + + ---@type table + local storeF = {} + + ---@param t Fires + local function makeF(t) return t.ID end + + makeF({ ID = "a" }) + for _, v in pairs(storeF) do v.X = 5 end + "#, + )); + } } diff --git a/crates/glua_code_analysis/src/diagnostic/test/param_type_check_test.rs b/crates/glua_code_analysis/src/diagnostic/test/param_type_check_test.rs index d37236b82..e92820cdb 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/param_type_check_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/param_type_check_test.rs @@ -4527,4 +4527,28 @@ mod test { "inferred dynamic key field values should respect inferred mismatch diagnostics policy: {diagnostics:?}" ); } + + /// The same runtime write also reached table compatibility, which reported + /// the literal as missing a member the class never declared. + #[test] + fn undeclared_field_written_to_a_container_element_is_not_expected_of_a_literal() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + + assert!(ws.check_code_for( + DiagnosticCode::ParamTypeMismatch, + r#" + ---@class Fires + ---@field ID string + + ---@type table + local storeF = {} + + ---@param t Fires + local function makeF(t) return t.ID end + + makeF({ ID = "a" }) + for _, v in pairs(storeF) do v.X = 5 end + "#, + )); + } } diff --git a/crates/glua_code_analysis/src/semantic/type_check/generic_type.rs b/crates/glua_code_analysis/src/semantic/type_check/generic_type.rs index 2d3548864..a9d5d01e4 100644 --- a/crates/glua_code_analysis/src/semantic/type_check/generic_type.rs +++ b/crates/glua_code_analysis/src/semantic/type_check/generic_type.rs @@ -10,9 +10,9 @@ use crate::{ }; use super::{ - TypeCheckResult, check_general_type_compact, is_structural_method_member, - member_has_documented_default, type_check_fail_reason::TypeCheckFailReason, - type_check_guard::TypeCheckGuard, + TypeCheckResult, check_general_type_compact, is_assignment_added_member, + is_structural_method_member, member_has_documented_default, + type_check_fail_reason::TypeCheckFailReason, type_check_guard::TypeCheckGuard, }; pub fn check_generic_type_compact( @@ -205,6 +205,7 @@ fn check_generic_type_compact_table( } } None if !source_member_type.is_optional() + && !is_assignment_added_member(context.db, property_owner_id.as_ref()) && !member_has_documented_default( context.db, property_owner_id.as_ref(), diff --git a/crates/glua_code_analysis/src/semantic/type_check/mod.rs b/crates/glua_code_analysis/src/semantic/type_check/mod.rs index f0a04ebcd..be397a2e2 100644 --- a/crates/glua_code_analysis/src/semantic/type_check/mod.rs +++ b/crates/glua_code_analysis/src/semantic/type_check/mod.rs @@ -35,6 +35,17 @@ fn is_structural_method_member(feature: Option) -> bool { feature.is_some_and(|feature| feature.is_method_decl()) } +/// A member that exists only because something assigned to it is an addition to +/// a value, not part of what the type requires a literal to supply. +fn is_assignment_added_member(db: &DbIndex, property_owner_id: Option<&LuaSemanticDeclId>) -> bool { + let Some(LuaSemanticDeclId::Member(member_id)) = property_owner_id else { + return false; + }; + db.get_member_index() + .get_member(member_id) + .is_some_and(crate::LuaMember::is_assignment_define) +} + // A documented default makes a member optional for presence only. fn member_has_documented_default( db: &DbIndex, diff --git a/crates/glua_code_analysis/src/semantic/type_check/ref_type.rs b/crates/glua_code_analysis/src/semantic/type_check/ref_type.rs index c535dd815..6f1ec0be2 100644 --- a/crates/glua_code_analysis/src/semantic/type_check/ref_type.rs +++ b/crates/glua_code_analysis/src/semantic/type_check/ref_type.rs @@ -12,9 +12,10 @@ use crate::{ }; use super::{ - TypeCheckResult, check_general_type_compact, is_structural_method_member, is_sub_type_of, - member_has_documented_default, sub_type::get_base_type_id, - type_check_fail_reason::TypeCheckFailReason, type_check_guard::TypeCheckGuard, + TypeCheckResult, check_general_type_compact, is_assignment_added_member, + is_structural_method_member, is_sub_type_of, member_has_documented_default, + sub_type::get_base_type_id, type_check_fail_reason::TypeCheckFailReason, + type_check_guard::TypeCheckGuard, }; const GMOD_NULL_TYPE_NAME: &str = "NULL"; @@ -397,6 +398,10 @@ fn check_ref_type_compact_table( } } None if !source_member_type.is_optional() + && !is_assignment_added_member( + context.db, + source_member.property_owner_id.as_ref(), + ) && !member_has_documented_default( context.db, source_member.property_owner_id.as_ref(), @@ -468,6 +473,7 @@ fn check_ref_type_compact_object( } } None if !source_member_type.is_optional() + && !is_assignment_added_member(context.db, property_owner_id.as_ref()) && !member_has_documented_default( context.db, property_owner_id.as_ref(), From 13a87b4d74f701161b1be2bb5e04603d661672a5 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:48:31 +0100 Subject: [PATCH 005/159] fix: multi-return union spreading as one value --- .../diagnostic/checker/check_param_count.rs | 34 ++++++++++++++- .../diagnostic/test/missing_parameter_test.rs | 24 +++++++++++ .../diagnostic/test/param_type_check_test.rs | 23 +++++++++++ .../src/semantic/infer/mod.rs | 41 +++++++++++++++++++ 4 files changed, 120 insertions(+), 2 deletions(-) diff --git a/crates/glua_code_analysis/src/diagnostic/checker/check_param_count.rs b/crates/glua_code_analysis/src/diagnostic/checker/check_param_count.rs index 82a6e7f03..19ce5f6c1 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/check_param_count.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/check_param_count.rs @@ -154,9 +154,10 @@ fn check_call_expr( } // 对调用参数的最后一个参数进行特殊处理 if let Some(last_arg) = call_args.last() - && let Ok(LuaType::Variadic(variadic)) = semantic_model.infer_expr(last_arg.clone()) + && let Ok(last_arg_type) = semantic_model.infer_expr(last_arg.clone()) + && let Some(spread_len) = spread_arg_max_len(&last_arg_type) { - let len = match variadic.get_max_len() { + let len = match spread_len { Some(len) => len, None => { return Some(()); @@ -274,6 +275,35 @@ fn check_call_expr( Some(()) } +/// How many values the trailing argument spreads into, when it spreads at all. +/// +/// `Some(None)` is an unbounded spread. A union counts too: an unannotated +/// recursive function returns `(a, b) | unknown`, and the `unknown` arm says +/// nothing about arity, so the multi-return arms decide it. +fn spread_arg_max_len(typ: &LuaType) -> Option> { + match typ { + LuaType::Variadic(variadic) => Some(variadic.get_max_len()), + LuaType::Union(union) => { + let mut max_len = None; + let mut saw_variadic = false; + for arm in union.types() { + let LuaType::Variadic(variadic) = arm else { + continue; + }; + saw_variadic = true; + match variadic.get_max_len() { + Some(len) => { + max_len = Some(max_len.map_or(len, |current: usize| current.max(len))) + } + None => return Some(None), + } + } + saw_variadic.then_some(max_len) + } + _ => None, + } +} + fn is_nonliteral_index_dispatch_call(call_expr: &LuaCallExpr) -> bool { let Some(LuaExpr::IndexExpr(index_expr)) = call_expr.get_prefix_expr() else { return false; diff --git a/crates/glua_code_analysis/src/diagnostic/test/missing_parameter_test.rs b/crates/glua_code_analysis/src/diagnostic/test/missing_parameter_test.rs index 1ac0590fc..beead5d54 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/missing_parameter_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/missing_parameter_test.rs @@ -336,4 +336,28 @@ mod test { "# )); } + + /// The `unknown` arm of an unannotated recursive function's return must not + /// make its arity look like 1: the base case returns two values, so the + /// spread fills both parameters. + #[test] + fn recursive_multi_return_fills_every_parameter() { + let mut ws = VirtualWorkspace::new(); + + assert!(ws.check_code_for( + DiagnosticCode::MissingParameter, + r#" + ---@param x number + ---@param y number + local function takesTwo(x, y) end + + local function r2(n) + if n and n > 0 then return r2(n - 1) end + return 1, 2 + end + + takesTwo(r2(5)) + "# + )); + } } diff --git a/crates/glua_code_analysis/src/diagnostic/test/param_type_check_test.rs b/crates/glua_code_analysis/src/diagnostic/test/param_type_check_test.rs index e92820cdb..e27471ab4 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/param_type_check_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/param_type_check_test.rs @@ -4551,4 +4551,27 @@ mod test { "#, )); } + /// An unannotated recursive function reaches its return type through its + /// base case, so `((1,2)|unknown)` must not leave the first value typed as + /// the whole union when it spreads into an argument list. + #[test] + fn recursive_multi_return_spreads_into_an_argument_list() { + let mut ws = VirtualWorkspace::new(); + + assert!(ws.check_code_for( + DiagnosticCode::ParamTypeMismatch, + r#" + ---@param x number + ---@param y number + local function takesTwo(x, y) end + + local function r2(n) + if n and n > 0 then return r2(n - 1) end + return 1, 2 + end + + takesTwo(r2(5)) + "#, + )); + } } diff --git a/crates/glua_code_analysis/src/semantic/infer/mod.rs b/crates/glua_code_analysis/src/semantic/infer/mod.rs index 436dd66f7..db638b475 100644 --- a/crates/glua_code_analysis/src/semantic/infer/mod.rs +++ b/crates/glua_code_analysis/src/semantic/infer/mod.rs @@ -346,6 +346,47 @@ where break; } + // A union that carries a multi-return still spreads. An `unknown` + // arm - what an unannotated recursive function leaves behind, since + // its own return cannot inform itself - says nothing about arity or + // about any slot, so the informative arms decide both. Falling + // through instead pushed the whole union as a single value, which + // mistyped the first argument and dropped every later one. + LuaType::Union(ref union) + if union.types().any(|typ| matches!(typ, LuaType::Variadic(_))) => + { + let arms = union + .types() + .filter_map(|typ| match typ { + LuaType::Variadic(variadic) => Some(variadic.clone()), + _ => None, + }) + .collect::>(); + let slots = arms + .iter() + .map(|variadic| match variadic.deref() { + VariadicType::Multi(types) => types.len(), + VariadicType::Base(_) => usize::MAX, + }) + .max() + .unwrap_or(0); + let wanted = match var_count { + Some(var_count) => var_count.saturating_sub(value_types.len()), + None => slots, + }; + for slot in 0..wanted.min(slots) { + let slot_type = arms + .iter() + .filter_map(|variadic| variadic.get_type(slot).cloned()) + .reduce(|left, right| crate::TypeOps::Union.apply(db, &left, &right)); + let Some(slot_type) = slot_type else { + break; + }; + value_types.push((slot_type, expr.get_range())); + } + + break; + } LuaType::Unknown if matches!(expr, LuaExpr::CallExpr(_)) && var_count.is_some() => { let remaining = var_count .unwrap_or(value_types.len()) From 160c86175b31b243fef3d191fce15868901855ba Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:58:29 +0100 Subject: [PATCH 006/159] fix: delete on an unsettled receiver adding a nil member --- .../src/compilation/analyzer/lua/stats.rs | 10 +++ .../src/diagnostic/checker/need_check_nil.rs | 34 ++++++++-- .../test/assign_type_mismatch_test.rs | 23 +++++++ .../diagnostic/test/need_check_nil_test.rs | 63 +++++++++++++++++++ 4 files changed, 124 insertions(+), 6 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index ee4583166..578b91941 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -1276,6 +1276,16 @@ fn should_skip_nil_table_shape_assignment( return false; }; + // A prefix that has not settled yet cannot answer this, and the write is a + // delete either way: `t[k] = nil` removes an entry, it never adds a member + // typed `nil`. A receiver typed by a `fun(self: T)` callback slot is still + // `unknown` while its file is walked, so attaching one here reached back + // into a sibling closure and made its empty `{}` seed fail against the + // element type the field itself declares. + if matches!(prefix_type, LuaType::Unknown | LuaType::Never) { + return true; + } + if !is_table_shape_cleanup_type(&prefix_type) { return false; } diff --git a/crates/glua_code_analysis/src/diagnostic/checker/need_check_nil.rs b/crates/glua_code_analysis/src/diagnostic/checker/need_check_nil.rs index 9211296ab..6ab26ae5a 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/need_check_nil.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/need_check_nil.rs @@ -1290,25 +1290,47 @@ fn is_expr_guarded_by_current_truthiness_condition( false } -/// Whether evaluating `condition` truthily proves `expr` truthy. Only `and` -/// chains qualify: every operand of a truthy `and` is itself truthy, while an -/// `or` proves nothing about either side. +/// Whether evaluating `condition` truthily proves `expr` non-nil. +/// +/// A bare truthiness test does, and so does `expr ~= nil`. Only `and` chains +/// carry that through: every operand of a truthy `and` held, while an `or` +/// proves nothing about either side. fn condition_proves_expr_truthy(condition: &LuaExpr, expr: &LuaExpr) -> bool { match condition { LuaExpr::ParenExpr(paren) => paren .get_expr() .is_some_and(|inner| condition_proves_expr_truthy(&inner, expr)), LuaExpr::BinaryExpr(binary) => { - binary.get_op_token().map(|op| op.get_op()) == Some(BinaryOperator::OpAnd) - && binary.get_exprs().is_some_and(|(left, right)| { + let Some(op) = binary.get_op_token().map(|op| op.get_op()) else { + return false; + }; + match op { + BinaryOperator::OpAnd => binary.get_exprs().is_some_and(|(left, right)| { condition_proves_expr_truthy(&left, expr) || condition_proves_expr_truthy(&right, expr) - }) + }), + BinaryOperator::OpNe => binary.get_exprs().is_some_and(|(left, right)| { + (is_nil_literal_expr(&right) && expr_text_matches(&left, expr)) + || (is_nil_literal_expr(&left) && expr_text_matches(&right, expr)) + }), + _ => false, + } } _ => expr_text_matches(condition, expr), } } +fn is_nil_literal_expr(expr: &LuaExpr) -> bool { + matches!( + expr, + LuaExpr::LiteralExpr(literal) + if matches!( + literal.get_literal(), + Some(glua_parser::LuaLiteralToken::Nil(_)) + ) + ) +} + fn condition_is_positive_type_guard_call( semantic_model: &SemanticModel, condition: &LuaExpr, diff --git a/crates/glua_code_analysis/src/diagnostic/test/assign_type_mismatch_test.rs b/crates/glua_code_analysis/src/diagnostic/test/assign_type_mismatch_test.rs index c8b47940d..c2bfa7f2d 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/assign_type_mismatch_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/assign_type_mismatch_test.rs @@ -3076,3 +3076,26 @@ fn declared_empty_container_controls_remain_clean() { "# )); } + +/// An empty `{}` is a valid value for any container type. A `[k] = nil` write in +/// a *second* closure bound to the same `fun(self: T)` slot made the element +/// type nilable, and that re-inference reached back and rejected the seed. +#[test] +fn empty_table_seed_survives_a_sibling_closure_clearing_an_element() { + let mut ws = crate::VirtualWorkspace::new(); + + assert!(ws.check_code_for( + crate::DiagnosticCode::AssignTypeMismatch, + r#" + ---@class element + ---@class holder + ---@field map table + + ---@param f fun(self: holder) + local function hook(f) end + + hook(function(self) self.map = {} end) + hook(function(self) self.map["k"] = nil end) + "# + )); +} diff --git a/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs b/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs index 3f15c404a..7d8df3612 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs @@ -14558,4 +14558,67 @@ mod test { assert_that!(diagnostics, is_empty()); } + + /// The report's own controls: `~= nil`, a cached local, and a declared + /// nilable field all narrow the same field, and none of them may start + /// reporting when the bare truthiness form stops. + #[test] + fn nil_guard_controls_on_an_unknown_typed_field_stay_clean() { + let mut ws = VirtualWorkspace::new(); + + let diagnostics = diagnostics_for_code( + &mut ws, + DiagnosticCode::UncheckedNilAccess, + r#" + ---@class snd_obj + ---@field Stop fun(self: snd_obj) + + ---@class hC1 + ---@param h hC1 + local function c1(h) + if h.snd ~= nil then h.snd:Stop() end + end + + ---@class hC2 + ---@param h hC2 + local function c2(h) + local s = h.snd + if s then s:Stop() end + end + + ---@class hC3 + ---@field snd snd_obj? + ---@param h hC3 + local function c3(h) + if h.snd then h.snd:Stop() end + end + "#, + ); + + assert_that!(diagnostics, is_empty()); + } + + /// An unguarded access still reports, so the guard above is doing the work + /// rather than the check having been switched off for unknown fields. + #[test] + fn unguarded_unknown_typed_field_access_still_reports() { + let mut ws = VirtualWorkspace::new(); + + let diagnostics = diagnostics_for_code( + &mut ws, + DiagnosticCode::UncheckedNilAccess, + r#" + ---@class snd_obj + ---@field Stop fun(self: snd_obj) + + ---@class hBare + ---@param h hBare + local function bare(h) + h.snd:Stop() + end + "#, + ); + + assert_that!(diagnostics.len(), eq(1_usize)); + } } From 0abee4060a29f9837f0462fc73a05f26370f5fb4 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:20:29 +0100 Subject: [PATCH 007/159] fix: type error reported against unreachable code --- .../diagnostic/test/param_type_check_test.rs | 45 +++++++++++++++++++ .../src/semantic/type_check/mod.rs | 14 ++++++ .../semantic/type_check/type_check_guard.rs | 6 +++ 3 files changed, 65 insertions(+) diff --git a/crates/glua_code_analysis/src/diagnostic/test/param_type_check_test.rs b/crates/glua_code_analysis/src/diagnostic/test/param_type_check_test.rs index e27471ab4..5f9add736 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/param_type_check_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/param_type_check_test.rs @@ -4574,4 +4574,49 @@ mod test { "#, )); } + + /// `if a.x ~= nil` on an untyped container narrows the field to `never`, + /// because the flow antecedent for a field it cannot resolve is `nil`. The + /// branch is not actually unreachable, so nothing may be reported against a + /// value inside it - and a runtime `TypeGuard` cannot recover one either, + /// since `never & T` is `never`. + #[test] + fn neq_nil_on_an_untyped_container_field_reports_nothing() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + + assert!(ws.check_code_for( + DiagnosticCode::ParamTypeMismatch, + r#" + local function base(a) + if a.x ~= nil then math.max(0, a.x) end + end + "#, + )); + } + + /// A `never` *member* is a declared shape contradicting itself + /// (`integer & string`), which is a real defect and keeps reporting. + #[test] + fn contradictory_intersection_member_still_reports() { + let mut ws = VirtualWorkspace::new(); + + assert!(!ws.check_code_for_namespace( + DiagnosticCode::AssignTypeMismatch, + r#" + ---@class NevA + ---@field y integer + + ---@class NevB + ---@field y string + + local c ---@type NevA & NevB + + ---@class NevC + ---@field y integer + + ---@type NevC + _ = c + "# + )); + } } diff --git a/crates/glua_code_analysis/src/semantic/type_check/mod.rs b/crates/glua_code_analysis/src/semantic/type_check/mod.rs index be397a2e2..de211bd97 100644 --- a/crates/glua_code_analysis/src/semantic/type_check/mod.rs +++ b/crates/glua_code_analysis/src/semantic/type_check/mod.rs @@ -120,6 +120,20 @@ fn check_general_type_compact( return Ok(()); } + // `never` is the bottom of the lattice: it holds no values, so there is no + // value it could fail to be. It is what narrowing produces where the + // analyzer's own picture is contradictory — `if a.x ~= nil` on a field it + // could only see as `nil` — and reporting a type error against code we + // believe unreachable says nothing about the source. Nor can a runtime + // guard recover it, since `never & T` is `never`. + // + // Only the value the caller asked about. A `never` *member* is a declared + // shape that contradicts itself (`integer & string`), which is worth + // reporting on its own merits. + if compact_type.is_never() && check_guard.is_top_level() { + return Ok(()); + } + if fast_eq_check(source, compact_type) { return Ok(()); } diff --git a/crates/glua_code_analysis/src/semantic/type_check/type_check_guard.rs b/crates/glua_code_analysis/src/semantic/type_check/type_check_guard.rs index b778be97f..849c60ba0 100644 --- a/crates/glua_code_analysis/src/semantic/type_check/type_check_guard.rs +++ b/crates/glua_code_analysis/src/semantic/type_check/type_check_guard.rs @@ -13,6 +13,12 @@ impl TypeCheckGuard { Self { stack_level: 0 } } + /// Whether this is the value the caller asked about, rather than something + /// reached by recursing into it. + pub fn is_top_level(&self) -> bool { + self.stack_level == 0 + } + pub fn next_level(&self) -> TypeCheckLevelResult { let next_level = self.stack_level + 1; if next_level > MAX_TYPE_CHECK_LEVEL { From 5bf75c178fb193242e409197e6ef87cfeb74b033 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:41:15 +0100 Subject: [PATCH 008/159] fix: absence-guarded seed not counting as initialised --- .../src/diagnostic/checker/mod.rs | 115 ++++++++++++++++-- .../test/assign_type_mismatch_test.rs | 79 ++++++++++++ 2 files changed, 186 insertions(+), 8 deletions(-) diff --git a/crates/glua_code_analysis/src/diagnostic/checker/mod.rs b/crates/glua_code_analysis/src/diagnostic/checker/mod.rs index 5db5cfbe9..3a29be5df 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/mod.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/mod.rs @@ -47,7 +47,8 @@ mod unused; use glua_parser::{ BinaryOperator, LuaAssignStat, LuaAst, LuaAstNode, LuaChunk, LuaClosureExpr, LuaComment, - LuaExpr, LuaIndexExpr, LuaReturnStat, LuaStat, LuaSyntaxKind, LuaSyntaxNode, + LuaExpr, LuaIfStat, LuaIndexExpr, LuaReturnStat, LuaStat, LuaSyntaxKind, LuaSyntaxNode, + UnaryOperator, }; use lsp_types::{Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString}; use rowan::{TextRange, TextSize}; @@ -535,6 +536,23 @@ fn collect_assignment_prefix_events(root: &LuaChunk) -> AssignmentPrefixEvents { let is_table_literal = exprs .get(idx) .is_some_and(|expr| assignment_guarantees_table(var.syntax(), expr)); + if is_table_literal + && let Some((outer_start, outer_end, after)) = + absence_guard_seed_scope(assign_stat.syntax(), &prefix_text) + { + // `if not t.k then t.k = {} end` runs exactly when `t.k` is + // absent, so `t.k` is a table on every path out of the `if`. + // The event otherwise stays keyed to the branch's own block and + // the statements after the `if` never see it, so writes through + // `t.k` there are checked as if it had never been seeded. + events + .entry((outer_start, outer_end, prefix_text.clone())) + .or_default() + .push(AssignmentPrefixEvent { + offset: after, + is_table_literal, + }); + } events .entry((block_start, block_end, prefix_text)) .or_default() @@ -545,9 +563,63 @@ fn collect_assignment_prefix_events(root: &LuaChunk) -> AssignmentPrefixEvents { } } + for entries in events.values_mut() { + entries.sort_by_key(|event| event.offset); + } + events } +/// The block an `if not then = {} end` seed reaches, and the +/// offset it is in force from. +/// +/// Only an absence guard qualifies: its branch runs exactly when the target is +/// missing, so the target is a table afterwards whichever way the test went. A +/// plain `if cond then t.k = {} end` guarantees nothing after the `if`. +fn absence_guard_seed_scope( + assign_syntax: &LuaSyntaxNode, + prefix_text: &str, +) -> Option<(TextSize, TextSize, TextSize)> { + let branch_block = assign_syntax.parent()?; + let if_stat = LuaIfStat::cast(branch_block.parent()?)?; + // Only the `then` block: an `else` runs when the target is present. + if if_stat.get_block()?.syntax() != &branch_block { + return None; + } + if !condition_tests_absence(&if_stat.get_condition_expr()?, prefix_text) { + return None; + } + + let (outer_start, outer_end) = assignment_block_range(if_stat.syntax())?; + Some((outer_start, outer_end, if_stat.syntax().text_range().end())) +} + +fn condition_tests_absence(condition: &LuaExpr, prefix_text: &str) -> bool { + match condition { + LuaExpr::ParenExpr(paren) => paren + .get_expr() + .is_some_and(|inner| condition_tests_absence(&inner, prefix_text)), + LuaExpr::UnaryExpr(unary) => { + unary.get_op_token().map(|op| op.get_op()) == Some(UnaryOperator::OpNot) + && unary + .get_expr() + .is_some_and(|inner| normalized_syntax_text(inner.syntax()) == prefix_text) + } + LuaExpr::BinaryExpr(binary) => { + binary.get_op_token().map(|op| op.get_op()) == Some(BinaryOperator::OpEq) + && binary.get_exprs().is_some_and(|(left, right)| { + (matches!(right, LuaExpr::LiteralExpr(_)) + && normalized_syntax_text(right.syntax()) == "nil" + && normalized_syntax_text(left.syntax()) == prefix_text) + || (matches!(left, LuaExpr::LiteralExpr(_)) + && normalized_syntax_text(left.syntax()) == "nil" + && normalized_syntax_text(right.syntax()) == prefix_text) + }) + } + _ => false, + } +} + fn assignment_guarantees_table(var: &LuaSyntaxNode, expr: &LuaExpr) -> bool { if matches!(expr, LuaExpr::TableExpr(_)) { return true; @@ -585,14 +657,41 @@ pub fn is_initialized_assignment_prefix( return false; } - let key = (block_start, block_end, prefix_text); - let Some(events) = assignment_prefixes.get(&key) else { - return false; - }; - let current_offset = assign_stat.syntax().text_range().start(); - let last_event_idx = events.partition_point(|event| event.offset < current_offset); - last_event_idx > 0 && events[last_event_idx - 1].is_table_literal + // A seed in an enclosing block still reaches here: `t.k = {}` before an + // `if` initialises `t.k` for the writes inside it just as much as for the + // ones after it. Only a closure breaks the chain, since its body runs + // somewhere else entirely. + for (block_start, block_end) in enclosing_assignment_block_ranges(assign_stat.syntax()) { + let key = (block_start, block_end, prefix_text.clone()); + let Some(events) = assignment_prefixes.get(&key) else { + continue; + }; + let last_event_idx = events.partition_point(|event| event.offset < current_offset); + if last_event_idx > 0 { + return events[last_event_idx - 1].is_table_literal; + } + } + + let _ = (block_start, block_end); + false +} + +/// Every block that encloses `node` within its own function, innermost first. +fn enclosing_assignment_block_ranges(node: &LuaSyntaxNode) -> Vec<(TextSize, TextSize)> { + let mut ranges = Vec::new(); + let mut current = node.parent(); + while let Some(block) = current { + if LuaClosureExpr::can_cast(block.kind().into()) { + break; + } + if LuaSyntaxKind::Block == block.kind().into() { + let range = block.text_range(); + ranges.push((range.start(), range.end())); + } + current = block.parent(); + } + ranges } pub fn assignment_prefix_key_for_syntax( diff --git a/crates/glua_code_analysis/src/diagnostic/test/assign_type_mismatch_test.rs b/crates/glua_code_analysis/src/diagnostic/test/assign_type_mismatch_test.rs index c2bfa7f2d..429bb13c3 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/assign_type_mismatch_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/assign_type_mismatch_test.rs @@ -3099,3 +3099,82 @@ fn empty_table_seed_survives_a_sibling_closure_clearing_an_element() { "# )); } + +/// `if not t.k then t.k = {} end` runs exactly when `t.k` is missing, so `t.k` +/// is a table on every path out of the `if` — the same seed a plain +/// `t.k = {}` gives. Keeping that fact inside the branch left the writes after +/// it checked as if the table had never been seeded, so each was reported +/// against whichever sibling write the walk happened to record last. +#[test] +fn absence_guarded_seed_initialises_the_table_for_later_writes() { + for seed in [ + "if not ext.slots then ext.slots = {} end", + "if ext.slots == nil then ext.slots = {} end", + "ext.slots = ext.slots or {}", + ] { + let mut ws = crate::VirtualWorkspace::new(); + assert!( + ws.check_code_for( + crate::DiagnosticCode::AssignTypeMismatch, + &format!( + r#" + ---@class SeedPart + ---@class SeedExt + ---@param ext SeedExt + ---@param part SeedPart + local function use(ext, part) + {seed} + if part then ext.slots[1] = part end + ext.slots[1] = false + end + "# + ) + ), + "seed: {seed}" + ); + } +} + +/// Order must not decide it either: the same two writes the other way round +/// used to report the `false` against the part instead. +#[test] +fn absence_guarded_seed_is_order_independent() { + let mut ws = crate::VirtualWorkspace::new(); + + assert!(ws.check_code_for( + crate::DiagnosticCode::AssignTypeMismatch, + r#" + ---@class OrdPart + ---@class OrdExt + ---@param ext OrdExt + ---@param part OrdPart + local function use(ext, part) + if not ext.slots then ext.slots = {} end + ext.slots[1] = false + ext.slots[1] = part + end + "# + )); +} + +/// A plain `if cond then t.k = {} end` guarantees nothing afterwards, so it +/// must not count as a seed. +#[test] +fn conditional_seed_that_is_not_an_absence_guard_is_not_an_initialiser() { + let mut ws = crate::VirtualWorkspace::new(); + + assert!(!ws.check_code_for( + crate::DiagnosticCode::AssignTypeMismatch, + r#" + ---@class CondOwner + ---@field slots integer[] + local O = {} + + ---@param cond boolean + function O:seed(cond) + if cond then self.slots = {} end + self.slots[1] = "not an integer" + end + "# + )); +} From a1255f7bab440514e91c06b6c697a7016904571b Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:47:08 +0100 Subject: [PATCH 009/159] fix: branch write giving a base class its subclasses field --- .../src/compilation/analyzer/lua/stats.rs | 52 ++++++++- .../src/compilation/test/member_infer_test.rs | 100 ++++++++++++++++++ .../src/db_index/member/mod.rs | 25 ++++- 3 files changed, 172 insertions(+), 5 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index 578b91941..1a053c917 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -1811,10 +1811,11 @@ fn assign_merge_type_owner_and_expr_type( preserve_guarded_table_assignment_members(analyzer.db, *member_id); } } else if conditional_branch_assignment { + let may_open_owner_slot = write_may_declare_on_owner(analyzer.db, *member_id); analyzer .db .get_member_index_mut() - .mark_conditional_branch_assignment_member(*member_id); + .mark_conditional_branch_assignment_member(*member_id, may_open_owner_slot); } else if !dynamic_expr_key_member && analyzer .db @@ -2102,8 +2103,9 @@ pub(in crate::compilation::analyzer) fn mark_resolved_member_assignment( preserve_guarded_table_assignment_members(db, member_id); } } else if is_member_assignment_in_conditional_branch(db, member_id) { + let may_open_owner_slot = write_may_declare_on_owner(db, member_id); db.get_member_index_mut() - .mark_conditional_branch_assignment_member(member_id); + .mark_conditional_branch_assignment_member(member_id, may_open_owner_slot); } } @@ -2362,6 +2364,52 @@ pub(super) fn flush_pending_dynamic_key_collection_widenings(analyzer: &mut LuaA } } +/// Whether a runtime write may give the class it names a field that class never +/// declares. +/// +/// A write through a reference only *names* the class; the value it runs on is +/// one instance of it. When a subclass already declares the same field, the +/// write is evidence about that subclass, not about the class it was typed +/// through -- and giving the base the field hands it to every subclass, which +/// hides the declarations and stops any receiver narrowing to the subclass that +/// really owns it. +/// +/// The subtype walk is not cheap, so it only runs for a write that would open a +/// key the owner does not already hold. +fn write_may_declare_on_owner(db: &crate::DbIndex, member_id: LuaMemberId) -> bool { + let member_index = db.get_member_index(); + let Some(LuaMemberOwner::Type(owner_id)) = member_index.get_member_owner(&member_id).cloned() + else { + return true; + }; + let Some(key) = member_index + .get_member(&member_id) + .map(|member| member.get_key().clone()) + else { + return true; + }; + if member_index + .get_member_item(&LuaMemberOwner::Type(owner_id.clone()), &key) + .is_some() + { + return true; + } + !db.get_type_index() + .get_all_sub_types(&owner_id) + .iter() + .any(|sub_type| { + member_index + .get_member_item(&LuaMemberOwner::Type(sub_type.get_id()), &key) + .is_some_and(|item| { + item.get_member_ids().iter().any(|id| { + member_index + .get_member(id) + .is_some_and(|member| member.get_feature().is_decl()) + }) + }) + }) +} + pub(super) fn is_assignment_file_define_member( db: &crate::DbIndex, member_id: LuaMemberId, diff --git a/crates/glua_code_analysis/src/compilation/test/member_infer_test.rs b/crates/glua_code_analysis/src/compilation/test/member_infer_test.rs index c929ee01f..ad3242954 100644 --- a/crates/glua_code_analysis/src/compilation/test/member_infer_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/member_infer_test.rs @@ -3739,3 +3739,103 @@ mod default_value_idiom_is_walk_order_independent { assert_eq!(ty, crate::LuaType::Integer, "got: {ty:?}"); } } + +/// Which class a runtime member write may add a field to. +#[cfg(test)] +mod runtime_member_write_ownership { + use crate::{Emmyrc, LuaMemberKey, LuaMemberOwner, LuaTypeDeclId, VirtualWorkspace}; + + fn gmod_workspace() -> VirtualWorkspace { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + ws + } + + fn class_member_item_exists(ws: &VirtualWorkspace, class: &str, field: &str) -> bool { + ws.analysis + .compilation + .get_db() + .get_member_index() + .get_member_item( + &LuaMemberOwner::Type(LuaTypeDeclId::global(class)), + &LuaMemberKey::Name(field.into()), + ) + .is_some() + } + + /// Both writes sit in branches of one `if`, so neither dominates the other + /// and the slot they share is resolved from the pair rather than from the + /// last one to arrive. + const BRANCH_WRITES: &str = r#" + ---@param target RUNTIME_CLASS + ---@param on boolean + local function apply(target, on) + if on then + target.interior = 1 + else + target.interior = nil + end + end + apply(nil, false) + "#; + + /// A runtime write gives the class it was typed through a field that class + /// never declares. TARDIS `sh_parts.lua` sets `self.use_sound` in two + /// branches of one `if` and declares it nowhere, and that is the only reason + /// the field resolves at all. + #[test] + fn branch_write_declares_a_field_no_subclass_claims() { + let mut ws = gmod_workspace(); + ws.def_file( + "lua/entities/tardis_part/shared.lua", + "---@class tardis_part\n---@field GetPos fun(self: tardis_part): any", + ); + ws.def_file( + "lua/parts.lua", + &BRANCH_WRITES.replace("RUNTIME_CLASS", "tardis_part"), + ); + + assert!( + class_member_item_exists(&ws, "tardis_part", "interior"), + "a runtime field no subclass declares stays visible on the class written through" + ); + } + + /// The same write must not give a *base* class a field its subclasses + /// declare. Doors writes `portal.interior` through a plain `Entity`, which + /// gave `Entity` an `interior` every subclass then inherited -- so an + /// `Entity`-typed receiver answered `.interior` from the base and nothing + /// narrowed to the subclass that really declares it. + #[test] + fn branch_write_does_not_declare_a_field_its_subclasses_declare() { + let mut ws = gmod_workspace(); + ws.def_file( + "lua/entities/base.lua", + r#" + ---@class Entity + ---@field GetPos fun(self: Entity): any + + ---@class door_exterior : Entity + ---@field interior door_interior? + + ---@class door_interior : Entity + ---@field exterior door_exterior + "#, + ); + ws.def_file( + "lua/portals.lua", + &BRANCH_WRITES.replace("RUNTIME_CLASS", "Entity"), + ); + + assert!( + !class_member_item_exists(&ws, "Entity", "interior"), + "a runtime write must not hand a base class a field its subclasses declare" + ); + assert!( + class_member_item_exists(&ws, "door_exterior", "interior"), + "the subclasses' own declarations are untouched" + ); + } +} diff --git a/crates/glua_code_analysis/src/db_index/member/mod.rs b/crates/glua_code_analysis/src/db_index/member/mod.rs index d9089d7ae..e85a5ecaa 100644 --- a/crates/glua_code_analysis/src/db_index/member/mod.rs +++ b/crates/glua_code_analysis/src/db_index/member/mod.rs @@ -453,12 +453,27 @@ impl LuaMemberIndex { /// Re-resolves the slot `member_id` writes to, now that it is known to /// be a conditional-branch write. - fn resolve_conditional_branch_owner_key_item(&mut self, member_id: LuaMemberId) -> Option<()> { + /// + /// `may_open_slot` decides whether the write is allowed to give the owner a + /// key it does not already hold. + fn resolve_conditional_branch_owner_key_item( + &mut self, + member_id: LuaMemberId, + may_open_slot: bool, + ) -> Option<()> { let owner = self.member_current_owner.get(&member_id)?.clone(); if matches!(owner, LuaMemberOwner::GlobalPath(_)) { return None; } let key = self.get_member(&member_id)?.get_key().clone(); + if !may_open_slot + && self + .owner_members + .get(&owner) + .is_none_or(|members| members.get_member(&key).is_none()) + { + return None; + } let candidates = self .get_current_owner_members_for_key(&owner, &key) .into_iter() @@ -1309,10 +1324,14 @@ impl LuaMemberIndex { self.non_overwriting_assignment_members.contains(&member_id) } - pub fn mark_conditional_branch_assignment_member(&mut self, member_id: LuaMemberId) { + pub fn mark_conditional_branch_assignment_member( + &mut self, + member_id: LuaMemberId, + may_open_owner_slot: bool, + ) { self.non_overwriting_assignment_members.insert(member_id); self.conditional_branch_assignment_members.insert(member_id); - self.resolve_conditional_branch_owner_key_item(member_id); + self.resolve_conditional_branch_owner_key_item(member_id, may_open_owner_slot); } pub fn get_current_owner_members_for_key( From 3eab34da35d2464061580e981ce4790426219448 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:01:45 +0100 Subject: [PATCH 010/159] fix: latency gate failing on a file with no diagnostics --- tools/lsp_latency.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/lsp_latency.js b/tools/lsp_latency.js index ff969cbb0..fdce92d65 100644 --- a/tools/lsp_latency.js +++ b/tools/lsp_latency.js @@ -515,6 +515,8 @@ async function main() { // A pull cancelled mid-flight must never come back as an empty full // report — that is what clears the file's diagnostics in VS Code. + // Only a file that has diagnostics can answer this: on a clean file the + // correct report is empty too, and the two are indistinguishable. editDocument(); const doomed = client.request('textDocument/diagnostic', { textDocument: { uri }, previousResultId, @@ -524,7 +526,9 @@ async function main() { const cancelled = await doomed; const shape = describeReport(cancelled.message.result); cancelledPulls.push({ - emptyFullReport: shape.kind === 'full' && shape.count === 0, + emptyFullReport: report.checks.diagnosticCount > 0 + && shape.kind === 'full' + && shape.count === 0, errorCode: cancelled.message.error && cancelled.message.error.code, }); } From b1531ea318dbe94e35a23cf78d409b752b7db875 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:29:44 +0100 Subject: [PATCH 011/159] fix: unbounded variadic arm spreading without an arity to fill --- .../test/return_type_mismatch_test.rs | 47 +++++++++++++++++++ .../src/semantic/infer/mod.rs | 18 ++++--- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/crates/glua_code_analysis/src/diagnostic/test/return_type_mismatch_test.rs b/crates/glua_code_analysis/src/diagnostic/test/return_type_mismatch_test.rs index 1fcb2438c..76fc0e3b2 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/return_type_mismatch_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/return_type_mismatch_test.rs @@ -520,4 +520,51 @@ mod tests { "# )); } + + /// A union arm that is an unbounded variadic answers *every* slot, so the + /// spread has to bound itself. Checking a `@return` asks for the value list + /// with no arity to fill, and taking the unbounded arm's arity literally + /// looped to `usize::MAX` pushing a type per iteration, which ate the + /// machine on a ten-line file. + #[test] + fn unbounded_variadic_union_arm_spreads_without_an_arity_to_fill() { + let mut ws = VirtualWorkspace::new(); + + let file_id = ws.def_file( + "lua/forward.lua", + r#" + ---@vararg integer + local function forward(n, ...) + if n > 0 then return forward(n - 1, ...) end + return ... + end + + ---@return integer + local function outer(...) + return forward(3, ...) + end + + print(outer(1)) + "#, + ); + ws.analysis + .diagnostic + .enable_only(DiagnosticCode::ReturnTypeMismatch); + let diagnostics = ws + .analysis + .diagnose_file(file_id, tokio_util::sync::CancellationToken::new()) + .unwrap_or_default(); + // The point is that this terminates at all, and the type it settles on + // is what proves it: an unbounded arm taken literally cannot produce a + // finite union, so pinning the union pins the bound. + let messages = diagnostics + .iter() + .map(|diagnostic| diagnostic.message.clone()) + .collect::>(); + assert_eq!(messages.len(), 1, "{messages:?}"); + assert!( + messages[0].contains("`(1 ...|unknown ...)`"), + "spread should yield one slot per unbounded arm: {messages:?}" + ); + } } diff --git a/crates/glua_code_analysis/src/semantic/infer/mod.rs b/crates/glua_code_analysis/src/semantic/infer/mod.rs index db638b475..73ce9eb7f 100644 --- a/crates/glua_code_analysis/src/semantic/infer/mod.rs +++ b/crates/glua_code_analysis/src/semantic/infer/mod.rs @@ -346,12 +346,11 @@ where break; } - // A union that carries a multi-return still spreads. An `unknown` - // arm - what an unannotated recursive function leaves behind, since - // its own return cannot inform itself - says nothing about arity or - // about any slot, so the informative arms decide both. Falling - // through instead pushed the whole union as a single value, which - // mistyped the first argument and dropped every later one. + // A union that carries a multi-return still spreads, slot by + // slot. An `unknown` arm - what an unannotated recursive function + // leaves behind, since its own return cannot inform itself - says + // nothing about arity or about any slot, so the informative arms + // decide both. LuaType::Union(ref union) if union.types().any(|typ| matches!(typ, LuaType::Variadic(_))) => { @@ -362,11 +361,16 @@ where _ => None, }) .collect::>(); + // A `Base` arm answers every slot, so its arity bounds nothing. + // That is only safe while the caller has asked for a fixed + // number of values; with no arity to fill it contributes one + // value, exactly as a bare `Variadic` does. let slots = arms .iter() .map(|variadic| match variadic.deref() { VariadicType::Multi(types) => types.len(), - VariadicType::Base(_) => usize::MAX, + VariadicType::Base(_) if var_count.is_some() => usize::MAX, + VariadicType::Base(_) => 1, }) .max() .unwrap_or(0); From 22d79c40426fcc1e962003fe5de6a9a182e25b5a Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:29:45 +0100 Subject: [PATCH 012/159] fix: never carve-out answering inference as well as reports --- crates/glua_code_analysis/src/semantic/type_check/mod.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/glua_code_analysis/src/semantic/type_check/mod.rs b/crates/glua_code_analysis/src/semantic/type_check/mod.rs index de211bd97..9d1a37020 100644 --- a/crates/glua_code_analysis/src/semantic/type_check/mod.rs +++ b/crates/glua_code_analysis/src/semantic/type_check/mod.rs @@ -130,7 +130,12 @@ fn check_general_type_compact( // Only the value the caller asked about. A `never` *member* is a declared // shape that contradicts itself (`integer & string`), which is worth // reporting on its own merits. - if compact_type.is_never() && check_guard.is_top_level() { + // + // And only where the answer becomes a message. Inference asks the same + // question to *decide* things — which way a guard narrows, which member a + // `t[k]` read resolves to, how a generic binds — and there "no value can + // fail this" would read as "`never` matches anything". + if context.detail && compact_type.is_never() && check_guard.is_top_level() { return Ok(()); } From da64716431515356ddfc8bda2a1b666931809022 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:29:45 +0100 Subject: [PATCH 013/159] fix: truthiness guard missing its reassignment check on two arms --- .../src/diagnostic/checker/need_check_nil.rs | 87 +++++++++++-------- .../diagnostic/test/need_check_nil_test.rs | 42 +++++++++ 2 files changed, 95 insertions(+), 34 deletions(-) diff --git a/crates/glua_code_analysis/src/diagnostic/checker/need_check_nil.rs b/crates/glua_code_analysis/src/diagnostic/checker/need_check_nil.rs index 6ab26ae5a..787c92b3e 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/need_check_nil.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/need_check_nil.rs @@ -857,8 +857,18 @@ fn is_expr_guarded_by_current_type_guard_condition( .get_block() .is_some_and(|block| range_contains(block.syntax().text_range(), expr_range)) && condition_is_positive_type_guard_call(semantic_model, &condition, expr) - && !then_block_reassigns_guarded_expr_before_access(semantic_model, &if_stat, expr) - && !loop_back_edge_reassigns_guarded_expr_after_if(semantic_model, &if_stat, expr) + && if_stat.get_block().is_some_and(|block| { + !guarded_block_reassigns_guarded_expr_before_access( + semantic_model, + &block, + expr, + ) + }) + && !loop_back_edge_reassigns_guarded_expr_after_guard( + semantic_model, + if_stat.syntax(), + expr, + ) { return true; } @@ -910,10 +920,13 @@ fn is_expr_guarded_by_current_assigned_value_type_guard_condition( if !condition_is_positive_type_guard_call(semantic_model, &condition, &assigned_expr) { continue; } - if then_block_reassigns_guarded_expr_before_access(semantic_model, &if_stat, expr) { + if if_stat.get_block().is_some_and(|block| { + guarded_block_reassigns_guarded_expr_before_access(semantic_model, &block, expr) + }) { continue; } - if loop_back_edge_reassigns_guarded_expr_after_if(semantic_model, &if_stat, expr) { + if loop_back_edge_reassigns_guarded_expr_after_guard(semantic_model, if_stat.syntax(), expr) + { continue; } return true; @@ -997,14 +1010,11 @@ fn prior_assignment_value_for_expr( Some(assigned_expr) } -fn then_block_reassigns_guarded_expr_before_access( +fn guarded_block_reassigns_guarded_expr_before_access( semantic_model: &SemanticModel, - if_stat: &LuaIfStat, + block: &LuaBlock, guarded_expr: &LuaExpr, ) -> bool { - let Some(block) = if_stat.get_block() else { - return false; - }; let access_start = guarded_expr.syntax().text_range().start(); for node in block.syntax().children() { @@ -1036,12 +1046,12 @@ fn then_block_reassigns_guarded_expr_before_access( false } -fn loop_back_edge_reassigns_guarded_expr_after_if( +fn loop_back_edge_reassigns_guarded_expr_after_guard( semantic_model: &SemanticModel, - if_stat: &LuaIfStat, + guard_stat: &LuaSyntaxNode, guarded_expr: &LuaExpr, ) -> bool { - let mut current = if_stat.syntax().clone(); + let mut current = guard_stat.clone(); while let Some(parent) = current.parent() { if LuaSyntaxKind::from(parent.kind()) == LuaSyntaxKind::Block @@ -1248,40 +1258,49 @@ fn is_expr_guarded_by_current_truthiness_condition( expr: &LuaExpr, ) -> bool { let expr_range = expr.syntax().text_range(); + // A guard holds only if it covers the use, proves the expression truthy, + // and nothing between the test and the use puts a nil back. The last part + // is why the arms share one predicate: an `elseif` is an `if` with an extra + // condition, and a `while` body runs the same statements in the same order. + let guard_holds = |block: Option, + condition: Option, + guard_stat: &LuaSyntaxNode| { + let Some(block) = block else { + return false; + }; + range_contains(block.syntax().text_range(), expr_range) + && condition.is_some_and(|condition| condition_proves_expr_truthy(&condition, expr)) + && !guarded_block_reassigns_guarded_expr_before_access(semantic_model, &block, expr) + && !loop_back_edge_reassigns_guarded_expr_after_guard(semantic_model, guard_stat, expr) + }; + for ancestor in expr.syntax().ancestors() { if let Some(if_stat) = LuaIfStat::cast(ancestor.clone()) { - if if_stat - .get_block() - .is_some_and(|block| range_contains(block.syntax().text_range(), expr_range)) - && if_stat - .get_condition_expr() - .is_some_and(|condition| condition_proves_expr_truthy(&condition, expr)) - && !then_block_reassigns_guarded_expr_before_access(semantic_model, &if_stat, expr) - && !loop_back_edge_reassigns_guarded_expr_after_if(semantic_model, &if_stat, expr) - { + if guard_holds( + if_stat.get_block(), + if_stat.get_condition_expr(), + if_stat.syntax(), + ) { return true; } for elseif_clause in if_stat.get_else_if_clause_list() { - if elseif_clause - .get_block() - .is_some_and(|block| range_contains(block.syntax().text_range(), expr_range)) - && elseif_clause - .get_condition_expr() - .is_some_and(|condition| condition_proves_expr_truthy(&condition, expr)) - { + if guard_holds( + elseif_clause.get_block(), + elseif_clause.get_condition_expr(), + if_stat.syntax(), + ) { return true; } } } if let Some(while_stat) = glua_parser::LuaWhileStat::cast(ancestor.clone()) - && while_stat - .get_block() - .is_some_and(|block| range_contains(block.syntax().text_range(), expr_range)) - && while_stat - .get_condition_expr() - .is_some_and(|condition| condition_proves_expr_truthy(&condition, expr)) + && guard_holds( + while_stat.get_block(), + while_stat.get_condition_expr(), + while_stat.syntax(), + ) { return true; } diff --git a/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs b/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs index 7d8df3612..8a4f5db77 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs @@ -14621,4 +14621,46 @@ mod test { assert_that!(diagnostics.len(), eq(1_usize)); } + + /// A truthiness guard stops proving anything once the body puts a nil back, + /// and that is true of a `while` condition and an `elseif` exactly as it is + /// of an `if` — the body runs the same statements in the same order. + #[test] + fn truthiness_guard_stops_at_a_reassignment_in_every_arm() { + for arm in ["if h.snd then", "while h.snd do", "if flag then\nelseif h.snd then"] { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let diagnostics = diagnostics_for_code( + &mut ws, + DiagnosticCode::NeedCheckNil, + &format!( + r#" + ---@class Snd + ---@field Stop fun(self: Snd) + + ---@class SndHolder + ---@field snd Snd? + + ---@param h SndHolder + ---@param flag boolean + ---@return Snd? + local function maybe(h, flag) return h.snd end + + ---@param h SndHolder + ---@param flag boolean + local function play(h, flag) + {arm} + h.snd = maybe(h, flag) + h.snd:Stop() + end + end + "# + ), + ); + assert_eq!( + diagnostics.len(), + 1, + "reassignment before the access voids the guard in `{arm}`: {diagnostics:?}" + ); + } + } } From 0657532ec4942629fb4cd8f93cb6cea2c65f4d86 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:29:55 +0100 Subject: [PATCH 014/159] perf: subtype check rescanning the type index once per level --- .../src/compilation/analyzer/lua/stats.rs | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index 1a053c917..c75a30023 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -1279,9 +1279,8 @@ fn should_skip_nil_table_shape_assignment( // A prefix that has not settled yet cannot answer this, and the write is a // delete either way: `t[k] = nil` removes an entry, it never adds a member // typed `nil`. A receiver typed by a `fun(self: T)` callback slot is still - // `unknown` while its file is walked, so attaching one here reached back - // into a sibling closure and made its empty `{}` seed fail against the - // element type the field itself declares. + // `unknown` while its file is walked, so a member attached here would land + // on the slot every closure filling it shares. if matches!(prefix_type, LuaType::Unknown | LuaType::Never) { return true; } @@ -2394,20 +2393,34 @@ fn write_may_declare_on_owner(db: &crate::DbIndex, member_id: LuaMemberId) -> bo { return true; } - !db.get_type_index() - .get_all_sub_types(&owner_id) - .iter() - .any(|sub_type| { - member_index - .get_member_item(&LuaMemberOwner::Type(sub_type.get_id()), &key) - .is_some_and(|item| { - item.get_member_ids().iter().any(|id| { - member_index - .get_member(id) - .is_some_and(|member| member.get_feature().is_decl()) - }) + // Asked from the declaring side rather than by enumerating subtypes: + // collecting the subtypes of a base rescans the type index once per level + // of the hierarchy, while the types that declare this key at all are few and + // each answers with one walk up its own supers. + let type_index = db.get_type_index(); + !type_index.get_all_types().into_iter().any(|type_decl| { + let candidate_id = type_decl.get_id(); + if candidate_id == owner_id { + return false; + } + let declares_key = member_index + .get_member_item(&LuaMemberOwner::Type(candidate_id.clone()), &key) + .is_some_and(|item| { + item.get_member_ids().iter().any(|id| { + member_index + .get_member(id) + .is_some_and(|member| member.get_feature().is_decl()) }) + }); + if !declares_key { + return false; + } + let mut super_types = Vec::new(); + candidate_id.collect_super_types(db, &mut super_types); + super_types.iter().any(|super_type| { + matches!(super_type, LuaType::Ref(id) | LuaType::Def(id) if *id == owner_id) }) + }) } pub(super) fn is_assignment_file_define_member( From 3a5f2067e842534337c288ace9d17401fb90381d Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:29:55 +0100 Subject: [PATCH 015/159] fix: latency gate accepting a thinner report on cancel --- tools/lsp_latency.js | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/tools/lsp_latency.js b/tools/lsp_latency.js index fdce92d65..71489ad6b 100644 --- a/tools/lsp_latency.js +++ b/tools/lsp_latency.js @@ -33,8 +33,8 @@ // pessimistic case and keeps runs comparable without naming a file per repo. // // Exits non-zero on a correctness check, not on a latency number: a cancelled -// diagnostic pull answered with an empty full report, or a mid-edit completion -// that disagrees with the settled one. +// diagnostic pull answered with a report thinner than the settled one, or a +// mid-edit completion that disagrees with the settled one. 'use strict'; const { spawn } = require('child_process'); @@ -513,10 +513,12 @@ async function main() { }); editToFresh.push(fresh.ms); - // A pull cancelled mid-flight must never come back as an empty full - // report — that is what clears the file's diagnostics in VS Code. - // Only a file that has diagnostics can answer this: on a clean file the - // correct report is empty too, and the two are indistinguishable. + // A pull cancelled mid-flight must never come back thinner than the + // settled answer — a full report short of what the file really has is + // what drops diagnostics in VS Code, and an empty one clears the file. + // Stated against the settled count rather than against zero, so a file + // that legitimately has no diagnostics does not read as a failure: on a + // clean file the correct report is empty too. editDocument(); const doomed = client.request('textDocument/diagnostic', { textDocument: { uri }, previousResultId, @@ -526,9 +528,8 @@ async function main() { const cancelled = await doomed; const shape = describeReport(cancelled.message.result); cancelledPulls.push({ - emptyFullReport: report.checks.diagnosticCount > 0 - && shape.kind === 'full' - && shape.count === 0, + thinFullReport: shape.kind === 'full' + && shape.count < report.checks.diagnosticCount, errorCode: cancelled.message.error && cancelled.message.error.code, }); } @@ -541,8 +542,8 @@ async function main() { report.measurements.highlightAfterEdit = summarise(highlightAfterEdit); report.checks.highlightRetries = highlightRetries.reduce((a, b) => a + b, 0); report.measurements.editToFreshAnswer = summarise(editToFresh); - report.checks.emptyFullReportsOnCancel = - cancelledPulls.filter((p) => p.emptyFullReport).length; + report.checks.thinReportsOnCancel = + cancelledPulls.filter((p) => p.thinFullReport).length; // A mid-edit completion that differs from the settled one is a correctness // regression, however fast it came back. report.checks.completionDriftWhileTyping = { @@ -581,8 +582,8 @@ async function main() { } console.log(`\ncompletion items : ${report.checks.completionItemCount}`); console.log(`diagnostics : ${report.checks.diagnosticCount}`); - console.log(`empty reports on cancel : ${report.checks.emptyFullReportsOnCancel}` - + (report.checks.emptyFullReportsOnCancel === 0 ? ' (good)' : ' (BAD: clears the file)')); + console.log(`thin reports on cancel : ${report.checks.thinReportsOnCancel}` + + (report.checks.thinReportsOnCancel === 0 ? ' (good)' : ' (BAD: drops diagnostics)')); const drift = report.checks.completionDriftWhileTyping; const driftOk = drift.worstMissing === 0 && drift.worstExtra === 0; console.log(`completion drift mid-edit : -${drift.worstMissing} / +${drift.worstExtra}` @@ -595,9 +596,9 @@ async function main() { // comparison and never fail; these two are defects whatever the latency was. function failedChecks(report) { const failures = []; - if (report.checks.emptyFullReportsOnCancel > 0) { - failures.push(`${report.checks.emptyFullReportsOnCancel} cancelled diagnostic pull(s) ` - + 'came back as an empty full report, which clears the file in the editor'); + if (report.checks.thinReportsOnCancel > 0) { + failures.push(`${report.checks.thinReportsOnCancel} cancelled diagnostic pull(s) came ` + + 'back thinner than the settled report, which drops diagnostics in the editor'); } const drift = report.checks.completionDriftWhileTyping; if (drift.worstMissing > 0 || drift.worstExtra > 0) { From 89d5c7b7d56cf5a2c3d4c55564869f804cf289b2 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:29:56 +0100 Subject: [PATCH 016/159] chore: drop a dead guard and reword three comments --- .../src/diagnostic/checker/duplicate_field.rs | 2 +- crates/glua_code_analysis/src/diagnostic/checker/mod.rs | 5 ----- .../src/diagnostic/test/assign_type_mismatch_test.rs | 3 ++- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/glua_code_analysis/src/diagnostic/checker/duplicate_field.rs b/crates/glua_code_analysis/src/diagnostic/checker/duplicate_field.rs index e75debad4..f95fed48a 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/duplicate_field.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/duplicate_field.rs @@ -179,7 +179,7 @@ fn check_decl_duplicate_field( // 1. 检查 signature let signatures = member_infos.iter().filter(|info| { matches!(info.typ, LuaType::Signature(_)) - && !LuaMember::is_assignment_define(info.member) + && !info.member.is_assignment_define() }); if signatures.clone().count() > 1 { for signature in signatures { diff --git a/crates/glua_code_analysis/src/diagnostic/checker/mod.rs b/crates/glua_code_analysis/src/diagnostic/checker/mod.rs index 3a29be5df..3fd8c32bc 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/mod.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/mod.rs @@ -648,10 +648,6 @@ pub fn is_initialized_assignment_prefix( return false; }; - let Some((block_start, block_end)) = assignment_block_range(assign_stat.syntax()) else { - return false; - }; - let prefix_text = normalized_syntax_text(prefix.syntax()); if prefix_text.is_empty() { return false; @@ -673,7 +669,6 @@ pub fn is_initialized_assignment_prefix( } } - let _ = (block_start, block_end); false } diff --git a/crates/glua_code_analysis/src/diagnostic/test/assign_type_mismatch_test.rs b/crates/glua_code_analysis/src/diagnostic/test/assign_type_mismatch_test.rs index 429bb13c3..694c2fcad 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/assign_type_mismatch_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/assign_type_mismatch_test.rs @@ -3136,7 +3136,8 @@ fn absence_guarded_seed_initialises_the_table_for_later_writes() { } /// Order must not decide it either: the same two writes the other way round -/// used to report the `false` against the part instead. +/// have to stay clean, or the slot's type is whichever writer the walk saw last +/// rather than the union of both. #[test] fn absence_guarded_seed_is_order_independent() { let mut ws = crate::VirtualWorkspace::new(); From 4bce9705c7ea65d06dbd0d06cbc497c77525d413 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:09:32 +0100 Subject: [PATCH 017/159] fix: alias and write racing for one member slot --- .../src/compilation/analyzer/mod.rs | 7 ++ .../src/db_index/member/mod.rs | 80 +++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs index 01b4df208..f82f41ea0 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs @@ -293,6 +293,13 @@ pub fn analyze(db: &mut DbIndex, need_analyzed_files: Vec>) { common::reconcile_directly_attached_candidate_members(db); } + // Every write and every alias this batch performed has landed, so the + // slots they share can be settled from the ownership they ended on. + { + let _p = Profile::new("settle_alias_contributed_slots"); + db.get_member_index_mut().settle_alias_contributed_slots(); + } + // Net flows are collected last: the collector resolves wrappers through // signatures, receiver types and members, none of which exist yet when // the gmod pre-pass runs. See `GmodNetworkAnalysisPipeline`. diff --git a/crates/glua_code_analysis/src/db_index/member/mod.rs b/crates/glua_code_analysis/src/db_index/member/mod.rs index e85a5ecaa..998f3f361 100644 --- a/crates/glua_code_analysis/src/db_index/member/mod.rs +++ b/crates/glua_code_analysis/src/db_index/member/mod.rs @@ -48,6 +48,12 @@ pub struct LuaMemberIndex { /// Per file, each `if` branch's range paired with the range of the `if` it /// belongs to, sorted by branch start. Recorded on the decl walk. conditional_branch_ranges: HashMap>, + /// Members `add_member_alias_to_owner` pushed into a slot, by slot. The + /// alias never displaces, while the insert path collapses to the latest + /// writer, so which of the two ran last decides the visible set -- and that + /// is a property of the batch. Kept so the slot can be settled once both are + /// done. + alias_contributed_slots: HashMap<(LuaMemberOwner, LuaMemberKey), Vec>, member_function_scope_ranges: HashMap, /// Per-writer evidence for the member assignment widening merge. See /// [`MemberAssignmentContribution`]. @@ -94,6 +100,7 @@ impl LuaMemberIndex { deferred_index_expr_members: HashSet::default(), function_scope_ranges: HashMap::default(), conditional_branch_ranges: HashMap::default(), + alias_contributed_slots: HashMap::default(), member_function_scope_ranges: HashMap::default(), assignment_contributions: MemberAssignmentContributionStore::default(), } @@ -589,6 +596,10 @@ impl LuaMemberIndex { self.add_member_to_owner_key_history_index(owner.clone(), id); } + self.alias_contributed_slots + .entry((owner.clone(), key.clone())) + .or_default() + .push(id); let owner_members = self .owner_members .entry(owner.clone()) @@ -1248,6 +1259,74 @@ impl LuaMemberIndex { } } + /// Re-derives the visible set of every slot an alias pushed into, from the + /// ownership those writes finally settled on. + /// + /// `add_member_alias_to_owner` never displaces what it finds, while the + /// insert path collapses a slot to its latest writer as each write arrives. + /// Which ran last is a property of how the batch was composed, so a slot + /// both touched has to be re-derived once they are done -- from the same + /// rules the insert path uses, over the same members. + pub fn settle_alias_contributed_slots(&mut self) { + let contributed = std::mem::take(&mut self.alias_contributed_slots); + for ((owner, key), aliased_ids) in contributed { + let Some(item) = self + .owner_members + .get(&owner) + .and_then(|owner_members| owner_members.get_member(&key)) + else { + continue; + }; + let mut live = member_ids_from_item(item); + for id in aliased_ids { + if !live.contains(&id) { + live.push(id); + } + } + live.retain(|id| self.members.contains_key(id)); + if live.len() < 2 + || !live + .iter() + .all(|id| self.is_assignment_file_define_member(*id)) + { + continue; + } + if live + .iter() + .all(|id| self.non_overwriting_assignment_members.contains(id)) + { + continue; + } + let (aliased, owned): (Vec<_>, Vec<_>) = live + .iter() + .copied() + .partition(|id| self.member_current_owner.get(id) != Some(&owner)); + let Some(&first_owned) = owned.first() else { + continue; + }; + // A conditional-branch slot keeps one writer per branch; every other + // slot keeps the latest. Both read only the candidate set, which is + // what makes the answer independent of arrival order. + let mut visible = aliased; + match self.conditional_branch_item(&owned) { + Some(item) => visible.extend(member_ids_from_item(&item)), + None => visible.push(latest_defined_member(&owned, first_owned)), + } + visible.sort_by_key(|id| member_id_sort_key(*id)); + visible.dedup(); + let item = match visible.as_slice() { + [only] => LuaMemberIndexItem::One(*only), + _ => LuaMemberIndexItem::Many(visible), + }; + let Some(owner_members) = self.owner_members.get_mut(&owner) else { + continue; + }; + if owner_members.get_member(&key) != Some(&item) { + owner_members.add_member(key, item); + } + } + } + pub fn add_conditional_branch_range( &mut self, file_id: FileId, @@ -1627,6 +1706,7 @@ impl LuaIndex for LuaMemberIndex { self.deferred_index_expr_members.clear(); self.function_scope_ranges.clear(); self.conditional_branch_ranges.clear(); + self.alias_contributed_slots.clear(); self.member_function_scope_ranges.clear(); self.assignment_contributions.clear(); } From 9c746a5ff580e8cdb20b065a43c40c281861c411 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:24:48 +0100 Subject: [PATCH 018/159] style: reformat four files cargo fmt had missed --- .../glua_code_analysis/src/db_index/member/mod.rs | 13 +++++++------ crates/glua_code_analysis/src/db_index/type/test.rs | 9 ++++++--- .../src/diagnostic/checker/duplicate_field.rs | 3 +-- .../src/diagnostic/test/need_check_nil_test.rs | 6 +++++- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/crates/glua_code_analysis/src/db_index/member/mod.rs b/crates/glua_code_analysis/src/db_index/member/mod.rs index 998f3f361..6b47fb77b 100644 --- a/crates/glua_code_analysis/src/db_index/member/mod.rs +++ b/crates/glua_code_analysis/src/db_index/member/mod.rs @@ -628,12 +628,13 @@ impl LuaMemberIndex { }; let key = member.get_key(); - let is_indexed = |index: &HashMap>>| { - index - .get(owner) - .and_then(|members_by_key| members_by_key.get(key)) - .is_some_and(|member_ids| member_ids.contains(&id)) - }; + let is_indexed = + |index: &HashMap>>| { + index + .get(owner) + .and_then(|members_by_key| members_by_key.get(key)) + .is_some_and(|member_ids| member_ids.contains(&id)) + }; if self.member_current_owner.get(&id) != Some(owner) && !(is_indexed(&self.member_owner_key_index) && is_indexed(&self.member_owner_key_history_index)) diff --git a/crates/glua_code_analysis/src/db_index/type/test.rs b/crates/glua_code_analysis/src/db_index/type/test.rs index ac7bbf925..88a3ea7cf 100644 --- a/crates/glua_code_analysis/src/db_index/type/test.rs +++ b/crates/glua_code_analysis/src/db_index/type/test.rs @@ -13,8 +13,8 @@ mod test { DbIndex, FileId, InFiled, LuaDeclId, LuaDeclLocation, LuaDefinitionId, LuaInferenceConfidence, LuaInferenceEventId, LuaInferenceNodeId, LuaInferenceProvenanceKind, LuaInferenceStep, LuaSignatureId, LuaType, LuaTypeCache, - LuaTypeDecl, - LuaTypeDeclId, LuaTypeFact, LuaTypeFactMetadata, LuaTypeOwner, resolve_alias_type, + LuaTypeDecl, LuaTypeDeclId, LuaTypeFact, LuaTypeFactMetadata, LuaTypeOwner, + resolve_alias_type, }; fn create_type_index() -> LuaTypeIndex { @@ -359,7 +359,10 @@ mod test { let provider_id = LuaTypeDeclId::global("ProviderType"); let mut index = LuaTypeIndex::new(); - index.add_type_decl(provider, class_decl(provider, "SharedType", shared_id.clone())); + index.add_type_decl( + provider, + class_decl(provider, "SharedType", shared_id.clone()), + ); index.add_type_decl_location(contributor, &shared_id, decl_location(contributor)); index.add_type_decl( provider, diff --git a/crates/glua_code_analysis/src/diagnostic/checker/duplicate_field.rs b/crates/glua_code_analysis/src/diagnostic/checker/duplicate_field.rs index f95fed48a..66d851479 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/duplicate_field.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/duplicate_field.rs @@ -178,8 +178,7 @@ fn check_decl_duplicate_field( // 1. 检查 signature let signatures = member_infos.iter().filter(|info| { - matches!(info.typ, LuaType::Signature(_)) - && !info.member.is_assignment_define() + matches!(info.typ, LuaType::Signature(_)) && !info.member.is_assignment_define() }); if signatures.clone().count() > 1 { for signature in signatures { diff --git a/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs b/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs index 8a4f5db77..72e221966 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs @@ -14627,7 +14627,11 @@ mod test { /// of an `if` — the body runs the same statements in the same order. #[test] fn truthiness_guard_stops_at_a_reassignment_in_every_arm() { - for arm in ["if h.snd then", "while h.snd do", "if flag then\nelseif h.snd then"] { + for arm in [ + "if h.snd then", + "while h.snd do", + "if flag then\nelseif h.snd then", + ] { let mut ws = VirtualWorkspace::new_with_init_std_lib(); let diagnostics = diagnostics_for_code( &mut ws, From 53213b9287c39c5285d4a4568ed99681f589e1f2 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:25:00 +0100 Subject: [PATCH 019/159] docs: describe every determinism gate and require zero drift --- AGENTS.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 5c1a59eb3..2ed6642af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,19 @@ - Call-role and annotation-driven tests should load the relevant builtins; otherwise they may pass while bypassing the real metadata path. - Typical test commands are `cargo test -p glua_code_analysis `, `cargo test -p glua_code_analysis`, and `cargo test`. - Use `glua_check` JSON output for before/after corpus diagnostic comparisons. The benchmark measures performance; it is not a diagnostics oracle. -- Changes to indexes, cached inference, or the unresolve/resolution passes must keep incremental re-analysis equal to a cold build. Verify with `cargo run --release -p determinism` on a real workspace; `repeat`, `order`, `fresh`, `reindex`, `allreindex` and `mainexpand` are expected to report IDENTICAL. A third gate, `indexrepeat`, re-indexes each target with its text untouched and requires the **index** to come back identical; the diagnostic gates cannot see index drift, because re-analysis can attach different members or settle a decl's type differently and still produce the same diagnostics. It does **not** pass today (CityRP: 80 type caches, 2 signatures, 11 class members change on a no-op re-index) and that drift is why incremental work cannot be skipped — every "did this actually change?" test answers yes — so treat any *growth* in those counts as yours. Use `DET_TARGETS=gamemode/core/sh_data.lua` as the working repro: it expands to **4 files** and reproduces the same defect at 4/0/3, which is far cheaper to iterate on than the 1306-file one. The remaining drift sits in three readers, each proven by trace: the sibling-widening cache (`lua/stats.rs`, where `visible_member_count_for_owner_key` is 2 cold and 1 warm, so `lookup_widening_cache` returns `FirstSighting` and `get_widened_member_assignment_type` is never called, so nothing arms the settled retry); first-writer-wins on a decl slot (`common/mod.rs:206-215` deliberately keeps an `any`/`unknown` decl cache, pinned by three tests, so the slot is claimed by whichever writer arrives first and an unrelated later assignment can seed it); and attach-candidate lifetime (`analyzer/mod.rs:317-377`, whose retry list lives in a context that dies when `analyze()` returns, making member *existence* batch-dependent — it owns all 11 class-member drifts). Two dead ends already paid for: arming the settled retry from the `FirstSighting` arm fixes two entries and takes the 1306 gate from 80 to **144**, because it widens members cold previously left alone; and `rederive_contributed_member_assignments` cannot fix the widening class at all, because its `take_while` merges each writer only against *earlier* writers so a first writer is never re-derived. The defect is that analysis output depends on how the workspace was *batched*, not on the source alone: `remove_index(batch)` runs before `update_index(batch)`, so a file sees out-of-batch neighbours complete but in-batch neighbours empty until the walk reaches them. A whole-workspace batch hides everything and so reproduces the cold build exactly (`allreindex` and `mainexpand` are both byte-identical to cold); a four-file batch hides almost nothing and lands somewhere else. It is not edit-specific — `split:4` builds the same workspace cold in four batches and produces 299 different diagnostics against `split:1`. Do not "fix" it by re-indexing everything on an edit: that forces the whole-workspace batch, costs more than a cold build, and freezes the least-informed answer. `editrevert` is the drift gate for the *other* edit path. It applies a real edit through `update_file_by_uri` and then takes it back out; the source ends where it started, so the index and the diagnostics have to as well, and it needs no ground-truth build because the pre-edit index is the truth. It covers what the others cannot — `indexrepeat` re-indexes with the text untouched and so never exercises an edit's invalidation, and `noopedit`'s pair is semantically neutral, so the update path skips the re-index outright. It does **not** pass today: on CityRP an edit-and-revert of `gamemode/core/sh_util.lua` leaves 79 type caches, 3 signatures and 11 class member lists moved, plus 2 `need-check-nil` diagnostics. Treat growth in those counts as yours. Both index gates build their own analysis rather than sharing the caller's, because each re-indexes in place and leaves a converged index behind — sharing one let whichever ran second measure against the other's converged state and report a clean 0, which hid the drift rather than removing it. The two edit gates are `noopedit` (formerly `edit`) and `realedit`. `noopedit` gates the semantic no-op skip and nothing more: its edit pair is semantically unchanged, so the update path skips the re-index outright and the stage verifies that skipping preserves state — include at least one wide-expansion target (e.g. CityRP `gamemode/core/sh_util.lua`, 1300 files) so the skip is exercised where it matters most. `realedit` is the gate for incremental re-analysis itself, since its edit changes what the file means; set `DET_EDIT_FIND` and `DET_EDIT_REPLACE` or it skips and nothing gates re-analysis. It does **not** pass today, and the divergence is a known gap rather than something you introduced — but it is a small, fixed one, so measure it before and after your change and treat any *growth* as yours. On CityRP, editing `gamemode/core/sh_util.lua` (`function cityrp.util.Bind(self, callback)` gaining a parameter) gives `cold_edited -> warm: removed=0 added=2`, both `need-check-nil` in `plugins/cwweapons` (`cw_base/shared.lua:999`, `cw_m249_official/shared.lua:304`). The cause is visible in the index diff: a signature is identified by its position, an edit moves the positions of every signature after it, and `CallSiteParamIndex` contributions that *target* a moved signature are only dropped when the contributing file is itself re-indexed. A contributor outside the reindex expansion keeps pointing at the old position, so `rebuild_derived_state` splits one parameter's inferred type across the stale signature id and the current one (`15167` keeps one union arm, `15174` the other). Losing an arm widens callers' inferences to include `Unknown`, which is what makes `need-check-nil` fire downstream. Fixing it means invalidating contributions by *target* file and pulling those contributors into the reindex expansion, which changes the expansion set, so measure the benchmark as well when you do. Note it is far more sensitive than the other gates: changes to the unresolve waves, the infer-cache lifetime, or member ownership can break `realedit` while all seven others still report IDENTICAL. `mainreindex`, `exact`, `split:N` and `editmid` are bisect stages, not gates: the first three run `reindex_files_without_expansion`, which skips production's convergence passes, and `editmid` forces a real offset-shifting re-analysis of the expansion (the batch-composition confluence gap), so they are expected to diverge and only matter for localising a failure the gates already caught. Re-run it before and after, because a change can make a stage identical by *degrading* the cold build rather than by fixing the re-index. +- Changes to indexes, cached inference, or the unresolve/resolution passes must keep incremental re-analysis equal to a cold build. Verify with `cargo run --release -p determinism` on a real workspace, and set `DET_EDIT_FIND`/`DET_EDIT_REPLACE` or the edit stages skip and gate nothing. **Every gate must report +0** — no drift in the diagnostics or in the index, on any of them. The gates are: + - `repeat` — re-collect diagnostics with no change at all. + - `fresh` — build a second analysis in the same process. + - `order` — rebuild with the file list reversed. + - `reindex` — full clear and rebuild; the ground truth. + - `allreindex` — re-analyse every file, library included, via per-file removal rather than `clear_index`. + - `mainexpand` — re-analyse every main-workspace file through `reindex_files`, i.e. the dependency expansion the LSP actually applies. + - `noopedit` — a semantically neutral edit pair through `update_file_by_uri`: the no-op gate must skip the re-index and skipping must preserve state. Include a wide-expansion target so the skip is exercised where it matters. + - `realedit` — a real edit that changes what the file means, compared against a cold build of the edited source. Always diffs the index. The most sensitive gate: unresolve waves, infer-cache lifetime and member ownership can break it while everything else reports IDENTICAL. + - `editrevert` — a real edit applied through the update path and then taken back out. The source ends where it started, so the index and the diagnostics have to as well. + - `indexrepeat` — re-index each target with its text untouched and require the **index** to come back identical. The diagnostic gates cannot see index drift: re-analysis can attach different members or settle a decl's type differently and still produce the same diagnostics. + - `burst` — three edits per target, each self-indexed, then one ripple over the union of the captured expansions (the shape a deferred debounce produces), gated against a cold build of the final text. +- `mainreindex`, `exact`, `split:N`, `editmid`, `restabilize`, `perfile`, `expandwhy` and `faithful` are bisect instruments, not gates: they run reduced or deliberately non-production paths, are expected to diverge, and only matter for localising a failure a gate already caught. Run the gates before and after a change — a change can make a stage identical by *degrading* the cold build rather than by fixing the re-index. `DET_TARGETS=gamemode/core/sh_data.lua` expands to 4 files and is far cheaper to iterate on than a 1300-file target. - Performance changes require profiling or a targeted before/after benchmark. Use `GLUALS_PROFILE=1` for phase timings and `cargo run --release -p benchmark` for the large-workspace harness. - For a sampling profile use `samply` (ETW-based on Windows, so it prompts for admin elevation on every run; the user has to approve it). Three things have to be right or you get a useless profile: build with `CARGO_PROFILE_RELEASE_DEBUG=1 cargo build --release -p benchmark` so the PDB exists, run the binary from `target/release` (samply resolves the PDB by the relative path recorded in the exe, so it only finds it from that directory), and do **not** pass `--main-thread-only` — the tools run analysis on a spawned big-stack thread, so the main thread only shows a join. A working invocation is `cd target/release && BENCH_CODEBASE= samply record --save-only --unstable-presymbolicate -o .json.gz ./benchmark.exe`. That writes `.json.gz` plus a `.json.syms.json` sidecar; the profile itself holds only addresses, so symbol names come from joining the two by `libs[].debugName` and the frame address against each module's `symbol_table` rva ranges. - Performance is extremely important; the language server must be quick and responsive on large workspaces without loss of functionality. You are to always optimise at the root cause of performance issues. Things such as budgets, string based prefilters / guards and other similar "hacks" are unacceptable since they will regress functionality in large or complex codebases. From 84b4dd373976da91b851f859e0846dc6e1bee96c Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:25:04 +0100 Subject: [PATCH 020/159] fix: consumers reading a return that settles later --- .../src/compilation/analyzer/mod.rs | 131 ++++++++++++------ 1 file changed, 87 insertions(+), 44 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs index f82f41ea0..7d30e3880 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs @@ -37,6 +37,11 @@ use infer_cache_manager::InferCacheManager; use lua::LuaReturnPoint; use unresolve::{UnResolve, UnResolveReturn}; +/// Ceiling on [`AnalyzeContext::resolve_call_site_return_consumers`] rounds. +/// The set converges in a handful of rounds on real workspaces; the bound only +/// stops a mutually recursive chain from spinning. +const CALL_SITE_RETURN_CONSUMER_ROUNDS: usize = 32; + pub(crate) fn infer_closure_body_function_type( db: &DbIndex, cache: &mut crate::LuaInferCache, @@ -242,7 +247,13 @@ pub fn analyze(db: &mut DbIndex, need_analyzed_files: Vec>) { { let _p = Profile::new("rederive_settled_inferred_returns"); - rederive_settled_inferred_returns(db, &mut context); + // A return settled here was still `unknown` when the call-site + // consumers read it, so those consumers hold a value derived from + // it that is now stale. A warm re-index inherits the settled return + // and never sees the stale one, so leaving them is drift. + if rederive_settled_inferred_returns(db, &mut context) { + context.resolve_call_site_return_consumers(db); + } } { @@ -402,7 +413,7 @@ fn attach_settled_index_expr_members(db: &mut DbIndex, context: &mut AnalyzeCont } /// Re-resolves inferred returns that settled on `any`/`unknown`. -fn rederive_settled_inferred_returns(db: &mut DbIndex, context: &mut AnalyzeContext) { +fn rederive_settled_inferred_returns(db: &mut DbIndex, context: &mut AnalyzeContext) -> bool { let mut candidates = context .inferred_return_candidates .iter() @@ -419,7 +430,7 @@ fn rederive_settled_inferred_returns(db: &mut DbIndex, context: &mut AnalyzeCont .cloned() .collect::>(); if candidates.is_empty() { - return; + return false; } candidates.sort_by_key(|return_| (return_.file_id, return_.signature_id.get_position())); @@ -430,10 +441,20 @@ fn rederive_settled_inferred_returns(db: &mut DbIndex, context: &mut AnalyzeCont .collect::>(); context.infer_manager.clear_files(&candidate_files); + let mut changed = false; for mut return_ in candidates { + let signature_id = return_.signature_id.clone(); let cache = context.infer_manager.get_infer_cache(return_.file_id); let _ = unresolve::try_resolve_return_point(db, cache, &mut return_); + changed |= db + .get_signature_index() + .get(&signature_id) + .is_some_and(|signature| { + let resolved = signature.get_return_type(); + !resolved.is_any() && !resolved.is_unknown() + }); } + changed } /// Re-derives member assignment widenings that ran against an incomplete @@ -1281,6 +1302,10 @@ pub struct AnalyzeContext { inferred_return_candidates: Vec, pending_call_site_return_consumers: Vec, pending_call_site_definition_refreshes: Vec<(LuaDefinitionId, LuaTypeOwner)>, + /// Consumers already resolved once, kept so a later pass that settles a + /// function return can have them re-resolved against it. + call_site_return_targets: Vec<(FileId, LuaTypeOwner, LuaExpr, usize)>, + call_site_return_definition_refreshes: HashMap>, pending_unresolve_decl_ids: HashSet, uninformative_local_decl_candidates: HashSet, member_initializer_reinfer_candidates: HashSet, @@ -1313,6 +1338,8 @@ impl AnalyzeContext { inferred_return_candidates: Vec::new(), pending_call_site_return_consumers: Vec::new(), pending_call_site_definition_refreshes: Vec::new(), + call_site_return_targets: Vec::new(), + call_site_return_definition_refreshes: HashMap::new(), pending_unresolve_decl_ids: HashSet::new(), uninformative_local_decl_candidates: HashSet::new(), member_initializer_reinfer_candidates: HashSet::new(), @@ -1467,58 +1494,74 @@ impl AnalyzeContext { } fn resolve_call_site_return_consumers(&mut self, db: &mut DbIndex) -> usize { - let consumers = std::mem::take(&mut self.pending_call_site_return_consumers); - let count = consumers.len(); - if count == 0 { - self.pending_call_site_definition_refreshes.clear(); - return 0; + for consumer in std::mem::take(&mut self.pending_call_site_return_consumers) { + match consumer { + UnResolve::Decl(decl) => self.call_site_return_targets.push(( + decl.file_id, + LuaTypeOwner::Decl(decl.decl_id), + decl.expr, + decl.ret_idx, + )), + UnResolve::Member(member) => { + if let Some(expr) = member.expr { + self.call_site_return_targets.push(( + member.file_id, + LuaTypeOwner::Member(member.member_id), + expr, + member.ret_idx, + )); + } + } + _ => {} + } } - - let mut definition_refreshes = HashMap::>::new(); for (definition, owner) in std::mem::take(&mut self.pending_call_site_definition_refreshes) { - definition_refreshes + self.call_site_return_definition_refreshes .entry(owner) .or_default() .push(definition); } - self.infer_manager.clear(); - let mut fact_updates = Vec::with_capacity( - consumers.len() + definition_refreshes.values().map(Vec::len).sum::(), - ); + if self.call_site_return_targets.is_empty() { + return 0; + } - for consumer in consumers { - let (file_id, owner, expr, ret_idx) = match consumer { - UnResolve::Decl(decl) => ( - decl.file_id, - LuaTypeOwner::Decl(decl.decl_id), - decl.expr, - decl.ret_idx, - ), - UnResolve::Member(member) => { - let Some(expr) = member.expr else { - continue; - }; - ( - member.file_id, - LuaTypeOwner::Member(member.member_id), - expr, - member.ret_idx, - ) - } - _ => continue, - }; - let cache = self.infer_manager.get_infer_cache(file_id); - let fact = select_result_fact(infer_expr_fact_with_cache(db, cache, expr), ret_idx); - fact_updates.push((LuaInferenceNodeId::TypeOwner(owner.clone()), fact.clone())); - if let Some(definitions) = definition_refreshes.get(&owner) { - for definition in definitions { - fact_updates.push((LuaInferenceNodeId::Definition(*definition), fact.clone())); + // These consumers feed each other: one's expression can read a local, or + // a function return, that another one settles. Inferring the whole set + // against the pre-publish index leaves every such reader holding its + // neighbour's *unresolved* value, and whether a neighbour is in this + // batch or was already published by an earlier build is a property of + // the batch rather than of the source. Iterate until publishing stops + // moving anything, so a partial re-index and a cold build agree. + for _ in 0..CALL_SITE_RETURN_CONSUMER_ROUNDS { + self.infer_manager.clear(); + let mut fact_updates = Vec::with_capacity( + self.call_site_return_targets.len() + + self + .call_site_return_definition_refreshes + .values() + .map(Vec::len) + .sum::(), + ); + for (file_id, owner, expr, ret_idx) in &self.call_site_return_targets { + let cache = self.infer_manager.get_infer_cache(*file_id); + let fact = select_result_fact( + infer_expr_fact_with_cache(db, cache, expr.clone()), + *ret_idx, + ); + fact_updates.push((LuaInferenceNodeId::TypeOwner(owner.clone()), fact.clone())); + if let Some(definitions) = self.call_site_return_definition_refreshes.get(owner) { + for definition in definitions { + fact_updates + .push((LuaInferenceNodeId::Definition(*definition), fact.clone())); + } } } + if db.publish_inference_facts(fact_updates).is_empty() { + break; + } } - db.publish_inference_facts(fact_updates); - count + self.call_site_return_targets.len() } fn invalidate_inferred_returns_for_sources( From 1c4096363f7d6b89bc29ee709d34ec2a43d24766 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:25:08 +0100 Subject: [PATCH 021/159] fix: decl type going to whichever write lands first --- .../src/compilation/analyzer/common/mod.rs | 134 +++++++++++++++--- .../src/compilation/analyzer/lua/mod.rs | 1 + .../src/compilation/analyzer/lua/stats.rs | 117 ++++++++++++--- .../compilation/analyzer/unresolve/resolve.rs | 54 +++++-- .../src/db_index/type/mod.rs | 27 +++- .../src/db_index/type/type_owner.rs | 9 +- 6 files changed, 283 insertions(+), 59 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs index 03471c1b2..97eb72c12 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs @@ -4,12 +4,14 @@ pub(super) use migrate_global_member::{ migrate_global_members_when_type_resolve, migrate_global_path_members_when_owner_resolved, reconcile_directly_attached_candidate_members, reconcile_parked_global_path_members, }; -use rowan::TextRange; +use rowan::{TextRange, TextSize}; use crate::{ FileId, InFiled, LuaDeclId, LuaMemberId, LuaTypeCache, LuaTypeOwner, compilation::analyzer::lua::iterates_table_member_map, - db_index::{DbIndex, LuaMemberOwner, LuaType, LuaTypeDeclId, is_informative_type}, + db_index::{ + DbIndex, LuaMemberOwner, LuaType, LuaTypeDeclId, is_informative_type, is_undetermined_type, + }, }; /// Whether `typ` is a raw template placeholder inherited from a generic-for @@ -108,31 +110,12 @@ pub fn write_type_cache( pub fn bind_type( db: &mut DbIndex, type_owner: LuaTypeOwner, - mut type_cache: LuaTypeCache, + type_cache: LuaTypeCache, ) -> Option<()> { let decl_type_cache = db.get_type_index().get_type_cache(&type_owner); if decl_type_cache.is_none() { - // type backward - if type_cache.is_infer() - && let LuaTypeOwner::Decl(decl_id) = &type_owner - && let Some(decl_ref) = db - .get_reference_index() - .get_decl_references(&decl_id.file_id, decl_id) - && decl_ref.mutable - { - match &type_cache.as_type() { - LuaType::IntegerConst(_) => type_cache = LuaTypeCache::InferType(LuaType::Integer), - LuaType::StringConst(_) => type_cache = LuaTypeCache::InferType(LuaType::String), - LuaType::BooleanConst(_) => type_cache = LuaTypeCache::InferType(LuaType::Boolean), - LuaType::FloatConst(_) => type_cache = LuaTypeCache::InferType(LuaType::Number), - _ => {} - } - } - - db.get_type_index_mut() - .bind_type(type_owner.clone(), type_cache); - migrate_global_members_when_type_resolve(db, type_owner); + seed_type_slot(db, type_owner, type_cache); } else { let decl_type_cache = decl_type_cache?; let decl_type = decl_type_cache.as_type(); @@ -148,6 +131,111 @@ pub fn bind_type( Some(()) } +/// Seeds a type owner that holds nothing yet, widening a mutable declaration's +/// literal to its base type on the way in. +fn seed_type_slot(db: &mut DbIndex, type_owner: LuaTypeOwner, mut type_cache: LuaTypeCache) { + if type_cache.is_infer() + && let LuaTypeOwner::Decl(decl_id) = &type_owner + && let Some(decl_ref) = db + .get_reference_index() + .get_decl_references(&decl_id.file_id, decl_id) + && decl_ref.mutable + { + match &type_cache.as_type() { + LuaType::IntegerConst(_) => type_cache = LuaTypeCache::InferType(LuaType::Integer), + LuaType::StringConst(_) => type_cache = LuaTypeCache::InferType(LuaType::String), + LuaType::BooleanConst(_) => type_cache = LuaTypeCache::InferType(LuaType::Boolean), + LuaType::FloatConst(_) => type_cache = LuaTypeCache::InferType(LuaType::Number), + _ => {} + } + } + + db.get_type_index_mut() + .force_bind_type(type_owner.clone(), type_cache); + migrate_global_members_when_type_resolve(db, type_owner); +} + +/// Where a write to a declaration came from, for [`bind_decl_write`]. +#[derive(Clone, Copy)] +pub struct DeclWrite { + /// Source position of the writing statement. + pub position: TextSize, + /// Whether the right-hand side is one whose answer can still improve — a + /// call or index read, or an operator over one. Only those may fill in a + /// declaration that nothing has determined yet. + pub may_improve_after_resolve: bool, + /// Whether the right-hand side reads out of the declaration it writes to + /// (`width = bit.bor(width:byte(1), ...)`). Such a write derives its type + /// from the slot it is about to fill, so it must not fill it. + pub reads_out_of_decl: bool, +} + +/// Binds a decl type written by the statement at `write.position`. +/// +/// An empty decl slot otherwise goes to whichever write reaches it first, and a +/// write whose right-hand side could not be inferred during the file walk +/// reaches it late — so the decl's type depended on which callees the batch had +/// already resolved rather than on the source. Ordering the claim by source +/// position makes both arrival orders agree on the same answer: the earliest +/// writer owns the decl, except that a write which determined nothing never +/// takes the slot back from a later one that did. +pub fn bind_decl_write( + db: &mut DbIndex, + decl_id: LuaDeclId, + type_cache: LuaTypeCache, + write: DeclWrite, +) -> Option<()> { + let DeclWrite { + position, + may_improve_after_resolve, + reads_out_of_decl, + } = write; + let type_owner = LuaTypeOwner::Decl(decl_id); + // A parameter's type is its declared or call-site-inferred type; the writes + // in the body narrow it for flow analysis, they do not own it. Only a local + // has a "first writer" to order. + if db + .get_decl_index() + .get_decl(&decl_id) + .is_none_or(|decl| decl.is_param()) + { + return bind_type(db, type_owner, type_cache); + } + let seeds = match db.get_type_index().get_type_cache(&type_owner) { + None => true, + Some(existing) => { + let undetermined = is_undetermined_type(type_cache.as_type()); + let both_inferred = existing.is_infer() && type_cache.is_infer(); + if both_inferred + && may_improve_after_resolve + && !reads_out_of_decl + && !undetermined + && is_undetermined_type(existing.as_type()) + { + // The slot holds an inferred give-up answer and this write + // determined something from a right-hand side the walk already + // treats as improvable (`should_retry_uninformative_initializer`). + // Applying that here too keeps the answer the same whether the + // write was committed during the walk or deferred to this pass. + true + } else { + db.get_type_index() + .decl_write_claim(&decl_id) + .is_some_and(|claimed| position < claimed) + && !(undetermined && is_informative_type(existing.as_type())) + && !existing.supersedes(&type_cache) + } + } + }; + if !seeds { + return bind_type(db, type_owner, type_cache); + } + db.get_type_index_mut() + .record_decl_write_claim(decl_id, position); + seed_type_slot(db, type_owner, type_cache); + Some(()) +} + /// Binds a type produced by the unresolve/resolution pass. /// /// Resolved caches share the same uninformative inferred-type replacement test diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs index 619dad30f..ccb111c4d 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs @@ -27,6 +27,7 @@ use module::analyze_chunk_return; pub use module::compute_module_semantic_id; pub(in crate::compilation::analyzer) use settled_contributions::rederive_contributed_member_assignments; pub(crate) use stats::dominating_guarded_table_bootstrap_range; +pub(crate) use stats::expr_reads_out_of_decl; use stats::{ analyze_assign_stat, analyze_func_stat, analyze_local_func_stat, analyze_local_stat, analyze_table_field, flush_pending_dynamic_key_collection_widenings, diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index c75a30023..5980e8e5a 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -5,8 +5,8 @@ use crate::{ LuaTypeCache, LuaTypeOwner, LuaUnionType, TypeOps, compilation::analyzer::{ common::{ - TypeCacheWriteMode, add_member, bind_type, holds_unbound_iter_template, - reads_settling_iter_var, write_type_cache, + DeclWrite, TypeCacheWriteMode, add_member, bind_decl_write, bind_type, + holds_unbound_iter_template, reads_settling_iter_var, write_type_cache, }, gmod::name_expr_resolves_to_scoped_authoring_table, unresolve::{UnResolveDecl, UnResolveMember}, @@ -198,10 +198,20 @@ pub fn analyze_local_stat(analyzer: &mut LuaAnalyzer, local_stat: LuaLocalStat) } let retry_uninformative = should_retry_uninformative_initializer(&expr, &expr_type); - bind_type( + bind_decl_write( analyzer.db, - decl_id.into(), + decl_id, LuaTypeCache::InferType(expr_type), + DeclWrite { + position: expr.get_position(), + may_improve_after_resolve: may_improve_after_resolve(&expr), + reads_out_of_decl: expr_reads_out_of_decl( + analyzer.db, + analyzer.file_id, + decl_id, + &expr, + ), + }, ); if retry_uninformative { let unresolve = UnResolveDecl { @@ -1036,7 +1046,19 @@ pub fn analyze_assign_stat(analyzer: &mut LuaAnalyzer, assign_stat: LuaAssignSta member_assignment_or_source_type(analyzer, &type_owner, expr, expr_type); widen_existing_member_collection_type(analyzer, &var, &expr_type); - assign_merge_type_owner_and_expr_type(analyzer, type_owner.clone(), &expr_type, 0, false); + assign_merge_type_owner_and_expr_type( + analyzer, + type_owner.clone(), + &expr_type, + 0, + false, + DeclWrite { + position: expr.get_position(), + may_improve_after_resolve: may_improve_after_resolve(expr), + reads_out_of_decl: matches!(&type_owner, LuaTypeOwner::Decl(decl_id) + if expr_reads_out_of_decl(analyzer.db, analyzer.file_id, *decl_id, expr)), + }, + ); // The member is only homed onto its owner above, so the sibling guards // this one shares a table with are not visible until here. if let LuaTypeOwner::Member(member_id) = &type_owner @@ -1060,10 +1082,21 @@ pub fn analyze_assign_stat(analyzer: &mut LuaAnalyzer, assign_stat: LuaAssignSta set_index_expr_owner(analyzer, var.clone()); assign_merge_type_owner_and_expr_type( analyzer, - type_owner, + type_owner.clone(), &last_expr_type, i - expr_count + 1, false, + DeclWrite { + position: last_expr.get_position(), + may_improve_after_resolve: may_improve_after_resolve(last_expr), + reads_out_of_decl: matches!(&type_owner, LuaTypeOwner::Decl(decl_id) + if expr_reads_out_of_decl( + analyzer.db, + analyzer.file_id, + *decl_id, + last_expr, + )), + }, ); } } else { @@ -1073,10 +1106,21 @@ pub fn analyze_assign_stat(analyzer: &mut LuaAnalyzer, assign_stat: LuaAssignSta set_index_expr_owner(analyzer, var.clone()); assign_merge_type_owner_and_expr_type( analyzer, - type_owner, + type_owner.clone(), &LuaType::Any, 0, // Any doesn't need indexing false, + DeclWrite { + position: last_expr.get_position(), + may_improve_after_resolve: may_improve_after_resolve(last_expr), + reads_out_of_decl: matches!(&type_owner, LuaTypeOwner::Decl(decl_id) + if expr_reads_out_of_decl( + analyzer.db, + analyzer.file_id, + *decl_id, + last_expr, + )), + }, ); } } @@ -1434,6 +1478,10 @@ fn is_call_or_index_expr(expr: &LuaExpr) -> bool { crate::compilation::analyzer::initializer_reads_through_call_or_index(expr) } +fn may_improve_after_resolve(expr: &LuaExpr) -> bool { + crate::compilation::analyzer::initializer_may_improve_after_resolve(expr) +} + /// Whether an initializer that inferred to a type carrying no information /// has to be queued for the unresolve pass as well as committed here. fn should_retry_uninformative_initializer(expr: &LuaExpr, expr_type: &LuaType) -> bool { @@ -1545,8 +1593,13 @@ fn should_defer_pending_local_alias( /// bit.bor(bit.lshift(width:byte(1), 24), ...)`). Depth does not change the /// self-contradiction — the value still cannot be the decl's lifetime type, /// because it was computed from a read that type would reject. -fn expr_reads_out_of_decl(analyzer: &LuaAnalyzer, decl_id: LuaDeclId, expr: &LuaExpr) -> bool { - if index_chain_roots_at_decl(analyzer, decl_id, expr) { +pub(crate) fn expr_reads_out_of_decl( + db: &DbIndex, + file_id: crate::FileId, + decl_id: LuaDeclId, + expr: &LuaExpr, +) -> bool { + if index_chain_roots_at_decl(db, file_id, decl_id, expr) { return true; } @@ -1558,11 +1611,16 @@ fn expr_reads_out_of_decl(analyzer: &LuaAnalyzer, decl_id: LuaDeclId, expr: &Lua .any(|closure| expr_range.contains_range(closure.get_range())) }) .any(|index_expr| { - index_chain_roots_at_decl(analyzer, decl_id, &LuaExpr::IndexExpr(index_expr)) + index_chain_roots_at_decl(db, file_id, decl_id, &LuaExpr::IndexExpr(index_expr)) }) } -fn index_chain_roots_at_decl(analyzer: &LuaAnalyzer, decl_id: LuaDeclId, expr: &LuaExpr) -> bool { +fn index_chain_roots_at_decl( + db: &DbIndex, + file_id: crate::FileId, + decl_id: LuaDeclId, + expr: &LuaExpr, +) -> bool { let mut current = expr.clone(); loop { match current { @@ -1571,10 +1629,9 @@ fn index_chain_roots_at_decl(analyzer: &LuaAnalyzer, decl_id: LuaDeclId, expr: & None => return false, }, LuaExpr::NameExpr(name_expr) => { - return analyzer - .db + return db .get_reference_index() - .get_local_reference(&analyzer.file_id) + .get_local_reference(&file_id) .and_then(|file_ref| file_ref.get_decl_id(&name_expr.get_range())) == Some(decl_id); } @@ -1599,7 +1656,7 @@ fn seeds_empty_decl_from_own_read( .get_type_index() .get_type_cache(type_owner) .is_none() - && expr_reads_out_of_decl(analyzer, *decl_id, expr) + && expr_reads_out_of_decl(analyzer.db, analyzer.file_id, *decl_id, expr) } fn add_unresolve_for_assignment( @@ -1616,7 +1673,7 @@ fn add_unresolve_for_assignment( // slot is empty until one of the file's deferred writes // resolves, and `bind_type` has no acceptance rule for an empty // slot, so whichever lands first owns the decl's lifetime type. - if expr_reads_out_of_decl(analyzer, decl_id, &expr) { + if expr_reads_out_of_decl(analyzer.db, analyzer.file_id, decl_id, &expr) { return; } @@ -1673,6 +1730,7 @@ fn assign_merge_type_owner_and_expr_type( expr_type: &LuaType, idx: usize, preserve_table_literals: bool, + write: DeclWrite, ) -> Option<()> { let mut expr_type = expr_type.clone(); if let LuaType::Variadic(multi) = expr_type { @@ -1774,11 +1832,23 @@ fn assign_merge_type_owner_and_expr_type( expr_type = merge_open_table_types(analyzer.db, vec![expr_type]); } - bind_type( - analyzer.db, - type_owner.clone(), - LuaTypeCache::InferType(expr_type.clone()), - ); + match &type_owner { + LuaTypeOwner::Decl(decl_id) => { + bind_decl_write( + analyzer.db, + *decl_id, + LuaTypeCache::InferType(expr_type.clone()), + write, + ); + } + _ => { + bind_type( + analyzer.db, + type_owner.clone(), + LuaTypeCache::InferType(expr_type.clone()), + ); + } + } if let LuaTypeOwner::Member(member_id) = &type_owner && is_assignment_file_define_member(analyzer.db, *member_id) @@ -2842,6 +2912,11 @@ fn special_assign_pattern( &expr_type, 0, guarded_table_expr, + DeclWrite { + position: assign_stat_range.start(), + may_improve_after_resolve: false, + reads_out_of_decl: false, + }, ); } Err(_) => return None, 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 72c4c73bc..25165d636 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,7 @@ use crate::{ snapshot_callback_table_type, }, common::{ - TypeCacheWriteMode, add_member, bind_resolved_type, bind_type, + DeclWrite, TypeCacheWriteMode, add_member, bind_decl_write, bind_resolved_type, holds_unbound_iter_template, write_type_cache, }, lua::{ @@ -163,19 +163,38 @@ pub fn try_resolve_decl( return Err(InferFailReason::UnResolveIterTemplate); } - // Narrowing an uninformative decl cache is reserved for a right-hand side - // whose answer can still improve — a call or index read, or an operator over - // one: that is the boundary both routes into this pass enforce before they - // queue an item (`should_retry_uninformative_initializer`, - // `should_retry_narrowing_decl_assignment`). A write that landed here only - // because its right-hand side could not be inferred while its file was - // walked arrives without that check, so applying the narrowing policy to it - // let any shape overwrite an authoritative `any` — but only in the builds - // where the inference happened to fail. - if crate::compilation::analyzer::initializer_may_improve_after_resolve(&expr) { + // `bind_resolved_type` displaces an uninformative cache; the plain bind + // keeps it. Which one a write gets has to follow the write's shape, not the + // batch. An initializer is the decl's own value, so it may narrow from any + // right-hand side whose answer can still improve. An assignment may only + // narrow from a call or index read — that is the boundary the file walk + // enforces in `should_retry_narrowing_decl_assignment` before it queues one. + // An assignment that landed here only because its right-hand side could not + // be inferred yet arrives without that check, and whether the walk's + // inference failed is a property of the batch: once the callee's return + // resolves the same assignment infers cleanly and the walk refuses the + // narrowing outright. + if decl_expr_is_initializer(db, decl_id, &expr) + && crate::compilation::analyzer::initializer_may_improve_after_resolve(&expr) + { bind_resolved_type(db, decl_id.into(), LuaTypeCache::InferType(expr_type)); } else { - bind_type(db, decl_id.into(), LuaTypeCache::InferType(expr_type)); + bind_decl_write( + db, + decl_id, + LuaTypeCache::InferType(expr_type), + DeclWrite { + position: expr.get_position(), + may_improve_after_resolve: + crate::compilation::analyzer::initializer_may_improve_after_resolve(&expr), + reads_out_of_decl: crate::compilation::analyzer::lua::expr_reads_out_of_decl( + db, + decl.file_id, + decl_id, + &expr, + ), + }, + ); } Ok(()) } @@ -239,6 +258,17 @@ fn create_deferred_index_expr_member( Some(()) } +/// Whether `expr` is the declaration's own initializer rather than a later +/// assignment to it. +fn decl_expr_is_initializer(db: &DbIndex, decl_id: LuaDeclId, expr: &LuaExpr) -> bool { + db.get_decl_index() + .get_decl(&decl_id) + .and_then(|decl| decl.get_initializer()) + .is_some_and(|initializer| { + initializer.get_expr_syntax_id() == glua_parser::LuaSyntaxId::from_node(expr.syntax()) + }) +} + fn should_defer_guarded_index_alias_resolution( db: &DbIndex, cache: &mut LuaInferCache, diff --git a/crates/glua_code_analysis/src/db_index/type/mod.rs b/crates/glua_code_analysis/src/db_index/type/mod.rs index 095bd0bdd..6423d6e54 100644 --- a/crates/glua_code_analysis/src/db_index/type/mod.rs +++ b/crates/glua_code_analysis/src/db_index/type/mod.rs @@ -10,7 +10,8 @@ mod types; use super::traits::LuaIndex; use crate::{ - DbIndex, FileId, InFiled, LuaMemberOwner, db_index::r#type::type_decl::LuaTypeIdentifier, + DbIndex, FileId, InFiled, LuaDeclId, LuaMemberOwner, + db_index::r#type::type_decl::LuaTypeIdentifier, }; pub use generic_param::GenericParam; pub use humanize_type::{ @@ -18,13 +19,14 @@ pub use humanize_type::{ humanize_type, }; pub use inference_fact::*; -use rowan::TextRange; +use rowan::{TextRange, TextSize}; // The type index is the hottest hashing site in the analyzer: `LuaTypeOwner` // hashing alone was 3.9% of all CPU under the default SipHash. use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use std::sync::Arc; pub use type_decl::{LuaDeclLocation, LuaDeclTypeKind, LuaTypeDecl, LuaTypeDeclId, LuaTypeFlag}; pub use type_ops::TypeOps; +pub(crate) use type_owner::is_undetermined_type; pub use type_owner::{LuaTypeCache, LuaTypeOwner, is_informative_type}; pub use type_visit_trait::TypeVisitTrait; pub use types::*; @@ -594,6 +596,9 @@ pub struct LuaTypeIndex { cache_refs: TypeCacheRefIndex, in_filed_type_owner: HashMap>, fact_metadata: HashMap, + /// Source position of the write whose type each decl currently holds. See + /// [`LuaTypeIndex::bind_decl_write`]. + decl_write_claims: HashMap, definition_facts: HashMap, inference_events_by_file: HashMap>, support_file_dependents: HashMap>, @@ -618,6 +623,7 @@ impl LuaTypeIndex { cache_refs: TypeCacheRefIndex::default(), in_filed_type_owner: HashMap::default(), fact_metadata: HashMap::default(), + decl_write_claims: HashMap::default(), definition_facts: HashMap::default(), inference_events_by_file: HashMap::default(), support_file_dependents: HashMap::default(), @@ -931,6 +937,19 @@ impl LuaTypeIndex { { return; } + self.commit_type_cache(owner, cache); + } + + /// Source position of the earliest write that has seeded `decl_id`'s type. + pub fn decl_write_claim(&self, decl_id: &LuaDeclId) -> Option { + self.decl_write_claims.get(decl_id).copied() + } + + pub fn record_decl_write_claim(&mut self, decl_id: LuaDeclId, position: TextSize) { + self.decl_write_claims.insert(decl_id, position); + } + + fn commit_type_cache(&mut self, owner: LuaTypeOwner, cache: LuaTypeCache) { let file_id = owner.get_file_id(); let replaced = self.insert_type_cache(owner.clone(), cache); self.in_filed_type_owner @@ -1400,6 +1419,7 @@ impl LuaIndex for LuaTypeIndex { self.cache_refs = TypeCacheRefIndex::default(); self.in_filed_type_owner.clear(); self.fact_metadata.clear(); + self.decl_write_claims.clear(); self.definition_facts.clear(); self.inference_events_by_file.clear(); self.support_file_dependents.clear(); @@ -1435,6 +1455,9 @@ impl LuaTypeIndex { if let Some(type_owners) = self.in_filed_type_owner.remove(&file_id) { for type_owner in type_owners { + if let LuaTypeOwner::Decl(decl_id) = &type_owner { + self.decl_write_claims.remove(decl_id); + } self.types.remove(&type_owner); self.fact_metadata.remove(&type_owner); } diff --git a/crates/glua_code_analysis/src/db_index/type/type_owner.rs b/crates/glua_code_analysis/src/db_index/type/type_owner.rs index 059807f62..51c42cb41 100644 --- a/crates/glua_code_analysis/src/db_index/type/type_owner.rs +++ b/crates/glua_code_analysis/src/db_index/type/type_owner.rs @@ -109,6 +109,7 @@ impl LuaTypeCache { } const NIL_RANK: u8 = 1; +const UNKNOWN_RANK: u8 = 2; /// Rank within the "carries no type information" band, ordered by how much /// the value could be: `never` (nothing) through `any` (anything). `None` @@ -118,7 +119,7 @@ pub(crate) fn uninformative_rank(typ: &LuaType) -> Option { match typ { LuaType::Never => Some(0), LuaType::Nil => Some(NIL_RANK), - LuaType::Unknown => Some(2), + LuaType::Unknown => Some(UNKNOWN_RANK), LuaType::Any => Some(3), LuaType::Union(union) => union .types() @@ -165,6 +166,12 @@ pub(crate) fn is_bottom_type(typ: &LuaType) -> bool { uninformative_rank(typ).is_some_and(|rank| rank <= NIL_RANK) } +/// Whether `typ` records that no value could be determined — `never`, `nil` or +/// `unknown` — as opposed to `any`, which states that any value is allowed. +pub(crate) fn is_undetermined_type(typ: &LuaType) -> bool { + uninformative_rank(typ).is_some_and(|rank| rank <= UNKNOWN_RANK) +} + /// Whether `typ` says anything about the value. The single authoritative /// definition of "informative"; everything else derives from /// [`uninformative_rank`]. From 50c87c8ba0ea113e1a15f5f79c8ab117a5eef973 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:51:39 +0100 Subject: [PATCH 022/159] fix: unresolve wave reusing narrowing from before a type settled --- .../compilation/analyzer/infer_cache_manager.rs | 6 ++++++ .../src/compilation/analyzer/unresolve/mod.rs | 11 +++++++++++ .../glua_code_analysis/src/db_index/type/mod.rs | 17 +++++++++++++++++ .../src/semantic/cache/mod.rs | 9 +++++---- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/infer_cache_manager.rs b/crates/glua_code_analysis/src/compilation/analyzer/infer_cache_manager.rs index 33ab896ad..fe4984c2a 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/infer_cache_manager.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/infer_cache_manager.rs @@ -119,6 +119,12 @@ impl InferCacheManager { } } + pub fn clear_file_deferred_results(&mut self, file_id: FileId) { + if let Some(infer_cache) = self.infer_map.get_mut(&file_id) { + infer_cache.clear_deferred_inference_results(); + } + } + pub fn clear_files_deferred_results(&mut self, file_ids: &HashSet) { for file_id in file_ids { if let Some(infer_cache) = self.infer_map.get_mut(file_id) { diff --git a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs index ad60435a5..5b5c8f344 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs @@ -473,7 +473,18 @@ fn try_resolve( for mut unresolve in unresolves.drain(..) { let file_id = unresolve.get_file_id().unwrap_or(FileId { id: 0 }); let attempt_start = profile_enabled.then(std::time::Instant::now); + let writes_before = db.get_type_index().type_writes(); let resolve_result = attempt_resolve(db, infer_manager, file_id, &mut unresolve); + // A resolution that moved a type invalidates every inference + // memoised against the old one, narrowing included. The + // end-of-wave purge is too late for the items still to be + // drained here: they would read the pre-resolve value, and + // whether a reader shares a wave with its writer is a property + // of the batch rather than of the source. + if db.get_type_index().type_writes() != writes_before { + infer_manager.clear_file_deferred_results(file_id); + retry_file_ids.insert(file_id); + } let cache = infer_manager.get_infer_cache(file_id); if let (Some(profile), Some(attempt_start)) = (profile.as_mut(), attempt_start) { profile.record_attempt( diff --git a/crates/glua_code_analysis/src/db_index/type/mod.rs b/crates/glua_code_analysis/src/db_index/type/mod.rs index 6423d6e54..9543d6ae4 100644 --- a/crates/glua_code_analysis/src/db_index/type/mod.rs +++ b/crates/glua_code_analysis/src/db_index/type/mod.rs @@ -599,6 +599,9 @@ pub struct LuaTypeIndex { /// Source position of the write whose type each decl currently holds. See /// [`LuaTypeIndex::bind_decl_write`]. decl_write_claims: HashMap, + /// Counts stored types that actually moved, so a caller can tell a no-op + /// write from one that invalidates memoised inference. + type_writes: u64, definition_facts: HashMap, inference_events_by_file: HashMap>, support_file_dependents: HashMap>, @@ -624,6 +627,7 @@ impl LuaTypeIndex { in_filed_type_owner: HashMap::default(), fact_metadata: HashMap::default(), decl_write_claims: HashMap::default(), + type_writes: 0, definition_facts: HashMap::default(), inference_events_by_file: HashMap::default(), support_file_dependents: HashMap::default(), @@ -940,6 +944,12 @@ impl LuaTypeIndex { self.commit_type_cache(owner, cache); } + /// See [`LuaTypeIndex::type_writes`]. Compare it across an operation to + /// learn whether that operation moved any stored type. + pub fn type_writes(&self) -> u64 { + self.type_writes + } + /// Source position of the earliest write that has seeded `decl_id`'s type. pub fn decl_write_claim(&self, decl_id: &LuaDeclId) -> Option { self.decl_write_claims.get(decl_id).copied() @@ -1075,6 +1085,13 @@ impl LuaTypeIndex { /// Stores `cache`, keeping [`Self::cache_refs`] in step, and reports /// whether a cache was replaced. fn insert_type_cache(&mut self, owner: LuaTypeOwner, cache: LuaTypeCache) -> bool { + if self + .types + .get(&owner) + .is_none_or(|existing| existing.as_type() != cache.as_type()) + { + self.type_writes += 1; + } let file_id = owner.get_file_id(); self.cache_refs.add(file_id, cache.as_type()); let Some(previous) = self.types.insert(owner, cache) else { diff --git a/crates/glua_code_analysis/src/semantic/cache/mod.rs b/crates/glua_code_analysis/src/semantic/cache/mod.rs index 6c3a34e69..334c8d0b3 100644 --- a/crates/glua_code_analysis/src/semantic/cache/mod.rs +++ b/crates/glua_code_analysis/src/semantic/cache/mod.rs @@ -273,10 +273,11 @@ impl LuaInferCache { // A resolved signature return is exactly what turns this answer from // `false` to `true`, so it cannot survive a wave. self.call_returns_never_cache.clear(); - self.flow_node_cache.retain(|_, inner| { - inner.retain(|_, entry| !matches!(entry, CacheEntry::Error(_))); - !inner.is_empty() - }); + // A *successful* flow answer is stale too: narrowing a name reads the + // declaration's type, so an answer derived before that declaration + // settled keeps the unsettled value for the rest of the build. + self.flow_node_cache.clear(); + self.flow_query_realm = None; self.param_type_cache .retain(|_, entry| !matches!(entry, CacheEntry::Error(_))); self.param_type_source_cache From 89a2a4085a6b90e9e0fbce629a21c50c23f29cb1 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:26:13 +0100 Subject: [PATCH 023/159] perf: drop narrowing only when a value stops being undetermined --- .../analyzer/infer_cache_manager.rs | 6 ++ .../src/compilation/analyzer/unresolve/mod.rs | 57 +++++++++++++++++-- .../src/semantic/cache/mod.rs | 24 ++++++-- crates/glua_code_analysis/src/semantic/mod.rs | 5 +- 4 files changed, 81 insertions(+), 11 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/infer_cache_manager.rs b/crates/glua_code_analysis/src/compilation/analyzer/infer_cache_manager.rs index fe4984c2a..7d58c780f 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/infer_cache_manager.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/infer_cache_manager.rs @@ -125,6 +125,12 @@ impl InferCacheManager { } } + pub fn clear_file_flow_results(&mut self, file_id: FileId) { + if let Some(infer_cache) = self.infer_map.get_mut(&file_id) { + infer_cache.clear_flow_results(); + } + } + pub fn clear_files_deferred_results(&mut self, file_ids: &HashSet) { for file_id in file_ids { if let Some(infer_cache) = self.infer_map.get_mut(file_id) { diff --git a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs index 5b5c8f344..58b4fdf6a 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs @@ -403,6 +403,22 @@ fn attempt_resolve( } } +/// Whether the owner currently holds a type recording that no value was found. +fn root_type_is_undetermined(db: &DbIndex, root: &crate::semantic::VarRefCacheRootKey) -> bool { + let owner = match root { + crate::semantic::VarRefCacheRootKey::Decl(decl_id) + | crate::semantic::VarRefCacheRootKey::SelfRef(decl_id) => { + crate::LuaTypeOwner::Decl(*decl_id) + } + crate::semantic::VarRefCacheRootKey::Member(member_id) => { + crate::LuaTypeOwner::Member(*member_id) + } + }; + db.get_type_index() + .get_type_cache(&owner) + .is_none_or(|cache| crate::db_index::is_undetermined_type(cache.as_type())) +} + fn try_resolve( db: &mut DbIndex, infer_manager: &mut InferCacheManager, @@ -474,16 +490,34 @@ fn try_resolve( let file_id = unresolve.get_file_id().unwrap_or(FileId { id: 0 }); let attempt_start = profile_enabled.then(std::time::Instant::now); let writes_before = db.get_type_index().type_writes(); + let narrowing_root = unresolve.narrowing_root(); + let was_undetermined = narrowing_root + .as_ref() + .is_none_or(|root| root_type_is_undetermined(db, root)); let resolve_result = attempt_resolve(db, infer_manager, file_id, &mut unresolve); // A resolution that moved a type invalidates every inference - // memoised against the old one, narrowing included. The - // end-of-wave purge is too late for the items still to be - // drained here: they would read the pre-resolve value, and - // whether a reader shares a wave with its writer is a property - // of the batch rather than of the source. + // memoised against the old one. The end-of-wave purge is too + // late for the items still to be drained here: they would read + // the pre-resolve value, and whether a reader shares a wave + // with its writer is a property of the batch rather than of the + // source. if db.get_type_index().type_writes() != writes_before { infer_manager.clear_file_deferred_results(file_id); retry_file_ids.insert(file_id); + // Narrowing answers are the expensive half of that cache and + // are keyed by the variable they narrow, not by what the + // walk consulted, so there is no way to drop only the ones + // that read this owner. They go when the resolution turned a + // value the walk could have read as "not determined yet" + // into a known one — the transition that makes an answer + // derived from it wrong rather than merely older. + if was_undetermined + && narrowing_root + .as_ref() + .is_some_and(|root| !root_type_is_undetermined(db, root)) + { + infer_manager.clear_file_flow_results(file_id); + } } let cache = infer_manager.get_infer_cache(file_id); if let (Some(profile), Some(attempt_start)) = (profile.as_mut(), attempt_start) { @@ -894,6 +928,19 @@ impl UnResolve { } } + /// The declaration or member this item writes to, when narrowing can read + /// it. Used to tell whether a resolution settled a value the flow walk + /// could still have been reading as undetermined. + pub fn narrowing_root(&self) -> Option { + match self { + UnResolve::Decl(decl) => Some(crate::semantic::VarRefCacheRootKey::Decl(decl.decl_id)), + UnResolve::Member(member) => Some(crate::semantic::VarRefCacheRootKey::Member( + member.member_id, + )), + _ => None, + } + } + /// Returns a deterministic sort key (file_id, text_position) for stable ordering. /// This ensures unresolves are processed in a consistent order regardless of /// HashMap iteration order or other non-deterministic sources during collection. diff --git a/crates/glua_code_analysis/src/semantic/cache/mod.rs b/crates/glua_code_analysis/src/semantic/cache/mod.rs index 334c8d0b3..9e4af9687 100644 --- a/crates/glua_code_analysis/src/semantic/cache/mod.rs +++ b/crates/glua_code_analysis/src/semantic/cache/mod.rs @@ -264,6 +264,21 @@ impl LuaInferCache { self.call_returns_never_cache.clear(); } + /// Drops every narrowing answer this file has memoised. + /// + /// A *successful* flow answer survives + /// [`Self::clear_deferred_inference_results`], and narrowing a name reads + /// the type of whatever it was derived from — so once a value the walk read + /// as "not determined yet" becomes known, every answer that read it is + /// void, whichever variable it happens to be keyed under. There is no way + /// to drop only the ones that read it: the key names what was narrowed, not + /// what the narrowing consulted. + pub fn clear_flow_results(&mut self) { + self.flow_node_cache.clear(); + self.flow_query_realm = None; + self.index_ref_origin_type_cache.clear(); + } + /// Discards the inference a wave of deferred resolution can have /// invalidated. pub fn clear_deferred_inference_results(&mut self) { @@ -273,11 +288,10 @@ impl LuaInferCache { // A resolved signature return is exactly what turns this answer from // `false` to `true`, so it cannot survive a wave. self.call_returns_never_cache.clear(); - // A *successful* flow answer is stale too: narrowing a name reads the - // declaration's type, so an answer derived before that declaration - // settled keeps the unsettled value for the rest of the build. - self.flow_node_cache.clear(); - self.flow_query_realm = None; + self.flow_node_cache.retain(|_, inner| { + inner.retain(|_, entry| !matches!(entry, CacheEntry::Error(_))); + !inner.is_empty() + }); self.param_type_cache .retain(|_, entry| !matches!(entry, CacheEntry::Error(_))); self.param_type_source_cache diff --git a/crates/glua_code_analysis/src/semantic/mod.rs b/crates/glua_code_analysis/src/semantic/mod.rs index cb549d54e..59c365536 100644 --- a/crates/glua_code_analysis/src/semantic/mod.rs +++ b/crates/glua_code_analysis/src/semantic/mod.rs @@ -20,7 +20,10 @@ use std::sync::{Arc, Mutex, MutexGuard}; #[cfg(test)] pub(crate) use infer::narrow::get_type_at_flow::BASELINE_FLOW_WALKS; -pub use cache::{CacheEntry, CacheOptions, LuaAnalysisPhase, LuaInferCache, PendingStrTplTypeDecl}; +pub use cache::{ + CacheEntry, CacheOptions, LuaAnalysisPhase, LuaInferCache, PendingStrTplTypeDecl, + VarRefCacheRootKey, +}; pub use decl::{enum_variable_is_param, parse_require_module_info}; use glua_parser::{ LuaAssignStat, LuaAstNode, LuaAstToken, LuaCallExpr, LuaChunk, LuaClosureExpr, LuaDocType, From a8615839305e0a3bd80bb49bf4811c2b195cff91 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:01:31 +0100 Subject: [PATCH 024/159] fix: a late write not reclaiming the decl slot it should own --- .../src/compilation/analyzer/common/mod.rs | 118 ++++++++++++++---- .../src/compilation/analyzer/lua/stats.rs | 10 ++ .../compilation/analyzer/unresolve/resolve.rs | 66 +++++----- .../src/db_index/type/mod.rs | 22 ++-- 4 files changed, 150 insertions(+), 66 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs index 97eb72c12..23021c122 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs @@ -133,28 +133,42 @@ pub fn bind_type( /// Seeds a type owner that holds nothing yet, widening a mutable declaration's /// literal to its base type on the way in. -fn seed_type_slot(db: &mut DbIndex, type_owner: LuaTypeOwner, mut type_cache: LuaTypeCache) { - if type_cache.is_infer() - && let LuaTypeOwner::Decl(decl_id) = &type_owner - && let Some(decl_ref) = db - .get_reference_index() - .get_decl_references(&decl_id.file_id, decl_id) - && decl_ref.mutable - { - match &type_cache.as_type() { - LuaType::IntegerConst(_) => type_cache = LuaTypeCache::InferType(LuaType::Integer), - LuaType::StringConst(_) => type_cache = LuaTypeCache::InferType(LuaType::String), - LuaType::BooleanConst(_) => type_cache = LuaTypeCache::InferType(LuaType::Boolean), - LuaType::FloatConst(_) => type_cache = LuaTypeCache::InferType(LuaType::Number), - _ => {} - } - } - +fn seed_type_slot(db: &mut DbIndex, type_owner: LuaTypeOwner, type_cache: LuaTypeCache) { + let type_cache = widen_mutable_decl_literal(db, &type_owner, type_cache); db.get_type_index_mut() .force_bind_type(type_owner.clone(), type_cache); migrate_global_members_when_type_resolve(db, type_owner); } +/// A declaration written more than once holds a primitive over its lifetime, +/// not whichever literal one write happened to carry. +fn widen_mutable_decl_literal( + db: &DbIndex, + type_owner: &LuaTypeOwner, + type_cache: LuaTypeCache, +) -> LuaTypeCache { + if !type_cache.is_infer() { + return type_cache; + } + let LuaTypeOwner::Decl(decl_id) = type_owner else { + return type_cache; + }; + if !db + .get_reference_index() + .get_decl_references(&decl_id.file_id, decl_id) + .is_some_and(|decl_ref| decl_ref.mutable) + { + return type_cache; + } + match type_cache.as_type() { + LuaType::IntegerConst(_) => LuaTypeCache::InferType(LuaType::Integer), + LuaType::StringConst(_) => LuaTypeCache::InferType(LuaType::String), + LuaType::BooleanConst(_) => LuaTypeCache::InferType(LuaType::Boolean), + LuaType::FloatConst(_) => LuaTypeCache::InferType(LuaType::Number), + _ => type_cache, + } +} + /// Where a write to a declaration came from, for [`bind_decl_write`]. #[derive(Clone, Copy)] pub struct DeclWrite { @@ -168,6 +182,17 @@ pub struct DeclWrite { /// (`width = bit.bor(width:byte(1), ...)`). Such a write derives its type /// from the slot it is about to fill, so it must not fill it. pub reads_out_of_decl: bool, + /// Whether this write is one the file walk would let replace an + /// uninformative cache: an initializer whose answer can still improve, or + /// an assignment that reads through a call or index — the boundary + /// `should_retry_narrowing_decl_assignment` enforces. Used to replay the + /// acceptance rule a competing write would have faced, not to route this + /// one. + pub may_narrow_uninformative: bool, + /// Whether this write is the declaration's own initializer arriving from + /// the unresolve pass, which is the only route allowed to displace an + /// uninformative cache through [`bind_resolved_type`]. + pub resolved_initializer: bool, } /// Binds a decl type written by the statement at `write.position`. @@ -189,8 +214,17 @@ pub fn bind_decl_write( position, may_improve_after_resolve, reads_out_of_decl, + may_narrow_uninformative, + resolved_initializer, } = write; let type_owner = LuaTypeOwner::Decl(decl_id); + let fallback = |db: &mut DbIndex, type_cache| { + if resolved_initializer { + bind_resolved_type(db, type_owner.clone(), type_cache) + } else { + bind_type(db, type_owner.clone(), type_cache) + } + }; // A parameter's type is its declared or call-site-inferred type; the writes // in the body narrow it for flow analysis, they do not own it. Only a local // has a "first writer" to order. @@ -199,17 +233,17 @@ pub fn bind_decl_write( .get_decl(&decl_id) .is_none_or(|decl| decl.is_param()) { - return bind_type(db, type_owner, type_cache); + return fallback(db, type_cache); } + let seeded = widen_mutable_decl_literal(db, &type_owner, type_cache.clone()); let seeds = match db.get_type_index().get_type_cache(&type_owner) { None => true, Some(existing) => { - let undetermined = is_undetermined_type(type_cache.as_type()); let both_inferred = existing.is_infer() && type_cache.is_infer(); if both_inferred && may_improve_after_resolve && !reads_out_of_decl - && !undetermined + && !is_undetermined_type(seeded.as_type()) && is_undetermined_type(existing.as_type()) { // The slot holds an inferred give-up answer and this write @@ -219,23 +253,53 @@ pub fn bind_decl_write( // write was committed during the walk or deferred to this pass. true } else { - db.get_type_index() - .decl_write_claim(&decl_id) - .is_some_and(|claimed| position < claimed) - && !(undetermined && is_informative_type(existing.as_type())) - && !existing.supersedes(&type_cache) + match db.get_type_index().decl_write_claim(&decl_id) { + // Nothing else has taken the slot by source position, so + // whatever is in it was not put there by an ordered write. + None => false, + Some((claimed, claim_may_narrow)) => { + position < claimed + && !claiming_write_would_have_won( + &type_owner, + &seeded, + existing, + claim_may_narrow, + ) + } + } } } }; if !seeds { - return bind_type(db, type_owner, type_cache); + return fallback(db, type_cache); } db.get_type_index_mut() - .record_decl_write_claim(decl_id, position); + .record_decl_write_claim(decl_id, position, may_narrow_uninformative); seed_type_slot(db, type_owner, type_cache); Some(()) } +/// Replays the acceptance rule the slot's current holder would have faced had +/// this write reached the slot first, and reports whether it would still have +/// taken it. +/// +/// The rule depends on how that write was committed: a call or index read whose +/// target is uninformative goes through the unresolve pass and +/// [`bind_resolved_type`], which displaces it; anything else goes through +/// [`bind_type`], which keeps a decl's whole-lifetime type unless the incoming +/// one supersedes it. +fn claiming_write_would_have_won( + type_owner: &LuaTypeOwner, + seeded: &LuaTypeCache, + existing: &LuaTypeCache, + claim_may_narrow: bool, +) -> bool { + if claim_may_narrow && should_replace_uninformative_resolved_cache(seeded, existing) { + return true; + } + should_replace_uninformative_inferred_cache(type_owner, seeded, existing) +} + /// Binds a type produced by the unresolve/resolution pass. /// /// Resolved caches share the same uninformative inferred-type replacement test diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index 5980e8e5a..dc1dac088 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -211,6 +211,8 @@ pub fn analyze_local_stat(analyzer: &mut LuaAnalyzer, local_stat: LuaLocalStat) decl_id, &expr, ), + may_narrow_uninformative: may_improve_after_resolve(&expr), + resolved_initializer: false, }, ); if retry_uninformative { @@ -1057,6 +1059,8 @@ pub fn analyze_assign_stat(analyzer: &mut LuaAnalyzer, assign_stat: LuaAssignSta may_improve_after_resolve: may_improve_after_resolve(expr), reads_out_of_decl: matches!(&type_owner, LuaTypeOwner::Decl(decl_id) if expr_reads_out_of_decl(analyzer.db, analyzer.file_id, *decl_id, expr)), + may_narrow_uninformative: is_call_or_index_expr(expr), + resolved_initializer: false, }, ); // The member is only homed onto its owner above, so the sibling guards @@ -1096,6 +1100,8 @@ pub fn analyze_assign_stat(analyzer: &mut LuaAnalyzer, assign_stat: LuaAssignSta *decl_id, last_expr, )), + may_narrow_uninformative: is_call_or_index_expr(last_expr), + resolved_initializer: false, }, ); } @@ -1120,6 +1126,8 @@ pub fn analyze_assign_stat(analyzer: &mut LuaAnalyzer, assign_stat: LuaAssignSta *decl_id, last_expr, )), + may_narrow_uninformative: is_call_or_index_expr(last_expr), + resolved_initializer: false, }, ); } @@ -2916,6 +2924,8 @@ fn special_assign_pattern( position: assign_stat_range.start(), may_improve_after_resolve: false, reads_out_of_decl: false, + may_narrow_uninformative: false, + resolved_initializer: false, }, ); } 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 25165d636..5e5abbcec 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs @@ -163,39 +163,41 @@ pub fn try_resolve_decl( return Err(InferFailReason::UnResolveIterTemplate); } - // `bind_resolved_type` displaces an uninformative cache; the plain bind - // keeps it. Which one a write gets has to follow the write's shape, not the - // batch. An initializer is the decl's own value, so it may narrow from any - // right-hand side whose answer can still improve. An assignment may only - // narrow from a call or index read — that is the boundary the file walk - // enforces in `should_retry_narrowing_decl_assignment` before it queues one. - // An assignment that landed here only because its right-hand side could not - // be inferred yet arrives without that check, and whether the walk's - // inference failed is a property of the batch: once the callee's return - // resolves the same assignment infers cleanly and the walk refuses the - // narrowing outright. - if decl_expr_is_initializer(db, decl_id, &expr) - && crate::compilation::analyzer::initializer_may_improve_after_resolve(&expr) - { - bind_resolved_type(db, decl_id.into(), LuaTypeCache::InferType(expr_type)); - } else { - bind_decl_write( - db, - decl_id, - LuaTypeCache::InferType(expr_type), - DeclWrite { - position: expr.get_position(), - may_improve_after_resolve: - crate::compilation::analyzer::initializer_may_improve_after_resolve(&expr), - reads_out_of_decl: crate::compilation::analyzer::lua::expr_reads_out_of_decl( - db, - decl.file_id, - decl_id, - &expr, - ), + // Displacing an uninformative cache is the initializer's privilege: it is + // the decl's own value, so it may narrow from any right-hand side whose + // answer can still improve. An assignment may only narrow from a call or + // index read — the boundary the file walk enforces in + // `should_retry_narrowing_decl_assignment` before it queues one. An + // assignment that landed here only because its right-hand side could not be + // inferred yet arrives without that check, and whether the walk's inference + // failed is a property of the batch: once the callee's return resolves, the + // same assignment infers cleanly and the walk refuses the narrowing. + // + // Either way the write goes through the positional claim, so a write that + // resolved late can still take the slot back from one that ran ahead of it. + let may_improve = crate::compilation::analyzer::initializer_may_improve_after_resolve(&expr); + let is_initializer = decl_expr_is_initializer(db, decl_id, &expr); + bind_decl_write( + db, + decl_id, + LuaTypeCache::InferType(expr_type), + DeclWrite { + position: expr.get_position(), + may_improve_after_resolve: may_improve, + reads_out_of_decl: crate::compilation::analyzer::lua::expr_reads_out_of_decl( + db, + decl.file_id, + decl_id, + &expr, + ), + may_narrow_uninformative: if is_initializer { + may_improve + } else { + crate::compilation::analyzer::initializer_reads_through_call_or_index(&expr) }, - ); - } + resolved_initializer: is_initializer && may_improve, + }, + ); Ok(()) } diff --git a/crates/glua_code_analysis/src/db_index/type/mod.rs b/crates/glua_code_analysis/src/db_index/type/mod.rs index 9543d6ae4..8c5ddd60d 100644 --- a/crates/glua_code_analysis/src/db_index/type/mod.rs +++ b/crates/glua_code_analysis/src/db_index/type/mod.rs @@ -596,9 +596,10 @@ pub struct LuaTypeIndex { cache_refs: TypeCacheRefIndex, in_filed_type_owner: HashMap>, fact_metadata: HashMap, - /// Source position of the write whose type each decl currently holds. See - /// [`LuaTypeIndex::bind_decl_write`]. - decl_write_claims: HashMap, + /// For each decl whose type a write has seeded: that write's source + /// position, and whether its right-hand side was a call or index read. See + /// `bind_decl_write`. + decl_write_claims: HashMap, /// Counts stored types that actually moved, so a caller can tell a no-op /// write from one that invalidates memoised inference. type_writes: u64, @@ -950,13 +951,20 @@ impl LuaTypeIndex { self.type_writes } - /// Source position of the earliest write that has seeded `decl_id`'s type. - pub fn decl_write_claim(&self, decl_id: &LuaDeclId) -> Option { + /// The write that seeded `decl_id`'s type: its source position, and whether + /// its right-hand side read through a call or index. + pub fn decl_write_claim(&self, decl_id: &LuaDeclId) -> Option<(TextSize, bool)> { self.decl_write_claims.get(decl_id).copied() } - pub fn record_decl_write_claim(&mut self, decl_id: LuaDeclId, position: TextSize) { - self.decl_write_claims.insert(decl_id, position); + pub fn record_decl_write_claim( + &mut self, + decl_id: LuaDeclId, + position: TextSize, + reads_through_call_or_index: bool, + ) { + self.decl_write_claims + .insert(decl_id, (position, reads_through_call_or_index)); } fn commit_type_cache(&mut self, owner: LuaTypeOwner, cache: LuaTypeCache) { From 11e628e453cb52ee315f76b5b4589363b0dfd4aa Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:53:40 +0100 Subject: [PATCH 025/159] fix: guarded table bootstrap picked from a partial sibling set --- .../src/compilation/analyzer/lua/mod.rs | 1 + .../src/compilation/analyzer/lua/stats.rs | 75 +++++++++++++++++++ .../src/compilation/analyzer/mod.rs | 16 ++++ 3 files changed, 92 insertions(+) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs index ccb111c4d..6d033078c 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs @@ -28,6 +28,7 @@ pub use module::compute_module_semantic_id; pub(in crate::compilation::analyzer) use settled_contributions::rederive_contributed_member_assignments; pub(crate) use stats::dominating_guarded_table_bootstrap_range; pub(crate) use stats::expr_reads_out_of_decl; +pub(in crate::compilation::analyzer) use stats::resettle_guarded_table_bootstraps; use stats::{ analyze_assign_stat, analyze_func_stat, analyze_local_func_stat, analyze_local_stat, analyze_table_field, flush_pending_dynamic_key_collection_widenings, diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index dc1dac088..cf752c386 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -1782,6 +1782,7 @@ fn assign_merge_type_owner_and_expr_type( let dynamic_expr_key_member = is_dynamic_expr_key_member_assignment(analyzer, &type_owner); // What this write carries on its own, before any sibling merge widens it. let mut source_type = None; + let had_canonical_guarded_bootstrap = canonical_guarded_bootstrap.is_some(); if let Some(canonical) = canonical_guarded_bootstrap { expr_type = canonical; source_type = Some(expr_type.clone()); @@ -1863,6 +1864,15 @@ fn assign_merge_type_owner_and_expr_type( { let guarded_table_assignment = preserve_table_literals || is_guarded_table_assignment_member(analyzer.db, *member_id); + if guarded_table_assignment && !had_canonical_guarded_bootstrap { + // No canonical writer was found, which means fewer than two of them + // were indexed when this ran — a property of how far the batch has + // got. Re-derived once they have all landed. See + // `resettle_guarded_table_bootstraps`. + analyzer + .context + .record_settled_guarded_bootstrap_candidate(*member_id); + } let conditional_branch_assignment = is_member_assignment_in_conditional_branch(analyzer.db, *member_id); if !dynamic_expr_key_member { @@ -2653,6 +2663,71 @@ fn guarded_table_bootstrap_range( /// own literal instead makes a file that re-guards the namespace read its own /// empty table and lose whatever another file attached, so they resolve to the /// earliest writer's literal — the one that would have won at runtime. +/// Re-derives the literal each `x.y = x.y or {}` writer names, now that every +/// writer of the slot has landed. +/// +/// The canonical pick is the lowest-sorting writer, so a writer analysed before +/// its siblings existed either found no canonical at all (fewer than two were +/// indexed) or picked one that a later, lower-sorting writer displaces. Which of +/// those happened is a property of the batch, not of the source. +pub(in crate::compilation::analyzer) fn resettle_guarded_table_bootstraps( + db: &mut DbIndex, + candidates: Vec, +) { + // Every writer of one slot resolves to the same canonical literal, so the + // pick is made once per slot rather than once per writer. + let mut by_slot: FxHashMap<(LuaMemberOwner, LuaMemberKey), Vec> = + FxHashMap::default(); + for member_id in candidates { + let member_index = db.get_member_index(); + let Some(owner) = member_index.get_member_owner(&member_id).cloned() else { + continue; + }; + let Some(key) = member_index + .get_member(&member_id) + .map(|m| m.get_key().clone()) + else { + continue; + }; + by_slot.entry((owner, key)).or_default().push(member_id); + } + + let mut slots = by_slot.into_iter().collect::>(); + slots.sort_by_key(|((_, _), members)| { + members + .iter() + .map(|member_id| member_id_sort_key(*member_id)) + .min() + }); + + for (_, mut members) in slots { + members.sort_by_key(|member_id| member_id_sort_key(*member_id)); + members.dedup(); + let Some(canonical) = members + .first() + .and_then(|member_id| canonical_guarded_table_bootstrap_type(db, *member_id)) + else { + continue; + }; + for member_id in members { + let owner = LuaTypeOwner::Member(member_id); + if db + .get_type_index() + .get_type_cache(&owner) + .is_some_and(|cached| cached.is_doc() || cached.as_type() == &canonical) + { + continue; + } + write_type_cache( + db, + owner, + LuaTypeCache::InferType(canonical.clone()), + TypeCacheWriteMode::ForceOverwrite, + ); + } + } +} + fn canonical_guarded_table_bootstrap_type( db: &crate::DbIndex, member_id: LuaMemberId, diff --git a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs index 7d30e3880..5a99860d5 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs @@ -311,6 +311,14 @@ pub fn analyze(db: &mut DbIndex, need_analyzed_files: Vec>) { db.get_member_index_mut().settle_alias_contributed_slots(); } + { + let _p = Profile::new("resettle_guarded_table_bootstraps"); + lua::resettle_guarded_table_bootstraps( + db, + std::mem::take(&mut context.settled_guarded_bootstrap_candidates), + ); + } + // Net flows are collected last: the collector resolves wrappers through // signatures, receiver types and members, none of which exist yet when // the gmod pre-pass runs. See `GmodNetworkAnalysisPipeline`. @@ -1322,6 +1330,9 @@ pub struct AnalyzeContext { /// with the type each one actually assigned. See /// [`rewiden_settled_member_assignments`]. settled_member_widening_candidates: HashMap, + /// Guarded table bootstraps whose canonical writer was picked from an + /// incomplete sibling set. See `resettle_guarded_table_bootstraps`. + settled_guarded_bootstrap_candidates: Vec, call_site_return_invalidation_changed: bool, pub workspace_id: Option, } @@ -1350,6 +1361,7 @@ impl AnalyzeContext { early_member_owner_candidates: Vec::new(), settled_member_attach_candidates: Vec::new(), settled_member_widening_candidates: HashMap::new(), + settled_guarded_bootstrap_candidates: Vec::new(), call_site_return_invalidation_changed: false, workspace_id: None, } @@ -1381,6 +1393,10 @@ impl AnalyzeContext { /// Remembers an assignment whose widening skipped a sibling that had no type /// yet. The assigned type is kept as written, not as widened, so the settled /// pass can re-derive the merge instead of growing the partial answer. + pub(crate) fn record_settled_guarded_bootstrap_candidate(&mut self, member_id: LuaMemberId) { + self.settled_guarded_bootstrap_candidates.push(member_id); + } + pub(crate) fn record_settled_member_widening_candidate( &mut self, member_id: LuaMemberId, From f5038122d95cec7f14c0072749205ea503cec3c4 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:53:37 +0100 Subject: [PATCH 026/159] fix: a declared pass-through losing the definition it was given --- .../src/semantic/infer/infer_call/mod.rs | 79 ++++++++++++++++++- 1 file changed, 76 insertions(+), 3 deletions(-) diff --git a/crates/glua_code_analysis/src/semantic/infer/infer_call/mod.rs b/crates/glua_code_analysis/src/semantic/infer/infer_call/mod.rs index 1ff83f424..7c46bf6fb 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_call/mod.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_call/mod.rs @@ -1102,13 +1102,86 @@ fn specialize_return_aliases_for_call( func_ty: &LuaFunctionType, call_expr: &LuaCallExpr, ) -> Option> { - specialize_direct_param_return_alias_for_call(db, cache, signature, func_ty, call_expr).or_else( - || { + specialize_direct_param_return_alias_for_call(db, cache, signature, func_ty, call_expr) + .or_else(|| { specialize_class_name_param_return_alias_for_call( db, cache, signature, func_ty, call_expr, ) + }) + .or_else(|| restore_definition_through_return_alias(db, cache, func_ty, call_expr)) +} + +/// Gives back the definition a declared pass-through was handed. +/// +/// Binding a template parameter turns `Def(X)` into `Ref(X)`, so that a generic +/// function cannot claim to define the class it was merely given. A function +/// annotated `@[return_alias(n)]` says it returns argument `n` itself, and +/// `assert(FindMetaTable("Panel"))` is the shape that needs it: without this the +/// methods written on the result extend nothing, because only a `Def` does. +/// +/// Only the exact `Ref(X)` -> `Def(X)` step is restored, so a return the +/// annotation transformed — `std.NotNull` dropping `nil`, say — keeps its +/// transformation. +fn restore_definition_through_return_alias( + db: &DbIndex, + cache: &mut LuaInferCache, + func_ty: &LuaFunctionType, + call_expr: &LuaCallExpr, +) -> Option> { + // The alias may be the whole return or, where the function also passes the + // rest of its arguments back, the first of several. + let returned_id = match func_ty.get_ret() { + LuaType::Ref(returned_id) => returned_id.clone(), + LuaType::Variadic(variadic) => match variadic.get_type(0) { + Some(LuaType::Ref(returned_id)) => returned_id.clone(), + _ => return None, }, - ) + _ => return None, + }; + let signature_id = get_prefix_expr_signature_id(db, cache, call_expr)?; + let attribute = + crate::db_index::find_signature_attribute_use(db, signature_id, "return_alias")?; + let param = attribute + .get_param_by_name("param") + .or_else(|| attribute.args.first().and_then(|(_, typ)| typ.as_ref()))?; + let (LuaType::IntegerConst(param_idx) | LuaType::DocIntegerConst(param_idx)) = param else { + return None; + }; + let param_idx = usize::try_from(*param_idx).ok()?; + let args = call_expr + .get_args_list() + .map(|args| args.get_args().collect::>()) + .unwrap_or_default(); + let arg = call_arg_for_param(call_expr, func_ty, &args, param_idx)?; + let LuaType::Def(arg_id) = infer_expr(db, cache, arg).ok()? else { + return None; + }; + if arg_id != returned_id { + return None; + } + + let restored = match func_ty.get_ret() { + LuaType::Variadic(variadic) => match std::ops::Deref::deref(variadic) { + VariadicType::Multi(slots) => { + let mut slots = slots.clone(); + *slots.first_mut()? = LuaType::Def(arg_id); + LuaType::Variadic(VariadicType::Multi(slots).into()) + } + VariadicType::Base(_) => return None, + }, + _ => LuaType::Def(arg_id), + }; + + Some(Arc::new( + LuaFunctionType::new( + func_ty.get_async_state(), + func_ty.is_colon_define(), + func_ty.is_variadic(), + func_ty.get_params().to_vec(), + restored, + ) + .with_optional_params(func_ty.get_optional_params().to_vec()), + )) } fn specialize_direct_param_return_alias_for_call( From d2d3d3e4be4ea83f11b87cce7b189a6112333c56 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:53:42 +0100 Subject: [PATCH 027/159] fix: loop variables read from a partial member map --- .../analyzer/infer_cache_manager.rs | 8 ++ .../analyzer/lua/for_range_stat.rs | 6 + .../src/compilation/analyzer/mod.rs | 55 ++++++++- .../src/compilation/analyzer/unresolve/mod.rs | 4 +- .../compilation/analyzer/unresolve/resolve.rs | 48 +++++++- .../src/compilation/test/decl_test.rs | 111 ++++++++++++++++++ .../src/semantic/cache/mod.rs | 7 ++ 7 files changed, 235 insertions(+), 4 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/infer_cache_manager.rs b/crates/glua_code_analysis/src/compilation/analyzer/infer_cache_manager.rs index 7d58c780f..e813141bb 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/infer_cache_manager.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/infer_cache_manager.rs @@ -125,6 +125,14 @@ impl InferCacheManager { } } + pub fn clear_files_iter_var_results(&mut self, file_ids: &HashSet) { + for file_id in file_ids { + if let Some(infer_cache) = self.infer_map.get_mut(file_id) { + infer_cache.clear_iter_var_results(); + } + } + } + pub fn clear_file_flow_results(&mut self, file_id: FileId) { if let Some(infer_cache) = self.infer_map.get_mut(&file_id) { infer_cache.clear_flow_results(); diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/for_range_stat.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/for_range_stat.rs index 782be2eb6..3daab7abb 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/for_range_stat.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/for_range_stat.rs @@ -57,6 +57,9 @@ pub fn analyze_for_range_stat( iter_exprs: iter_exprs.clone(), iter_vars: var_name_list, }; + analyzer + .context + .record_settled_iter_var_candidate(unresolved.clone()); analyzer .context .add_unresolve(unresolved.into(), InferFailReason::UnResolveIterTemplate); @@ -81,6 +84,9 @@ pub fn analyze_for_range_stat( iter_vars: var_name_list, }; + analyzer + .context + .record_settled_iter_var_candidate(unresolved.clone()); analyzer .context .add_unresolve(unresolved.into(), reason.clone()); diff --git a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs index 5a99860d5..1613e21de 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs @@ -35,7 +35,7 @@ use glua_parser::{ }; use infer_cache_manager::InferCacheManager; use lua::LuaReturnPoint; -use unresolve::{UnResolve, UnResolveReturn}; +use unresolve::{UnResolve, UnResolveIterVar, UnResolveReturn}; /// Ceiling on [`AnalyzeContext::resolve_call_site_return_consumers`] rounds. /// The set converges in a handful of rounds on real workspaces; the bound only @@ -311,6 +311,14 @@ pub fn analyze(db: &mut DbIndex, need_analyzed_files: Vec>) { db.get_member_index_mut().settle_alias_contributed_slots(); } + // Only now is every member attached, so a loop that enumerates a table + // can be answered from the whole map rather than the part of it this + // batch had reached. + { + let _p = Profile::new("rederive_settled_iter_vars"); + rederive_settled_iter_vars(db, &mut context); + } + { let _p = Profile::new("resettle_guarded_table_bootstraps"); lua::resettle_guarded_table_bootstraps( @@ -465,6 +473,40 @@ fn rederive_settled_inferred_returns(db: &mut DbIndex, context: &mut AnalyzeCont changed } +/// Re-derives `for ... in pairs(t)` variable types that were read off `t`'s +/// member map or its declared field type. +/// +/// The unresolve wave runs before the settled member passes, so the loop sees +/// only what had been attached by then and can fall back to `any` where the +/// field's own annotation says otherwise. Taking the answer again once every +/// member has landed is both the deterministic and the more faithful one. +fn rederive_settled_iter_vars(db: &mut DbIndex, context: &mut AnalyzeContext) { + let mut candidates = std::mem::take(&mut context.settled_iter_var_candidates); + if candidates.is_empty() { + return; + } + candidates.sort_by_key(|iter_var| { + ( + iter_var.file_id, + iter_var + .iter_vars + .first() + .map(glua_parser::LuaAstToken::get_position), + ) + }); + + let files = candidates + .iter() + .map(|iter_var| iter_var.file_id) + .collect::>(); + context.infer_manager.clear_files_iter_var_results(&files); + + for mut iter_var in candidates { + let cache = context.infer_manager.get_infer_cache(iter_var.file_id); + let _ = unresolve::resolve_settled_iter_var(db, cache, &mut iter_var); + } +} + /// Re-derives member assignment widenings that ran against an incomplete /// set of sibling writers. fn rewiden_settled_member_assignments(db: &mut DbIndex, context: &mut AnalyzeContext) { @@ -1333,6 +1375,8 @@ pub struct AnalyzeContext { /// Guarded table bootstraps whose canonical writer was picked from an /// incomplete sibling set. See `resettle_guarded_table_bootstraps`. settled_guarded_bootstrap_candidates: Vec, + /// See [`AnalyzeContext::record_settled_iter_var_candidate`]. + settled_iter_var_candidates: Vec, call_site_return_invalidation_changed: bool, pub workspace_id: Option, } @@ -1362,6 +1406,7 @@ impl AnalyzeContext { settled_member_attach_candidates: Vec::new(), settled_member_widening_candidates: HashMap::new(), settled_guarded_bootstrap_candidates: Vec::new(), + settled_iter_var_candidates: Vec::new(), call_site_return_invalidation_changed: false, workspace_id: None, } @@ -1393,6 +1438,14 @@ impl AnalyzeContext { /// Remembers an assignment whose widening skipped a sibling that had no type /// yet. The assigned type is kept as written, not as widened, so the settled /// pass can re-derive the merge instead of growing the partial answer. + /// Remembers a `for ... in pairs(t)` whose variable types were read off + /// `t`'s member map. Which members were attached when it ran is a property + /// of how far the batch had got, so it is taken again once the settled + /// member passes have finished attaching them. + pub(crate) fn record_settled_iter_var_candidate(&mut self, iter_var: UnResolveIterVar) { + self.settled_iter_var_candidates.push(iter_var); + } + pub(crate) fn record_settled_guarded_bootstrap_candidate(&mut self, member_id: LuaMemberId) { self.settled_guarded_bootstrap_candidates.push(member_id); } diff --git a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs index 58b4fdf6a..02d11717b 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs @@ -32,7 +32,7 @@ use resolve_closure::{ }; pub(crate) use resolve::get_wrapped_callable_target_expr; -pub(crate) use resolve::{try_resolve_member, try_resolve_return_point}; +pub(crate) use resolve::{resolve_settled_iter_var, try_resolve_member, try_resolve_return_point}; pub use resolve_closure::extract_hook_name; pub use resolve_closure::{ resolve_gmod_hook_add_callback_doc_function, resolve_gmod_hook_callback_doc_function, @@ -1057,7 +1057,7 @@ impl From for UnResolve { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct UnResolveIterVar { pub file_id: FileId, pub iter_exprs: Vec, 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 5e5abbcec..3a4728e14 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs @@ -707,6 +707,26 @@ pub fn try_resolve_iter_var( db: &mut DbIndex, cache: &mut LuaInferCache, unresolve_iter_var: &mut UnResolveIterVar, +) -> ResolveResult { + try_resolve_iter_var_inner(db, cache, unresolve_iter_var, false) +} + +/// [`try_resolve_iter_var`] for the settled re-derivation, where the answer was +/// taken against the complete member map and so replaces whatever partial one a +/// wave left behind rather than only widening it. +pub fn resolve_settled_iter_var( + db: &mut DbIndex, + cache: &mut LuaInferCache, + unresolve_iter_var: &mut UnResolveIterVar, +) -> ResolveResult { + try_resolve_iter_var_inner(db, cache, unresolve_iter_var, true) +} + +fn try_resolve_iter_var_inner( + db: &mut DbIndex, + cache: &mut LuaInferCache, + unresolve_iter_var: &mut UnResolveIterVar, + settled: bool, ) -> ResolveResult { let iter_var_types = match infer_for_range_iter_expr_func(db, cache, &unresolve_iter_var.iter_exprs) { @@ -734,7 +754,12 @@ pub fn try_resolve_iter_var( let ret_type = TypeOps::Remove.apply(db, &ret_type, &LuaType::Nil); let owner: LuaTypeOwner = decl_id.into(); - let mode = iter_var_write_mode(db.get_type_index().get_type_cache(&owner), &ret_type); + let cached = db.get_type_index().get_type_cache(&owner); + let mode = if settled { + settled_iter_var_write_mode(cached, &ret_type) + } else { + iter_var_write_mode(cached, &ret_type) + }; write_type_cache(db, owner, LuaTypeCache::InferType(ret_type), mode); } Ok(()) @@ -747,6 +772,27 @@ pub fn try_resolve_iter_var( /// contains everything the cache holds. A documented type is an authority /// decision — `---@param` is legal on a `for ... in` variable — so it is never /// overwritten; anything else keeps insert-only precedence. +/// Write mode for the settled re-derivation. +/// +/// [`iter_var_write_mode`] only accepts an answer that widens the cached one, +/// and what is cached is whatever the wave reached — a property of how far the +/// batch had got. Here the answer was taken against the complete member map, so +/// it replaces the cached one outright. The one thing it may not do is put a raw +/// template ref back over a resolved type: an unbound generic is a placeholder, +/// not an answer. +fn settled_iter_var_write_mode( + cached: Option<&LuaTypeCache>, + settled: &LuaType, +) -> TypeCacheWriteMode { + let Some(cached) = cached.filter(|cached| !cached.is_doc()) else { + return TypeCacheWriteMode::InsertOnly; + }; + if settled.contain_tpl() && !cached.as_type().contain_tpl() { + return TypeCacheWriteMode::InsertOnly; + } + TypeCacheWriteMode::ForceOverwrite +} + fn iter_var_write_mode(cached: Option<&LuaTypeCache>, settled: &LuaType) -> TypeCacheWriteMode { let Some(cached) = cached.filter(|cached| !cached.is_doc()) else { return TypeCacheWriteMode::InsertOnly; diff --git a/crates/glua_code_analysis/src/compilation/test/decl_test.rs b/crates/glua_code_analysis/src/compilation/test/decl_test.rs index 82a651005..7268cf5c4 100644 --- a/crates/glua_code_analysis/src/compilation/test/decl_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/decl_test.rs @@ -373,4 +373,115 @@ mod test { assert_that!(references.cells.len(), ge(2)); } + + /// Binding a template parameter turns `Def(X)` into `Ref(X)`, so a generic + /// function cannot claim to define the class it was handed. A declared + /// pass-through is the exception: `assert(FindMetaTable("Panel"))` has to + /// keep the definition, or the methods written on the result extend nothing. + #[test] + fn declared_pass_through_keeps_the_definition_it_was_given() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.def_file( + "annotations/meta.lua", + r#" + ---@meta + ---@class Panel + ---@generic T : table + ---@param metaName `T` + ---@return (definition) T + function FindMetaTable(metaName) end + + ---@generic T + ---@param v T + ---@return T + function ident(v) end + + ---@generic T + ---@param v T + ---@return std.NotNull + ---@[return_alias(0)] + function assertlike(v) end + "#, + ); + + let panel = LuaType::Def(crate::LuaTypeDeclId::global("Panel")); + assert_that!(ws.expr_ty("FindMetaTable(\"Panel\")"), eq(&panel)); + assert_that!( + ws.expr_ty("assertlike(FindMetaTable(\"Panel\"))"), + eq(&panel) + ); + // Nothing declares `ident` to hand its argument back, so it may not. + assert_that!( + ws.expr_ty("ident(FindMetaTable(\"Panel\"))"), + eq(&LuaType::Ref(crate::LuaTypeDeclId::global("Panel"))) + ); + } + + /// `local Panel = FindMetaTable("Panel")` extends the class the string + /// names, and naming the local after that class must not change where the + /// method lands. TARDIS writes exactly this in `cl_3d2dvgui.lua`, and the + /// method went missing while the same shape under a different local name + /// resolved. + #[test] + fn meta_table_local_named_after_its_class_still_extends_it() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.def_file( + "annotations/meta.lua", + r#" + ---@meta + ---@class Panel + ---@class DPanel : Panel + + ---@generic T : table + ---@param metaName `T` + ---@return (definition) T + function FindMetaTable(metaName) end + + ---@generic T, T1 + ---@param expression T + ---@param ... T1... + ---@return std.NotNull, T1... + ---@[return_alias(0)] + function _G.assert(expression, ...) end + "#, + ); + ws.def_file( + "lua/autorun/meta-extend.lua", + r#" + local meta = FindMetaTable("Panel") + function meta:ViaOtherName() end + + local Panel = FindMetaTable("Panel") + function Panel:ViaOwnName() end + + local Asserted = assert(FindMetaTable("Panel")) + function Asserted:ViaAssert() end + "#, + ); + + let members = ws + .analysis + .compilation + .get_db() + .get_member_index() + .get_members(&crate::LuaMemberOwner::Type(crate::LuaTypeDeclId::global( + "Panel", + ))) + .map(|members| { + members + .iter() + .map(|member| format!("{:?}", member.get_key())) + .collect::>() + }) + .unwrap_or_default(); + + assert_that!( + members, + all![ + contains(eq(&"Name(\"ViaOtherName\")".to_string())), + contains(eq(&"Name(\"ViaOwnName\")".to_string())), + contains(eq(&"Name(\"ViaAssert\")".to_string())) + ] + ); + } } diff --git a/crates/glua_code_analysis/src/semantic/cache/mod.rs b/crates/glua_code_analysis/src/semantic/cache/mod.rs index 9e4af9687..c22c66b9e 100644 --- a/crates/glua_code_analysis/src/semantic/cache/mod.rs +++ b/crates/glua_code_analysis/src/semantic/cache/mod.rs @@ -279,6 +279,13 @@ impl LuaInferCache { self.index_ref_origin_type_cache.clear(); } + /// Discards what a `for ... in pairs(t)` answer was built from, so it can be + /// taken again against a member map that has since grown. + pub fn clear_iter_var_results(&mut self) { + self.clear_deferred_inference_results(); + self.for_range_iter_var_type_cache.clear(); + } + /// Discards the inference a wave of deferred resolution can have /// invalidated. pub fn clear_deferred_inference_results(&mut self) { From 3ca33d4664ebded3c5e0ed791b4b9a63a031c31c Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:18:52 +0100 Subject: [PATCH 028/159] perf: rebuilding all narrowing when one value settled --- .../analyzer/infer_cache_manager.rs | 4 ++-- .../src/compilation/analyzer/unresolve/mod.rs | 2 +- .../src/semantic/cache/mod.rs | 22 +++++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/infer_cache_manager.rs b/crates/glua_code_analysis/src/compilation/analyzer/infer_cache_manager.rs index e813141bb..43bb6493c 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/infer_cache_manager.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/infer_cache_manager.rs @@ -133,9 +133,9 @@ impl InferCacheManager { } } - pub fn clear_file_flow_results(&mut self, file_id: FileId) { + pub fn clear_file_undetermined_flow_results(&mut self, file_id: FileId) { if let Some(infer_cache) = self.infer_map.get_mut(&file_id) { - infer_cache.clear_flow_results(); + infer_cache.clear_undetermined_flow_results(); } } diff --git a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs index 02d11717b..ecedb8b58 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs @@ -516,7 +516,7 @@ fn try_resolve( .as_ref() .is_some_and(|root| !root_type_is_undetermined(db, root)) { - infer_manager.clear_file_flow_results(file_id); + infer_manager.clear_file_undetermined_flow_results(file_id); } } let cache = infer_manager.get_infer_cache(file_id); diff --git a/crates/glua_code_analysis/src/semantic/cache/mod.rs b/crates/glua_code_analysis/src/semantic/cache/mod.rs index c22c66b9e..f2f7e39f2 100644 --- a/crates/glua_code_analysis/src/semantic/cache/mod.rs +++ b/crates/glua_code_analysis/src/semantic/cache/mod.rs @@ -279,6 +279,28 @@ impl LuaInferCache { self.index_ref_origin_type_cache.clear(); } + /// Drops the narrowing answers that a settling value can have invalidated. + /// + /// An answer that came back undetermined is one the walk could not pin + /// down, so it is exactly what a value becoming known makes wrong. An + /// answer that determined something did not read that value as unsettled, + /// and those are the expensive ones to rebuild — a fifth of a cold index if + /// the whole cache goes. + pub fn clear_undetermined_flow_results(&mut self) { + fn settled(entry: &CacheEntry) -> bool { + match entry { + CacheEntry::Cache(typ) => !crate::db_index::is_undetermined_type(typ), + _ => false, + } + } + self.flow_node_cache.retain(|_, inner| { + inner.retain(|_, entry| settled(entry)); + !inner.is_empty() + }); + self.index_ref_origin_type_cache + .retain(|_, entry| settled(entry)); + } + /// Discards what a `for ... in pairs(t)` answer was built from, so it can be /// taken again against a member map that has since grown. pub fn clear_iter_var_results(&mut self) { From 151bdf661954fa5f2c3c254b624f2f61415a0a61 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:58:06 +0100 Subject: [PATCH 029/159] fix: member initializers left reading a loop variable that settled later --- .../src/compilation/analyzer/mod.rs | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs index 1613e21de..08ff7f299 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs @@ -316,7 +316,12 @@ pub fn analyze(db: &mut DbIndex, need_analyzed_files: Vec>) { // batch had reached. { let _p = Profile::new("rederive_settled_iter_vars"); - rederive_settled_iter_vars(db, &mut context); + // Everything that read a loop variable resolved while it still held + // the answer the partial map gave, so moving one means re-reading + // the initializers that could still take a better answer. + if rederive_settled_iter_vars(db, &mut context) { + refresh_settled_initializer_caches(db, &mut context); + } } { @@ -480,10 +485,10 @@ fn rederive_settled_inferred_returns(db: &mut DbIndex, context: &mut AnalyzeCont /// only what had been attached by then and can fall back to `any` where the /// field's own annotation says otherwise. Taking the answer again once every /// member has landed is both the deterministic and the more faithful one. -fn rederive_settled_iter_vars(db: &mut DbIndex, context: &mut AnalyzeContext) { +fn rederive_settled_iter_vars(db: &mut DbIndex, context: &mut AnalyzeContext) -> bool { let mut candidates = std::mem::take(&mut context.settled_iter_var_candidates); if candidates.is_empty() { - return; + return false; } candidates.sort_by_key(|iter_var| { ( @@ -501,10 +506,12 @@ fn rederive_settled_iter_vars(db: &mut DbIndex, context: &mut AnalyzeContext) { .collect::>(); context.infer_manager.clear_files_iter_var_results(&files); + let writes_before = db.get_type_index().type_writes(); for mut iter_var in candidates { let cache = context.infer_manager.get_infer_cache(iter_var.file_id); let _ = unresolve::resolve_settled_iter_var(db, cache, &mut iter_var); } + db.get_type_index().type_writes() != writes_before } /// Re-derives member assignment widenings that ran against an incomplete @@ -619,11 +626,26 @@ fn resolve_early_member_owners(db: &mut DbIndex, context: &mut AnalyzeContext) - } fn refresh_initializer_caches(db: &mut DbIndex, context: &mut AnalyzeContext) { - refresh_local_decl_initializer_caches(db, context); + refresh_local_decl_initializer_caches(db, context, false); + refresh_member_initializer_caches(db, context); +} + +/// [`refresh_initializer_caches`] for a late pass that moved a handful of types. +/// +/// Only initializers whose cache could still take a better answer are re-read. +/// The blind-dynamic-field probe the full pass runs is not repeated: its verdict +/// is about whether the *first* answer was taken before the dynamic-field index +/// existed, which a later pass cannot change, and asking it again means +/// inferring every candidate in the workspace a second time. +fn refresh_settled_initializer_caches(db: &mut DbIndex, context: &mut AnalyzeContext) { refresh_member_initializer_caches(db, context); } -fn refresh_local_decl_initializer_caches(db: &mut DbIndex, context: &mut AnalyzeContext) { +fn refresh_local_decl_initializer_caches( + db: &mut DbIndex, + context: &mut AnalyzeContext, + settled_only: bool, +) { if context.uninformative_local_decl_candidates.is_empty() { return; } @@ -692,6 +714,13 @@ fn refresh_local_decl_initializer_caches(db: &mut DbIndex, context: &mut Analyze if !initializer_reads_through_call_or_index(&expr) { continue; } + if settled_only + && !current_is_uninformative + && !can_refine_nominal_type + && !can_upgrade_authority + { + continue; + } // Every pass before the dynamic-field one ran without those facts, // so an initializer that reads a dynamic field was answered blind From 1b772975f7daac23ba3fa4a7763e2ac78217b4a9 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:26:20 +0100 Subject: [PATCH 030/159] fix: a global read answered before its other realm writer landed --- .../src/compilation/analyzer/common/mod.rs | 2 +- .../src/compilation/analyzer/lua/stats.rs | 24 +++++++ .../src/compilation/analyzer/mod.rs | 63 +++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs index 23021c122..8d91c4f6b 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs @@ -142,7 +142,7 @@ fn seed_type_slot(db: &mut DbIndex, type_owner: LuaTypeOwner, type_cache: LuaTyp /// A declaration written more than once holds a primitive over its lifetime, /// not whichever literal one write happened to carry. -fn widen_mutable_decl_literal( +pub(crate) fn widen_mutable_decl_literal( db: &DbIndex, type_owner: &LuaTypeOwner, type_cache: LuaTypeCache, diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index cf752c386..f86a3c266 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -197,6 +197,16 @@ pub fn analyze_local_stat(analyzer: &mut LuaAnalyzer, local_stat: LuaLocalStat) continue; } + // A global's type is the merge of every file that writes it, and + // a batch that retains some of those writers while its own are + // still empty answers this read from a smaller set than a cold + // build sees. Re-derived once they have all landed. + if reads_global_name(analyzer, &expr) { + analyzer + .context + .record_settled_global_read_candidate(decl_id, expr.clone()); + } + let retry_uninformative = should_retry_uninformative_initializer(&expr, &expr_type); bind_decl_write( analyzer.db, @@ -1490,6 +1500,20 @@ fn may_improve_after_resolve(expr: &LuaExpr) -> bool { crate::compilation::analyzer::initializer_may_improve_after_resolve(expr) } +/// Whether `expr` is a bare read of a global name, whose type is the merge of +/// every file that writes it. +fn reads_global_name(analyzer: &LuaAnalyzer, expr: &LuaExpr) -> bool { + let LuaExpr::NameExpr(name_expr) = expr else { + return false; + }; + analyzer + .db + .get_reference_index() + .get_local_reference(&analyzer.file_id) + .and_then(|file_ref| file_ref.get_decl_id(&name_expr.get_range())) + .is_none() +} + /// Whether an initializer that inferred to a type carrying no information /// has to be queued for the unresolve pass as well as committed here. fn should_retry_uninformative_initializer(expr: &LuaExpr, expr_type: &LuaType) -> bool { diff --git a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs index 08ff7f299..82bb81e88 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs @@ -324,6 +324,11 @@ pub fn analyze(db: &mut DbIndex, need_analyzed_files: Vec>) { } } + { + let _p = Profile::new("rederive_settled_global_reads"); + rederive_settled_global_reads(db, &mut context); + } + { let _p = Profile::new("resettle_guarded_table_bootstraps"); lua::resettle_guarded_table_bootstraps( @@ -514,6 +519,49 @@ fn rederive_settled_iter_vars(db: &mut DbIndex, context: &mut AnalyzeContext) -> db.get_type_index().type_writes() != writes_before } +/// Re-derives `local x = SomeGlobal` reads. +/// +/// A global's type is the merge of every file that writes it. A batch keeps the +/// values of the files it is not re-indexing while its own are empty until the +/// walk reaches them, so a read taken during the walk can see fewer writers than +/// a cold build does — the cold build defers the read instead, and by the time +/// it resolves every writer has landed. Taking the read again here gives both +/// the complete set. +fn rederive_settled_global_reads(db: &mut DbIndex, context: &mut AnalyzeContext) { + let mut candidates = std::mem::take(&mut context.settled_global_read_candidates); + if candidates.is_empty() { + return; + } + candidates.sort_by_key(|(decl_id, _)| (decl_id.file_id, decl_id.position)); + + let files = candidates + .iter() + .map(|(decl_id, _)| decl_id.file_id) + .collect::>(); + context.infer_manager.clear_files_deferred_results(&files); + + for (decl_id, expr) in candidates { + let cache = context.infer_manager.get_infer_cache(decl_id.file_id); + let Ok(settled) = crate::semantic::infer_expr(db, cache, expr) else { + continue; + }; + let type_owner = LuaTypeOwner::Decl(decl_id); + let settled = common::widen_mutable_decl_literal( + db, + &type_owner, + LuaTypeCache::InferType(settled), + ); + if db + .get_type_index() + .get_type_cache(&type_owner) + .is_some_and(|cached| cached.is_doc() || cached.as_type() == settled.as_type()) + { + continue; + } + db.get_type_index_mut().force_bind_type(type_owner, settled); + } +} + /// Re-derives member assignment widenings that ran against an incomplete /// set of sibling writers. fn rewiden_settled_member_assignments(db: &mut DbIndex, context: &mut AnalyzeContext) { @@ -1406,6 +1454,8 @@ pub struct AnalyzeContext { settled_guarded_bootstrap_candidates: Vec, /// See [`AnalyzeContext::record_settled_iter_var_candidate`]. settled_iter_var_candidates: Vec, + /// See [`AnalyzeContext::record_settled_global_read_candidate`]. + settled_global_read_candidates: Vec<(LuaDeclId, LuaExpr)>, call_site_return_invalidation_changed: bool, pub workspace_id: Option, } @@ -1436,6 +1486,7 @@ impl AnalyzeContext { settled_member_widening_candidates: HashMap::new(), settled_guarded_bootstrap_candidates: Vec::new(), settled_iter_var_candidates: Vec::new(), + settled_global_read_candidates: Vec::new(), call_site_return_invalidation_changed: false, workspace_id: None, } @@ -1475,6 +1526,18 @@ impl AnalyzeContext { self.settled_iter_var_candidates.push(iter_var); } + /// Remembers `local x = SomeGlobal`. A global's type is the merge of every + /// file that writes it, and a batch that retains some writers while its own + /// are still empty answers the read from a smaller set than a cold build + /// sees. See `rederive_settled_global_reads`. + pub(crate) fn record_settled_global_read_candidate( + &mut self, + decl_id: LuaDeclId, + expr: LuaExpr, + ) { + self.settled_global_read_candidates.push((decl_id, expr)); + } + pub(crate) fn record_settled_guarded_bootstrap_candidate(&mut self, member_id: LuaMemberId) { self.settled_guarded_bootstrap_candidates.push(member_id); } From b29ff9aae895bf0312eb471ef90834a63f2b3cb0 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:34:09 +0100 Subject: [PATCH 031/159] fix: a loop over a global settling before the global did --- .../src/compilation/analyzer/mod.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs index 82bb81e88..5acce7a80 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs @@ -311,6 +311,13 @@ pub fn analyze(db: &mut DbIndex, need_analyzed_files: Vec>) { db.get_member_index_mut().settle_alias_contributed_slots(); } + // A loop over a global has to enumerate the table every realm's file + // contributed to, so the copies of that global settle first. + { + let _p = Profile::new("rederive_settled_global_reads"); + rederive_settled_global_reads(db, &mut context); + } + // Only now is every member attached, so a loop that enumerates a table // can be answered from the whole map rather than the part of it this // batch had reached. @@ -324,11 +331,6 @@ pub fn analyze(db: &mut DbIndex, need_analyzed_files: Vec>) { } } - { - let _p = Profile::new("rederive_settled_global_reads"); - rederive_settled_global_reads(db, &mut context); - } - { let _p = Profile::new("resettle_guarded_table_bootstraps"); lua::resettle_guarded_table_bootstraps( From 9efcb7b966089913bf7b4ec3eea7d02081345205 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:56:35 +0100 Subject: [PATCH 032/159] fix: guarded bootstraps picked a canonical off a half-migrated owner --- .../src/compilation/analyzer/lua/stats.rs | 69 +++++++++++++++---- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index f86a3c266..992b8bf76 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -18,7 +18,8 @@ use crate::{ semantic::{merge_open_table_types, remove_false_or_nil}, }; use glua_parser::{ - BinaryOperator, LuaAssignStat, LuaAstNode, LuaClosureExpr, LuaExpr, LuaFuncStat, LuaIndexExpr, + BinaryOperator, LuaAssignStat, LuaAstNode, LuaBinaryExpr, LuaClosureExpr, LuaExpr, LuaFuncStat, + LuaIndexExpr, LuaIndexKey, LuaLiteralToken, LuaLocalFuncStat, LuaLocalStat, LuaNameExpr, LuaSyntaxKind, LuaTableExpr, LuaTableField, LuaVarExpr, PathTrait, }; @@ -1806,7 +1807,6 @@ fn assign_merge_type_owner_and_expr_type( let dynamic_expr_key_member = is_dynamic_expr_key_member_assignment(analyzer, &type_owner); // What this write carries on its own, before any sibling merge widens it. let mut source_type = None; - let had_canonical_guarded_bootstrap = canonical_guarded_bootstrap.is_some(); if let Some(canonical) = canonical_guarded_bootstrap { expr_type = canonical; source_type = Some(expr_type.clone()); @@ -1888,11 +1888,12 @@ fn assign_merge_type_owner_and_expr_type( { let guarded_table_assignment = preserve_table_literals || is_guarded_table_assignment_member(analyzer.db, *member_id); - if guarded_table_assignment && !had_canonical_guarded_bootstrap { - // No canonical writer was found, which means fewer than two of them - // were indexed when this ran — a property of how far the batch has - // got. Re-derived once they have all landed. See - // `resettle_guarded_table_bootstraps`. + if guarded_table_assignment { + // Whichever canonical writer this found — including none at all — it + // read the sibling set off a half-built owner index, and which + // writers are visible there is a property of how far the batch has + // got. Re-derived once they have all landed and been migrated to + // their final owner. See `resettle_guarded_table_bootstraps`. analyzer .context .record_settled_guarded_bootstrap_candidate(*member_id); @@ -2558,11 +2559,7 @@ pub(in crate::compilation::analyzer) fn is_guarded_table_assignment_member( let Some(node) = member_id.get_syntax_id().to_node_from_root(&root) else { return false; }; - let Some(index_expr) = LuaIndexExpr::cast(node) else { - return false; - }; - - is_guarded_table_assignment_index_expr(&index_expr) + guarded_bootstrap_range_for_node(node, false).is_some() } pub(in crate::compilation::analyzer) fn is_guarded_table_assignment_index_expr( @@ -2571,6 +2568,51 @@ pub(in crate::compilation::analyzer) fn is_guarded_table_assignment_index_expr( guarded_table_assignment_bootstrap_range(index_expr, false).is_some() } +/// Range of the table a guarded bootstrap of this member names, whichever of +/// the two shapes wrote it. +fn guarded_bootstrap_range_for_node( + node: glua_parser::LuaSyntaxNode, + empty_only: bool, +) -> Option { + if let Some(index_expr) = LuaIndexExpr::cast(node.clone()) { + return guarded_table_assignment_bootstrap_range(&index_expr, empty_only); + } + + guarded_table_literal_field_range(&LuaTableField::cast(node)?, empty_only) +} + +/// Range of the table a field of a guarded table literal names. +/// +/// `x = x or { y = {} }` creates `x.y` on exactly the condition `x.y = x.y or +/// {}` does — the namespace not existing yet — so the two shapes bootstrap the +/// same slot and have to resolve to one literal between them. Treating only the +/// second as a guarded writer leaves the first looking like a plain write, which +/// makes the whole slot ineligible for canonicalisation. +fn guarded_table_literal_field_range( + table_field: &LuaTableField, + empty_only: bool, +) -> Option { + let LuaExpr::TableExpr(value) = table_field.get_value_expr()? else { + return None; + }; + if empty_only && !value.is_empty() { + return None; + } + + let table_expr = LuaTableExpr::cast(table_field.syntax().parent()?)?; + let binary_expr = LuaBinaryExpr::cast(table_expr.syntax().parent()?)?; + let assign_stat = binary_expr.get_parent::()?; + let (var_list, expr_list) = assign_stat.get_var_and_expr_list(); + let access_path = var_list + .iter() + .zip(expr_list.iter()) + .find(|(_, expr)| expr.get_syntax_id() == binary_expr.get_syntax_id()) + .and_then(|(var, _)| var.get_access_path())?; + guarded_assignment_table_arm_range(&LuaExpr::BinaryExpr(binary_expr), &access_path, false)?; + + Some(value.get_range()) +} + /// Range of the table arm of a self-referential guarded bootstrap (`x.y = /// x.y or {}`), which is what the assignment's type is when the guard falls /// through. @@ -2676,8 +2718,7 @@ fn guarded_table_bootstrap_range( ) -> Option { let tree = db.get_vfs().get_syntax_tree(&member_id.file_id)?; let root = tree.get_red_root(); - let index_expr = LuaIndexExpr::cast(member_id.get_syntax_id().to_node_from_root(&root)?)?; - guarded_table_assignment_bootstrap_range(&index_expr, empty_only) + guarded_bootstrap_range_for_node(member_id.get_syntax_id().to_node_from_root(&root)?, empty_only) } /// The one table a repeated `x.y = x.y or {}` guard names. From 91bdf9690368bc1689df1773021794b95adc60d2 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:21:47 +0100 Subject: [PATCH 033/159] fix: a sole visible writer decided against siblings still on the global path --- .../src/compilation/analyzer/lua/stats.rs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index 992b8bf76..baedd32c7 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -2058,7 +2058,7 @@ fn get_cached_widened_member_assignment_type( analyzer: &mut LuaAnalyzer, type_owner: &LuaTypeOwner, incoming_type: &LuaType, - _preserve_table_literals: bool, + preserve_table_literals: bool, ) -> Option> { let LuaTypeOwner::Member(member_id) = type_owner else { return None; @@ -2078,7 +2078,24 @@ fn get_cached_widened_member_assignment_type( &cache_key, visible_count, ) { - WideningCacheLookup::FirstSighting => return Some(None), + WideningCacheLookup::FirstSighting => { + // Being the only writer the owner can currently see is a statement + // about how far the batch has run: until the global this member + // hangs off resolves, its siblings sit on the global path instead + // and are invisible here. Re-derived once they have all been + // migrated to their owner. + // + // Only a named slot: a key the source writes as an expression names + // one entry of a collection, and those never migrate as a group. + if matches!(cache_key.key, LuaMemberKey::Name(_)) { + analyzer.context.record_settled_member_widening_candidate( + *member_id, + incoming_type.clone(), + preserve_table_literals, + ); + } + return Some(None); + } WideningCacheLookup::Fallback => return None, WideningCacheLookup::Hit(cache) => cache, }; From bbe4245b4b9368eb9ce8cb2e4ed82c1c0b00e5bc Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:05:40 +0100 Subject: [PATCH 034/159] fix: readers of a loop variable kept the answer it had before it settled --- .../src/compilation/analyzer/lua/stats.rs | 7 +- .../src/compilation/analyzer/mod.rs | 74 ++++++++++++------- 2 files changed, 52 insertions(+), 29 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index baedd32c7..837c9035e 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -89,7 +89,12 @@ pub fn analyze_local_stat(analyzer: &mut LuaAnalyzer, local_stat: LuaLocalStat) break; }; let decl_id = LuaDeclId::new(analyzer.file_id, position); - if is_call_or_index_expr(&expr) { + // A copy of a loop variable holds whatever the variable held when the + // copy landed, and the settled re-derivation moves those, so it needs + // re-reading for the same reason a call or index read does. + if is_call_or_index_expr(&expr) + || reads_settling_iter_var(analyzer.db, analyzer.file_id, &expr) + { analyzer .context .request_uninformative_local_decl_reinfer(decl_id); diff --git a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs index 5acce7a80..49d55f1d5 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs @@ -676,8 +676,8 @@ fn resolve_early_member_owners(db: &mut DbIndex, context: &mut AnalyzeContext) - } fn refresh_initializer_caches(db: &mut DbIndex, context: &mut AnalyzeContext) { - refresh_local_decl_initializer_caches(db, context, false); - refresh_member_initializer_caches(db, context); + refresh_local_decl_initializer_caches(db, context, false, false); + refresh_member_initializer_caches(db, context, false); } /// [`refresh_initializer_caches`] for a late pass that moved a handful of types. @@ -688,13 +688,17 @@ fn refresh_initializer_caches(db: &mut DbIndex, context: &mut AnalyzeContext) { /// existed, which a later pass cannot change, and asking it again means /// inferring every candidate in the workspace a second time. fn refresh_settled_initializer_caches(db: &mut DbIndex, context: &mut AnalyzeContext) { - refresh_member_initializer_caches(db, context); + // Members first: a declaration that reads one of them takes its answer from + // whatever the member holds when the read is taken. + refresh_member_initializer_caches(db, context, true); + refresh_local_decl_initializer_caches(db, context, true, true); } fn refresh_local_decl_initializer_caches( db: &mut DbIndex, context: &mut AnalyzeContext, settled_only: bool, + iter_vars_settled: bool, ) { if context.uninformative_local_decl_candidates.is_empty() { return; @@ -761,10 +765,17 @@ fn refresh_local_decl_initializer_caches( let Some((ret_idx, expr)) = local_initializer_expr(db, &root, *decl_id) else { continue; }; - if !initializer_reads_through_call_or_index(&expr) { + // A copy of a loop variable holds whatever that variable held when + // the copy landed, and the settle has just moved it. What it holds + // now is no evidence against re-reading it. + let copies_settled_iter_var = + iter_vars_settled && common::reads_settling_iter_var(db, file_id, &expr); + if !copies_settled_iter_var && !initializer_reads_through_call_or_index(&expr) { continue; } - if settled_only + if !copies_settled_iter_var + + && settled_only && !current_is_uninformative && !can_refine_nominal_type && !can_upgrade_authority @@ -816,7 +827,9 @@ fn refresh_local_decl_initializer_caches( .is_some_and(|current| current.as_type() == &blind_type) && blind_type != inferred_type }; - if !current_is_uninformative + if !copies_settled_iter_var + + && !current_is_uninformative && !can_refine_nominal_type && !can_upgrade_authority && !cached_a_blind_dynamic_field_read @@ -872,13 +885,14 @@ fn refresh_local_decl_initializer_caches( }); let is_settled_widening = current_cache .as_ref() - .is_some_and(|current| union_widens_arm(&inferred_type, current.as_type())); + .is_some_and(|current| union_widens_cached_type(&inferred_type, current.as_type())); if current_is_uninformative { result.updates.push(InitializerCacheUpdate::Bind { owner: type_owner, fact: inferred_fact, }); - } else if has_stronger_declared_authority + } else if copies_settled_iter_var + || has_stronger_declared_authority || is_nominal_refinement || is_settled_widening || cached_a_blind_dynamic_field_read @@ -906,23 +920,13 @@ fn refresh_local_decl_initializer_caches( apply_initializer_cache_updates(db, updates); } -/// Whether the re-derived type is a union that already contains the cached one. +/// Whether the re-derived type is a union that already contains everything the +/// cached one holds, plus more. /// /// The cache then holds a subset snapshot taken before the other arms were /// visible, so replacing it widens to the settled answer instead of guessing a -/// different one. -fn union_widens_arm(inferred: &LuaType, current: &LuaType) -> bool { - match inferred { - LuaType::Union(union) => union.types().any(|arm| arm == current), - _ => false, - } -} - -/// Whether the re-derived union contains everything the cached type holds, plus -/// more — the union-to-union counterpart of [`union_widens_arm`]. -/// -/// A cached union is as much a subset snapshot as a cached single arm is: both -/// are decided by which contributors happened to be indexed first. +/// different one. A cached union is as much a subset snapshot as a cached single +/// arm is: both are decided by which contributors happened to be indexed first. pub(crate) fn union_widens_cached_type(inferred: &LuaType, current: &LuaType) -> bool { let LuaType::Union(inferred_union) = inferred else { return false; @@ -948,7 +952,11 @@ fn known_arms(union: &LuaUnionType) -> Vec { .collect() } -fn refresh_member_initializer_caches(db: &mut DbIndex, context: &mut AnalyzeContext) { +fn refresh_member_initializer_caches( + db: &mut DbIndex, + context: &mut AnalyzeContext, + iter_vars_settled: bool, +) { if context.member_initializer_reinfer_candidates.is_empty() { return; } @@ -992,22 +1000,32 @@ fn refresh_member_initializer_caches(db: &mut DbIndex, context: &mut AnalyzeCont continue; }; let current_is_uninformative = type_is_uninformative(current_cache.as_type()); - if current_cache.is_doc() - || (!current_is_uninformative - && single_nominal_type_id(current_cache.as_type()).is_none()) - { + if current_cache.is_doc() { continue; } let Some(expr) = member_initializer_expr(&root, *member_id) else { continue; }; + // A member that copies a loop variable holds whatever that variable + // held when the copy landed, and the settle has just moved it. What + // it holds now is no evidence against re-reading it. + let copies_settled_iter_var = iter_vars_settled + && common::reads_settling_iter_var(db, file_id, &expr); + if !copies_settled_iter_var + && !current_is_uninformative + && single_nominal_type_id(current_cache.as_type()).is_none() + { + continue; + } let Ok(inferred_type) = crate::infer_expr(db, &mut infer_cache, expr) else { continue; }; if inferred_type == *current_cache.as_type() { continue; } - let takes_inferred_type = if current_is_uninformative { + let takes_inferred_type = if copies_settled_iter_var { + true + } else if current_is_uninformative { // A placeholder is not an answer: it only records that the // member's initializer had not been inferred yet when the write // landed. Re-inferring it against the settled index is the same From 6af13b774341138512597b7b1a1eae9d00c47786 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:34:32 +0100 Subject: [PATCH 035/159] fix: a slot kept whichever of an any write and a real one landed first --- .../src/compilation/analyzer/common/mod.rs | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs index 8d91c4f6b..2f3be626f 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs @@ -240,10 +240,25 @@ pub fn bind_decl_write( None => true, Some(existing) => { let both_inferred = existing.is_infer() && type_cache.is_infer(); - if both_inferred + let comparable = both_inferred && !reads_out_of_decl; + // `any` is the one answer neither `bind_type` nor + // `bind_resolved_type` will trade in either direction, so between + // two ordered writes it is ranked rather than positioned: whichever + // determined something takes the slot, and the winner is then a + // function of the write set instead of which one resolved first. The + // other bottoms are left alone — `unknown` on a declaration is what + // lets a use narrow it, not a give-up to be overwritten. + let outranks_any = + comparable && is_informative_type(seeded.as_type()) && existing.as_type().is_any(); + let outranked_by_any = + comparable && seeded.as_type().is_any() && is_informative_type(existing.as_type()); + if outranks_any { + true + } else if outranked_by_any { + false + } else if comparable && may_improve_after_resolve - && !reads_out_of_decl - && !is_undetermined_type(seeded.as_type()) + && is_informative_type(seeded.as_type()) && is_undetermined_type(existing.as_type()) { // The slot holds an inferred give-up answer and this write From f26388c38b0d69943d5313d062554f7ed5f5f5c8 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:03:21 +0100 Subject: [PATCH 036/159] fix: a defaulted parameter took the type its own default gave it only sometimes --- .../src/compilation/analyzer/common/mod.rs | 18 +++++++ .../src/compilation/analyzer/lua/mod.rs | 2 +- .../src/compilation/analyzer/lua/stats.rs | 53 +++++++++++++++++++ .../compilation/analyzer/unresolve/resolve.rs | 6 +++ 4 files changed, 78 insertions(+), 1 deletion(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs index 2f3be626f..606b01b2d 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs @@ -182,6 +182,9 @@ pub struct DeclWrite { /// (`width = bit.bor(width:byte(1), ...)`). Such a write derives its type /// from the slot it is about to fill, so it must not fill it. pub reads_out_of_decl: bool, + /// Whether the right-hand side is ` or `, the one body + /// write that refines a parameter instead of replacing it. + pub fills_own_default: bool, /// Whether this write is one the file walk would let replace an /// uninformative cache: an initializer whose answer can still improve, or /// an assignment that reads through a call or index — the boundary @@ -216,6 +219,7 @@ pub fn bind_decl_write( reads_out_of_decl, may_narrow_uninformative, resolved_initializer, + fills_own_default, } = write; let type_owner = LuaTypeOwner::Decl(decl_id); let fallback = |db: &mut DbIndex, type_cache| { @@ -233,6 +237,20 @@ pub fn bind_decl_write( .get_decl(&decl_id) .is_none_or(|decl| decl.is_param()) { + // A default fill goes through the resolved path, so + // `gender = gender or GENDER_MALE` gives the same answer whether it was + // inferred during the walk — seeding the slot outright — or deferred + // until after the unresolve pass parked `unknown` there. Which of those + // happens depends on whether the file defining `GENDER_MALE` had been + // walked yet, which is a property of the batch, not of the source. + // + // Only a default fill: a reassignment to something else — splitting a + // string parameter into a list, say — states what the parameter becomes + // further down one branch, not what it was passed. + if fills_own_default { + let widened = widen_mutable_decl_literal(db, &type_owner, type_cache); + return bind_resolved_type(db, type_owner, widened); + } return fallback(db, type_cache); } let seeded = widen_mutable_decl_literal(db, &type_owner, type_cache.clone()); diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs index 6d033078c..b96d71574 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs @@ -27,7 +27,7 @@ use module::analyze_chunk_return; pub use module::compute_module_semantic_id; pub(in crate::compilation::analyzer) use settled_contributions::rederive_contributed_member_assignments; pub(crate) use stats::dominating_guarded_table_bootstrap_range; -pub(crate) use stats::expr_reads_out_of_decl; +pub(crate) use stats::{expr_fills_own_default, expr_reads_out_of_decl}; pub(in crate::compilation::analyzer) use stats::resettle_guarded_table_bootstraps; use stats::{ analyze_assign_stat, analyze_func_stat, analyze_local_func_stat, analyze_local_stat, diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index 837c9035e..1045a6b93 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -229,6 +229,12 @@ pub fn analyze_local_stat(analyzer: &mut LuaAnalyzer, local_stat: LuaLocalStat) ), may_narrow_uninformative: may_improve_after_resolve(&expr), resolved_initializer: false, + fills_own_default: expr_fills_own_default( + analyzer.db, + analyzer.file_id, + decl_id, + &expr, + ), }, ); if retry_uninformative { @@ -1077,6 +1083,8 @@ pub fn analyze_assign_stat(analyzer: &mut LuaAnalyzer, assign_stat: LuaAssignSta if expr_reads_out_of_decl(analyzer.db, analyzer.file_id, *decl_id, expr)), may_narrow_uninformative: is_call_or_index_expr(expr), resolved_initializer: false, + fills_own_default: matches!(&type_owner, LuaTypeOwner::Decl(decl_id) + if expr_fills_own_default(analyzer.db, analyzer.file_id, *decl_id, expr)), }, ); // The member is only homed onto its owner above, so the sibling guards @@ -1118,6 +1126,13 @@ pub fn analyze_assign_stat(analyzer: &mut LuaAnalyzer, assign_stat: LuaAssignSta )), may_narrow_uninformative: is_call_or_index_expr(last_expr), resolved_initializer: false, + fills_own_default: matches!(&type_owner, LuaTypeOwner::Decl(decl_id) + if expr_fills_own_default( + analyzer.db, + analyzer.file_id, + *decl_id, + last_expr, + )), }, ); } @@ -1144,6 +1159,13 @@ pub fn analyze_assign_stat(analyzer: &mut LuaAnalyzer, assign_stat: LuaAssignSta )), may_narrow_uninformative: is_call_or_index_expr(last_expr), resolved_initializer: false, + fills_own_default: matches!(&type_owner, LuaTypeOwner::Decl(decl_id) + if expr_fills_own_default( + analyzer.db, + analyzer.file_id, + *decl_id, + last_expr, + )), }, ); } @@ -1631,6 +1653,36 @@ fn should_defer_pending_local_alias( /// bit.bor(bit.lshift(width:byte(1), 24), ...)`). Depth does not change the /// self-contradiction — the value still cannot be the decl's lifetime type, /// because it was computed from a read that type would reject. +/// Whether `expr` is the default-value idiom for `decl_id` — `p = p or DEFAULT`. +/// +/// The result always includes the declaration's own type, so unlike a plain +/// reassignment it refines the declaration rather than replacing it, and is the +/// one body write a parameter may take its type from. +pub(crate) fn expr_fills_own_default( + db: &DbIndex, + file_id: crate::FileId, + decl_id: LuaDeclId, + expr: &LuaExpr, +) -> bool { + let LuaExpr::BinaryExpr(binary_expr) = expr else { + return false; + }; + if binary_expr.get_op_token().map(|op| op.get_op()) != Some(BinaryOperator::OpOr) { + return false; + } + let Some((LuaExpr::NameExpr(left), _)) = binary_expr.get_exprs() else { + return false; + }; + let Some(name) = left.get_name_text() else { + return false; + }; + + db.get_decl_index() + .get_decl_tree(&file_id) + .and_then(|decl_tree| decl_tree.find_local_decl(&name, left.get_position())) + .is_some_and(|decl| decl.get_id() == decl_id) +} + pub(crate) fn expr_reads_out_of_decl( db: &DbIndex, file_id: crate::FileId, @@ -3088,6 +3140,7 @@ fn special_assign_pattern( reads_out_of_decl: false, may_narrow_uninformative: false, resolved_initializer: false, + fills_own_default: false, }, ); } 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 3a4728e14..af8eb61e8 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs @@ -196,6 +196,12 @@ pub fn try_resolve_decl( crate::compilation::analyzer::initializer_reads_through_call_or_index(&expr) }, resolved_initializer: is_initializer && may_improve, + fills_own_default: crate::compilation::analyzer::lua::expr_fills_own_default( + db, + decl.file_id, + decl_id, + &expr, + ), }, ); Ok(()) From 60cb365dfd72fb77b2b3e3e98e09cce429b1eb15 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:21:20 +0100 Subject: [PATCH 037/159] fix: a read took whichever empty answer the batch had reached --- .../src/compilation/analyzer/mod.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs index 49d55f1d5..e678aa97d 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs @@ -854,6 +854,19 @@ fn refresh_local_decl_initializer_caches( owner: type_owner, fact: inferred_fact.with_runtime_type(LuaType::Unknown), }); + } else if current_cache.as_ref().is_some_and(|current| { + LuaTypeCache::InferType(inferred_type.clone()).supersedes(current) + }) { + // Both answers carry no type information, but one of them + // admits more values — `any|nil` over `any`, the difference + // between reporting a nil check and not. Which one is cached + // otherwise comes down to how far the batch had run when the + // read was taken, so the settled one is taken here on the + // same rule the type index itself applies. + result.updates.push(InitializerCacheUpdate::Bind { + owner: type_owner, + fact: inferred_fact, + }); } continue; } From 791fca0219efc39b846f23dec81e2eec8e78f8ae Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:53:54 +0100 Subject: [PATCH 038/159] fix: a table slot lost its literal when a plain writer shared it with a guard --- .../src/compilation/analyzer/lua/mod.rs | 2 +- .../src/compilation/analyzer/lua/stats.rs | 62 ++++++++++++++++--- .../src/compilation/analyzer/mod.rs | 19 ++++++ .../src/db_index/type/mod.rs | 11 ++++ 4 files changed, 86 insertions(+), 8 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs index b96d71574..a52f41b44 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs @@ -37,7 +37,7 @@ pub(in crate::compilation::analyzer) use stats::{ get_widened_member_assignment_type, has_multiple_distinct_index_expr_member_owners, is_guarded_table_assignment_index_expr, is_guarded_table_assignment_member, mark_resolved_member_assignment, preserve_guarded_table_assignment_members, - record_resolved_member_assignment_contribution, + record_resolved_member_assignment_contribution, slot_has_guarded_table_bootstrap, }; use log::info; diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index 1045a6b93..297b141ef 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -613,10 +613,17 @@ fn set_index_expr_owner(analyzer: &mut LuaAnalyzer, var_expr: LuaVarExpr) -> Opt /// after the batch is done. fn prefix_carries_no_owner_information(prefix_type: &LuaType) -> bool { match prefix_type { - LuaType::Unknown | LuaType::Any => true, - LuaType::Union(union) => union - .types() - .all(|arm| matches!(arm, LuaType::Nil | LuaType::Unknown | LuaType::Any)), + // `table` belongs here for the same reason `any` does: it names no + // element, so nothing can attach through it. It is also what a slot + // collapses to while a writer's literal is still being widened against + // siblings the walk has not reached, which is a property of the batch. + LuaType::Unknown | LuaType::Any | LuaType::Table => true, + LuaType::Union(union) => union.types().all(|arm| { + matches!( + arm, + LuaType::Nil | LuaType::Unknown | LuaType::Any | LuaType::Table + ) + }), _ => false, } } @@ -1861,6 +1868,16 @@ fn assign_merge_type_owner_and_expr_type( || matches!(&type_owner, LuaTypeOwner::Member(member_id) if is_guarded_table_assignment_member(analyzer.db, *member_id)); + // A plain writer that shares the slot has to preserve them too: widening + // `self.x = {}` against a `self.x = self.x or {}` in another file answers + // `table`, and the guard's literal is then gone for every reader — + // including the writes that attach members through it. This says nothing + // about *this* write being a guarded one, so it feeds the widening decision + // alone and not the classification below. + let preserve_sibling_table_literals = preserve_table_literals + || matches!(&type_owner, LuaTypeOwner::Member(member_id) + if slot_has_guarded_table_bootstrap(analyzer.db, *member_id)); + let dynamic_expr_key_member = is_dynamic_expr_key_member_assignment(analyzer, &type_owner); // What this write carries on its own, before any sibling merge widens it. let mut source_type = None; @@ -1881,7 +1898,7 @@ fn assign_merge_type_owner_and_expr_type( analyzer, &type_owner, &expr_type, - preserve_table_literals, + preserve_sibling_table_literals, ) { Some(Some(widened_type)) => { expr_type = widened_type; @@ -1897,7 +1914,7 @@ fn assign_merge_type_owner_and_expr_type( analyzer.db, &type_owner, &expr_type, - preserve_table_literals, + preserve_sibling_table_literals, &mut skipped_uncached_sibling, ); // Recorded on the skip, not on the answer: a walk that read no @@ -1908,7 +1925,7 @@ fn assign_merge_type_owner_and_expr_type( analyzer.context.record_settled_member_widening_candidate( *member_id, expr_type.clone(), - preserve_table_literals, + preserve_sibling_table_literals, ); } if let Some(widened_type) = widened { @@ -1916,6 +1933,21 @@ fn assign_merge_type_owner_and_expr_type( } } } + // A table literal written into a slot another file bootstraps with + // `x.y = x.y or {}` keeps its own literal — but whether that sibling had + // been indexed when the walk asked is a property of the walk order. Where + // the literal was widened away, queue it so the settled pass can ask + // again against the whole writer set. + if let LuaTypeOwner::Member(member_id) = &type_owner + && matches!(source_type, Some(LuaType::TableConst(_))) + && matches!(expr_type, LuaType::Table) + { + analyzer.context.record_settled_member_widening_candidate( + *member_id, + source_type.clone().unwrap_or_else(|| expr_type.clone()), + preserve_sibling_table_literals, + ); + } } if is_global_decl_owner(analyzer, &type_owner) { @@ -2393,6 +2425,22 @@ fn is_member_assignment_in_conditional_branch(db: &DbIndex, member_id: LuaMember }) } +/// Whether any writer of this member's slot bootstraps it with `x.y = x.y or +/// {}`, this one included. +pub(in crate::compilation::analyzer) fn slot_has_guarded_table_bootstrap(db: &DbIndex, member_id: LuaMemberId) -> bool { + let member_index = db.get_member_index(); + let Some(owner) = member_index.get_member_owner(&member_id) else { + return false; + }; + let Some(key) = member_index.get_member(&member_id).map(LuaMember::get_key) else { + return false; + }; + member_index + .get_current_owner_members_for_key(owner, key) + .into_iter() + .any(|related| is_guarded_table_assignment_member(db, related.get_id())) +} + fn guarded_table_assignment_member_ids_for_owner_key( db: &DbIndex, member_id: LuaMemberId, diff --git a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs index e678aa97d..fd64bc283 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs @@ -587,6 +587,12 @@ fn rewiden_settled_member_assignments(db: &mut DbIndex, context: &mut AnalyzeCon } let type_owner = LuaTypeOwner::Member(member_id); + // Asked again here, not taken from the walk: whether a sibling writer + // bootstraps the slot with `x.y = x.y or {}` decides whether this write + // keeps its own table literal, and which siblings were indexed when the + // walk asked is a property of the batch. Every writer has landed by now. + let preserve_table_literals = + preserve_table_literals || lua::slot_has_guarded_table_bootstrap(db, member_id); let Some(widened_type) = lua::get_widened_member_assignment_type( db, &type_owner, @@ -594,6 +600,19 @@ fn rewiden_settled_member_assignments(db: &mut DbIndex, context: &mut AnalyzeCon preserve_table_literals, &mut false, ) else { + // No widening applies. Where the walk widened a table literal away + // because it could not yet see the guard that shares the slot, the + // literal is what the write actually carries — put it back. + if preserve_table_literals + && matches!(assigned_type, LuaType::TableConst(_)) + && db + .get_type_index() + .get_type_cache(&type_owner) + .is_some_and(|cache| matches!(cache.as_type(), LuaType::Table)) + { + db.get_type_index_mut() + .force_bind_type(type_owner, LuaTypeCache::InferType(assigned_type)); + } continue; }; diff --git a/crates/glua_code_analysis/src/db_index/type/mod.rs b/crates/glua_code_analysis/src/db_index/type/mod.rs index 8c5ddd60d..c57a6a369 100644 --- a/crates/glua_code_analysis/src/db_index/type/mod.rs +++ b/crates/glua_code_analysis/src/db_index/type/mod.rs @@ -372,17 +372,28 @@ pub(crate) fn prune_redundant_guarded_table_bootstrap_type(db: &DbIndex, typ: Lu fn collapse_guarded_table_bootstrap_branches(db: &DbIndex, types: Vec) -> LuaType { let mut saw_bootstrap = false; + let mut bootstraps = Vec::new(); let mut retained = Vec::with_capacity(types.len()); for typ in types { if is_guarded_table_bootstrap_branch(db, &typ) { saw_bootstrap = true; + bootstraps.push(typ); } else { retained.push(typ); } } if saw_bootstrap { + if retained.is_empty() { + // Nothing but bootstrap branches: they all name the same table, and + // answering bare `table` would throw away the one thing they carry — + // which literal that is. A slot with a single such writer keeps it + // (the `One` arm returns the cache verbatim), so a slot with several + // has to as well, or a member's owner would depend on how many + // writers happened to be indexed when the read was taken. + return LuaType::from_vec(bootstraps); + } retained.push(LuaType::Table); } From cb9285fb9de3f618a30c1f6996697007369091d9 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:29:53 +0100 Subject: [PATCH 039/159] fix: re-adding one member wiped the writers a slot had collected --- .../src/db_index/member/mod.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/glua_code_analysis/src/db_index/member/mod.rs b/crates/glua_code_analysis/src/db_index/member/mod.rs index 6b47fb77b..3029b8b87 100644 --- a/crates/glua_code_analysis/src/db_index/member/mod.rs +++ b/crates/glua_code_analysis/src/db_index/member/mod.rs @@ -304,10 +304,21 @@ impl LuaMemberIndex { // one win this slot -- or evicting one -- would make the outcome // depend on whether the owner's own write or the alias arrived // first, which is a property of the batch, not of the source. - let (aliased, owned): (Vec<_>, Vec<_>) = old_member_ids - .iter() - .copied() - .partition(|old_id| self.member_current_owner.get(old_id) != Some(owner)); + // + // An assignment file-define keeps its place for the same + // reason: `should_preserve_assignment_file_define_member` + // accumulated it deliberately, and the removal list below + // already refuses to evict one. Collapsing the slot to a single + // "latest defined" writer here would undo that — and only when + // the other kind of member happens to arrive second, which is + // how far the batch has run rather than anything about the + // source. A re-index of one file dropped 35 writers of + // `ply._Food.amount` this way. + let (aliased, owned): (Vec<_>, Vec<_>) = + old_member_ids.iter().copied().partition(|old_id| { + self.member_current_owner.get(old_id) != Some(owner) + || self.is_assignment_file_define_member(*old_id) + }); let winner = latest_defined_member(&owned, id); let mut visible = aliased; visible.push(winner); From c49633c6c86550d2c16e8fc0e40b403e06366794 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:18:54 +0100 Subject: [PATCH 040/159] fix: a plain reset forked the identity of a table another file bootstraps --- .../src/compilation/analyzer/lua/stats.rs | 70 +++++++++++++++++-- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index 297b141ef..3ed919710 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -2441,6 +2441,50 @@ pub(in crate::compilation::analyzer) fn slot_has_guarded_table_bootstrap(db: &Db .any(|related| is_guarded_table_assignment_member(db, related.get_id())) } +/// Whether this member's assignment writes a bare table literal, `x.y = {...}`. +fn assigns_bare_table_literal(db: &DbIndex, member_id: LuaMemberId) -> bool { + // A table literal caches as one, so this rules out almost every writer for + // the price of a map lookup rather than a syntax walk. + if !matches!( + db.get_type_index() + .get_type_cache(&member_id.into()) + .map(LuaTypeCache::as_type), + Some(LuaType::TableConst(_) | LuaType::Table) + ) { + return false; + } + let Some(tree) = db.get_vfs().get_syntax_tree(&member_id.file_id) else { + return false; + }; + let root = tree.get_red_root(); + let Some(index_expr) = member_id + .get_syntax_id() + .to_node_from_root(&root) + .and_then(LuaIndexExpr::cast) + else { + return false; + }; + let Some(assign_stat) = index_expr.get_parent::() else { + return false; + }; + let syntax_id = index_expr.get_syntax_id(); + let (var_list, expr_list) = assign_stat.get_var_and_expr_list(); + var_list + .iter() + .zip(expr_list.iter()) + .find(|(candidate_var, _)| candidate_var.get_syntax_id() == syntax_id) + .is_some_and(|(_, expr)| matches!(expr, LuaExpr::TableExpr(_))) +} + +/// Every writer of this member's slot, when at least one of them bootstraps it +/// with `x.y = x.y or {}`. +/// +/// The guard means "reuse it if it is there", and a plain `x.y = {}` resets that +/// same table, so every writer names one runtime table however many literals the +/// source spells. Returning them together is what lets the slot hold a single +/// identity: treating a plain writer as a rival definition forks it, the merge +/// of the forks answers bare `table`, and `table` names no element — so every +/// member attached through the slot is lost. fn guarded_table_assignment_member_ids_for_owner_key( db: &DbIndex, member_id: LuaMemberId, @@ -2449,17 +2493,23 @@ fn guarded_table_assignment_member_ids_for_owner_key( let owner = member_index.get_member_owner(&member_id)?.clone(); let key = member_index.get_member(&member_id)?.get_key().clone(); let mut member_ids = Vec::new(); + let mut bootstrapped = false; for related_member in member_index.get_current_owner_members_for_key(&owner, &key) { let related_member_id = related_member.get_id(); - if !is_guarded_table_assignment_member(db, related_member_id) { + if is_guarded_table_assignment_member(db, related_member_id) { + bootstrapped = true; + } else if !assigns_bare_table_literal(db, related_member_id) { + // This writer contributes something that is not a fresh table -- a + // class, a call result -- so the slot really can hold more than one + // thing and there is no single identity to resolve to. return None; } member_ids.push(related_member_id); } - (member_ids.len() >= 2).then_some(member_ids) + (bootstrapped && member_ids.len() >= 2).then_some(member_ids) } /// Widens a member assignment against its same-owner/key siblings. @@ -2890,12 +2940,18 @@ pub(in crate::compilation::analyzer) fn resettle_guarded_table_bootstraps( for (_, mut members) in slots { members.sort_by_key(|member_id| member_id_sort_key(*member_id)); members.dedup(); - let Some(canonical) = members - .first() - .and_then(|member_id| canonical_guarded_table_bootstrap_type(db, *member_id)) - else { + let Some(first) = members.first().copied() else { + continue; + }; + let Some(canonical) = canonical_guarded_table_bootstrap_type(db, first) else { continue; }; + // Every writer of the slot, not only the ones queued as candidates: the + // slot holds one table, so a writer left on its own literal forks the + // identity again, and whether it was queued depends on how far the walk + // had got when it ran. + let members = guarded_table_assignment_member_ids_for_owner_key(db, first) + .unwrap_or(members); for member_id in members { let owner = LuaTypeOwner::Member(member_id); if db @@ -2919,8 +2975,10 @@ fn canonical_guarded_table_bootstrap_type( db: &crate::DbIndex, member_id: LuaMemberId, ) -> Option { + // The guard is what names the table; a plain reset only points at it. let canonical = guarded_table_assignment_member_ids_for_owner_key(db, member_id)? .into_iter() + .filter(|candidate| is_guarded_table_assignment_member(db, *candidate)) .min_by_key(|candidate| member_id_sort_key(*candidate))?; guarded_table_bootstrap_member_type(db, canonical, false) From a5af2001674e155e382873f558e98fa64c9ea153 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:09:01 +0100 Subject: [PATCH 041/159] fix: a slot several files each gave a table lost every field to bare table --- .../lua/member_write_policy/scalar.rs | 110 +++++++---- .../src/compilation/test/member_infer_test.rs | 176 +++++++++++++++++- .../src/compilation/test/type_check_test.rs | 143 +++++++++++++- .../src/db_index/member/lua_member_item.rs | 24 ++- .../src/db_index/type/mod.rs | 100 ++++++++-- 5 files changed, 495 insertions(+), 58 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/scalar.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/scalar.rs index a4631d999..e3fc2c5a4 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/scalar.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/scalar.rs @@ -1,7 +1,7 @@ use crate::{ DbIndex, LuaTypeCache, TypeOps, db_index::LuaType, is_class_bootstrap_compatible_type, is_class_neutral_bootstrap_type, is_same_class_type, is_table_assignment_merge_type, - prefer_class_assignment_type, widen_related_assignment_type, + merge_table_assignment_types, prefer_class_assignment_type, widen_related_assignment_type, }; #[derive(Debug, Clone)] @@ -52,11 +52,22 @@ pub(in crate::compilation::analyzer::lua) fn merge_member_assignment_widening_st new_state: MemberAssignmentWideningState, assigned_type: &LuaType, ) { - state.no_table_literal_widen_type = TypeOps::Union.apply( - db, - &state.no_table_literal_widen_type, - &new_state.no_table_literal_widen_type, - ); + state.all_table_assignment_merge_types &= new_state.all_table_assignment_merge_types; + if state.all_table_assignment_merge_types { + state.no_table_literal_widen_type = merge_table_assignment_types( + db, + vec![ + state.no_table_literal_widen_type.clone(), + new_state.no_table_literal_widen_type, + ], + ); + } else { + state.no_table_literal_widen_type = TypeOps::Union.apply( + db, + &state.no_table_literal_widen_type, + &new_state.no_table_literal_widen_type, + ); + } state.table_literal_widen_type = TypeOps::Union.apply( db, &state.table_literal_widen_type, @@ -68,7 +79,6 @@ pub(in crate::compilation::analyzer::lua) fn merge_member_assignment_widening_st None => doc_type, }); } - state.all_table_assignment_merge_types &= new_state.all_table_assignment_merge_types; merge_class_bootstrap_cache_state( state, assigned_type, @@ -80,7 +90,7 @@ pub(in crate::compilation::analyzer::lua) fn merge_member_assignment_widening_st pub(in crate::compilation::analyzer::lua) fn decide_member_assignment_widening<'a>( db: &DbIndex, incoming_type: &LuaType, - allow_table_literal_widening: bool, + _allow_table_literal_widening: bool, previous_states: impl IntoIterator, ) -> MemberAssignmentWideningDecision { let previous_states = previous_states.into_iter().collect::>(); @@ -120,23 +130,22 @@ pub(in crate::compilation::analyzer::lua) fn decide_member_assignment_widening<' return MemberAssignmentWideningDecision::ClassBootstrapRejected; } - let should_widen_table_literals = allow_table_literal_widening - && is_table_assignment_merge_type(incoming_type) - && previous_states - .iter() - .all(|state| state.all_table_assignment_merge_types); + if should_merge_table_literals(incoming_type, &previous_states) { + return MemberAssignmentWideningDecision::Widened(merged_table_assignment_type( + db, + incoming_type, + &previous_states, + )); + } + let previous_type = merge_assignment_types( db, - previous_states.iter().map(|state| { - if should_widen_table_literals { - &state.table_literal_widen_type - } else { - &state.no_table_literal_widen_type - } - }), + previous_states + .iter() + .map(|state| &state.no_table_literal_widen_type), ) .expect("previous states are non-empty"); - let incoming_type = widen_related_assignment_type(incoming_type, should_widen_table_literals); + let incoming_type = widen_related_assignment_type(incoming_type, false); MemberAssignmentWideningDecision::Widened(TypeOps::Union.apply( db, @@ -145,28 +154,59 @@ pub(in crate::compilation::analyzer::lua) fn decide_member_assignment_widening<' )) } +/// Whether every writer of this slot assigns a table, so the answer is their +/// merge rather than a union of widened forms. +fn should_merge_table_literals( + incoming_type: &LuaType, + previous_states: &[&MemberAssignmentWideningState], +) -> bool { + is_table_assignment_merge_type(incoming_type) + && previous_states + .iter() + .all(|state| state.all_table_assignment_merge_types) +} + +/// The merge of every writer's table type. +/// +/// Answering bare `table` here -- which is what widening each writer to +/// `table_literal_widen_type` and unioning amounts to -- throws away the only +/// thing the writers carry, and every field of the slot then reads as nil-able. +/// The writers name one runtime table, so merging them is both more precise and +/// independent of which writer the batch happened to reach first. +fn merged_table_assignment_type( + db: &DbIndex, + incoming_type: &LuaType, + previous_states: &[&MemberAssignmentWideningState], +) -> LuaType { + let mut components = Vec::with_capacity(previous_states.len() + 1); + components.push(widen_related_assignment_type(incoming_type, false)); + for state in previous_states { + let component = &state.no_table_literal_widen_type; + if !components.contains(component) { + components.push(component.clone()); + } + } + + merge_table_assignment_types(db, components) +} + pub(in crate::compilation::analyzer::lua) fn union_member_assignment_widening<'a>( db: &DbIndex, incoming_type: &LuaType, - allow_table_literal_widening: bool, + _allow_table_literal_widening: bool, previous_states: impl IntoIterator, ) -> LuaType { let previous_states = previous_states.into_iter().collect::>(); - let should_widen_table_literals = allow_table_literal_widening - && is_table_assignment_merge_type(incoming_type) - && previous_states - .iter() - .all(|state| state.all_table_assignment_merge_types); - let incoming_type = widen_related_assignment_type(incoming_type, should_widen_table_literals); + if !previous_states.is_empty() && should_merge_table_literals(incoming_type, &previous_states) { + return merged_table_assignment_type(db, incoming_type, &previous_states); + } + + let incoming_type = widen_related_assignment_type(incoming_type, false); let Some(previous_type) = merge_assignment_types( db, - previous_states.iter().map(|state| { - if should_widen_table_literals { - &state.table_literal_widen_type - } else { - &state.no_table_literal_widen_type - } - }), + previous_states + .iter() + .map(|state| &state.no_table_literal_widen_type), ) else { return incoming_type; }; diff --git a/crates/glua_code_analysis/src/compilation/test/member_infer_test.rs b/crates/glua_code_analysis/src/compilation/test/member_infer_test.rs index ad3242954..b729c2e1f 100644 --- a/crates/glua_code_analysis/src/compilation/test/member_infer_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/member_infer_test.rs @@ -3502,8 +3502,8 @@ marauth.character = marauth.character or {} let cfg_type = local_name_type(&mut ws, consumer, "cfg"); assert!( - matches!(cfg_type, LuaType::Table), - "cross-file member merge should widen table literals to `table`, got {cfg_type:?}" + matches!(cfg_type, LuaType::MergedTable(_)), + "cross-file member merge should merge table literals to `MergedTable`, got {cfg_type:?}" ); } @@ -3839,3 +3839,175 @@ mod runtime_member_write_ownership { ); } } + +#[cfg(test)] +mod multi_site_member_and_alias_inference { + use googletest::assert_that; + use googletest::prelude::*; + use lsp_types::NumberOrString; + use tokio_util::sync::CancellationToken; + + use crate::{DiagnosticCode, VirtualWorkspace}; + + fn file_diagnostic_messages( + ws: &mut VirtualWorkspace, + file_id: crate::FileId, + diagnostic_code: DiagnosticCode, + ) -> Vec { + ws.analysis.diagnostic.enable_only(diagnostic_code); + let diagnostics = ws + .analysis + .diagnose_file(file_id, CancellationToken::new()) + .unwrap_or_default(); + let code = Some(NumberOrString::String( + diagnostic_code.get_name().to_string(), + )); + diagnostics + .iter() + .filter(|diagnostic| diagnostic.code == code) + .map(|diagnostic| diagnostic.message.clone()) + .collect() + } + + /// Guarded bootstrap globals across multiple files must merge their table + /// fields rather than widening to bare `table`, so fields like `.stored` + /// remain defined and non-nil. + #[test] + fn test_guarded_bootstrap_multi_file_table_fields_not_nil() { + let mut ws = VirtualWorkspace::new(); + ws.def_file( + "lua/cityrp/sh_item.lua", + r#" +cityrp = cityrp or {} +if not cityrp.item then + cityrp.item = { + stored = {}, + count = 0, + } +end +"#, + ); + ws.def_file( + "lua/cityrp/sv_item.lua", + r#" +cityrp = cityrp or {} +if not cityrp.item then + cityrp.item = { + stored = {}, + count = 0, + } +end +"#, + ); + let consumer = ws.def_file( + "lua/cityrp/consumer.lua", + r#" +local stored = cityrp.item.stored +local count = cityrp.item.count +"#, + ); + + assert_that!( + file_diagnostic_messages(&mut ws, consumer, DiagnosticCode::NeedCheckNil), + is_empty() + ); + assert_that!( + file_diagnostic_messages(&mut ws, consumer, DiagnosticCode::UndefinedField), + is_empty() + ); + } + + /// Nested table literals inside table fields looped over with `pairs` + /// must retain their populated fields on the loop variable. + #[test] + fn test_pairs_loop_over_table_with_nested_table_literals_preserves_field_types() { + let mut ws = VirtualWorkspace::new(); + let weapon_file = ws.def_file( + "lua/weapons/weapon_test.lua", + r#" +SWEP = {} +SWEP.VElements = { + ["element1"] = { + pos = {}, + angle = {}, + size = {}, + scale = 1, + }, + ["element2"] = { + pos = {}, + angle = {}, + size = {}, + scale = 2, + }, +} + +for k, v in pairs(SWEP.VElements) do + local pos = v.pos + local angle = v.angle + local size = v.size + local scale = v.scale +end +"#, + ); + + assert_that!( + file_diagnostic_messages(&mut ws, weapon_file, DiagnosticCode::NeedCheckNil), + is_empty() + ); + assert_that!( + file_diagnostic_messages(&mut ws, weapon_file, DiagnosticCode::UndefinedField), + is_empty() + ); + } + + /// Locals aliasing a shared global table across plugins, extended via + /// methods and included files, must not have their local read replaced by an + /// arbitrary settled global from a different plugin. + #[test] + fn test_plugin_shared_local_alias_preserves_included_fields_and_methods() { + let mut ws = VirtualWorkspace::new(); + ws.def_file( + "lua/plugins/plugin_a/config.lua", + r#" +local PLUGIN = PLUGIN_SHARED +PLUGIN.config = { enabled = true } +"#, + ); + let plugin_a = ws.def_file( + "lua/plugins/plugin_a/sh_init.lua", + r#" +PLUGIN_SHARED = PLUGIN_SHARED or {} +local PLUGIN = PLUGIN_SHARED +include("config.lua") + +function PLUGIN:IsColor(val) + return true +end + +function PLUGIN:Test() + local cfg = self.config + local c = self:IsColor(1) + local cfg2 = PLUGIN.config + local c2 = PLUGIN:IsColor(1) +end +"#, + ); + ws.def_file( + "lua/plugins/plugin_b/sh_init.lua", + r#" +PLUGIN_SHARED = PLUGIN_SHARED or {} +local PLUGIN = PLUGIN_SHARED +PLUGIN.other_field = 123 +"#, + ); + + assert_that!( + file_diagnostic_messages(&mut ws, plugin_a, DiagnosticCode::UndefinedField), + is_empty() + ); + assert_that!( + file_diagnostic_messages(&mut ws, plugin_a, DiagnosticCode::UndefinedMethod), + is_empty() + ); + } +} diff --git a/crates/glua_code_analysis/src/compilation/test/type_check_test.rs b/crates/glua_code_analysis/src/compilation/test/type_check_test.rs index 9bf66b5ac..113b189bc 100644 --- a/crates/glua_code_analysis/src/compilation/test/type_check_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/type_check_test.rs @@ -1,7 +1,34 @@ #[cfg(test)] mod test { + use crate::{DiagnosticCode, LuaType, VirtualWorkspace}; + use glua_parser::{LuaAstNode, LuaAstToken, LuaLocalName}; - use crate::{DiagnosticCode, VirtualWorkspace}; + #[allow(dead_code)] + fn local_name_type(ws: &mut VirtualWorkspace, file_id: crate::FileId, name: &str) -> LuaType { + let semantic_model = ws + .analysis + .compilation + .get_semantic_model(file_id) + .expect("expected semantic model"); + + let local_name = semantic_model + .get_root() + .descendants::() + .find(|local_name| { + local_name + .get_name_token() + .is_some_and(|token| token.get_name_text() == name) + }) + .expect("expected local name"); + let token = local_name + .get_name_token() + .expect("expected local name token"); + + semantic_model + .get_semantic_info(token.syntax().clone().into()) + .map(|info| info.display_typ().clone()) + .expect("expected semantic info for local name") + } #[test] fn test_issue_421() { @@ -34,4 +61,118 @@ mod test { "#, )); } + + #[test] + fn test_guarded_bootstrap_assign_type_mismatch() { + let mut ws = VirtualWorkspace::new(); + let file_1 = ws.def_file( + "lua/sh_item.lua", + r#" +cityrp = cityrp or {} +if not cityrp.item then cityrp.item = {stored = {}, cats = {}, catIndex = 1} end +function cityrp.item.new(base) + return {} +end +"#, + ); + let _file_2 = ws.def_file( + "lua/sv_item.lua", + r#" +cityrp = cityrp or {} +if not cityrp.item then cityrp.item = {stored = {}, cats = {}, catIndex = 1} end +"#, + ); + ws.analysis + .diagnostic + .enable_only(DiagnosticCode::AssignTypeMismatch); + let diags = ws + .analysis + .diagnose_file(file_1, tokio_util::sync::CancellationToken::new()) + .unwrap_or_default(); + println!("DIAGNOSTICS: {:?}", diags); + assert!( + diags.is_empty(), + "expected no assign type mismatch, got {:?}", + diags + ); + } + + #[test] + fn test_weapon_velements_need_check_nil() { + let mut ws = VirtualWorkspace::new(); + let _file_0 = ws.def_file( + "gamemodes/test/entities/weapons/base/shared.lua", + r#" +---@class Vector +---@field x number +---@field y number +---@field z number + +---@class Angle +---@field p number +---@field y number +---@field r number + +---@return Vector +function Vector(x, y, z) return {} end + +---@return Angle +function Angle(p, y, r) return {} end +"#, + ); + let file_1 = ws.def_file( + "gamemodes/test/entities/weapons/swep_test/shared.lua", + r#" +SWEP = {} +SWEP.VElements = { + ["element_name"] = { type = "Model", pos = Vector(1, 2, 3), angle = Angle(0, 0, 0), size = Vector(1, 1, 1) } +} + +function SWEP:Initialize() + if CLIENT then + self.VElements = table.FullCopy( self.VElements ) + end +end + +if CLIENT then + function SWEP:ViewModelDrawn() + local v = self.VElements["element_name"] + if not v then return end + local px = v.pos.x + local ax = v.angle.y + local sx = v.size.z + end + + function table.FullCopy(tab) + if not tab then return nil end + local res = {} + for k, v in pairs(tab) do + if (type(v) == "table") then + res[k] = table.FullCopy(v) + elseif (type(v) == "Vector") then + res[k] = Vector(v.x, v.y, v.z) + elseif (type(v) == "Angle") then + res[k] = Angle(v.p, v.y, v.r) + else + res[k] = v + end + end + return res + end +end +"#, + ); + ws.analysis + .diagnostic + .enable_only(DiagnosticCode::NeedCheckNil); + let diags = ws + .analysis + .diagnose_file(file_1, tokio_util::sync::CancellationToken::new()) + .unwrap_or_default(); + assert!( + diags.is_empty(), + "expected 0 need-check-nil diagnostics, got: {:?}", + diags + ); + } } diff --git a/crates/glua_code_analysis/src/db_index/member/lua_member_item.rs b/crates/glua_code_analysis/src/db_index/member/lua_member_item.rs index 98fd038b7..b5f3b81f6 100644 --- a/crates/glua_code_analysis/src/db_index/member/lua_member_item.rs +++ b/crates/glua_code_analysis/src/db_index/member/lua_member_item.rs @@ -499,7 +499,8 @@ fn resolve_member_type( match resolve_state { MemberTypeResolveState::All => { - let mut typ = LuaType::Never; + let mut collected_types = Vec::new(); + let mut all_are_table_merges = true; for member in &members { let member_type_cache = db .get_type_index() @@ -510,13 +511,32 @@ fn resolve_member_type( } let member_type = member_type_cache.as_type(); + if !is_table_assignment_merge_type(member_type) { + all_are_table_merges = false; + } let member_type = if should_widen_file_defines { widen_file_define_member_type(member_type, should_widen_table_literals) } else { member_type.clone() }; - typ = TypeOps::Union.apply(db, &typ, &member_type); + collected_types.push(member_type); } + + // Whether a writer is worth keeping is not decided here: a + // sibling still sitting at `unknown` is one the analysis has + // not reached yet, not one that carries nothing, and reading + // this slot before and after it lands would then answer + // differently for the same source. + let mut typ = if all_are_table_merges && !collected_types.is_empty() { + crate::merge_table_assignment_types(db, collected_types) + } else { + let mut t = LuaType::Never; + for member_type in collected_types { + t = TypeOps::Union.apply(db, &t, &member_type); + } + t + }; + if let Some(adapters) = build_generic_arity_adapters_for_overrides(db, &typ, &members) { diff --git a/crates/glua_code_analysis/src/db_index/type/mod.rs b/crates/glua_code_analysis/src/db_index/type/mod.rs index c57a6a369..e1894e1b0 100644 --- a/crates/glua_code_analysis/src/db_index/type/mod.rs +++ b/crates/glua_code_analysis/src/db_index/type/mod.rs @@ -241,14 +241,22 @@ pub(crate) fn widen_file_define_member_type(typ: &LuaType, widen_table_literals: } pub(crate) fn is_table_assignment_merge_type(typ: &LuaType) -> bool { - matches!( - typ, + match typ { LuaType::Table - | LuaType::TableConst(_) - | LuaType::Object(_) - | LuaType::MergedTable(_) - | LuaType::TableOf(_) - ) + | LuaType::TableConst(_) + | LuaType::Object(_) + | LuaType::MergedTable(_) + | LuaType::TableGeneric(_) + | LuaType::TableOf(_) => true, + LuaType::Union(union) => union + .types() + .all(|t| matches!(t, LuaType::Nil) || is_table_assignment_merge_type(t)), + LuaType::MultiLineUnion(multi) => multi + .get_unions() + .iter() + .all(|(t, _)| matches!(t, LuaType::Nil) || is_table_assignment_merge_type(t)), + _ => false, + } } pub(crate) fn prefer_class_assignment_type(typ: &LuaType) -> Option { @@ -362,7 +370,8 @@ pub(crate) fn prune_redundant_guarded_table_bootstrap_type(db: &DbIndex, typ: Lu return collapse_guarded_table_bootstrap_branches(db, types); } - merge_guarded_table_bootstrap_result( + merge_table_assignment_types( + db, types .into_iter() .filter(|typ| !is_guarded_table_bootstrap_branch(db, typ)) @@ -397,10 +406,16 @@ fn collapse_guarded_table_bootstrap_branches(db: &DbIndex, types: Vec) retained.push(LuaType::Table); } - merge_guarded_table_bootstrap_result(retained) + merge_table_assignment_types(db, retained) } -fn merge_guarded_table_bootstrap_result(types: Vec) -> LuaType { +/// Folds several writers' table types into one answer. +/// +/// Table components merge rather than union: a slot several files each assign a +/// table literal holds one table at runtime, and every field any writer spells +/// is a field it can have. Bare `table` drops out whenever a more precise +/// component is present, since it names no field and would only dilute them. +pub(crate) fn merge_table_assignment_types(db: &DbIndex, types: Vec) -> LuaType { let mut table_components = Vec::new(); let mut other_components = Vec::new(); @@ -409,6 +424,11 @@ fn merge_guarded_table_bootstrap_result(types: Vec) -> LuaType { } if table_components + .iter() + .any(|component| is_informative_guarded_table_branch(db, component)) + { + table_components.retain(|component| is_informative_guarded_table_branch(db, component)); + } else if table_components .iter() .any(|component| !matches!(component, LuaType::Table)) { @@ -447,25 +467,55 @@ fn collect_guarded_table_merge_components( ); } } + LuaType::Union(union) => { + for component in union.types() { + collect_guarded_table_merge_components( + component.clone(), + table_components, + other_components, + ); + } + } + LuaType::MultiLineUnion(multi_line) => { + for (component, _) in multi_line.get_unions() { + collect_guarded_table_merge_components( + component.clone(), + table_components, + other_components, + ); + } + } LuaType::Table | LuaType::TableConst(_) | LuaType::Object(_) | LuaType::TableGeneric(_) - | LuaType::TableOf(_) => table_components.push(typ), - _ => other_components.push(typ), + | LuaType::TableOf(_) => { + if !table_components.contains(&typ) { + table_components.push(typ); + } + } + _ => { + if !other_components.contains(&typ) { + other_components.push(typ); + } + } } } fn is_informative_guarded_table_branch(db: &DbIndex, typ: &LuaType) -> bool { match typ { LuaType::TableConst(table_id) => { - db.get_member_index() - .get_member_len(&LuaMemberOwner::Element(table_id.clone())) - > 0 - } - LuaType::Object(object) => { - !object.get_fields().is_empty() || !object.get_index_access().is_empty() + let member_index = db.get_member_index(); + let owner = LuaMemberOwner::Element(table_id.clone()); + if let Some(members) = member_index.get_members(&owner) { + members + .iter() + .any(|m| matches!(m.get_key(), crate::LuaMemberKey::Name(_))) + } else { + false + } } + LuaType::Object(object) => !object.get_fields().is_empty(), LuaType::MergedTable(merged) => merged .get_types() .iter() @@ -1104,6 +1154,20 @@ impl LuaTypeIndex { /// Stores `cache`, keeping [`Self::cache_refs`] in step, and reports /// whether a cache was replaced. fn insert_type_cache(&mut self, owner: LuaTypeOwner, cache: LuaTypeCache) -> bool { + if let Ok(want) = std::env::var("GLUALS_TRACE_WRITE") { + let key = format!("{:?}", owner); + if key.contains(&want) { + eprintln!( + "WRITE {} <- {:?} (was {:?})", + key, + cache.as_type(), + self.types.get(&owner).map(|c| c.as_type().clone()) + ); + if std::env::var("GLUALS_TRACE_BT").is_ok() { + eprintln!("{}", std::backtrace::Backtrace::force_capture()); + } + } + } if self .types .get(&owner) From 3011dd72207120092db1d2ce34ad05b04ecde3e3 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:09:08 +0100 Subject: [PATCH 042/159] fix: a global every plugin reassigns answered each file with another one --- .../src/compilation/analyzer/mod.rs | 63 +++++++++++++++---- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs index fd64bc283..08339f3a1 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs @@ -543,20 +543,36 @@ fn rederive_settled_global_reads(db: &mut DbIndex, context: &mut AnalyzeContext) context.infer_manager.clear_files_deferred_results(&files); for (decl_id, expr) in candidates { + let type_owner = LuaTypeOwner::Decl(decl_id); + let existing = db.get_type_index().get_type_cache(&type_owner); + if existing.is_some_and(|cached| cached.is_doc()) { + continue; + } + let cached = existing.map(|cached| cached.as_type().clone()); let cache = context.infer_manager.get_infer_cache(decl_id.file_id); let Ok(settled) = crate::semantic::infer_expr(db, cache, expr) else { continue; }; - let type_owner = LuaTypeOwner::Decl(decl_id); - let settled = common::widen_mutable_decl_literal( - db, - &type_owner, - LuaTypeCache::InferType(settled), - ); + // Re-deriving may only add to what the walk found, never swap it. The + // complete writer set is what a global read needs when the walk saw + // none of it, and it is what puts the second writer of a two-file table + // back after a re-index. But a global every file reassigns -- + // `PLUGIN_SHARED = PLUGIN` in each plugin -- settles to one arbitrary + // writer, and taking that over the writer the reading file's own + // include chain reaches would substitute an unrelated answer for a + // right one. + if let Some(cached) = &cached + && !crate::is_undetermined_type(cached) + && !settled_type_subsumes(cached, &settled) + { + continue; + } + let settled = + common::widen_mutable_decl_literal(db, &type_owner, LuaTypeCache::InferType(settled)); if db .get_type_index() .get_type_cache(&type_owner) - .is_some_and(|cached| cached.is_doc() || cached.as_type() == settled.as_type()) + .is_some_and(|cached| cached.as_type() == settled.as_type()) { continue; } @@ -564,6 +580,33 @@ fn rederive_settled_global_reads(db: &mut DbIndex, context: &mut AnalyzeContext) } } +/// Whether `settled` is `cached` with more of the writer set folded in, rather +/// than a different answer. +/// +/// A merge or a union the cached type is a component of says the walk saw part +/// of what has since landed; anything else says the two reads resolved to +/// different things, and the settled one carries no more authority for that +/// than the walk's. +fn settled_type_subsumes(cached: &LuaType, settled: &LuaType) -> bool { + if cached == settled { + return true; + } + match settled { + LuaType::MergedTable(merged) => merged + .get_types() + .iter() + .any(|component| settled_type_subsumes(cached, component)), + LuaType::Union(union) => union + .types() + .any(|component| settled_type_subsumes(cached, component)), + LuaType::MultiLineUnion(union) => union + .get_unions() + .iter() + .any(|(component, _)| settled_type_subsumes(cached, component)), + _ => false, + } +} + /// Re-derives member assignment widenings that ran against an incomplete /// set of sibling writers. fn rewiden_settled_member_assignments(db: &mut DbIndex, context: &mut AnalyzeContext) { @@ -793,7 +836,6 @@ fn refresh_local_decl_initializer_caches( continue; } if !copies_settled_iter_var - && settled_only && !current_is_uninformative && !can_refine_nominal_type @@ -847,7 +889,6 @@ fn refresh_local_decl_initializer_caches( && blind_type != inferred_type }; if !copies_settled_iter_var - && !current_is_uninformative && !can_refine_nominal_type && !can_upgrade_authority @@ -1041,8 +1082,8 @@ fn refresh_member_initializer_caches( // A member that copies a loop variable holds whatever that variable // held when the copy landed, and the settle has just moved it. What // it holds now is no evidence against re-reading it. - let copies_settled_iter_var = iter_vars_settled - && common::reads_settling_iter_var(db, file_id, &expr); + let copies_settled_iter_var = + iter_vars_settled && common::reads_settling_iter_var(db, file_id, &expr); if !copies_settled_iter_var && !current_is_uninformative && single_nominal_type_id(current_cache.as_type()).is_none() From c5ba463a34bd188f3486065290aacec0ea0aad4a Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:09:09 +0100 Subject: [PATCH 043/159] fix: a local guessed from its uses carried a nil none of its writers gave --- .../analyzer/local_inference/mod.rs | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/local_inference/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/local_inference/mod.rs index 66bf0f045..14be7b83e 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/local_inference/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/local_inference/mod.rs @@ -16,7 +16,7 @@ use crate::{ InFiled, LuaDefinitionId, LuaInferenceConfidence, LuaInferenceEventId, LuaInferenceNodeId, LuaInferenceProvenanceKind, LuaInferenceStep, LuaInferredGuardOwner, LuaInferredPositiveGuard, LuaMemberKey, LuaMemberOwner, LuaSignatureId, LuaType, LuaTypeDeclId, LuaTypeFact, - SignatureReturnStatus, + SignatureReturnStatus, TypeOps, compilation::analyzer::AnalyzeContext, semantic::{ infer_bind_value_type, infer_expr, infer_true_condition_narrowing, @@ -80,6 +80,20 @@ pub(super) fn stabilize_unknown_locals( let flow_tree = db.get_flow_index().get_flow_tree(&file_id); let mut cells = references.cells; cells.sort_by_key(|cell| cell.range.start()); + // One read the value could not survive as nil settles it for the whole + // declaration: reaching that read at all means no assignment left a nil + // behind. Without this the local keeps a `nil` it only ever picked up + // from the slots its other reads feed -- `draw.SimpleText`'s `number?` + // parameter -- and every use then wants a guard against it. + let proven_non_nil = cells.iter().any(|cell| { + !cell.is_write + && root + .covering_element(cell.range) + .ancestors() + .find_map(LuaNameExpr::cast) + .filter(|name| name.get_range() == cell.range) + .is_some_and(|name| read_would_error_on_nil(&name)) + }); for cell in cells { let Some(name_expr) = root .covering_element(cell.range) @@ -107,6 +121,11 @@ pub(super) fn stabilize_unknown_locals( else { continue; }; + let candidate = if proven_non_nil { + TypeOps::Remove.apply(db, &candidate, &LuaType::Nil) + } else { + candidate + }; if super::type_is_uninformative(&candidate) { continue; } @@ -163,6 +182,51 @@ pub(super) fn stabilize_unknown_locals( changed_any } +/// Whether reaching this read with a nil value would be a runtime error. +/// +/// Arithmetic, concatenation and length take a value apart; indexing and calling +/// dereference it. Each errors on nil, so the read stands as proof the value is +/// not nil. Every other position -- an argument, a return, the right side of an +/// assignment -- passes the value along and proves nothing. +fn read_would_error_on_nil(name_expr: &LuaNameExpr) -> bool { + let Some(parent) = name_expr.syntax().parent() else { + return false; + }; + if let Some(binary) = LuaBinaryExpr::cast(parent.clone()) { + return binary.get_op_token().is_some_and(|token| { + matches!( + token.get_op(), + BinaryOperator::OpAdd + | BinaryOperator::OpSub + | BinaryOperator::OpMul + | BinaryOperator::OpDiv + | BinaryOperator::OpIDiv + | BinaryOperator::OpMod + | BinaryOperator::OpPow + | BinaryOperator::OpConcat + ) + }); + } + if let Some(unary) = glua_parser::LuaUnaryExpr::cast(parent.clone()) { + return unary.get_op_token().is_some_and(|token| { + matches!( + token.get_op(), + glua_parser::UnaryOperator::OpUnm | glua_parser::UnaryOperator::OpLen + ) + }); + } + // The prefix of an index or a call is dereferenced; an argument is not. + if let Some(index) = LuaIndexExpr::cast(parent.clone()) { + return index + .get_prefix_expr() + .is_some_and(|prefix| prefix.syntax() == name_expr.syntax()); + } + LuaCallExpr::cast(parent).is_some_and(|call| { + call.get_prefix_expr() + .is_some_and(|prefix| prefix.syntax() == name_expr.syntax()) + }) +} + /// Re-derives what each candidate's own initializer says before anything is /// guessed from how it is used. /// From 32721c35dd08517a9424aa9868b2107bbe8a6369 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:09:09 +0100 Subject: [PATCH 044/159] fix: narrowing away table took the engine classes that are not tables --- .../src/db_index/type/type_ops/remove_type.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/glua_code_analysis/src/db_index/type/type_ops/remove_type.rs b/crates/glua_code_analysis/src/db_index/type/type_ops/remove_type.rs index df9d1d6fe..0b8a50f26 100644 --- a/crates/glua_code_analysis/src/db_index/type/type_ops/remove_type.rs +++ b/crates/glua_code_analysis/src/db_index/type/type_ops/remove_type.rs @@ -82,6 +82,11 @@ pub fn remove_type(db: &DbIndex, source: LuaType, removed_type: LuaType) -> Opti return remove_type(db, alias_ref.clone(), removed_type); } + // In Garry's Mod, engine/userdata classes have distinct type() names ("Vector", "Player", etc.) and are not table + if is_gmod_non_table_class(db, type_decl_id) { + return Some(source.clone()); + } + // 需要对`userdata`进行特殊处理 if let Some(super_types) = db.get_type_index().get_super_types_iter(type_decl_id) { for super_type in super_types { @@ -165,3 +170,28 @@ pub fn remove_type(db: &DbIndex, source: LuaType, removed_type: LuaType) -> Opti Some(source.clone()) } + +fn is_gmod_non_table_class(db: &crate::DbIndex, type_decl_id: &crate::LuaTypeDeclId) -> bool { + if !db.get_emmyrc().gmod.enabled { + return false; + } + let name = type_decl_id.get_name(); + match name { + "Vector" | "Angle" | "VMatrix" | "Entity" | "Player" | "NPC" | "Weapon" | "Vehicle" + | "NextBot" | "Panel" | "PhysObj" | "File" | "IMaterial" | "ITexture" | "ISave" + | "IRestore" | "IGModAudioChannel" | "PathFollower" | "CLuaEmitter" | "CLuaParticle" + | "CNavArea" | "CNavLadder" | "CNewParticleEffect" | "CSoundPatch" | "CTakeDamageInfo" + | "CUserCmd" | "bf_read" => true, + _ => { + let mut supers = Vec::new(); + type_decl_id.collect_super_types(db, &mut supers); + supers.iter().any(|st| { + if let LuaType::Ref(sid) | LuaType::Def(sid) = st { + matches!(sid.get_name(), "Entity" | "Panel") + } else { + false + } + }) + } + } +} From b4d4e1c826bdf5c0757cb28cbdfb05cc9990ff27 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:09:09 +0100 Subject: [PATCH 045/159] fix: a loop value union kept any beside the record types it stood in for --- .../src/compilation/analyzer/lua/for_range_stat.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/for_range_stat.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/for_range_stat.rs index 3daab7abb..7f0e804b5 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/for_range_stat.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/for_range_stat.rs @@ -421,11 +421,14 @@ fn compact_pairs_key_type(keys: &[LuaType]) -> LuaType { } fn compact_pairs_value_type(db: &DbIndex, values: Vec) -> LuaType { - let values = values + let mut values = values .into_iter() .map(|value| remove_pairs_yield_nil(db, &value)) .filter(|value| !value.is_unknown() && !value.is_never()) .collect::>(); + if values.iter().any(|v| !v.is_any()) { + values.retain(|v| !v.is_any()); + } if values.is_empty() { // All observed values were nil-only or otherwise uninformative; avoid collapsing to Nil. return LuaType::Unknown; From 4f89c5d94d8641cd57f5518f92dc00770ed503e5 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:09:16 +0100 Subject: [PATCH 046/159] fix: a nil check asked only the union arm the prefix happened to name --- .../src/diagnostic/checker/need_check_nil.rs | 219 ++++++++++++++++-- 1 file changed, 194 insertions(+), 25 deletions(-) diff --git a/crates/glua_code_analysis/src/diagnostic/checker/need_check_nil.rs b/crates/glua_code_analysis/src/diagnostic/checker/need_check_nil.rs index 787c92b3e..3f4968f58 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/need_check_nil.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/need_check_nil.rs @@ -697,6 +697,9 @@ fn check_index_expr( } let prefix_type = semantic_model.infer_expr(prefix.clone()).ok()?; + if prefix_type.is_never() { + return Some(()); + } if prefix_type.is_nullable() { if !prefix_type.is_nil() && let LuaExpr::IndexExpr(prefix_index_expr) = &prefix @@ -748,26 +751,98 @@ fn index_expr_has_non_nullable_current_member( let Ok(prefix_type) = semantic_model.infer_expr(prefix_expr) else { return false; }; - let Some(owner) = member_owner_for_type(prefix_type) else { - return false; - }; let Some(key) = literal_member_key(index_expr) else { return false; }; let db = semantic_model.get_db(); - let Some(member_item) = db.get_member_index().get_member_item(&owner, &key) else { - return false; - }; - let Ok(member_type) = member_item.resolve_type_with_realm_at_offset( + type_has_non_nullable_member( db, &semantic_model.get_file_id(), index_expr.get_position(), - ) else { - return false; - }; + &prefix_type, + &key, + ) +} - !member_type.is_nullable() +fn type_has_non_nullable_member( + db: &crate::DbIndex, + caller_file_id: &crate::FileId, + position: rowan::TextSize, + typ: &LuaType, + key: &LuaMemberKey, +) -> bool { + match typ { + LuaType::TableConst(in_file_range) => { + let owner = LuaMemberOwner::Element(in_file_range.clone()); + let Some(member_item) = db.get_member_index().get_member_item(&owner, key) else { + return false; + }; + let Ok(member_type) = + member_item.resolve_type_with_realm_at_offset(db, caller_file_id, position) + else { + return false; + }; + !member_type.is_nullable() + } + LuaType::Def(def_id) | LuaType::Ref(def_id) => { + let member_index = db.get_member_index(); + let all_types = def_id.collect_super_types_with_self(db, typ.clone()); + for t in all_types { + let owner = match t { + LuaType::Ref(id) | LuaType::Def(id) => LuaMemberOwner::Type(id), + _ => continue, + }; + if let Some(member_item) = member_index.get_member_item(&owner, key) { + if let Ok(member_type) = + member_item.resolve_type_with_realm_at_offset(db, caller_file_id, position) + { + if !member_type.is_nullable() { + return true; + } + } + } + } + false + } + LuaType::Instance(instance) => { + type_has_non_nullable_member(db, caller_file_id, position, instance.get_base(), key) + } + LuaType::Object(object) => { + if let Some(field_type) = object.get_field(key) { + !field_type.is_nullable() + } else { + false + } + } + LuaType::MergedTable(merged) => { + let types = merged.get_types(); + !types.is_empty() + && types + .iter() + .all(|t| type_has_non_nullable_member(db, caller_file_id, position, t, key)) + } + LuaType::Union(union) => { + let types: Vec<_> = union.types().filter(|t| !t.is_nil()).collect(); + !types.is_empty() + && types + .iter() + .all(|t| type_has_non_nullable_member(db, caller_file_id, position, t, key)) + } + LuaType::MultiLineUnion(mlu) => { + let types: Vec<_> = mlu + .get_unions() + .iter() + .map(|(t, _)| t) + .filter(|t| !t.is_nil()) + .collect(); + !types.is_empty() + && types + .iter() + .all(|t| type_has_non_nullable_member(db, caller_file_id, position, t, key)) + } + _ => false, + } } fn member_owner_for_type(typ: LuaType) -> Option { @@ -3287,6 +3362,32 @@ fn return_type_is_non_nullable_type_guard(return_type: &LuaType) -> bool { } } +fn is_definitely_nullable(typ: &LuaType) -> bool { + if typ.is_unknown() || typ.is_any() { + return false; + } + match typ { + LuaType::Nil => true, + LuaType::Union(union) => { + let has_nil = union.types().any(|t| matches!(t, LuaType::Nil)); + let has_unconstrained = union.types().any(|t| t.is_any() || t.is_unknown()); + has_nil && !has_unconstrained + } + LuaType::MultiLineUnion(mlu) => { + let has_nil = mlu + .get_unions() + .iter() + .any(|(t, _)| matches!(t, LuaType::Nil)); + let has_unconstrained = mlu + .get_unions() + .iter() + .any(|(t, _)| t.is_any() || t.is_unknown()); + has_nil && !has_unconstrained + } + _ => false, + } +} + fn check_binary_expr( context: &mut DiagnosticContext, semantic_model: &SemanticModel, @@ -3335,31 +3436,47 @@ fn check_binary_expr( ) { let left_type = semantic_model.infer_expr(left.clone()).ok()?; - if left_type.is_nullable() + if is_definitely_nullable(&left_type) && !is_expr_guarded_by_prior_nil_early_return(semantic_model, &left) && !is_expr_guarded_by_correlated_multi_return(semantic_model, &left) && !is_expr_proven_by_falsy_param_nil_free_return_slot(semantic_model, &left) { - context.add_diagnostic( - DiagnosticCode::NeedCheckNil, - left.get_range(), - format!("{name} value may be nil", name = left.syntax().text()).to_string(), - None, - ); + let is_non_nullable_member = match &left { + LuaExpr::IndexExpr(left_index) => { + index_expr_has_non_nullable_current_member(semantic_model, left_index) + } + _ => false, + }; + if !is_non_nullable_member { + context.add_diagnostic( + DiagnosticCode::NeedCheckNil, + left.get_range(), + format!("{name} value may be nil", name = left.syntax().text()).to_string(), + None, + ); + } } let right_type = semantic_model.infer_expr(right.clone()).ok()?; - if right_type.is_nullable() + if is_definitely_nullable(&right_type) && !is_expr_guarded_by_prior_nil_early_return(semantic_model, &right) && !is_expr_guarded_by_correlated_multi_return(semantic_model, &right) && !is_expr_proven_by_falsy_param_nil_free_return_slot(semantic_model, &right) { - context.add_diagnostic( - DiagnosticCode::NeedCheckNil, - right.get_range(), - format!("{name} value may be nil", name = right.syntax().text()).to_string(), - None, - ); + let is_non_nullable_member = match &right { + LuaExpr::IndexExpr(right_index) => { + index_expr_has_non_nullable_current_member(semantic_model, right_index) + } + _ => false, + }; + if !is_non_nullable_member { + context.add_diagnostic( + DiagnosticCode::NeedCheckNil, + right.get_range(), + format!("{name} value may be nil", name = right.syntax().text()).to_string(), + None, + ); + } } } @@ -3395,6 +3512,19 @@ fn check_condition_expr( } } expr => { + let truthy_expr = match &expr { + LuaExpr::UnaryExpr(unary) + if unary + .get_op_token() + .is_some_and(|t| t.get_op() == UnaryOperator::OpNot) => + { + unary.get_expr().unwrap_or(expr.clone()) + } + _ => expr.clone(), + }; + if is_sentinel_followed_by_type_guard(semantic_model, &truthy_expr) { + return; + } if let Ok(expr_type) = semantic_model.infer_expr(expr.clone()) && contains_gmod_null_type(semantic_model.get_db(), &expr_type) { @@ -3561,6 +3691,45 @@ fn is_nil_sentinel_comparison_before_type_guard_elseif( }) } +fn is_sentinel_followed_by_type_guard(semantic_model: &SemanticModel, expr: &LuaExpr) -> bool { + let Some(if_stat) = expr.syntax().ancestors().find_map(LuaIfStat::cast) else { + return false; + }; + if !if_body_has_return(&if_stat) { + return false; + } + let mut next_sibling = if_stat.syntax().next_sibling(); + while let Some(sibling) = next_sibling { + if let Some(next_if) = LuaIfStat::cast(sibling.clone()) { + if let Some(cond) = next_if.get_condition_expr() { + let truthy_cond = match &cond { + LuaExpr::UnaryExpr(unary) + if unary + .get_op_token() + .is_some_and(|t| t.get_op() == UnaryOperator::OpNot) => + { + unary.get_expr().unwrap_or(cond.clone()) + } + _ => cond.clone(), + }; + if let Some(guard_call) = unwrap_paren_call(truthy_cond) { + if is_type_guard_call_guarding_expr(semantic_model, &guard_call, expr) + || type_guard_call_textually_guards_expr(semantic_model, &guard_call, expr) + { + return true; + } + } + } + break; + } + if !sibling.kind().to_token().is_trivia() { + break; + } + next_sibling = sibling.next_sibling(); + } + false +} + fn unwrap_paren_call(expr: LuaExpr) -> Option { match expr { LuaExpr::CallExpr(call_expr) => Some(call_expr), From 3e4d802a47c36db90cb649f9fd6b125658fcf6bc Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:09:16 +0100 Subject: [PATCH 047/159] fix: assigning a table literal to the slot it merges into read as a mismatch --- .../diagnostic/checker/assign_type_mismatch.rs | 15 ++++++++++++++- .../src/semantic/type_check/complex_type/mod.rs | 11 ++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/glua_code_analysis/src/diagnostic/checker/assign_type_mismatch.rs b/crates/glua_code_analysis/src/diagnostic/checker/assign_type_mismatch.rs index 53aca7274..f3f751383 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/assign_type_mismatch.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/assign_type_mismatch.rs @@ -189,7 +189,20 @@ fn check_index_expr( let source_is_inferred = inferred_member_flags(semantic_model, index_expr) .map(|(is_inferred, _)| is_inferred) - .unwrap_or(false); + .unwrap_or_else(|| { + index_expr + .get_prefix_expr() + .and_then(|prefix| semantic_model.infer_expr(prefix).ok()) + .is_some_and(|t| { + matches!( + t, + LuaType::TableConst(_) + | LuaType::Table + | LuaType::Object(_) + | LuaType::MergedTable(_) + ) + }) + }); // Prefer the pre-write member type to avoid the current assignment // widening the target field type before comparison. diff --git a/crates/glua_code_analysis/src/semantic/type_check/complex_type/mod.rs b/crates/glua_code_analysis/src/semantic/type_check/complex_type/mod.rs index 4d33589b0..95cb96136 100644 --- a/crates/glua_code_analysis/src/semantic/type_check/complex_type/mod.rs +++ b/crates/glua_code_analysis/src/semantic/type_check/complex_type/mod.rs @@ -172,10 +172,19 @@ fn check_merged_table_type_compact( compact_type: &LuaType, check_guard: TypeCheckGuard, ) -> TypeCheckResult { - if matches!(compact_type, LuaType::Any | LuaType::Table) { + if matches!( + compact_type, + LuaType::Any | LuaType::Table | LuaType::TableConst(_) + ) { return Ok(()); } + if let LuaType::MergedTable(merged) = source { + if merged.get_types().iter().any(|comp| comp == compact_type) { + return Ok(()); + } + } + let Some(object) = structural_object_from_members(context, source) else { return Err(TypeCheckFailReason::DonotCheck); }; From 3150f279a12c5bd71473c8800a9941a81edf46e0 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:09:17 +0100 Subject: [PATCH 048/159] feat: parse ldoc bracketed return groups --- crates/glua_parser/src/grammar/doc/tag.rs | 10 ++++++++++ crates/glua_parser/src/lexer/lua_doc_lexer.rs | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/glua_parser/src/grammar/doc/tag.rs b/crates/glua_parser/src/grammar/doc/tag.rs index 8568c62aa..48ee274e3 100644 --- a/crates/glua_parser/src/grammar/doc/tag.rs +++ b/crates/glua_parser/src/grammar/doc/tag.rs @@ -413,6 +413,16 @@ fn parse_tag_return(p: &mut LuaDocParser) -> DocParseResult { let m = p.mark(LuaSyntaxKind::DocTagReturn); p.bump(); + if p.current_token() == LuaTokenKind::TkLeftBracket { + p.bump(); + while p.current_token() != LuaTokenKind::TkRightBracket + && p.current_token() != LuaTokenKind::TkEof + { + p.bump(); + } + if_token_bump(p, LuaTokenKind::TkRightBracket); + } + if p.current_token() == LuaTokenKind::TkLeftParen && is_type_modifier_flag(p) { parse_doc_type_flag(p)?; } diff --git a/crates/glua_parser/src/lexer/lua_doc_lexer.rs b/crates/glua_parser/src/lexer/lua_doc_lexer.rs index 9077c8c8a..e3720c96a 100644 --- a/crates/glua_parser/src/lexer/lua_doc_lexer.rs +++ b/crates/glua_parser/src/lexer/lua_doc_lexer.rs @@ -708,7 +708,7 @@ fn to_tag(text: &str) -> LuaTokenKind { "field" => LuaTokenKind::TkTagField, "type" => LuaTokenKind::TkTagType, "param" => LuaTokenKind::TkTagParam, - "return" => LuaTokenKind::TkTagReturn, + "return" | "treturn" => LuaTokenKind::TkTagReturn, "return_cast" => LuaTokenKind::TkTagReturnCast, "generic" => LuaTokenKind::TkTagGeneric, "see" => LuaTokenKind::TkTagSee, From 857549d880bce9afbd74a0f454e169caa0f49022 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:09:17 +0100 Subject: [PATCH 049/159] fix: a dynamic key read answered nil for a table only such writes fill --- .../src/compilation/analyzer/lua/mod.rs | 2 +- .../src/compilation/analyzer/lua/stats.rs | 24 ++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs index a52f41b44..6def389cd 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs @@ -27,12 +27,12 @@ use module::analyze_chunk_return; pub use module::compute_module_semantic_id; pub(in crate::compilation::analyzer) use settled_contributions::rederive_contributed_member_assignments; pub(crate) use stats::dominating_guarded_table_bootstrap_range; -pub(crate) use stats::{expr_fills_own_default, expr_reads_out_of_decl}; pub(in crate::compilation::analyzer) use stats::resettle_guarded_table_bootstraps; use stats::{ analyze_assign_stat, analyze_func_stat, analyze_local_func_stat, analyze_local_stat, analyze_table_field, flush_pending_dynamic_key_collection_widenings, }; +pub(crate) use stats::{expr_fills_own_default, expr_reads_out_of_decl}; pub(in crate::compilation::analyzer) use stats::{ get_widened_member_assignment_type, has_multiple_distinct_index_expr_member_owners, is_guarded_table_assignment_index_expr, is_guarded_table_assignment_member, diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index 3ed919710..c3019a0f1 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -19,9 +19,8 @@ use crate::{ }; use glua_parser::{ BinaryOperator, LuaAssignStat, LuaAstNode, LuaBinaryExpr, LuaClosureExpr, LuaExpr, LuaFuncStat, - LuaIndexExpr, - LuaIndexKey, LuaLiteralToken, LuaLocalFuncStat, LuaLocalStat, LuaNameExpr, LuaSyntaxKind, - LuaTableExpr, LuaTableField, LuaVarExpr, PathTrait, + LuaIndexExpr, LuaIndexKey, LuaLiteralToken, LuaLocalFuncStat, LuaLocalStat, LuaNameExpr, + LuaSyntaxKind, LuaTableExpr, LuaTableField, LuaVarExpr, PathTrait, }; use rustc_hash::FxHashMap; @@ -2427,7 +2426,10 @@ fn is_member_assignment_in_conditional_branch(db: &DbIndex, member_id: LuaMember /// Whether any writer of this member's slot bootstraps it with `x.y = x.y or /// {}`, this one included. -pub(in crate::compilation::analyzer) fn slot_has_guarded_table_bootstrap(db: &DbIndex, member_id: LuaMemberId) -> bool { +pub(in crate::compilation::analyzer) fn slot_has_guarded_table_bootstrap( + db: &DbIndex, + member_id: LuaMemberId, +) -> bool { let member_index = db.get_member_index(); let Some(owner) = member_index.get_member_owner(&member_id) else { return false; @@ -2890,7 +2892,10 @@ fn guarded_table_bootstrap_range( ) -> Option { let tree = db.get_vfs().get_syntax_tree(&member_id.file_id)?; let root = tree.get_red_root(); - guarded_bootstrap_range_for_node(member_id.get_syntax_id().to_node_from_root(&root)?, empty_only) + guarded_bootstrap_range_for_node( + member_id.get_syntax_id().to_node_from_root(&root)?, + empty_only, + ) } /// The one table a repeated `x.y = x.y or {}` guard names. @@ -2950,8 +2955,8 @@ pub(in crate::compilation::analyzer) fn resettle_guarded_table_bootstraps( // slot holds one table, so a writer left on its own literal forks the // identity again, and whether it was queued depends on how far the walk // had got when it ran. - let members = guarded_table_assignment_member_ids_for_owner_key(db, first) - .unwrap_or(members); + let members = + guarded_table_assignment_member_ids_for_owner_key(db, first).unwrap_or(members); for member_id in members { let owner = LuaTypeOwner::Member(member_id); if db @@ -3689,7 +3694,10 @@ mod tests { ) .expect("preserved table-literal cache should stay enabled") .expect("preserved table-literal cache should return a widened type"); - assert_eq!(cached_type, LuaType::Table, "unexpected type at member {i}"); + assert!( + matches!(cached_type, LuaType::MergedTable(_)), + "unexpected type at member {i}: {cached_type:?}" + ); cache_hits += 1; } From cb8243fd6d1a14015013fec1711b446526fc8692 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:28:55 +0100 Subject: [PATCH 050/159] fix: a computed key gave one slot as many owners as ways to spell it --- .../src/compilation/analyzer/decl/members.rs | 12 ++--- .../src/diagnostic/checker/check_field.rs | 4 +- .../src/semantic/infer/infer_index/mod.rs | 4 +- .../src/syntax/node/lua/path_trait.rs | 45 +++++++++++++++++++ 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/decl/members.rs b/crates/glua_code_analysis/src/compilation/analyzer/decl/members.rs index 139b4105c..fead7a96f 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/decl/members.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/decl/members.rs @@ -13,13 +13,13 @@ pub fn find_index_owner( if let Some(prefix_expr) = index_expr.get_prefix_expr() { match prefix_expr { LuaExpr::IndexExpr(parent_index_expr) => { - if let Some(parent_access_path) = parent_index_expr.get_access_path() { + if let Some(parent_access_path) = parent_index_expr.get_owner_access_path() { if let Some(module_path) = rewrite_legacy_module_member_path( analyzer, &parent_access_path, index_expr.get_position(), ) { - if let Some(access_path) = index_expr.get_access_path() + if let Some(access_path) = index_expr.get_owner_access_path() && let Some(global_path) = rewrite_legacy_module_member_path( analyzer, &access_path, @@ -42,7 +42,7 @@ pub fn find_index_owner( ); } - if let Some(access_path) = index_expr.get_access_path() { + if let Some(access_path) = index_expr.get_owner_access_path() { return ( LuaMemberOwner::GlobalPath(GlobalId( SmolStr::new(parent_access_path).into(), @@ -70,7 +70,7 @@ pub fn find_index_owner( parent_path.as_str(), index_expr.get_position(), ) { - if let Some(access_path) = index_expr.get_access_path() + if let Some(access_path) = index_expr.get_owner_access_path() && let Some(global_path) = rewrite_legacy_module_member_path( analyzer, &access_path, @@ -93,7 +93,7 @@ pub fn find_index_owner( ); } - if let Some(access_path) = index_expr.get_access_path() { + if let Some(access_path) = index_expr.get_owner_access_path() { return ( LuaMemberOwner::GlobalPath(GlobalId( SmolStr::new(parent_path).into(), @@ -110,7 +110,7 @@ pub fn find_index_owner( } _ => {} } - } else if let Some(access_path) = index_expr.get_access_path() { + } else if let Some(access_path) = index_expr.get_owner_access_path() { return ( LuaMemberOwner::LocalUnresolve, Some(GlobalId(SmolStr::new(access_path).into())), diff --git a/crates/glua_code_analysis/src/diagnostic/checker/check_field.rs b/crates/glua_code_analysis/src/diagnostic/checker/check_field.rs index d261522ed..b29717f08 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/check_field.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/check_field.rs @@ -1519,8 +1519,8 @@ fn global_expr_access_path( } match expr { - LuaExpr::NameExpr(name_expr) => name_expr.get_access_path(), - LuaExpr::IndexExpr(index_expr) => index_expr.get_access_path(), + LuaExpr::NameExpr(name_expr) => name_expr.get_owner_access_path(), + LuaExpr::IndexExpr(index_expr) => index_expr.get_owner_access_path(), _ => 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 ed1169d52..de4013476 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 @@ -2595,8 +2595,8 @@ fn global_expr_access_path(db: &DbIndex, file_id: FileId, expr: &LuaExpr) -> Opt } match expr { - LuaExpr::NameExpr(name_expr) => name_expr.get_access_path().map(Into::into), - LuaExpr::IndexExpr(index_expr) => index_expr.get_access_path().map(Into::into), + LuaExpr::NameExpr(name_expr) => name_expr.get_owner_access_path().map(Into::into), + LuaExpr::IndexExpr(index_expr) => index_expr.get_owner_access_path().map(Into::into), _ => None, } } diff --git a/crates/glua_parser/src/syntax/node/lua/path_trait.rs b/crates/glua_parser/src/syntax/node/lua/path_trait.rs index 13c910791..5e01d1a9e 100644 --- a/crates/glua_parser/src/syntax/node/lua/path_trait.rs +++ b/crates/glua_parser/src/syntax/node/lua/path_trait.rs @@ -68,6 +68,51 @@ pub trait PathTrait: LuaAstNode { } } + /// The access path used for *member-owner identity*, where a computed key + /// collapses to `[]`. + /// + /// [`get_access_path`](Self::get_access_path) spells a computed key out, so + /// `t[a]` and `t[b]` are distinct there -- which is what flow narrowing + /// needs, since those are different values. An owner is the other question: + /// both index the same table, so a field written through one has to be + /// visible to the other. Keeping the key text here gave one runtime slot as + /// many owners as the source had ways to spell its key, and + /// `clans[v.id].models = {}` was then invisible to `clans[ply._Clan].models`. + fn get_owner_access_path(&self) -> Option { + let mut paths: Vec = Vec::new(); + let mut current_node = self.syntax().clone(); + loop { + match LuaExpr::cast(current_node)? { + LuaExpr::NameExpr(name_expr) => { + let name = name_expr.get_name_text()?; + if paths.is_empty() { + return Some(name); + } + paths.push(name); + paths.reverse(); + return Some(join_path(&paths)); + } + LuaExpr::CallExpr(call_expr) => { + current_node = call_expr.get_prefix_expr()?.syntax().clone(); + } + LuaExpr::IndexExpr(index_expr) => { + match index_expr.get_index_key()? { + LuaIndexKey::String(s) => paths.push(SmolStr::new(s.get_value())), + LuaIndexKey::Name(name) => paths.push(SmolStr::new(name.get_name_text())), + LuaIndexKey::Integer(i) => { + paths.push(SmolStr::new(i.get_number_value().to_string())) + } + LuaIndexKey::Expr(_) | LuaIndexKey::Idx(_) => { + paths.push(SmolStr::new_static("[]")) + } + } + current_node = index_expr.get_prefix_expr()?.syntax().clone(); + } + _ => return None, + } + } + } + fn get_member_path(&self) -> Option { let mut paths = Vec::new(); let mut current_node = self.syntax().clone(); From 328047fa7a7abc049fa55f9a5bdf1a1b22db6e02 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:05:15 +0100 Subject: [PATCH 051/159] fix: an earlier write that resolved to nothing took the slot from one that did --- .../src/compilation/analyzer/common/mod.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs index 606b01b2d..d8a2c6c76 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs @@ -291,7 +291,17 @@ pub fn bind_decl_write( // whatever is in it was not put there by an ordered write. None => false, Some((claimed, claim_may_narrow)) => { + // Source position arbitrates between two answers, not + // between an answer and none. An earlier write that came + // back undetermined -- an unresolve retry that still + // cannot see through its initializer -- must not take the + // slot from a later one that resolved, or the + // declaration is left needing its type guessed from how + // it is used. + let displaces_an_answer = is_undetermined_type(seeded.as_type()) + && is_informative_type(existing.as_type()); position < claimed + && !displaces_an_answer && !claiming_write_would_have_won( &type_owner, &seeded, From bfece741ac7c2cdb2cf1340077adb3d4c20eaade Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:49:34 +0100 Subject: [PATCH 052/159] fix: a named read of a registry another file fills came back nil-able --- .../src/semantic/infer/infer_index/mod.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 de4013476..08fb5ee0d 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 @@ -679,7 +679,15 @@ fn infer_table_member_owner( } if table_has_cross_file_matching_expr_key_member(db, &owner, &key, cache.get_file_id()) { - return Ok(nullable_any_type()); + // Another file fills this table under a computed key, so the value + // behind a named key is not something the source states -- but that + // another file writes it is weak evidence the key IS there, never + // evidence it is absent. Answering `any?` would put a nil on every + // named read of a registry (`cityrp.item.stored.pot`) that a bare + // `table` answers as `any` with no nil at all, which is strictly + // less that we know. Whether a computed key may be missing is + // decided per access by `table_index_result_may_be_nil`. + return Ok(LuaType::Any); } } From 7561efb889699e370d6379b3b4a5f2b394873e58 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:06:01 +0100 Subject: [PATCH 053/159] fix: an isvalid guard narrowed a boolean to true instead of dropping it --- .../src/compilation/test/flow.rs | 57 +++++++++++++++++++ .../infer/narrow/condition_flow/call_flow.rs | 31 +++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/crates/glua_code_analysis/src/compilation/test/flow.rs b/crates/glua_code_analysis/src/compilation/test/flow.rs index 5c9eb4af0..070153ea4 100644 --- a/crates/glua_code_analysis/src/compilation/test/flow.rs +++ b/crates/glua_code_analysis/src/compilation/test/flow.rs @@ -3051,6 +3051,63 @@ _2 = a[1] )); } + #[gtest] + fn test_isvalid_guard_drops_boolean_from_union() { + let mut ws = VirtualWorkspace::new(); + set_gmod_enabled(&mut ws); + def_isvalid_guard(&mut ws); + + // `getEntOrBool` widens to `Entity|boolean` the way a `local rp = false` + // that later takes an entity does; `IsValid` answers true only for a live + // handle, so the true branch must be `Entity`, never `Entity|true`. + let file_id = ws.def( + r#" + ---@return Entity|boolean + local function getEntOrBool() end + + local function use() + local x = getEntOrBool() + if IsValid(x) then + local narrowed = x + print(narrowed) + end + end + "#, + ); + + let narrowed = nth_name_expr_type_from_end(&mut ws, file_id, "narrowed", 0); + assert_eq!(ws.humanize_type(narrowed), "Entity"); + } + + #[gtest] + fn test_plain_truthiness_keeps_boolean_true_unlike_isvalid() { + let mut ws = VirtualWorkspace::new(); + set_gmod_enabled(&mut ws); + def_isvalid_guard(&mut ws); + + // The `IsValid` narrowing above is stronger than truthiness on purpose: + // a bare `if x` cannot rule out `x` being the boolean `true`, so the + // truthy component stays. This pins that the fix did not collapse the + // two into one behaviour. + let file_id = ws.def( + r#" + ---@return Entity|boolean + local function getEntOrBool() end + + local function use() + local x = getEntOrBool() + if x then + local narrowed = x + print(narrowed) + end + end + "#, + ); + + let narrowed = nth_name_expr_type_from_end(&mut ws, file_id, "narrowed", 0); + assert_eq!(ws.humanize_type(narrowed), "(Entity|true)"); + } + #[gtest] fn test_unannotated_predicate_wrapper_narrows_member_expression_on_true_branch() { let mut ws = VirtualWorkspace::new(); diff --git a/crates/glua_code_analysis/src/semantic/infer/narrow/condition_flow/call_flow.rs b/crates/glua_code_analysis/src/semantic/infer/narrow/condition_flow/call_flow.rs index 725b3ba26..1a4ebc23a 100644 --- a/crates/glua_code_analysis/src/semantic/infer/narrow/condition_flow/call_flow.rs +++ b/crates/glua_code_analysis/src/semantic/infer/narrow/condition_flow/call_flow.rs @@ -716,7 +716,36 @@ fn narrow_valid_guard_true_branch( } let truthy_type = remove_false_or_nil(antecedent_type); - TypeOps::Remove.apply(db, &truthy_type, &gmod_null_type()) + let non_null = TypeOps::Remove.apply(db, &truthy_type, &gmod_null_type()); + // `IsValid` answers true only for a live engine handle, never for a + // boolean/number/string. Plain truthiness keeps a `boolean` as `true`, but + // that surviving `true` cannot be the thing `IsValid` accepted, so any + // primitive left in a union has to drop out -- otherwise a value typed + // `Entity|boolean` narrows to `Entity|true` and every later field read on it + // reports a phantom nil. + remove_non_validatable_primitives(non_null) +} + +/// Strips the primitive value types `IsValid` can never accept from a union. +/// A lone primitive is left untouched: `IsValid` being true over a purely +/// primitive type is unreachable rather than informative, and answering `never` +/// there would only trade a phantom nil for a phantom empty type. +fn remove_non_validatable_primitives(typ: LuaType) -> LuaType { + let LuaType::Union(union) = &typ else { + return typ; + }; + let kept: Vec = union + .types() + .filter(|component| { + !(component.is_boolean() || component.is_number() || component.is_string()) + }) + .cloned() + .collect(); + if kept.is_empty() { + typ + } else { + LuaType::from_vec(kept) + } } fn apply_positive_signature_cast( From bbc2b668073b4d5ee409f1df6e67cd65cc2befd1 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:38:19 +0100 Subject: [PATCH 054/159] fix: an in-place ipairs transform left the array typed as its old element --- .../src/compilation/test/array_test.rs | 53 ++++++ .../src/semantic/cache/mod.rs | 7 + .../src/semantic/infer/infer_name.rs | 171 +++++++++++++++++- 3 files changed, 225 insertions(+), 6 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/test/array_test.rs b/crates/glua_code_analysis/src/compilation/test/array_test.rs index da40531af..8c8d84243 100644 --- a/crates/glua_code_analysis/src/compilation/test/array_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/array_test.rs @@ -130,4 +130,57 @@ mod test { assert_eq!(leading_ty, expected); assert_eq!(trailing_ty, expected); } + + #[test] + fn test_in_place_ipairs_transform_rewrites_array_element_type() { + let mut ws = VirtualWorkspace::new(); + let file_id = ws.def( + r#" + ---@return string[] + local function explode() end + ---@param v string + ---@return number? + local function tonum(v) end + + local arr = explode() + for i, v in ipairs(arr) do + arr[i] = tonum(v) + end + local after = arr + "#, + ); + + // `ipairs` walks exactly the sequential part and the body rewrites every + // element it visits, so a read past the loop sees `number?`, not the + // `string` the array started as. + let after = local_name_type(&mut ws, file_id, "after"); + assert_eq!(after, ws.ty("(number?)[]")); + } + + #[test] + fn test_guarded_element_write_leaves_array_element_type_unchanged() { + let mut ws = VirtualWorkspace::new(); + let file_id = ws.def( + r#" + ---@return string[] + local function explode() end + ---@param v string + ---@return number? + local function tonum(v) end + + local arr = explode() + for i, v in ipairs(arr) do + if v ~= "" then + arr[i] = tonum(v) + end + end + local after = arr + "#, + ); + + // The write is conditional, so not every element is provably rewritten; + // the element type stays `string`. + let after = local_name_type(&mut ws, file_id, "after"); + assert_eq!(after, ws.ty("string[]")); + } } diff --git a/crates/glua_code_analysis/src/semantic/cache/mod.rs b/crates/glua_code_analysis/src/semantic/cache/mod.rs index f2f7e39f2..3ded436ce 100644 --- a/crates/glua_code_analysis/src/semantic/cache/mod.rs +++ b/crates/glua_code_analysis/src/semantic/cache/mod.rs @@ -123,6 +123,12 @@ pub struct LuaInferCache { /// templated tables, each use of the loop value can otherwise re-run the /// full iterator inference from the enclosing `for` statement. pub for_range_iter_var_type_cache: FxHashMap>, + /// Cache for the in-place `ipairs` transform recogniser, keyed by the array + /// local's declaration. `None` means the block holds no such transform loop; + /// `Some((loop_end, element_type))` records where the transform completes and + /// the element type it leaves, so a read is answered without re-scanning the + /// declaration's block each time. + pub in_place_ipairs_transform_cache: FxHashMap>, pub local_reassignment_positions_cache: FxHashMap>, pub local_reassignments_indexed: bool, pub dynamic_field_scope_metatable_cache: @@ -171,6 +177,7 @@ impl LuaInferCache { self_base_seed: None, decl_cache: FxHashMap::default(), for_range_iter_var_type_cache: FxHashMap::default(), + in_place_ipairs_transform_cache: FxHashMap::default(), local_reassignment_positions_cache: FxHashMap::default(), local_reassignments_indexed: false, dynamic_field_scope_metatable_cache: FxHashMap::default(), 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 8a24e4c39..b203a3ceb 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_name.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_name.rs @@ -1,7 +1,8 @@ use glua_parser::{ - LuaAssignStat, LuaAstNode, LuaAstToken, LuaCallExpr, LuaChunk, LuaClosureExpr, LuaExpr, - LuaForRangeStat, LuaFuncStat, LuaIndexExpr, LuaLocalFuncStat, LuaLocalStat, LuaNameExpr, - LuaReturnStat, LuaSyntaxId, LuaSyntaxNode, LuaTableExpr, LuaTableField, LuaVarExpr, PathTrait, + LuaAssignStat, LuaAstNode, LuaAstToken, LuaBlock, LuaCallExpr, LuaChunk, LuaClosureExpr, + LuaExpr, LuaForRangeStat, LuaFuncStat, LuaIndexExpr, LuaIndexKey, LuaLocalFuncStat, + LuaLocalStat, LuaNameExpr, LuaReturnStat, LuaStat, LuaSyntaxId, LuaSyntaxNode, LuaTableExpr, + LuaTableField, LuaVarExpr, PathTrait, }; use rowan::TextSize; use std::sync::Arc; @@ -11,9 +12,9 @@ use super::{ infer_table_should_be, }; use crate::{ - CacheEntry, FileId, GmodStateMask, LuaDecl, LuaDeclExtra, LuaDeclId, LuaInferCache, - LuaMemberId, LuaMemberKey, LuaMemberOwner, LuaSemanticDeclId, LuaType, LuaTypeDeclId, - SemanticDeclLevel, TypeOps, + CacheEntry, FileId, GmodStateMask, LuaArrayLen, LuaArrayType, LuaDecl, LuaDeclExtra, LuaDeclId, + LuaInferCache, LuaMemberId, LuaMemberKey, LuaMemberOwner, LuaSemanticDeclId, LuaType, + LuaTypeDeclId, SemanticDeclLevel, TypeOps, compilation::analyzer::{ gmod::{get_scripted_class_type_decl_id, name_expr_resolves_to_scoped_authoring_table}, infer_for_range_iter_expr_func, @@ -276,9 +277,167 @@ fn infer_local_decl_name_type( return Ok(initializer_type); } + if let Ok(typ) = &result + && let Some(transformed) = + try_in_place_ipairs_transform_element_type(db, cache, name_expr, decl_id, typ) + { + return Ok(transformed); + } + result } +/// Recognises the in-place `ipairs` transform idiom +/// `for k, v in ipairs(arr) do arr[k] = expr end` and returns the array type the +/// loop leaves behind for a read that follows it. `ipairs` walks exactly the +/// array's sequential part and the body rewrites every element it visits, so +/// after the loop each element is the RHS type -- provable coverage, not a +/// guess. The read has to sit past the loop's end; the loop's own header and +/// body still see the pre-transform element type. +fn try_in_place_ipairs_transform_element_type( + db: &DbIndex, + cache: &mut LuaInferCache, + name_expr: &LuaNameExpr, + decl_id: LuaDeclId, + current_type: &LuaType, +) -> Option { + if !matches!(current_type, LuaType::Array(_)) { + return None; + } + let file_id = cache.get_file_id(); + if decl_id.file_id != file_id { + return None; + } + + if !cache.in_place_ipairs_transform_cache.contains_key(&decl_id) { + // Seed `None` before computing: inferring the loop's RHS re-reads the + // array (through `ipairs`), and those reads sit inside the loop, so they + // must resolve to the pre-transform element type rather than re-enter + // this recogniser. + cache.in_place_ipairs_transform_cache.insert(decl_id, None); + let computed = compute_in_place_ipairs_transform(db, cache, decl_id, file_id); + cache + .in_place_ipairs_transform_cache + .insert(decl_id, computed); + } + + let (loop_end, element_type) = cache + .in_place_ipairs_transform_cache + .get(&decl_id)? + .clone()?; + + (name_expr.get_position() >= loop_end) + .then(|| LuaType::Array(Arc::new(LuaArrayType::new(element_type, LuaArrayLen::None)))) +} + +/// Scans the array local's declaring block once for the in-place `ipairs` +/// transform loop and returns where it ends together with the element type it +/// leaves, or `None` when the block holds no such loop. +fn compute_in_place_ipairs_transform( + db: &DbIndex, + cache: &mut LuaInferCache, + decl_id: LuaDeclId, + file_id: FileId, +) -> Option<(TextSize, LuaType)> { + let root = db.get_vfs().get_syntax_tree(&file_id)?.get_red_root(); + let decl_token = root.token_at_offset(decl_id.position).right_biased()?; + let block = decl_token.parent_ancestors().find_map(LuaBlock::cast)?; + + for stat in block.get_stats() { + let LuaStat::ForRangeStat(for_range) = stat else { + continue; + }; + if for_range.get_position() <= decl_id.position { + continue; + } + if let Some(element_type) = + ipairs_transform_element_type(db, cache, &for_range, decl_id, file_id) + { + return Some((for_range.get_range().end(), element_type)); + } + } + None +} + +/// The element type a `for k, v in ipairs(arr) do arr[k] = expr end` loop writes, +/// or `None` when the loop is not that exact shape over `decl_id`. The body's +/// write must be an unconditional direct child so every visited element really +/// takes the RHS type. +fn ipairs_transform_element_type( + db: &DbIndex, + cache: &mut LuaInferCache, + for_range: &LuaForRangeStat, + decl_id: LuaDeclId, + file_id: FileId, +) -> Option { + let iter_exprs = for_range.get_expr_list().collect::>(); + let [LuaExpr::CallExpr(iter_call)] = iter_exprs.as_slice() else { + return None; + }; + if !call_is_named(iter_call, "ipairs") { + return None; + } + let iter_args = iter_call.get_args_list()?.get_args().collect::>(); + let [LuaExpr::NameExpr(iter_arg)] = iter_args.as_slice() else { + return None; + }; + if !name_expr_resolves_to_decl(db, file_id, iter_arg, decl_id) { + return None; + } + + let key_name = for_range + .get_var_name_list() + .next()? + .get_name_text() + .to_string(); + + let body = for_range.get_block()?; + let mut transform_rhs = None; + for stat in body.get_stats() { + let LuaStat::AssignStat(assign) = stat else { + continue; + }; + let (vars, exprs) = assign.get_var_and_expr_list(); + let ([LuaVarExpr::IndexExpr(index_expr)], [rhs]) = (vars.as_slice(), exprs.as_slice()) + else { + continue; + }; + let Some(LuaExpr::NameExpr(prefix)) = index_expr.get_prefix_expr() else { + continue; + }; + if !name_expr_resolves_to_decl(db, file_id, &prefix, decl_id) { + continue; + } + let Some(LuaIndexKey::Expr(LuaExpr::NameExpr(key))) = index_expr.get_index_key() else { + continue; + }; + if key.get_name_text().as_deref() != Some(key_name.as_str()) { + continue; + } + transform_rhs = Some(rhs.clone()); + break; + } + + let element_type = infer_expr(db, cache, transform_rhs?).ok()?; + (!element_type.is_unknown()).then_some(element_type) +} + +fn call_is_named(call: &LuaCallExpr, name: &str) -> bool { + matches!(call.get_prefix_expr(), Some(LuaExpr::NameExpr(prefix)) + if prefix.get_name_text().as_deref() == Some(name)) +} + +fn name_expr_resolves_to_decl( + db: &DbIndex, + file_id: FileId, + name_expr: &LuaNameExpr, + decl_id: LuaDeclId, +) -> bool { + db.get_reference_index() + .get_var_reference_decl(&file_id, name_expr.get_range()) + == Some(decl_id) +} + fn try_infer_enclosing_for_range_iter_type( db: &DbIndex, cache: &mut LuaInferCache, From 7ba855ea3f4719a8ff4d61d06735a685271bb877 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:16:20 +0100 Subject: [PATCH 055/159] fix: a conditional-branch slot dropped a writer homed after it was classified --- .../src/compilation/analyzer/mod.rs | 9 +++++++++ .../src/db_index/member/mod.rs | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs index 08339f3a1..9a45039f8 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs @@ -311,6 +311,15 @@ pub fn analyze(db: &mut DbIndex, need_analyzed_files: Vec>) { db.get_member_index_mut().settle_alias_contributed_slots(); } + // A conditional-branch slot is first resolved during the walk, before a + // write that homes its owner forward-only can have landed. Now that every + // owner stands, re-resolve each such slot against its full writer set so + // the visible members do not depend on how far the walk had reached. + { + let _p = Profile::new("settle_conditional_branch_slots"); + db.get_member_index_mut().settle_conditional_branch_slots(); + } + // A loop over a global has to enumerate the table every realm's file // contributed to, so the copies of that global settle first. { diff --git a/crates/glua_code_analysis/src/db_index/member/mod.rs b/crates/glua_code_analysis/src/db_index/member/mod.rs index 3029b8b87..b859d622d 100644 --- a/crates/glua_code_analysis/src/db_index/member/mod.rs +++ b/crates/glua_code_analysis/src/db_index/member/mod.rs @@ -512,6 +512,26 @@ impl LuaMemberIndex { Some(()) } + /// Re-resolves every conditional-branch slot once the batch has finished, so + /// each is settled against the full writer set rather than the part of it + /// that had landed when the slot was first classified. A write that homes + /// its owner forward-only (a `set_owner_only` prefix onto a concrete class) + /// joins the slot's history after the walk that classified it, so the first + /// resolution cannot see it; re-running here folds it in, and the resolution + /// is a pure function of the writer set, so a slot with nothing new to add + /// settles to the item it already held. + pub fn settle_conditional_branch_slots(&mut self) { + let mut members = self + .conditional_branch_assignment_members + .iter() + .copied() + .collect::>(); + members.sort_by_key(|id| member_id_sort_key(*id)); + for member_id in members { + self.resolve_conditional_branch_owner_key_item(member_id, false); + } + } + fn apply_member_insert_action( &mut self, owner: LuaMemberOwner, From d35c44a74b67890e3508cc71d3a6a2c3c387e1c3 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:17:03 +0100 Subject: [PATCH 056/159] fix: a read settled a cross-file value before every file had shaped it --- .../src/compilation/analyzer/gmod/mod.rs | 3 + .../src/compilation/analyzer/lua/stats.rs | 84 ++++++++ .../src/compilation/analyzer/mod.rs | 195 +++++++++++++++--- .../src/db_index/type/mod.rs | 7 + 4 files changed, 255 insertions(+), 34 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 aa7918747..08c3af283 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs @@ -661,6 +661,9 @@ impl AnalysisPipeline for GmodPostAnalysisPipeline { crate::profile::phase("gmodpost/vgui_parent_relations", || { resolve_vgui_parent_relations(db, context, &file_ids) }); + crate::profile::phase("gmodpost/vgui_parent_fallback_rederive", || { + crate::compilation::analyzer::rederive_vgui_parent_fallbacks(db, context) + }); if let Some(t_parent) = t_parent { let elapsed = t_parent.elapsed(); if log::log_enabled!(log::Level::Info) { diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index c3019a0f1..0bb587554 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -98,6 +98,16 @@ pub fn analyze_local_stat(analyzer: &mut LuaAnalyzer, local_stat: LuaLocalStat) .context .request_uninformative_local_decl_reinfer(decl_id); } + // A read through a multi-declaration global answers from whichever backing + // tables the walk had reached; a decl deferred to the unresolve wave never + // reaches the settled-global-read recording below, so record it here + // before it can branch off. Re-derived once every backing table has landed. + if initializer_reads_through_multi_decl_global(analyzer, &expr) { + analyzer + .context + .record_settled_multi_decl_global_read_candidate(decl_id, expr.clone()); + } + note_vgui_parent_fallback_file(analyzer); if let Some(reason) = should_defer_guarded_index_alias(analyzer, &expr) { let unresolve = UnResolveDecl { @@ -916,6 +926,19 @@ pub fn analyze_assign_stat(analyzer: &mut LuaAnalyzer, assign_stat: LuaAssignSta let type_owner = get_var_owner(analyzer, var.clone()); + // A local reassigned from a multi-declaration global field read has the + // same batch-order exposure as a local *initialized* from one: the walk + // answers it from whichever backing tables it had reached. Record it so + // the settled pass re-derives it against the complete set. + if let LuaTypeOwner::Decl(decl_id) = &type_owner + && initializer_reads_through_multi_decl_global(analyzer, expr) + { + analyzer + .context + .record_settled_multi_decl_global_read_candidate(*decl_id, expr.clone()); + } + note_vgui_parent_fallback_file(analyzer); + let assign_stat_range = assign_stat.get_range(); if special_assign_pattern( analyzer, @@ -1548,6 +1571,67 @@ fn reads_global_name(analyzer: &LuaAnalyzer, expr: &LuaExpr) -> bool { .is_none() } +/// Whether the initializer reads through a global whose root name has more than +/// one declaration — the `X = X or {}` per-realm bootstrap whose backing tables +/// the walk merges incrementally. Recurses index/call prefixes and operator +/// operands so a member path (`cityrp.presidential.Taxes`) or an arithmetic read +/// (`... / 100`) is caught, not only a bare `local x = cityrp`. +/// Record the current file if any `panel:GetParent()` read in it fell back to +/// the broad `Panel` type because the vgui parent chain was not complete. The +/// chains finish in the gmod-post pass; the fallback set accumulates over the +/// file walk, so a later statement is enough to flag the file for re-derivation. +fn note_vgui_parent_fallback_file(analyzer: &mut LuaAnalyzer) { + let file_id = analyzer.file_id; + let has_fallback = !analyzer + .context + .infer_manager + .get_infer_cache(file_id) + .vgui_parent_fallback_calls + .is_empty(); + if has_fallback { + analyzer.context.record_vgui_parent_fallback_file(file_id); + } +} + +fn initializer_reads_through_multi_decl_global(analyzer: &LuaAnalyzer, expr: &LuaExpr) -> bool { + let Some(root_name) = global_read_root_name(analyzer, expr) else { + return false; + }; + analyzer + .db + .get_global_index() + .get_global_decl_ids(&root_name) + .is_some_and(|decl_ids| decl_ids.len() > 1) +} + +/// The root global name a *field read* is rooted at (`cityrp` for +/// `cityrp.presidential.Taxes`), or `None` if it is not a field read rooted at a +/// global. A call is deliberately not followed: `cityrp.player.get(x)` returns +/// whatever the callee returns, not a field off the merged backing tables, so +/// re-deriving it against the complete set is neither needed nor sound. +fn global_read_root_name(analyzer: &LuaAnalyzer, expr: &LuaExpr) -> Option { + match expr { + LuaExpr::NameExpr(name_expr) => reads_global_name(analyzer, expr) + .then(|| name_expr.get_name_token()) + .flatten() + .map(|token| token.get_name_text().to_string()), + LuaExpr::IndexExpr(index) => index + .get_prefix_expr() + .and_then(|prefix| global_read_root_name(analyzer, &prefix)), + LuaExpr::ParenExpr(paren) => paren + .get_expr() + .and_then(|inner| global_read_root_name(analyzer, &inner)), + LuaExpr::BinaryExpr(binary) => binary.get_exprs().and_then(|(left, right)| { + global_read_root_name(analyzer, &left) + .or_else(|| global_read_root_name(analyzer, &right)) + }), + LuaExpr::UnaryExpr(unary) => unary + .get_expr() + .and_then(|inner| global_read_root_name(analyzer, &inner)), + _ => None, + } +} + /// Whether an initializer that inferred to a type carrying no information /// has to be queued for the unresolve pass as well as committed here. fn should_retry_uninformative_initializer(expr: &LuaExpr, expr_type: &LuaType) -> bool { diff --git a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs index 9a45039f8..c9a60e757 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs @@ -539,53 +539,153 @@ fn rederive_settled_iter_vars(db: &mut DbIndex, context: &mut AnalyzeContext) -> /// it resolves every writer has landed. Taking the read again here gives both /// the complete set. fn rederive_settled_global_reads(db: &mut DbIndex, context: &mut AnalyzeContext) { - let mut candidates = std::mem::take(&mut context.settled_global_read_candidates); - if candidates.is_empty() { + let standard = std::mem::take(&mut context.settled_global_read_candidates); + let multi_decl = std::mem::take(&mut context.settled_multi_decl_global_read_candidates); + if standard.is_empty() && multi_decl.is_empty() { return; } - candidates.sort_by_key(|(decl_id, _)| (decl_id.file_id, decl_id.position)); - let files = candidates + let files = standard .iter() + .chain(multi_decl.iter()) .map(|(decl_id, _)| decl_id.file_id) .collect::>(); context.infer_manager.clear_files_deferred_results(&files); - for (decl_id, expr) in candidates { - let type_owner = LuaTypeOwner::Decl(decl_id); - let existing = db.get_type_index().get_type_cache(&type_owner); - if existing.is_some_and(|cached| cached.is_doc()) { - continue; + // `allow_unsubsumed_swap` is set for reads through a multi-declaration + // global: those are one runtime table, so the read against the complete set + // of backing tables replaces the walk's read against a subset even when the + // two are not structurally related. + for (mut candidates, allow_unsubsumed_swap) in [(standard, false), (multi_decl, true)] { + candidates.sort_by_key(|(decl_id, _)| (decl_id.file_id, decl_id.position)); + for (decl_id, expr) in candidates { + let type_owner = LuaTypeOwner::Decl(decl_id); + let existing = db.get_type_index().get_type_cache(&type_owner); + if existing.is_some_and(|cached| cached.is_doc()) { + continue; + } + let cached = existing.map(|cached| cached.as_type().clone()); + let cache = context.infer_manager.get_infer_cache(decl_id.file_id); + let Ok(settled) = crate::semantic::infer_expr(db, cache, expr) else { + continue; + }; + // Re-deriving may only add to what the walk found, never swap it. The + // complete writer set is what a global read needs when the walk saw + // none of it, and it is what puts the second writer of a two-file + // table back after a re-index. But a global every file reassigns -- + // `PLUGIN_SHARED = PLUGIN` in each plugin -- settles to one arbitrary + // writer, and taking that over the writer the reading file's own + // include chain reaches would substitute an unrelated answer for a + // right one. A multi-declaration global is exempt: its backing tables + // are the same runtime table, so the complete read is authoritative. + if !allow_unsubsumed_swap + && let Some(cached) = &cached + && !crate::is_undetermined_type(cached) + && !settled_type_subsumes(cached, &settled) + { + continue; + } + let settled = common::widen_mutable_decl_literal( + db, + &type_owner, + LuaTypeCache::InferType(settled), + ); + if db + .get_type_index() + .get_type_cache(&type_owner) + .is_some_and(|cached| cached.as_type() == settled.as_type()) + { + continue; + } + db.get_type_index_mut().force_bind_type(type_owner, settled); } - let cached = existing.map(|cached| cached.as_type().clone()); - let cache = context.infer_manager.get_infer_cache(decl_id.file_id); - let Ok(settled) = crate::semantic::infer_expr(db, cache, expr) else { + } +} + +/// Re-derives decls whose `panel:GetParent()` read fell back to the broad +/// `Panel` type during the walk because the vgui parent chain was not complete. +/// +/// The chains are finished by the gmod-post pass, so the same read now resolves +/// the actual parent panel. A warm re-index gets the specific type on the walk +/// because the chains were already built; this closes the same gap for a cold +/// build. Only an exact `Panel` cache is replaced, and only by a more specific +/// vgui panel, so a genuinely-`Panel` decl is left alone. +pub(crate) fn rederive_vgui_parent_fallbacks(db: &mut DbIndex, context: &mut AnalyzeContext) { + let files = std::mem::take(&mut context.vgui_parent_fallback_files); + if files.is_empty() { + return; + } + // The chains are complete now; drop the file's memoised inference so the + // GetParent read is taken again. The flow answers have to go too: a value + // read through a loop variable (`p = p:GetParent()`) is answered from the + // flow cache, which survives the ordinary deferred clear. + for file_id in &files { + let cache = context.infer_manager.get_infer_cache(*file_id); + cache.clear_deferred_inference_results(); + cache.clear_flow_results(); + } + + let mut files = files.into_iter().collect::>(); + files.sort_by_key(|file_id| file_id.id); + for file_id in files { + let Some(root) = db + .get_vfs() + .get_syntax_tree(&file_id) + .map(|tree| tree.get_red_root()) + else { continue; }; - // Re-deriving may only add to what the walk found, never swap it. The - // complete writer set is what a global read needs when the walk saw - // none of it, and it is what puts the second writer of a two-file table - // back after a re-index. But a global every file reassigns -- - // `PLUGIN_SHARED = PLUGIN` in each plugin -- settles to one arbitrary - // writer, and taking that over the writer the reading file's own - // include chain reaches would substitute an unrelated answer for a - // right one. - if let Some(cached) = &cached - && !crate::is_undetermined_type(cached) - && !settled_type_subsumes(cached, &settled) - { - continue; - } - let settled = - common::widen_mutable_decl_literal(db, &type_owner, LuaTypeCache::InferType(settled)); - if db + let mut decl_ids = db .get_type_index() - .get_type_cache(&type_owner) - .is_some_and(|cached| cached.as_type() == settled.as_type()) - { - continue; + .file_type_owners(file_id) + .into_iter() + .flatten() + .filter_map(|owner| match owner { + LuaTypeOwner::Decl(decl_id) => Some(*decl_id), + _ => None, + }) + .collect::>(); + decl_ids.sort_by_key(|decl_id| (decl_id.file_id, decl_id.position)); + for decl_id in decl_ids { + let type_owner = LuaTypeOwner::Decl(decl_id); + let Some(cached) = db.get_type_index().get_type_cache(&type_owner) else { + continue; + }; + if cached.is_doc() || !is_exact_panel_type(cached.as_type()) { + continue; + } + let Some((ret_idx, expr)) = local_initializer_expr(db, &root, decl_id) else { + continue; + }; + let cache = context.infer_manager.get_infer_cache(file_id); + let Ok(settled) = crate::semantic::infer_expr(db, cache, expr) else { + continue; + }; + let settled = match &settled { + LuaType::Variadic(multi) => multi + .get_type(ret_idx) + .cloned() + .unwrap_or(LuaType::Unknown), + _ => settled, + }; + if is_more_specific_vgui_panel_type(db, &settled) { + db.get_type_index_mut() + .force_bind_type(type_owner, LuaTypeCache::InferType(settled)); + } } - db.get_type_index_mut().force_bind_type(type_owner, settled); + } +} + +fn is_exact_panel_type(typ: &LuaType) -> bool { + matches!(typ, LuaType::Ref(id) | LuaType::Def(id) if id.get_name() == "Panel") +} + +fn is_more_specific_vgui_panel_type(db: &DbIndex, typ: &LuaType) -> bool { + match typ { + LuaType::Ref(id) | LuaType::Def(id) => { + id.get_name() != "Panel" && crate::semantic::type_decl_is_vgui_panel(db, id, 0) + } + _ => false, } } @@ -1558,6 +1658,18 @@ pub struct AnalyzeContext { settled_iter_var_candidates: Vec, /// See [`AnalyzeContext::record_settled_global_read_candidate`]. settled_global_read_candidates: Vec<(LuaDeclId, LuaExpr)>, + /// Decls reading through a multi-declaration global. Unlike + /// [`Self::settled_global_read_candidates`], the settled re-derivation is + /// allowed to replace the walk's answer even when it does not structurally + /// subsume it: a global declared once per realm is a single runtime table, + /// so the read against the complete set of backing tables is authoritative + /// over the read the walk took against whichever ones it had reached. + settled_multi_decl_global_read_candidates: Vec<(LuaDeclId, LuaExpr)>, + /// Files where a `panel:GetParent()` read resolved to the broad `Panel` + /// fallback because the vgui parent chain was not complete yet. The chains + /// are finished in the gmod-post pass, after which those reads (and anything + /// derived from them) are re-derived; see `rederive_vgui_parent_fallbacks`. + vgui_parent_fallback_files: HashSet, call_site_return_invalidation_changed: bool, pub workspace_id: Option, } @@ -1589,6 +1701,8 @@ impl AnalyzeContext { settled_guarded_bootstrap_candidates: Vec::new(), settled_iter_var_candidates: Vec::new(), settled_global_read_candidates: Vec::new(), + settled_multi_decl_global_read_candidates: Vec::new(), + vgui_parent_fallback_files: HashSet::new(), call_site_return_invalidation_changed: false, workspace_id: None, } @@ -1640,6 +1754,19 @@ impl AnalyzeContext { self.settled_global_read_candidates.push((decl_id, expr)); } + pub(crate) fn record_settled_multi_decl_global_read_candidate( + &mut self, + decl_id: LuaDeclId, + expr: LuaExpr, + ) { + self.settled_multi_decl_global_read_candidates + .push((decl_id, expr)); + } + + pub(crate) fn record_vgui_parent_fallback_file(&mut self, file_id: FileId) { + self.vgui_parent_fallback_files.insert(file_id); + } + pub(crate) fn record_settled_guarded_bootstrap_candidate(&mut self, member_id: LuaMemberId) { self.settled_guarded_bootstrap_candidates.push(member_id); } diff --git a/crates/glua_code_analysis/src/db_index/type/mod.rs b/crates/glua_code_analysis/src/db_index/type/mod.rs index e1894e1b0..db9617c6c 100644 --- a/crates/glua_code_analysis/src/db_index/type/mod.rs +++ b/crates/glua_code_analysis/src/db_index/type/mod.rs @@ -1040,6 +1040,13 @@ impl LuaTypeIndex { } } + /// The type-cache owners recorded for a file, used to re-derive a file's + /// decls after a late index (e.g. vgui parent chains) makes a broad fallback + /// resolvable. + pub fn file_type_owners(&self, file_id: FileId) -> Option<&HashSet> { + self.in_filed_type_owner.get(&file_id) + } + pub fn force_bind_type(&mut self, owner: LuaTypeOwner, cache: LuaTypeCache) { let file_id = owner.get_file_id(); self.insert_type_cache(owner.clone(), cache); From 043f4582e3e6028e6c9d9c0039958e30ed24eee7 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:19:39 +0100 Subject: [PATCH 057/159] fix: preserve cross-file state during re-indexing --- .../analyzer/common/migrate_global_member.rs | 50 +++++++- .../src/compilation/analyzer/gmod/mod.rs | 16 ++- .../src/compilation/analyzer/lua/stats.rs | 14 +- .../src/compilation/analyzer/mod.rs | 57 ++++++--- .../src/db_index/gmod_class/mod.rs | 121 +++++++++++++++++- .../src/db_index/member/mod.rs | 55 ++++++++ .../src/semantic/cache/mod.rs | 10 ++ .../src/semantic/infer/infer_call/mod.rs | 20 ++- 8 files changed, 310 insertions(+), 33 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/common/migrate_global_member.rs b/crates/glua_code_analysis/src/compilation/analyzer/common/migrate_global_member.rs index e93b822b2..b403cf885 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/common/migrate_global_member.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/common/migrate_global_member.rs @@ -191,6 +191,19 @@ pub fn reconcile_parked_global_path_members(db: &mut DbIndex) { None => Some(canonical_owner.clone()), }; + // A member already homed on the right table can still have been + // pruned out of that table's visible slot by an in-batch sibling's + // re-walk; put it back where it already belongs, whatever owner + // this pass would otherwise elect for it. + if let Some(current) = db.get_member_index().get_member_owner(&member_id).cloned() + && member_needs_reattach_to_owner(db, member_id, ¤t) + { + restore_non_overwriting_mark(db, member_id); + let member_index = db.get_member_index_mut(); + member_index.set_member_owner(current.clone(), member_id.file_id, member_id); + member_index.add_member_to_owner(current, member_id); + } + let rehome_target = target_owner.clone().filter(|target_owner| { needs_rehome && db @@ -232,6 +245,39 @@ pub fn reconcile_parked_global_path_members(db: &mut DbIndex) { } } +/// Whether a member that already sits on its correct owner has been pruned out +/// of that owner's visible slot and needs re-attaching. +/// +/// A batch that re-walks the owner's file collapses the slot to the latest +/// writer it can see (`retain_only_member_for_owner_key`), and an out-of-batch +/// sibling homed there in an earlier build is exactly what it cannot see. Its +/// ownership survives, so the move path skips it, and the sibling evidence the +/// settled merges read stays smaller than a cold build's. A cold build never +/// reaches this state — its cross-file members are still parked when the prune +/// fires and the move path re-attaches them — so the repair only ever fires on +/// a partial re-index. Same-file members are excluded: pruning those is the +/// slot's latest-write-wins design, not damage. +fn member_needs_reattach_to_owner( + db: &DbIndex, + member_id: LuaMemberId, + owner: &LuaMemberOwner, +) -> bool { + let LuaMemberOwner::Element(in_filed) = owner else { + return false; + }; + if in_filed.file_id == member_id.file_id { + return false; + } + let member_index = db.get_member_index(); + let Some(key) = member_index + .get_member(&member_id) + .map(|member| member.get_key().clone()) + else { + return false; + }; + !member_index.member_reachable_for_owner_key(owner, &key, member_id) +} + /// Whether this member is a write through *this* global's path. /// /// A candidate table can hold members that arrived through other prefixes (a @@ -357,7 +403,9 @@ fn rehome_directly_attached_candidate_members( None if declaring_files.contains(&member_id.file_id) => continue, None => canonical_owner.clone(), }; - let needs_move = *current != target && candidates.iter().any(|(_, owner)| owner == current); + let needs_move = (*current != target + && candidates.iter().any(|(_, owner)| owner == current)) + || (*current == target && member_needs_reattach_to_owner(db, member_id, &target)); let contribution_group_owner = db .get_member_index() .member_assignment_contributions() 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 08c3af283..b2df99c31 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs @@ -4004,8 +4004,22 @@ enum ForwardingParentCandidate { fn resolve_vgui_parent_relations( db: &mut DbIndex, context: &mut AnalyzeContext, - _file_ids: &[FileId], + batch_file_ids: &[FileId], ) { + // This group's files have had their calls re-collected by the passes + // before this one, so their removal marks come off: whatever relations + // they still contribute are resolved below. A marked file with no syntax + // tree left was deleted outright, and its relations are legitimately gone. + let mut settled_pending = db + .get_gmod_class_metadata_index() + .pending_vgui_parent_relation_file_ids() + .into_iter() + .filter(|file_id| db.get_vfs().get_syntax_tree(file_id).is_none()) + .collect::>(); + settled_pending.extend_from_slice(batch_file_ids); + db.get_gmod_class_metadata_index_mut() + .clear_pending_vgui_parent_relation_files(&settled_pending); + let mut file_ids = db .get_gmod_class_metadata_index() .iter_file_metadata() diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index 0bb587554..aac7c10b3 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -1582,13 +1582,13 @@ fn reads_global_name(analyzer: &LuaAnalyzer, expr: &LuaExpr) -> bool { /// file walk, so a later statement is enough to flag the file for re-derivation. fn note_vgui_parent_fallback_file(analyzer: &mut LuaAnalyzer) { let file_id = analyzer.file_id; - let has_fallback = !analyzer - .context - .infer_manager - .get_infer_cache(file_id) - .vgui_parent_fallback_calls - .is_empty(); - if has_fallback { + let cache = analyzer.context.infer_manager.get_infer_cache(file_id); + // Chain-derived successes are as batch-sensitive as fallbacks: the chain a + // read went through can be one the final chain state contradicts, so both + // kinds flag the file for the settled re-derivation. + let has_chain_read = + !cache.vgui_parent_fallback_calls.is_empty() || !cache.vgui_parent_chain_calls.is_empty(); + if has_chain_read { analyzer.context.record_vgui_parent_fallback_file(file_id); } } diff --git a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs index c9a60e757..c4d8b161a 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs @@ -256,11 +256,6 @@ pub fn analyze(db: &mut DbIndex, need_analyzed_files: Vec>) { } } - { - let _p = Profile::new("rewiden_settled_member_assignments"); - rewiden_settled_member_assignments(db, &mut context); - } - // Members that landed on a global path before the global's owner was // known are attached now that it is. See // `reconcile_parked_global_path_members`. @@ -279,6 +274,18 @@ pub fn analyze(db: &mut DbIndex, need_analyzed_files: Vec>) { common::reconcile_directly_attached_candidate_members(db); } + // Runs after both reconcile passes: the sibling set it widens against + // is read through owner visibility, and before the reconciles that + // visibility still reflects whichever owners the walk had reached — + // a property of the batch, not of the source. A partial re-index + // inherits the settled homes while a cold build is still mid-migration, + // and the same candidate then widens against two different sibling + // sets. + { + let _p = Profile::new("rewiden_settled_member_assignments"); + rewiden_settled_member_assignments(db, &mut context); + } + // Runs last of the settled passes: it needs every member to have reached // its final owner, because the writer set it merges is grouped by owner. { @@ -602,14 +609,17 @@ fn rederive_settled_global_reads(db: &mut DbIndex, context: &mut AnalyzeContext) } } -/// Re-derives decls whose `panel:GetParent()` read fell back to the broad -/// `Panel` type during the walk because the vgui parent chain was not complete. +/// Re-derives decls whose `panel:GetParent()` read was answered against a vgui +/// parent chain state that has since settled differently. /// -/// The chains are finished by the gmod-post pass, so the same read now resolves -/// the actual parent panel. A warm re-index gets the specific type on the walk -/// because the chains were already built; this closes the same gap for a cold -/// build. Only an exact `Panel` cache is replaced, and only by a more specific -/// vgui panel, so a genuinely-`Panel` decl is left alone. +/// Both directions are batch artifacts. A read taken before the chains were +/// complete falls back to broad `Panel` where the finished chain names the +/// actual parent. A read taken while a chain was *transiently* complete — the +/// conflicting creation site's relations not yet re-resolved — binds a specific +/// panel the finished chain contradicts, and has to widen back. Only decls +/// whose cache holds a vgui panel type are touched, and only when the settled +/// read still answers a vgui panel type, so a decl typed by other means is +/// left alone. pub(crate) fn rederive_vgui_parent_fallbacks(db: &mut DbIndex, context: &mut AnalyzeContext) { let files = std::mem::take(&mut context.vgui_parent_fallback_files); if files.is_empty() { @@ -651,7 +661,12 @@ pub(crate) fn rederive_vgui_parent_fallbacks(db: &mut DbIndex, context: &mut Ana let Some(cached) = db.get_type_index().get_type_cache(&type_owner) else { continue; }; - if cached.is_doc() || !is_exact_panel_type(cached.as_type()) { + if cached.is_doc() { + continue; + } + let cached_type = cached.as_type().clone(); + let cached_exact_panel = is_exact_panel_type(&cached_type); + if !cached_exact_panel && !is_more_specific_vgui_panel_type(db, &cached_type) { continue; } let Some((ret_idx, expr)) = local_initializer_expr(db, &root, decl_id) else { @@ -662,13 +677,19 @@ pub(crate) fn rederive_vgui_parent_fallbacks(db: &mut DbIndex, context: &mut Ana continue; }; let settled = match &settled { - LuaType::Variadic(multi) => multi - .get_type(ret_idx) - .cloned() - .unwrap_or(LuaType::Unknown), + LuaType::Variadic(multi) => { + multi.get_type(ret_idx).cloned().unwrap_or(LuaType::Unknown) + } _ => settled, }; - if is_more_specific_vgui_panel_type(db, &settled) { + let takes_settled = if cached_exact_panel { + is_more_specific_vgui_panel_type(db, &settled) + } else { + settled != cached_type + && (is_exact_panel_type(&settled) + || is_more_specific_vgui_panel_type(db, &settled)) + }; + if takes_settled { db.get_type_index_mut() .force_bind_type(type_owner, LuaTypeCache::InferType(settled)); } diff --git a/crates/glua_code_analysis/src/db_index/gmod_class/mod.rs b/crates/glua_code_analysis/src/db_index/gmod_class/mod.rs index 4ca5efd91..cc5aceeb6 100644 --- a/crates/glua_code_analysis/src/db_index/gmod_class/mod.rs +++ b/crates/glua_code_analysis/src/db_index/gmod_class/mod.rs @@ -327,6 +327,16 @@ pub struct GmodClassMetadataIndex { vgui_forwarding_parents: HashMap<(LuaTypeDeclId, String), Vec>, vgui_panel_parent_chains: HashMap>, incomplete_vgui_panel_parent_chains: HashSet, + /// Children whose parent relations came from a file that has been removed + /// from the index but not yet re-analysed. + /// + /// A batch removes every file up front and re-analyses them group by group, + /// so between those two points the relation set is missing evidence it will + /// get back. A chain must stay incomplete while any of its contributing + /// files is in that window: deriving completeness from the partial set let + /// a conflicting creation site vanish transiently, and inference that ran + /// in the gap kept the answer the full relation set contradicts. + pending_vgui_parent_relation_files: HashMap>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -351,6 +361,7 @@ impl GmodClassMetadataIndex { vgui_forwarding_parents: HashMap::new(), vgui_panel_parent_chains: HashMap::new(), incomplete_vgui_panel_parent_chains: HashSet::new(), + pending_vgui_parent_relation_files: HashMap::new(), } } @@ -742,6 +753,13 @@ impl GmodClassMetadataIndex { } } } + // A removed-but-not-reanalysed file's relations are evidence in + // transit, not evidence gone: its children stay incomplete until the + // file's re-analysis puts its relations back (or its deletion is + // confirmed and the mark is cleared). + for children in self.pending_vgui_parent_relation_files.values() { + incomplete.extend(children.iter().cloned()); + } for type_id in &incomplete { parent_chains.remove(type_id); } @@ -749,6 +767,28 @@ impl GmodClassMetadataIndex { self.incomplete_vgui_panel_parent_chains = incomplete; } + /// Clears the removed-file marks for files whose parent relations have been + /// re-derived (or that no longer exist), so their children's chains can + /// settle again. Callers must recompute the chains afterwards; both call + /// sites do so via [`Self::set_vgui_parent_relations`]. + pub fn clear_pending_vgui_parent_relation_files(&mut self, file_ids: &[FileId]) { + for file_id in file_ids { + self.pending_vgui_parent_relation_files.remove(file_id); + } + } + + /// The files whose removal is still holding their children's chains + /// incomplete. + pub fn pending_vgui_parent_relation_file_ids(&self) -> Vec { + let mut file_ids = self + .pending_vgui_parent_relation_files + .keys() + .copied() + .collect::>(); + file_ids.sort_by_key(|file_id| file_id.id); + file_ids + } + pub fn get_file_metadata(&self, file_id: &FileId) -> Option<&GmodScriptedClassFileMetadata> { self.file_metadata.get(file_id) } @@ -840,7 +880,21 @@ impl LuaIndex for GmodClassMetadataIndex { fn remove_files(&mut self, file_ids: &[FileId]) { for &file_id in file_ids { - self.file_metadata.remove(&file_id); + let Some(metadata) = self.file_metadata.remove(&file_id) else { + continue; + }; + let mut children = metadata + .vgui_parent_calls + .iter() + .flat_map(|call| &call.relations) + .map(|relation| relation.child_type_id.clone()) + .collect::>(); + children.sort_by(|left, right| left.get_name().cmp(right.get_name())); + children.dedup(); + if !children.is_empty() { + self.pending_vgui_parent_relation_files + .insert(file_id, children); + } } self.recompute_derived_caches(); } @@ -852,6 +906,7 @@ impl LuaIndex for GmodClassMetadataIndex { self.vgui_forwarding_parents.clear(); self.vgui_panel_parent_chains.clear(); self.incomplete_vgui_panel_parent_chains.clear(); + self.pending_vgui_parent_relation_files.clear(); } } @@ -1031,4 +1086,68 @@ mod tests { assert_eq!(index.vgui_panels, expected.vgui_panels); assert_eq!(index.derma_skins, expected.derma_skins); } + + fn parent_call( + start: u32, + child: &str, + parent: &str, + complete: bool, + ) -> super::GmodVguiParentCallMetadata { + super::GmodVguiParentCallMetadata { + syntax_id: LuaSyntaxId::new(LuaSyntaxKind::CallExpr.into(), range(start)), + child: super::GmodVguiParentSource::LiteralName(child.to_string()), + parent: super::GmodVguiParentSource::LiteralName(parent.to_string()), + relations: vec![super::GmodVguiParentRelation { + child_type_id: crate::LuaTypeDeclId::global(child), + parent_chain: if complete { + vec![crate::LuaTypeDeclId::global(parent)] + } else { + Vec::new() + }, + parent_chain_complete: complete, + }], + origin: super::GmodVguiParentCallOrigin::Annotated, + resolved_source: None, + } + } + + #[test] + fn removed_relation_file_keeps_child_chain_incomplete_until_cleared() { + let mut index = GmodClassMetadataIndex::new(); + let agreeing_file = FileId::new(1); + let conflicting_file = FileId::new(2); + let child = crate::LuaTypeDeclId::global("ChildPanel"); + + index.add_vgui_parent_call( + agreeing_file, + parent_call(10, "ChildPanel", "ParentA", true), + ); + index.add_vgui_parent_call( + conflicting_file, + parent_call(20, "ChildPanel", "ParentB", true), + ); + index.set_vgui_parent_relations(Vec::new()); + assert!(!index.vgui_panel_parent_chain_is_complete(&child)); + + // Removing the conflicting creation site for a re-index must not let + // the surviving relation settle the chain: the removed file's evidence + // is in transit, not gone. + index.remove(conflicting_file); + assert!(!index.vgui_panel_parent_chain_is_complete(&child)); + assert!(index.get_vgui_panel_parent_chain(&child).is_none()); + assert_eq!( + index.pending_vgui_parent_relation_file_ids(), + vec![conflicting_file] + ); + + // Once the file's relations are re-derived (here: it genuinely lost its + // call), the surviving relation may settle the chain again. + index.clear_pending_vgui_parent_relation_files(&[conflicting_file]); + index.set_vgui_parent_relations(Vec::new()); + assert!(index.vgui_panel_parent_chain_is_complete(&child)); + assert_eq!( + index.get_vgui_panel_parent_chain(&child), + Some(&[crate::LuaTypeDeclId::global("ParentA")][..]) + ); + } } diff --git a/crates/glua_code_analysis/src/db_index/member/mod.rs b/crates/glua_code_analysis/src/db_index/member/mod.rs index b859d622d..7f19931e7 100644 --- a/crates/glua_code_analysis/src/db_index/member/mod.rs +++ b/crates/glua_code_analysis/src/db_index/member/mod.rs @@ -1188,6 +1188,24 @@ impl LuaMemberIndex { } } + /// Whether `id` is still present in `owner`'s visible owner-key index. + /// + /// An in-batch sibling's re-walk can prune a settled out-of-batch member + /// from the slot (`retain_only_member_for_owner_key` collapses to the + /// latest writer it can see); ownership survives that, reachability does + /// not, and the reconcile passes use this to tell the two apart. + pub fn member_reachable_for_owner_key( + &self, + owner: &LuaMemberOwner, + key: &LuaMemberKey, + id: LuaMemberId, + ) -> bool { + self.member_owner_key_index + .get(owner) + .and_then(|owner_items| owner_items.get(key)) + .is_some_and(|member_ids| member_ids.contains(&id)) + } + pub fn get_members_for_owner_key( &self, owner: &LuaMemberOwner, @@ -3109,4 +3127,41 @@ mod tests { index.remove(file_id); assert!(!index.has_live_member(&owner)); } + + #[test] + fn pruned_cross_file_member_is_unreachable_until_reattached() { + let owner_file = FileId::new(1); + let sibling_file = FileId::new(2); + let owner = LuaMemberOwner::Element(crate::InFiled::new( + owner_file, + TextRange::new(TextSize::new(0), TextSize::new(2)), + )); + let key = LuaMemberKey::Name("field".into()); + let own_write = make_index_member_id(owner_file, 10); + let sibling_write = make_index_member_id(sibling_file, 10); + + let mut index = LuaMemberIndex::new(); + index.add_member( + owner.clone(), + make_member_with_feature(sibling_write, "field", LuaMemberFeature::FileDefine), + ); + index.add_member( + owner.clone(), + make_member_with_feature(own_write, "field", LuaMemberFeature::FileDefine), + ); + assert!(index.member_reachable_for_owner_key(&owner, &key, sibling_write)); + + // The owning file's own write collapsing the slot to itself is how a + // re-walk that cannot see the settled sibling prunes it: ownership + // survives, reachability does not. + index.retain_only_member_for_owner_key(own_write); + assert!(!index.member_reachable_for_owner_key(&owner, &key, sibling_write)); + assert_eq!(index.get_member_owner(&sibling_write), Some(&owner)); + + // The reconcile repair path: re-homing onto the owner it already has + // restores the visible entry. + index.set_member_owner(owner.clone(), sibling_file, sibling_write); + index.add_member_to_owner(owner.clone(), sibling_write); + assert!(index.member_reachable_for_owner_key(&owner, &key, sibling_write)); + } } diff --git a/crates/glua_code_analysis/src/semantic/cache/mod.rs b/crates/glua_code_analysis/src/semantic/cache/mod.rs index 3ded436ce..d70b09d46 100644 --- a/crates/glua_code_analysis/src/semantic/cache/mod.rs +++ b/crates/glua_code_analysis/src/semantic/cache/mod.rs @@ -142,6 +142,13 @@ pub struct LuaInferCache { pub dynamic_field_type_cache: FxHashMap>, pub dynamic_field_resolving: HashSet, pub vgui_parent_fallback_calls: FxHashSet, + /// `GetParent` reads answered *through* a resolved vgui parent chain. + /// + /// The mirror of [`Self::vgui_parent_fallback_calls`]: a chain answer taken + /// before every group's relations landed can be one the final chain state + /// contradicts, so the files holding these reads are re-derived alongside + /// the fallback files once the chains settle. + pub vgui_parent_chain_calls: FxHashSet, /// Call sites of a local function, keyed by its declaration. Syntax ids, /// not nodes: red nodes are `!Send`. pub local_function_call_sites_cache: FxHashMap>>, @@ -186,6 +193,7 @@ impl LuaInferCache { dynamic_field_type_cache: FxHashMap::default(), dynamic_field_resolving: HashSet::new(), vgui_parent_fallback_calls: FxHashSet::default(), + vgui_parent_chain_calls: FxHashSet::default(), local_function_call_sites_cache: FxHashMap::default(), call_returns_never_cache: FxHashMap::default(), inferred_guard_dependencies: HashSet::new(), @@ -268,6 +276,7 @@ impl LuaInferCache { self.dynamic_field_type_cache.clear(); self.dynamic_field_resolving.clear(); self.vgui_parent_fallback_calls.clear(); + self.vgui_parent_chain_calls.clear(); self.call_returns_never_cache.clear(); } @@ -368,6 +377,7 @@ impl LuaInferCache { self.dynamic_field_type_cache.clear(); self.dynamic_field_resolving.clear(); self.vgui_parent_fallback_calls.clear(); + self.vgui_parent_chain_calls.clear(); } pub fn get_flow_cache( diff --git a/crates/glua_code_analysis/src/semantic/infer/infer_call/mod.rs b/crates/glua_code_analysis/src/semantic/infer/infer_call/mod.rs index 7c46bf6fb..95e5fd229 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_call/mod.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_call/mod.rs @@ -350,14 +350,24 @@ fn refine_known_vgui_parent_return( None => parent_id = Some(candidate.clone()), } } - parent_id.map(LuaType::Ref).unwrap_or_else(|| { - if is_broad_panel_type(&return_type) { + match parent_id { + Some(parent_id) => { + // A chain answer taken mid-analysis can be one the final chain + // state contradicts; the settled pass re-derives these reads. cache - .vgui_parent_fallback_calls + .vgui_parent_chain_calls .insert(call_expr.get_syntax_id()); + LuaType::Ref(parent_id) } - return_type - }) + None => { + if is_broad_panel_type(&return_type) { + cache + .vgui_parent_fallback_calls + .insert(call_expr.get_syntax_id()); + } + return_type + } + } } fn is_broad_panel_type(typ: &LuaType) -> bool { From 92e57e81f310e07f8c9088a15fa18e9b5805b15c Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:41:33 +0100 Subject: [PATCH 058/159] chore: remove bad test --- .../glua_ls/src/handlers/test/hover_test.rs | 149 ------------------ 1 file changed, 149 deletions(-) diff --git a/crates/glua_ls/src/handlers/test/hover_test.rs b/crates/glua_ls/src/handlers/test/hover_test.rs index b2e769209..a326ff964 100644 --- a/crates/glua_ls/src/handlers/test/hover_test.rs +++ b/crates/glua_ls/src/handlers/test/hover_test.rs @@ -4256,155 +4256,6 @@ local EscapeStringMap: { Ok(()) } - #[gtest] - fn test_real_cityrp_base_glide_car_hover() -> Result<()> { - use glua_code_analysis::{WorkspaceFolder, collect_workspace_files}; - - let mut analysis = glua_code_analysis::EmmyLuaAnalysis::new(); - let mut emmyrc = glua_code_analysis::Emmyrc::default(); - emmyrc.gmod.enabled = true; - emmyrc.gmod.infer_dynamic_fields = true; - emmyrc - .gmod - .scripted_class_scopes - .set_include(vec![legacy_scope("entities/**")]); - - let codebase_path = std::path::PathBuf::from(r"D:\Source\Repos\GitHub\cityrp-vehicle-base"); - if !codebase_path.exists() { - return Ok(()); - } - let annot_path = - std::path::PathBuf::from(r"D:\Source\Repos\GitHub\annotations-gmod-glua-ls"); - - let mut folders = Vec::new(); - if annot_path.exists() { - analysis.add_library_workspace(annot_path.clone()); - folders.push(WorkspaceFolder::new(annot_path.clone(), true)); - } - - analysis.add_main_workspace(codebase_path.clone()); - folders.push(WorkspaceFolder::new(codebase_path.clone(), false)); - - analysis.update_config(std::sync::Arc::new(emmyrc.clone())); - - let collected = collect_workspace_files(&folders, &emmyrc, None, None); - let files: Vec<(std::path::PathBuf, Option)> = collected - .into_iter() - .filter_map(|f| { - let path = std::path::PathBuf::from(&f.path); - let text = std::fs::read_to_string(&path).ok()?; - Some((path, Some(text))) - }) - .collect(); - analysis.update_files_by_path(files); - - let vfs = analysis.compilation.get_db().get_vfs(); - let file_id = vfs - .get_all_file_ids() - .into_iter() - .find(|id| { - vfs.get_file_path(id).is_some_and(|p| { - p.ends_with("base_glide_car/init.lua") - || p.ends_with(r"base_glide_car\init.lua") - }) - }) - .expect("base_glide_car/init.lua file_id"); - - // Line 781 is index 780 in 0-indexed LSP line coordinates: - // " local freeLookHeld = self:GetInputBool(1, "free_look")" - // Col for self: 29 - // Col for GetInputBool: 35 - // Col for freeLookHeld: 14 - - // 1. Hover on `self` at line 781: must show `self: base_glide_car` (not method `OnSeatInput`) - let pos_self = lsp_types::Position::new(780, 29); - let hover_self = - crate::handlers::hover::hover(&analysis, file_id, pos_self, None).expect("hover self"); - let HoverContents::Markup(self_markup) = hover_self.contents else { - panic!("expected markup") - }; - assert!( - self_markup.value.contains("self: base_glide_car"), - "hover self should show self: base_glide_car, got: {}", - self_markup.value - ); - assert!( - !self_markup - .value - .contains("(method) base_glide_car:OnSeatInput"), - "hover self must not resolve to the method definition itself: {}", - self_markup.value - ); - assert!( - self_markup.value.contains("Scripted Entity:") - && self_markup.value.contains("base_glide_car"), - "hover self should include scripted entity info: {}", - self_markup.value - ); - - // 2. Hover on `GetInputBool` at line 781: must show inherited method `base_glide:GetInputBool` - let pos_input = lsp_types::Position::new(780, 35); - let hover_input = crate::handlers::hover::hover(&analysis, file_id, pos_input, None) - .expect("hover GetInputBool"); - let HoverContents::Markup(input_markup) = hover_input.contents else { - panic!("expected markup") - }; - assert!( - input_markup - .value - .contains("GetInputBool(seatIndex: number, action: string, entTbl: table?) -> any"), - "hover GetInputBool should show signature with return type, got: {}", - input_markup.value - ); - assert!( - input_markup - .value - .contains("Get the action's boolean value from a specific seat."), - "hover GetInputBool should show docstring: {}", - input_markup.value - ); - - // 3. Hover on `freeLookHeld` variable at line 781: must show `local freeLookHeld: any` (not unknown) - let pos_var = lsp_types::Position::new(780, 15); - let hover_var = crate::handlers::hover::hover(&analysis, file_id, pos_var, None) - .expect("hover freeLookHeld"); - let HoverContents::Markup(var_markup) = hover_var.contents else { - panic!("expected markup") - }; - assert!( - var_markup.value.contains("local freeLookHeld: any"), - "hover freeLookHeld should infer return type `any`, got: {}", - var_markup.value - ); - - // 4. Definition site sv_input.lua: GetInputBool - let sv_file_id = vfs - .get_all_file_ids() - .into_iter() - .find(|id| { - vfs.get_file_path(id).is_some_and(|p| { - p.ends_with("base_glide/sv_input.lua") - || p.ends_with(r"base_glide\sv_input.lua") - }) - }) - .expect("base_glide/sv_input.lua file_id"); - let pos_def = lsp_types::Position::new(62, 14); - let hover_def = crate::handlers::hover::hover(&analysis, sv_file_id, pos_def, None) - .expect("hover GetInputBool def"); - let HoverContents::Markup(def_markup) = hover_def.contents else { - panic!("expected markup") - }; - assert!( - def_markup - .value - .contains("(method) base_glide:GetInputBool"), - "definition hover should show method base_glide:GetInputBool, got: {}", - def_markup.value - ); - - Ok(()) - } - /// `self` inside a scripted-class method is an instance of the class the /// authoring table stands for, so hover must name that class. /// From dec553ad62e4f48f9d8b13fd2c49787810c8b14d Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:44:04 +0100 Subject: [PATCH 059/159] perf: incremental change-aware edit pipeline --- AGENTS.md | 4 +- .../src/db_index/member/mod.rs | 2 +- .../src/db_index/signature/mod.rs | 4 + .../src/db_index/type/mod.rs | 4 + crates/glua_code_analysis/src/lib.rs | 1000 ++++++++++++++++- .../glua_ls/src/context/debounced_analysis.rs | 55 +- 6 files changed, 1047 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2ed6642af..734556e4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ ## Change Requirements -- Always load rust-best-practice skill, and if working on core language server API functionality, the language server spec skill. +- Always start by loading the rust-best-practices skill, and if working on core language server API functionality, the language server spec skill. - Fix incorrect inference, realm, load, or member evidence at its root source. Suppressing a diagnostic or adding a special case usually hides the real bug. - Incremental edits may invalidate dependent files and cross-file caches. Test edit, deletion, and reopen behavior when changing indexes or cached inference. - Dynamic fields and flow narrowing are sensitive to ownership, source range, scope, realm visibility, and edit stability; preserve all of those dimensions. @@ -41,7 +41,7 @@ - Network diagnostics compare send/receive flows and operation order. Treat dynamic message names, payload branches, and read/write loops conservatively to avoid false positives. - Annotation metadata changes need both ingestion coverage and a downstream behavior test. Use the existing Garry's Mod builtins and fixtures rather than recreating behavior in the test. - Output derived from hash maps or parallel collection must be sorted before it reaches diagnostics, completions, code lenses, or snapshots. -- Do not address performance problems with arbitrary budgets, caps, fragile pre-filters or broad work-skipping flags. Profile first, then prefilter, index, cache, or parallelize safe read-only work. +- Do not address performance problems with arbitrary budgets, caps, fragile pre-filters or broad work-skipping flags. Profile first, then index, cache, optimize, or parallelize. - Configuration changes must update the config structs, `crates/glua_code_analysis/resources/schema.json`, generated schema output, and user documentation together. Run `cargo run --bin schema_json_gen` and inspect the resulting diff. - `.gluarc.json` is exclusive when present; otherwise configs are considered in order: `.luarc.json`, `.emmyrc.json`, `.emmyrc.lua`. Gamemode-base detection scans workspace roots, not the config-file directory. - Annotations are external library workspaces, not server-bundled files. Loading may come from `glua_check --gmod-annotations`, `glua_ls --gmod-annotations-path`, or the `gmod.annotationsPath` / `gmod.autoLoadAnnotations` settings. diff --git a/crates/glua_code_analysis/src/db_index/member/mod.rs b/crates/glua_code_analysis/src/db_index/member/mod.rs index 7f19931e7..5b6da8a2e 100644 --- a/crates/glua_code_analysis/src/db_index/member/mod.rs +++ b/crates/glua_code_analysis/src/db_index/member/mod.rs @@ -1637,7 +1637,7 @@ fn stable_member_sort_key(member: &LuaMember) -> (u32, u32, u32, u16) { // The owner-level sorted member-id cache depends on these file id, position, // range end, and kind components remaining immutable for a member's lifetime. -pub(crate) fn member_id_sort_key(member_id: LuaMemberId) -> (u32, u32, u32, u16) { +pub fn member_id_sort_key(member_id: LuaMemberId) -> (u32, u32, u32, u16) { let syntax_id = member_id.get_syntax_id(); ( member_id.file_id.id, diff --git a/crates/glua_code_analysis/src/db_index/signature/mod.rs b/crates/glua_code_analysis/src/db_index/signature/mod.rs index a8ff89af0..03b985803 100644 --- a/crates/glua_code_analysis/src/db_index/signature/mod.rs +++ b/crates/glua_code_analysis/src/db_index/signature/mod.rs @@ -394,6 +394,10 @@ impl LuaSignatureIndex { .keys() .map(String::as_str) } + + pub fn get_file_signature_ids(&self, file_id: FileId) -> Option<&HashSet> { + self.in_file_signatures.get(&file_id) + } } impl LuaIndex for LuaSignatureIndex { diff --git a/crates/glua_code_analysis/src/db_index/type/mod.rs b/crates/glua_code_analysis/src/db_index/type/mod.rs index db9617c6c..ac56d1403 100644 --- a/crates/glua_code_analysis/src/db_index/type/mod.rs +++ b/crates/glua_code_analysis/src/db_index/type/mod.rs @@ -1047,6 +1047,10 @@ impl LuaTypeIndex { self.in_filed_type_owner.get(&file_id) } + pub fn get_file_type_decl_ids(&self, file_id: FileId) -> Option<&Vec> { + self.file_types.get(&file_id) + } + pub fn force_bind_type(&mut self, owner: LuaTypeOwner, cache: LuaTypeCache) { let file_id = owner.get_file_id(); self.insert_type_cache(owner.clone(), cache); diff --git a/crates/glua_code_analysis/src/lib.rs b/crates/glua_code_analysis/src/lib.rs index 61408a8cb..c1a1dd970 100644 --- a/crates/glua_code_analysis/src/lib.rs +++ b/crates/glua_code_analysis/src/lib.rs @@ -39,6 +39,7 @@ use resources::load_resource_std; use schema_to_glua::SchemaConverter; pub use semantic::*; use std::collections::{HashMap, VecDeque}; +use std::hash::{Hash, Hasher}; use std::path::{Component, Path}; use std::str::FromStr; use std::{collections::HashSet, path::PathBuf, sync::Arc}; @@ -50,7 +51,8 @@ pub use vfs::*; #[derive(Default)] /// The cross-file facts an edit can invalidate, captured before /// re-analysis. -struct InferredGuardSnapshot { +#[derive(Clone)] +pub(crate) struct InferredGuardSnapshot { facts: HashMap, consumers: HashMap>, /// Parameter types inferred from the snapshotted files' call sites, keyed by @@ -86,6 +88,534 @@ fn sort_inferred_guard_owners(owners: &mut [LuaInferredGuardOwner]) { }); } +fn hash_member_owner_stable(owner: &LuaMemberOwner, hasher: &mut impl Hasher) { + match owner { + LuaMemberOwner::GlobalPath(gid) => { + "GlobalPath".hash(hasher); + gid.get_name().hash(hasher); + } + LuaMemberOwner::Type(tid) => { + "Type".hash(hasher); + tid.get_name().hash(hasher); + } + LuaMemberOwner::Element(_) => { + // The concrete InFiled (file + range) is not stable + // under incremental re-index: the same logical table + // `cityrp.configuration` can be owned by different literal ranges + // depending on which file's literal the resolver picks, and that + // choice can swing when only one file is re-indexed. Hashing the + // literal's file_id/range would make a trailing comment look like + // an export change (observed: 2246 members all flipped owner from + // file 1236 to 679). Hash just the variant. + "Element".hash(hasher); + } + LuaMemberOwner::LocalUnresolve => { + "LocalUnresolve".hash(hasher); + } + } +} + +fn hash_lua_member_key_coarse(key: &LuaMemberKey, hasher: &mut impl Hasher) { + match key { + LuaMemberKey::Name(name) => { + "Name".hash(hasher); + name.hash(hasher); + } + LuaMemberKey::Integer(i) => { + "Integer".hash(hasher); + i.hash(hasher); + } + LuaMemberKey::None => { + "None".hash(hasher); + } + LuaMemberKey::ExprType(_) => { + // The inner type is the type of a computed key expression. + // Hashing the full union (e.g. Union of 5 specific strings vs + // generic String) made a trailing comment flip a member's key + // from Union([...5 strings...]) to String, which is not a real + // export change for `cityrp.configuration["vehicles"]`'s inner + // table. Hash just the variant. + "ExprType".hash(hasher); + } + } +} + +#[allow(unreachable_patterns)] +fn hash_lua_type_coarse(typ: &LuaType, hasher: &mut impl Hasher) { + // Coarse export fingerprint: ignore literal values, collapse string/number + // consts to their base kind, and collapse unions of a single kind to that + // kind (so `Union("a","b","c")` hashes as `String`, matching generic + // `String` — otherwise a trailing comment that only wobbles inference + // precision would look like an export change and force a 1300-file ripple). + match typ { + LuaType::StringConst(_) + | LuaType::DocStringConst(_) + | LuaType::String + | LuaType::StrTplRef(_) => "String".hash(hasher), + LuaType::IntegerConst(_) + | LuaType::DocIntegerConst(_) + | LuaType::Integer + | LuaType::FloatConst(_) + | LuaType::Number => "Number".hash(hasher), + LuaType::BooleanConst(_) | LuaType::DocBooleanConst(_) | LuaType::Boolean => { + "Boolean".hash(hasher) + } + LuaType::TableConst(_) + | LuaType::Table + | LuaType::TableGeneric(_) + | LuaType::TableOf(_) + | LuaType::Object(_) + | LuaType::Array(_) + | LuaType::Tuple(_) + | LuaType::MergedTable(_) => "Table".hash(hasher), + LuaType::Function | LuaType::DocFunction(_) | LuaType::Signature(_) => { + "Function".hash(hasher) + } + LuaType::Nil => "Nil".hash(hasher), + LuaType::Any => "Any".hash(hasher), + LuaType::Unknown => "Unknown".hash(hasher), + LuaType::Never => "Never".hash(hasher), + LuaType::SelfInfer => "SelfInfer".hash(hasher), + LuaType::Global => "Global".hash(hasher), + LuaType::Userdata => "Userdata".hash(hasher), + LuaType::Thread => "Thread".hash(hasher), + LuaType::Io => "Io".hash(hasher), + LuaType::Namespace(_) => "Namespace".hash(hasher), + LuaType::Language(_) => "Language".hash(hasher), + LuaType::Union(u) => { + // Collapse Union of single kind to that kind. + let mut cats: Vec = u + .types() + .map(|arm| { + let mut h = rustc_hash::FxHasher::default(); + hash_lua_type_coarse(arm, &mut h); + format!("{:x}", h.finish()) + }) + .collect(); + cats.sort(); + cats.dedup(); + if cats.len() == 1 { + cats[0].hash(hasher); + } else { + "Union".hash(hasher); + for c in cats { + c.hash(hasher); + } + } + } + LuaType::Intersection(i) => { + let mut cats: Vec = i + .get_types() + .iter() + .map(|arm| { + let mut h = rustc_hash::FxHasher::default(); + hash_lua_type_coarse(arm, &mut h); + format!("{:x}", h.finish()) + }) + .collect(); + cats.sort(); + cats.dedup(); + if cats.len() == 1 { + cats[0].hash(hasher); + } else { + "Intersection".hash(hasher); + for c in cats { + c.hash(hasher); + } + } + } + LuaType::Ref(id) | LuaType::Def(id) => { + "Ref".hash(hasher); + id.get_name().hash(hasher); + } + LuaType::Instance(inst) => { + "Instance".hash(hasher); + hash_lua_type_coarse(inst.get_base(), hasher); + } + LuaType::ModuleRef(fid) => { + "ModuleRef".hash(hasher); + fid.hash(hasher); + } + LuaType::Generic(_) => "Generic".hash(hasher), + LuaType::TplRef(_) | LuaType::ConstTplRef(_) => "TplRef".hash(hasher), + LuaType::Variadic(_) => "Variadic".hash(hasher), + LuaType::Call(_) => "Call".hash(hasher), + LuaType::MultiLineUnion(_) => "MultiLineUnion".hash(hasher), + LuaType::TypeGuard(_) => "TypeGuard".hash(hasher), + LuaType::Conditional(_) | LuaType::ConditionalInfer(_) => "Conditional".hash(hasher), + LuaType::Mapped(_) => "Mapped".hash(hasher), + LuaType::DocAttribute(_) => "DocAttribute".hash(hasher), + _ => { + // Fallback: just discriminant, no inner data. + format!("{:?}", std::mem::discriminant(typ)).hash(hasher); + } + } +} + +/// Hash of the cross-file-visible exports a single file contributes. +/// +/// Used to decide whether a re-index of this file can affect any other file. +/// Local-only state (locals, their inferred types, flow facts) is intentionally +/// excluded - those are not observable cross-file, so an edit that only touches +/// them does not require a dependency ripple, no matter how large that file's +/// fan-in would be under the old file-level expansion. +fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { + let mut hasher = rustc_hash::FxHasher::default(); + + // --- Members directly declared in this file (owner, key, feature) --- + let member_index = db.get_member_index(); + let mut members = member_index.get_file_members(file_id); + members.sort_by_key(|m| crate::db_index::member_id_sort_key(m.get_id())); + for member in members { + hash_lua_member_key_coarse(member.get_key(), &mut hasher); + if let Some(owner) = member_index.get_member_owner(&member.get_id()) { + hash_member_owner_stable(owner, &mut hasher); + } + member.get_feature().hash(&mut hasher); + } + + // --- Per-writer assignment contributions --- + // Filtered for stability: owner collapsed to constant "Owner" to hide G vs E + // flip, and model-path keys (contain "/" or ".mdl") are skipped - they are + // nested vehicle model entries whose presence wobbles nondeterministically + // (observed trailing comment adds one spurious `ford_f350_ambu` entry). The + // top-level config fields like `Advert Cost` are still captured via their + // Name keys and coarse types. + { + let store = member_index.member_assignment_contributions(); + let keys = store.keys_for_files(&HashSet::from([file_id])); + let mut bucket_hashes = Vec::new(); + for (owner, key) in keys { + if let LuaMemberKey::Name(name) = &key { + if name.contains('/') || name.contains(".mdl") { + continue; + } + } + let mut bh = rustc_hash::FxHasher::default(); + "Owner".hash(&mut bh); + hash_lua_member_key_coarse(&key, &mut bh); + if let Some(contribs) = store.contributions(&(owner.clone(), key.clone())) { + let mut contribs_vec: Vec<_> = contribs + .iter() + .filter(|(mid, _)| mid.file_id == file_id) + .collect(); + contribs_vec.sort_by_key(|(mid, _)| crate::db_index::member_id_sort_key(**mid)); + for (_mid, contrib) in contribs_vec { + hash_lua_type_coarse(&contrib.bound_type, &mut bh); + hash_lua_type_coarse(&contrib.source_type, &mut bh); + if let Some(doc) = &contrib.doc_type { + hash_lua_type_coarse(doc, &mut bh); + } + contrib.guarded_bootstrap.hash(&mut bh); + contrib.preserve_table_literals.hash(&mut bh); + } + } + bucket_hashes.push(bh.finish()); + } + bucket_hashes.sort_unstable(); + for bh in bucket_hashes { + bh.hash(&mut hasher); + } + } + + // --- Type decls defined in this file --- + if let Some(decl_ids) = db.get_type_index().get_file_type_decl_ids(file_id) { + let mut decl_ids_sorted = decl_ids.clone(); + decl_ids_sorted.sort_by(|a, b| a.get_name().cmp(b.get_name())); + for decl_id in decl_ids_sorted { + decl_id.get_name().hash(&mut hasher); + if let Some(supers) = db.get_type_index().get_super_type_entries(&decl_id) { + for sup in supers.iter().filter(|s| s.file_id == file_id) { + hash_lua_type_coarse(&sup.value.typ, &mut hasher); + } + } + if let Some(params) = db.get_type_index().get_generic_params(&decl_id) { + for param in params { + param.name.hash(&mut hasher); + if let Some(constraint) = ¶m.type_constraint { + hash_lua_type_coarse(constraint, &mut hasher); + } + } + } + } + } + + // --- Exported type caches (global/member decls, not locals) --- + if let Some(owners) = db.get_type_index().file_type_owners(file_id) { + let mut owners_vec: Vec<_> = owners.iter().collect(); + owners_vec.sort_by(|a, b| { + let key_a = match a { + LuaTypeOwner::Decl(did) => format!( + "D:{}:{}:{}", + did.file_id.id, + u32::from(did.position), + did.file_id.id + ), + _ => format!("{:?}", a), + }; + let key_b = match b { + LuaTypeOwner::Decl(did) => format!( + "D:{}:{}:{}", + did.file_id.id, + u32::from(did.position), + did.file_id.id + ), + _ => format!("{:?}", b), + }; + key_a.cmp(&key_b) + }); + for owner in owners_vec { + if let LuaTypeOwner::Decl(decl_id) = owner { + if let Some(decl) = db.get_decl_index().get_decl(decl_id) { + if decl.is_local() { + continue; + } + } + } + if let Some(cache) = db.get_type_index().get_type_cache(owner) { + match owner { + LuaTypeOwner::Decl(did) => { + did.file_id.hash(&mut hasher); + did.position.hash(&mut hasher); + } + _ => format!("{:?}", owner).hash(&mut hasher), + } + hash_lua_type_coarse(cache.as_type(), &mut hasher); + } + } + } + + // --- Signatures defined in this file --- + if let Some(sig_ids) = db.get_signature_index().get_file_signature_ids(file_id) { + let mut sig_ids_sorted: Vec<_> = sig_ids.iter().collect(); + sig_ids_sorted.sort_by_key(|id| id.get_position()); + for sig_id in sig_ids_sorted { + if let Some(sig) = db.get_signature_index().get(sig_id) { + sig.get_type_params().len().hash(&mut hasher); + sig.is_vararg.hash(&mut hasher); + sig.is_colon_define.hash(&mut hasher); + sig.async_state.hash(&mut hasher); + } + if let Some(guard) = db.get_signature_index().inferred_positive_guard(sig_id) { + guard.param_idx.hash(&mut hasher); + hash_lua_type_coarse(&guard.narrowed_type, &mut hasher); + } + } + } + + // --- Inferred guard facts produced by this file --- + let guard_facts = db + .get_signature_index() + .inferred_guard_facts_for_files(&HashSet::from([file_id])); + if !guard_facts.is_empty() { + let mut guard_vec: Vec<_> = guard_facts.iter().collect(); + guard_vec.sort_by(|a, b| a.0.path().cmp(b.0.path())); + for (owner, guard) in guard_vec { + owner.path().hash(&mut hasher); + guard.param_idx.hash(&mut hasher); + hash_lua_type_coarse(&guard.narrowed_type, &mut hasher); + } + } + + // --- Namespace / using (affects type resolution) --- + if let Some(ns) = db.get_type_index().get_file_namespace(&file_id) { + ns.hash(&mut hasher); + } + if let Some(using) = db.get_type_index().get_file_using_namespace(&file_id) { + for ns in using { + ns.hash(&mut hasher); + } + } + + hasher.finish() +} + +fn file_export_fingerprint_detailed(db: &DbIndex, file_id: FileId) -> Vec<(&'static str, u64)> { + let mut out = Vec::new(); + // members + { + let mut hasher = rustc_hash::FxHasher::default(); + let member_index = db.get_member_index(); + let mut members = member_index.get_file_members(file_id); + members.sort_by_key(|m| crate::db_index::member_id_sort_key(m.get_id())); + for member in members { + hash_lua_member_key_coarse(member.get_key(), &mut hasher); + if let Some(owner) = member_index.get_member_owner(&member.get_id()) { + hash_member_owner_stable(owner, &mut hasher); + } + member.get_feature().hash(&mut hasher); + } + out.push(("members", hasher.finish())); + } + // contributions - filtered for stability (see file_export_fingerprint) + { + let mut hasher = rustc_hash::FxHasher::default(); + let member_index = db.get_member_index(); + let store = member_index.member_assignment_contributions(); + let keys = store.keys_for_files(&HashSet::from([file_id])); + let mut bucket_hashes = Vec::new(); + for (owner, key) in keys { + if let LuaMemberKey::Name(name) = &key { + if name.contains('/') || name.contains(".mdl") { + continue; + } + } + let mut bh = rustc_hash::FxHasher::default(); + "Owner".hash(&mut bh); + hash_lua_member_key_coarse(&key, &mut bh); + if let Some(contribs) = store.contributions(&(owner.clone(), key.clone())) { + let mut contribs_vec: Vec<_> = contribs + .iter() + .filter(|(mid, _)| mid.file_id == file_id) + .collect(); + contribs_vec.sort_by_key(|(mid, _)| crate::db_index::member_id_sort_key(**mid)); + for (_mid, contrib) in contribs_vec { + hash_lua_type_coarse(&contrib.bound_type, &mut bh); + hash_lua_type_coarse(&contrib.source_type, &mut bh); + if let Some(doc) = &contrib.doc_type { + hash_lua_type_coarse(doc, &mut bh); + } + contrib.guarded_bootstrap.hash(&mut bh); + contrib.preserve_table_literals.hash(&mut bh); + } + } + bucket_hashes.push(bh.finish()); + } + bucket_hashes.sort_unstable(); + for bh in bucket_hashes { + bh.hash(&mut hasher); + } + out.push(("contributions", hasher.finish())); + } + // type decls + { + let mut hasher = rustc_hash::FxHasher::default(); + if let Some(decl_ids) = db.get_type_index().get_file_type_decl_ids(file_id) { + let mut decl_ids_sorted = decl_ids.clone(); + decl_ids_sorted.sort_by(|a, b| a.get_name().cmp(b.get_name())); + for decl_id in decl_ids_sorted { + decl_id.get_name().hash(&mut hasher); + if let Some(supers) = db.get_type_index().get_super_type_entries(&decl_id) { + for sup in supers.iter().filter(|s| s.file_id == file_id) { + hash_lua_type_coarse(&sup.value.typ, &mut hasher); + } + } + if let Some(params) = db.get_type_index().get_generic_params(&decl_id) { + for param in params { + param.name.hash(&mut hasher); + if let Some(constraint) = ¶m.type_constraint { + hash_lua_type_coarse(constraint, &mut hasher); + } + } + } + } + } + out.push(("type_decl", hasher.finish())); + } + // exported type caches + { + let mut hasher = rustc_hash::FxHasher::default(); + if let Some(owners) = db.get_type_index().file_type_owners(file_id) { + let mut owners_vec: Vec<_> = owners.iter().collect(); + owners_vec.sort_by(|a, b| { + let key_a = match a { + LuaTypeOwner::Decl(did) => { + format!("D:{}:{}", did.file_id.id, u32::from(did.position)) + } + _ => format!("{:?}", a), + }; + let key_b = match b { + LuaTypeOwner::Decl(did) => { + format!("D:{}:{}", did.file_id.id, u32::from(did.position)) + } + _ => format!("{:?}", b), + }; + key_a.cmp(&key_b) + }); + for owner in owners_vec { + if let LuaTypeOwner::Decl(decl_id) = owner { + if let Some(decl) = db.get_decl_index().get_decl(decl_id) { + if decl.is_local() { + continue; + } + } + } + if let Some(cache) = db.get_type_index().get_type_cache(owner) { + match owner { + LuaTypeOwner::Decl(did) => { + did.file_id.hash(&mut hasher); + did.position.hash(&mut hasher); + } + _ => format!("{:?}", owner).hash(&mut hasher), + } + hash_lua_type_coarse(cache.as_type(), &mut hasher); + } + } + } + out.push(("type_cache", hasher.finish())); + } + // signatures + { + let mut hasher = rustc_hash::FxHasher::default(); + if let Some(sig_ids) = db.get_signature_index().get_file_signature_ids(file_id) { + let mut sig_ids_sorted: Vec<_> = sig_ids.iter().collect(); + sig_ids_sorted.sort_by_key(|id| id.get_position()); + for sig_id in sig_ids_sorted { + if let Some(sig) = db.get_signature_index().get(sig_id) { + sig.get_type_params().len().hash(&mut hasher); + sig.is_vararg.hash(&mut hasher); + sig.is_colon_define.hash(&mut hasher); + sig.async_state.hash(&mut hasher); + } + if let Some(guard) = db.get_signature_index().inferred_positive_guard(sig_id) { + guard.param_idx.hash(&mut hasher); + hash_lua_type_coarse(&guard.narrowed_type, &mut hasher); + } + } + } + out.push(("signature", hasher.finish())); + } + // guard facts + { + let mut hasher = rustc_hash::FxHasher::default(); + let guard_facts = db + .get_signature_index() + .inferred_guard_facts_for_files(&HashSet::from([file_id])); + if !guard_facts.is_empty() { + let mut guard_vec: Vec<_> = guard_facts.iter().collect(); + guard_vec.sort_by(|a, b| a.0.path().cmp(b.0.path())); + for (owner, guard) in guard_vec { + owner.path().hash(&mut hasher); + guard.param_idx.hash(&mut hasher); + hash_lua_type_coarse(&guard.narrowed_type, &mut hasher); + } + } + out.push(("guard", hasher.finish())); + } + // namespace + { + let mut hasher = rustc_hash::FxHasher::default(); + if let Some(ns) = db.get_type_index().get_file_namespace(&file_id) { + ns.hash(&mut hasher); + } + if let Some(using) = db.get_type_index().get_file_using_namespace(&file_id) { + for ns in using { + ns.hash(&mut hasher); + } + } + out.push(("namespace", hasher.finish())); + } + out +} + +#[allow(dead_code)] +fn file_export_fingerprints(db: &DbIndex, file_ids: &HashSet) -> HashMap { + file_ids + .iter() + .map(|fid| (*fid, file_export_fingerprint(db, *fid))) + .collect() +} + fn global_path_for_expr(expr: &LuaExpr) -> Option> { let mut path = match expr { LuaExpr::NameExpr(name_expr) => { @@ -513,20 +1043,358 @@ impl EmmyLuaAnalysis { return Some(file_id); } - // The expansion has to be derived before the new text lands, because - // re-indexing a file drops the record of what depends on it. - let existing_reindex_file_ids = profile::phase("edit/expand", || { - existing_file_id.map(|file_id| self.expand_reindex_file_ids(vec![file_id])) - }); + // Change-aware incremental edit: only expand to dependents when the + // edited file's exported interface (members, types, signatures) actually + // changed. A trailing comment or a local-only edit keeps the same + // fingerprint, so the ripple collapses to empty and the edit costs only + // the file's own analysis instead of seconds for a hub file. + if let Some(existing) = existing_file_id { + // Capture fingerprint and expansion before the VFS mutation. + // Expansion must be captured before reindexing the edited file, as + // in the original `update_file_by_uri` path: dependents are those + // that reference the file's *old* exports (e.g. a call site that + // already references `Predicates.IsPlayer`), and computing it after + // `self_index_files` would miss them (observed: guard addition + // expansion went from 2 to 1 and the consumer stayed `Entity`). + let before_fp = file_export_fingerprint(self.compilation.get_db(), existing); + let before_expansion = self.expand_reindex_file_ids(vec![existing]); + let old_guard_snapshot = self + .inferred_guard_snapshot(&before_expansion.iter().copied().collect::>()); + // For files that define inferred guards or VGUI forwarding, the + // `self_index` shortcut would clobber the `old_guard_snapshot` and + // VGUI metadata needed for correct ripple. Fall back to the original + // full reindex path for those (observed: guard addition stayed + // `Entity` and VGUI deletion left stale parent chain). + let is_special = { + let db = self.compilation.get_db(); + !db.get_signature_index() + .inferred_guard_facts_for_files(&HashSet::from([existing])) + .is_empty() + || db + .get_gmod_class_metadata_index() + .has_annotated_vgui_parent_calls(existing) + }; + if is_special { + let file_id = self + .compilation + .get_db_mut() + .get_vfs_mut() + .set_file_content(uri, text); + let expansion = before_expansion; + self.reindex_expanded_files_with_old_snapshot( + vec![file_id], + expansion, + old_guard_snapshot, + ); + profile::phase_report("update_file_by_uri"); + return Some(file_id); + } + let before_detailed = if std::env::var_os("GLUALS_DEBUG_FINGERPRINT").is_some() { + Some(file_export_fingerprint_detailed( + self.compilation.get_db(), + existing, + )) + } else { + None + }; + let before_members_debug = if std::env::var_os("GLUALS_DEBUG_FINGERPRINT").is_some() { + let db = self.compilation.get_db(); + let mut members = db.get_member_index().get_file_members(existing); + members.sort_by_key(|m| crate::db_index::member_id_sort_key(m.get_id())); + let strs: Vec = members + .iter() + .map(|m| { + let owner_str = db + .get_member_index() + .get_member_owner(&m.get_id()) + .map(|o| match o { + LuaMemberOwner::GlobalPath(g) => { + format!("GlobalPath({})", g.get_name()) + } + LuaMemberOwner::Type(t) => format!("Type({})", t.get_name()), + LuaMemberOwner::Element(_) => "Element".to_string(), + LuaMemberOwner::LocalUnresolve => "LocalUnresolve".to_string(), + }) + .unwrap_or_else(|| "None".to_string()); + format!( + "{:?} key={:?} feat={:?} owner={}", + m.get_id(), + m.get_key(), + m.get_feature(), + owner_str + ) + }) + .collect(); + Some(strs) + } else { + None + }; + let before_contribs_debug = if std::env::var_os("GLUALS_DEBUG_FINGERPRINT").is_some() { + let db = self.compilation.get_db(); + let member_index = db.get_member_index(); + let store = member_index.member_assignment_contributions(); + let keys = store.keys_for_files(&HashSet::from([existing])); + let mut keys_vec: Vec<_> = keys.into_iter().collect(); + keys_vec.sort_by(|(_, ka), (_, kb)| { + let key_str = |k: &LuaMemberKey| match k { + LuaMemberKey::Name(n) => n.to_string(), + LuaMemberKey::Integer(i) => i.to_string(), + LuaMemberKey::None => "".to_string(), + LuaMemberKey::ExprType(_) => "".to_string(), + }; + key_str(ka).cmp(&key_str(kb)) + }); + let mut out = Vec::new(); + for (owner, key) in keys_vec { + if let LuaMemberKey::Name(name) = &key { + if name.contains('/') || name.contains(".mdl") { + continue; + } + } + if let Some(contribs) = store.contributions(&(owner.clone(), key.clone())) { + let mut contribs_vec: Vec<_> = contribs + .iter() + .filter(|(mid, _)| mid.file_id == existing) + .collect(); + contribs_vec + .sort_by_key(|(mid, _)| crate::db_index::member_id_sort_key(**mid)); + for (mid, contrib) in contribs_vec { + let owner_str = "Owner".to_string(); + let key_str = match &key { + LuaMemberKey::Name(n) => format!("N:{}", n), + LuaMemberKey::Integer(i) => format!("I:{}", i), + LuaMemberKey::None => "None".to_string(), + LuaMemberKey::ExprType(t) => { + let mut h = rustc_hash::FxHasher::default(); + hash_lua_type_coarse(t, &mut h); + format!("E:{:x}", h.finish()) + } + }; + let mut h1 = rustc_hash::FxHasher::default(); + hash_lua_type_coarse(&contrib.bound_type, &mut h1); + let mut h2 = rustc_hash::FxHasher::default(); + hash_lua_type_coarse(&contrib.source_type, &mut h2); + out.push(format!( + "owner={} key={} mid={:?} bound={:x} source={:x} guarded={} preserve={}", + owner_str, + key_str, + mid, + h1.finish(), + h2.finish(), + contrib.guarded_bootstrap, + contrib.preserve_table_literals + )); + } + } + } + Some(out) + } else { + None + }; + let file_id = self + .compilation + .get_db_mut() + .get_vfs_mut() + .set_file_content(uri, text); + // Self-index the edited file so its entries match its text (all a + // request inside this file needs) and so the after-fingerprint can + // be taken from the new index. + profile::phase("edit/self-index", || { + self.self_index_files(vec![file_id]); + }); + let after_fp = file_export_fingerprint(self.compilation.get_db(), file_id); + if before_fp == after_fp { + profile::phase_report("update_file_by_uri (no-ripple)"); + return Some(file_id); + } + if std::env::var_os("GLUALS_DEBUG_FINGERPRINT").is_some() { + if let Some(before_detailed) = before_detailed { + let after_detailed = + file_export_fingerprint_detailed(self.compilation.get_db(), file_id); + if let Some(path) = self.compilation.get_db().get_vfs().get_file_path(&file_id) + { + eprintln!( + "[fingerprint] changed {} before={:x} after={:x}", + path.display(), + before_fp, + after_fp + ); + for ((name, before_cat), (_, after_cat)) in + before_detailed.iter().zip(after_detailed.iter()) + { + if before_cat != after_cat { + eprintln!( + " category {} before={:x} after={:x}", + name, before_cat, after_cat + ); + } + } + // Detailed member diff (stable owner) + if let Some(before_members) = before_members_debug { + let db = self.compilation.get_db(); + let mut after_members = db.get_member_index().get_file_members(file_id); + after_members + .sort_by_key(|m| crate::db_index::member_id_sort_key(m.get_id())); + let after_strs: Vec = after_members + .iter() + .map(|m| { + let owner_str = db + .get_member_index() + .get_member_owner(&m.get_id()) + .map(|o| match o { + LuaMemberOwner::GlobalPath(g) => { + format!("GlobalPath({})", g.get_name()) + } + LuaMemberOwner::Type(t) => { + format!("Type({})", t.get_name()) + } + LuaMemberOwner::Element(_) => "Element".to_string(), + LuaMemberOwner::LocalUnresolve => { + "LocalUnresolve".to_string() + } + }) + .unwrap_or_else(|| "None".to_string()); + format!( + "{:?} key={:?} feat={:?} owner={}", + m.get_id(), + m.get_key(), + m.get_feature(), + owner_str + ) + }) + .collect(); + eprintln!( + " members before={} after={}", + before_members.len(), + after_strs.len() + ); + let before_set: std::collections::HashSet<_> = + before_members.iter().collect(); + let after_set: std::collections::HashSet<_> = + after_strs.iter().collect(); + for b in &before_members { + if !after_set.contains(b) { + eprintln!(" - {}", b); + } + } + for a in &after_strs { + if !before_set.contains(a) { + eprintln!(" + {}", a); + } + } + } + if let Some(before_contribs) = before_contribs_debug { + let db = self.compilation.get_db(); + let member_index = db.get_member_index(); + let store = member_index.member_assignment_contributions(); + let keys = store.keys_for_files(&HashSet::from([file_id])); + let mut keys_vec: Vec<_> = keys.into_iter().collect(); + keys_vec.sort_by(|(_, ka), (_, kb)| { + let key_str = |k: &LuaMemberKey| match k { + LuaMemberKey::Name(n) => n.to_string(), + LuaMemberKey::Integer(i) => i.to_string(), + LuaMemberKey::None => "".to_string(), + LuaMemberKey::ExprType(_) => "".to_string(), + }; + key_str(ka).cmp(&key_str(kb)) + }); + let mut after_contribs = Vec::new(); + for (owner, key) in keys_vec { + if let LuaMemberKey::Name(name) = &key { + if name.contains('/') || name.contains(".mdl") { + continue; + } + } + if let Some(contribs) = + store.contributions(&(owner.clone(), key.clone())) + { + let mut contribs_vec: Vec<_> = contribs + .iter() + .filter(|(mid, _)| mid.file_id == file_id) + .collect(); + contribs_vec.sort_by_key(|(mid, _)| { + crate::db_index::member_id_sort_key(**mid) + }); + for (mid, contrib) in contribs_vec { + let owner_str = "Owner".to_string(); + let key_str = match &key { + LuaMemberKey::Name(n) => format!("N:{}", n), + LuaMemberKey::Integer(i) => format!("I:{}", i), + LuaMemberKey::None => "None".to_string(), + LuaMemberKey::ExprType(t) => { + let mut h = rustc_hash::FxHasher::default(); + hash_lua_type_coarse(t, &mut h); + format!("E:{:x}", h.finish()) + } + }; + let mut h1 = rustc_hash::FxHasher::default(); + hash_lua_type_coarse(&contrib.bound_type, &mut h1); + let mut h2 = rustc_hash::FxHasher::default(); + hash_lua_type_coarse(&contrib.source_type, &mut h2); + after_contribs.push(format!( + "owner={} key={} mid={:?} bound={:x} source={:x} guarded={} preserve={}", + owner_str, + key_str, + mid, + h1.finish(), + h2.finish(), + contrib.guarded_bootstrap, + contrib.preserve_table_literals + )); + } + } + } + eprintln!( + " contribs before={} after={}", + before_contribs.len(), + after_contribs.len() + ); + let before_set: std::collections::HashSet<_> = + before_contribs.iter().collect(); + let after_set: std::collections::HashSet<_> = + after_contribs.iter().collect(); + for b in &before_contribs { + if !after_set.contains(b) { + eprintln!(" - {}", b); + } + } + for a in &after_contribs { + if !before_set.contains(a) { + eprintln!(" + {}", a); + } + } + } + } + } + } + // Export changed - pay the ripple. Use the pre-computed expansion + // (before the edit) so that call-site dependents that already + // reference the old exports are included. Computing after + // `self_index_files` missed them (observed: guard consumer went from 2 + // to 1 and stayed `Entity`). Use the old guard snapshot for the + // guard propagation, which must be captured before `self_index` overwrites it. + let expansion = before_expansion; + if std::env::var_os("GLUALS_DEBUG_FINGERPRINT").is_some() { + eprintln!("[fingerprint] expansion {} files", expansion.len()); + } + profile::phase("edit/ripple", || { + self.reindex_expanded_files_with_old_snapshot( + vec![file_id], + expansion, + old_guard_snapshot, + ) + }); + profile::phase_report("update_file_by_uri"); + return Some(file_id); + } + // New file - no fingerprint to compare, fall back to full expansion. let file_id = self .compilation .get_db_mut() .get_vfs_mut() .set_file_content(uri, text); - - let expansion = existing_reindex_file_ids - .unwrap_or_else(|| self.expand_reindex_file_ids(vec![file_id])); + let expansion = self.expand_reindex_file_ids(vec![file_id]); profile::phase("edit/reindex", || { self.reindex_expanded_files(vec![file_id], expansion) }); @@ -761,8 +1629,14 @@ impl EmmyLuaAnalysis { }) .collect::>(); - let mut file_ids = expansion; + let mut file_ids = expansion.clone(); self.add_vgui_forwarding_removal_seed(&removed_file_ids, &mut file_ids); + if std::env::var_os("GLUALS_DEBUG_FINGERPRINT").is_some() && !removed_file_ids.is_empty() { + eprintln!( + "[vgui] removed {:?} expansion before {:?} after {:?}", + removed_file_ids, expansion, file_ids + ); + } let guard_fact_file_ids = file_ids.iter().copied().collect::>(); let old_guard_facts = self.inferred_guard_snapshot(&guard_fact_file_ids); self.compilation.remove_index(file_ids.clone()); @@ -790,6 +1664,60 @@ impl EmmyLuaAnalysis { self.reindex_changed_inferred_param_consumers(&old_guard_facts, &file_ids); } + pub(crate) fn reindex_expanded_files_with_old_snapshot( + &mut self, + file_ids: Vec, + expansion: Vec, + old_snapshot: InferredGuardSnapshot, + ) { + let incremental_source_file_ids = file_ids.iter().copied().collect::>(); + let removed_file_ids = file_ids + .iter() + .copied() + .filter(|file_id| { + self.compilation + .get_db() + .get_vfs() + .get_syntax_tree(file_id) + .is_none() + }) + .collect::>(); + + let mut file_ids = expansion.clone(); + self.add_vgui_forwarding_removal_seed(&removed_file_ids, &mut file_ids); + if std::env::var_os("GLUALS_DEBUG_FINGERPRINT").is_some() && !removed_file_ids.is_empty() { + eprintln!( + "[vgui] (old_snapshot) removed {:?} expansion before {:?} after {:?}", + removed_file_ids, expansion, file_ids + ); + } + let guard_fact_file_ids = file_ids.iter().copied().collect::>(); + let old_guard_facts = old_snapshot; + self.compilation.remove_index(file_ids.clone()); + let update_file_ids = file_ids + .iter() + .copied() + .filter(|file_id| !removed_file_ids.contains(file_id)) + .collect::>(); + if !update_file_ids.is_empty() { + self.compilation.update_index(update_file_ids.clone()); + self.stabilize_cross_file_type_caches(&update_file_ids); + } + for file_id in &incremental_source_file_ids { + self.compilation + .get_db_mut() + .get_call_site_param_index_mut() + .refresh_file_source_dependencies(*file_id); + } + self.reindex_changed_inferred_guard_references( + &guard_fact_file_ids, + &old_guard_facts, + &file_ids, + &incremental_source_file_ids, + ); + self.reindex_changed_inferred_param_consumers(&old_guard_facts, &file_ids); + } + /// Rebuilds only these files' own index entries. /// /// Nothing cross-file is settled: dependents keep whatever they inferred @@ -805,6 +1733,58 @@ impl EmmyLuaAnalysis { self.compilation.update_index(file_ids); } + pub fn self_index_files_and_get_ripple_with_changed( + &mut self, + file_ids: Vec, + ) -> (Vec, Vec) { + // Capture fingerprints and expansion before the mutation, as in + // `update_file_by_uri`. For guard/vgui files the fingerprint shortcut + // would clobber required state, so they are treated as always changed. + let has_special = file_ids.iter().any(|fid| { + let db = self.compilation.get_db(); + !db.get_signature_index() + .inferred_guard_facts_for_files(&HashSet::from([*fid])) + .is_empty() + || db + .get_gmod_class_metadata_index() + .has_annotated_vgui_parent_calls(*fid) + }); + if has_special { + let expansion = self.expand_reindex_file_ids(file_ids.clone()); + self.self_index_files(file_ids.clone()); + return (file_ids, expansion); + } + + let mut before_fps = HashMap::new(); + for fid in &file_ids { + before_fps.insert( + *fid, + file_export_fingerprint(self.compilation.get_db(), *fid), + ); + } + // Expansion must be captured before self_index, or dependents that + // reference the old exports are missed. + let before_expansion = self.expand_reindex_file_ids(file_ids.clone()); + self.self_index_files(file_ids.clone()); + let mut changed = Vec::new(); + for fid in &file_ids { + let after = file_export_fingerprint(self.compilation.get_db(), *fid); + if before_fps.get(fid) != Some(&after) { + changed.push(*fid); + } + } + if changed.is_empty() { + return (Vec::new(), Vec::new()); + } + // For the common non-special case the before expansion already + // contains the dependents of the changed files; filtering it to the + // changed subset would require per-file tracking, but the over-ripple + // is at most the same as the full edit and still <1s for a single-file + // hub edit when fingerprints are stable (observed sh_configuration 0.45s). + // Keep the before expansion for now. + (changed, before_expansion) + } + /// Re-analyses exactly `file_ids`, skipping dependency expansion. pub fn reindex_files_without_expansion(&mut self, file_ids: Vec) { self.compilation.remove_index(file_ids.clone()); diff --git a/crates/glua_ls/src/context/debounced_analysis.rs b/crates/glua_ls/src/context/debounced_analysis.rs index f8ee1b501..034375b0f 100644 --- a/crates/glua_ls/src/context/debounced_analysis.rs +++ b/crates/glua_ls/src/context/debounced_analysis.rs @@ -396,7 +396,10 @@ impl DebouncedAnalysis { /// This takes the write lock and gives it back, which is the whole point: a /// freshness flag published while the lock is still held buys a waiting /// request nothing, since it cannot read the index until the lock is free. - async fn self_index_without_queuing(&self, file_ids: Vec) -> Option> { + async fn self_index_without_queuing( + &self, + file_ids: Vec, + ) -> Option<(Vec, Vec)> { let analysis = self.analysis.clone(); let cache = self.shared_diagnostic_data_cache.clone(); @@ -404,12 +407,15 @@ impl DebouncedAnalysis { _ = self.shutdown.cancelled() => None, result = tokio::task::spawn_blocking(move || { let mut guard = analysis.blocking_write(); - let expansion = guard.expand_reindex_file_ids(file_ids.clone()); - guard.self_index_files(file_ids); + // Change-aware: only expand to dependents when the file's + // exported interface actually changed. Most keystrokes (typing + // inside a function, trailing comment, local rename) keep the + // same fingerprint and collapse the ripple to empty. + let (changed, expansion) = guard.self_index_files_and_get_ripple_with_changed(file_ids); cache.invalidate(); - expansion + (changed, expansion) }) => match result { - Ok(expansion) => Some(expansion), + Ok(result) => Some(result), Err(err) => { log::error!("self-index task failed: {}", err); None @@ -531,7 +537,8 @@ impl DebouncedAnalysis { // instead of waiting out the whole dependency ripple. The // ripple is by far the larger half — measured on a gamemode // workspace, 106ms against 5.1s. - let Some(expansion) = self.self_index_without_queuing(file_ids.clone()).await + let Some((changed_files, expansion)) = + self.self_index_without_queuing(file_ids.clone()).await else { if self.shutdown.is_cancelled() { return; @@ -565,9 +572,39 @@ impl DebouncedAnalysis { // would put them behind the whole ripple. self.await_reader_handoff().await; - owed_files.extend(file_ids.iter().copied()); - owed_expansion.extend(expansion); - burst_started_at.get_or_insert_with(Instant::now); + if expansion.is_empty() { + // No exports changed - the self-index already makes these + // files answerable, and no dependent needs re-indexing. + // Clear them from reindexing immediately so dirty state + // can settle without waiting for a ripple that will never + // come. + { + let mut reindexing = self.reindexing_files.lock().await; + for id in &file_ids { + reindexing.remove(id); + } + } + self.refresh_dirty_state().await; + self.reindex_notify.notify_waiters(); + if owed_files.is_empty() { + continue; + } + } else { + // Only the files whose exports actually changed need a + // ripple; the rest are already settled by the self-index. + let changed_set: HashSet = changed_files.iter().copied().collect(); + { + let mut reindexing = self.reindexing_files.lock().await; + for id in &file_ids { + if !changed_set.contains(id) { + reindexing.remove(id); + } + } + } + owed_files.extend(changed_files.iter().copied()); + owed_expansion.extend(expansion); + burst_started_at.get_or_insert_with(Instant::now); + } } if owed_files.is_empty() { From 75e93f614d0b82496b7eef968a38d5dec8aff2c8 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:40:03 +0100 Subject: [PATCH 060/159] chore: update instructions --- AGENTS.md | 105 +++++++++++++++++++++++------------------------------- 1 file changed, 45 insertions(+), 60 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 734556e4e..a29afc614 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,78 +2,63 @@ ## Repository Scope -- This repository is the Rust backend for GLuaLS, a Garry's Mod GLua language server forked from EmmyLua Analyzer Rust. -- Garry's Mod correctness and large-workspace performance take priority. Generic Lua language-server compatibility is out of scope unless a task explicitly requires it. -- The language server used by the VSCode extension is the primary product. `glua_check` and other tools must reuse the same analyzer behavior rather than grow separate rules. -- Editor UI and shipped annotations live in adjacent repositories, usually `vscode-gmod-glua-ls` and `annotations-gmod-glua-ls`. Locate annotations through the adjacent checkout or `BENCH_ANNOTATIONS` when cross-repository validation is needed. -- If expected GLua behavior is unclear, confirm the Garry's Mod semantics before implementing generic Lua behavior. +- Rust backend for GLuaLS, forked from EmmyLua Analyzer Rust. Garry's Mod correctness and large-workspace performance are primary; generic Lua compatibility is out of scope unless explicitly required. +- The VSCode language server is the product. `glua_check` and other tools must reuse the same analyzer behavior. +- Editor UI and shipped annotations live in adjacent repos (`vscode-gmod-glua-ls`, `annotations-gmod-glua-ls`). Use the adjacent checkout or `BENCH_ANNOTATIONS` env var. +- If GLua semantics are unclear, confirm Garry's Mod behavior before implementing generic Lua. ## Workspace Map -- `crates/glua_code_analysis`: VFS, indexes, analyzer, semantic model, diagnostics, configuration, embedded resources, and most tests. -- `crates/glua_ls`: LSP server and editor-facing handlers. Handlers should consume analyzer APIs and indexes, not reproduce semantic analysis. -- `crates/glua_parser`: parser, AST, and syntax APIs. -- `crates/glua_check`: CLI diagnostics runner and the preferred corpus-diagnostics entry point. -- `crates/glua_doc_cli`, `crates/schema_to_glua`, and `tools/schema_json_gen`: documentation and schema tooling. -- `tools/benchmark`: large-workspace benchmark. It requires `BENCH_CODEBASE` and `BENCH_ANNOTATIONS`. -- `tools/determinism`: diagnostic determinism harness. It requires `DET_CODEBASE` and `DET_ANNOTATIONS`, and answers whether re-analysing a workspace yields the same diagnostics as building it cold. See the module docs for the stage list. -- `tools/lsp_latency.js`: interactive latency harness. It requires `LSP_CODEBASE` and `LSP_ANNOTATIONS`, and drives a real `glua_ls` binary over stdio using the capabilities and cancellation behaviour VS Code actually uses. Reports completion and diagnostic latency settled versus mid-edit, and asserts that a cancelled diagnostic pull never returns an empty full report (which clears a file's diagnostics in VS Code). Use it before and after any change to reindexing or to the freshness gates — those costs are invisible to unit tests. -- `docs/mintlify`: user documentation. Follow its nested `AGENTS.md` for changes under that tree. +- `crates/glua_code_analysis`: VFS, indexes, analyzer, semantic model, diagnostics, config, embedded resources, most tests. +- `crates/glua_ls`: LSP server and handlers. Consume analyzer APIs; do not reimplement analysis. +- `crates/glua_parser`, `crates/glua_parser_desc`: parser, AST, syntax APIs. +- `crates/glua_check`: CLI diagnostics runner; preferred corpus entry point. +- `crates/glua_doc_cli`, `crates/schema_to_glua`, `tools/schema_json_gen`: docs and schema tooling. +- `tools/benchmark`: large-workspace benchmark (`BENCH_CODEBASE` + `BENCH_ANNOTATIONS` required). +- `tools/determinism`: determinism harness (`DET_CODEBASE` + `DET_ANNOTATIONS` required). +- `tools/lsp_latency.js`: latency harness (`LSP_CODEBASE` + `LSP_ANNOTATIONS`); drives `glua_ls` over stdio with VS Code capabilities. Reports settled vs mid-edit latency and asserts cancelled diagnostic pulls never return empty reports. Run before/after reindexing or freshness-gate changes. +- `docs/mintlify`: user documentation (see nested `AGENTS.md`). ## Analysis Architecture -- `EmmyLuaAnalysis` in `crates/glua_code_analysis/src/lib.rs` is the top-level owner of workspace state, configuration, VFS, compilation, diagnostics, and incremental updates. -- `glua_code_analysis` is the single source of semantic behavior. The LSP and `glua_check` should consume its indexes and APIs rather than implement their own versions of analysis rules. -- GLuaLS defaults to and assumes `gmod.enabled` is on; disabling it is unsupported. Do not treat Garry's Mod behavior as an optional compatibility layer. -- Extensible Garry's Mod API behavior is annotation-driven. Call roles, wrapper behavior, and guard metadata are shared through signature metadata; check `crates/glua_code_analysis/src/db_index/signature/gmod_domains.rs` before adding a name-based recognizer. -- Realm and load-order analysis are first-class. Consider them when changing semantic or editor behavior, and reuse the shared analyzer/index support rather than adding feature-local heuristics. It is very important for the language server to be realm aware. -- Realm evidence is not path-only: annotations, branches, load edges, filename conventions, and defaults can all contribute. Identically named declarations may legitimately coexist in different realms. -- Analyzer phase ordering should be treated with caution since it can result in severe regressions, always double-check the current order as in the codebase before making changes. -- Cross-file analysis should be indexed or precomputed. Diagnostics already provide shared batch data through `SharedDiagnosticData`; reuse it instead of scanning the workspace per file or request. +- `EmmyLuaAnalysis` in `crates/glua_code_analysis/src/lib.rs` owns workspace state, VFS, compilation, diagnostics, and incremental updates. +- `glua_code_analysis` is the single source of semantic behavior. +- `gmod.enabled` defaults on; disabling is unsupported. +- GMod API extensibility is annotation-driven via signature metadata; check `crates/glua_code_analysis/src/db_index/signature/gmod_domains.rs` before adding name-based recognizers. +- Realm and load-order are first-class; reuse shared analyzer/index support. Realm evidence includes annotations, branches, load edges, filenames, and defaults — same name may coexist across realms. +- Analyzer phase ordering is fragile; verify current order before changing it. +- Cross-file work must be indexed. Reuse `SharedDiagnosticData` for diagnostics instead of per-file workspace scans. ## Change Requirements -- Always start by loading the rust-best-practices skill, and if working on core language server API functionality, the language server spec skill. -- Fix incorrect inference, realm, load, or member evidence at its root source. Suppressing a diagnostic or adding a special case usually hides the real bug. -- Incremental edits may invalidate dependent files and cross-file caches. Test edit, deletion, and reopen behavior when changing indexes or cached inference. -- Dynamic fields and flow narrowing are sensitive to ownership, source range, scope, realm visibility, and edit stability; preserve all of those dimensions. -- VGUI/scripted classes and helpers such as `AccessorFunc` and `NetworkVar` often use indexed metadata or synthesized members rather than ordinary declarations. Extend the shared model instead of recognizing them separately in each feature. -- Network diagnostics compare send/receive flows and operation order. Treat dynamic message names, payload branches, and read/write loops conservatively to avoid false positives. -- Annotation metadata changes need both ingestion coverage and a downstream behavior test. Use the existing Garry's Mod builtins and fixtures rather than recreating behavior in the test. -- Output derived from hash maps or parallel collection must be sorted before it reaches diagnostics, completions, code lenses, or snapshots. -- Do not address performance problems with arbitrary budgets, caps, fragile pre-filters or broad work-skipping flags. Profile first, then index, cache, optimize, or parallelize. -- Configuration changes must update the config structs, `crates/glua_code_analysis/resources/schema.json`, generated schema output, and user documentation together. Run `cargo run --bin schema_json_gen` and inspect the resulting diff. -- `.gluarc.json` is exclusive when present; otherwise configs are considered in order: `.luarc.json`, `.emmyrc.json`, `.emmyrc.lua`. Gamemode-base detection scans workspace roots, not the config-file directory. -- Annotations are external library workspaces, not server-bundled files. Loading may come from `glua_check --gmod-annotations`, `glua_ls --gmod-annotations-path`, or the `gmod.annotationsPath` / `gmod.autoLoadAnnotations` settings. +- Load `rust-best-practices` skill first; also `language-server-spec` for LSP work. +- Fix inference/realm/load/member root cause; do not suppress diagnostics or add special cases. +- Incremental edits may invalidate dependents and caches; test edit, delete, and reopen when changing indexes or cached inference. Preserve ownership, range, scope, realm, and edit stability for dynamic fields/flow narrowing. +- VGUI/scripted classes (`AccessorFunc`, `NetworkVar`, etc.) use indexed/synthesized members; extend the shared model, don't duplicate per-feature. +- Network diagnostics compare send/receive flows and order; be conservative with dynamic names, branches, and loops. +- Annotation metadata changes need ingestion coverage plus a downstream behavior test via real builtins/fixtures. +- Sort any output derived from hash maps or parallel collection before diagnostics/completions/snapshots. +- No budgets, caps, or fragile prefilters for performance. Profile first, then index/cache/optimize/parallelize. +- Config changes must update structs, `crates/glua_code_analysis/resources/schema.json`, and docs together. Run `cargo run --bin schema_json_gen` and commit the diff. +- `.gluarc.json` is exclusive when present; otherwise consider `.luarc.json`, `.emmyrc.json`, `.emmyrc.lua` in order. Gamemode-base detection scans workspace roots. +- Annotations are external library workspaces: `glua_check --gmod-annotations`, `glua_ls --gmod-annotations-path` (or `gmod.annotationsPath` / `gmod.autoLoadAnnotations` in config). ## Testing and Performance -- Use `VirtualWorkspace` and realistic addon or gamemode paths when behavior depends on workspace layout, load order, or realm. Prefer the established Garry's Mod test modules and fixtures over isolated ad hoc cases. -- Call-role and annotation-driven tests should load the relevant builtins; otherwise they may pass while bypassing the real metadata path. -- Typical test commands are `cargo test -p glua_code_analysis `, `cargo test -p glua_code_analysis`, and `cargo test`. -- Use `glua_check` JSON output for before/after corpus diagnostic comparisons. The benchmark measures performance; it is not a diagnostics oracle. -- Changes to indexes, cached inference, or the unresolve/resolution passes must keep incremental re-analysis equal to a cold build. Verify with `cargo run --release -p determinism` on a real workspace, and set `DET_EDIT_FIND`/`DET_EDIT_REPLACE` or the edit stages skip and gate nothing. **Every gate must report +0** — no drift in the diagnostics or in the index, on any of them. The gates are: - - `repeat` — re-collect diagnostics with no change at all. - - `fresh` — build a second analysis in the same process. - - `order` — rebuild with the file list reversed. - - `reindex` — full clear and rebuild; the ground truth. - - `allreindex` — re-analyse every file, library included, via per-file removal rather than `clear_index`. - - `mainexpand` — re-analyse every main-workspace file through `reindex_files`, i.e. the dependency expansion the LSP actually applies. - - `noopedit` — a semantically neutral edit pair through `update_file_by_uri`: the no-op gate must skip the re-index and skipping must preserve state. Include a wide-expansion target so the skip is exercised where it matters. - - `realedit` — a real edit that changes what the file means, compared against a cold build of the edited source. Always diffs the index. The most sensitive gate: unresolve waves, infer-cache lifetime and member ownership can break it while everything else reports IDENTICAL. - - `editrevert` — a real edit applied through the update path and then taken back out. The source ends where it started, so the index and the diagnostics have to as well. - - `indexrepeat` — re-index each target with its text untouched and require the **index** to come back identical. The diagnostic gates cannot see index drift: re-analysis can attach different members or settle a decl's type differently and still produce the same diagnostics. - - `burst` — three edits per target, each self-indexed, then one ripple over the union of the captured expansions (the shape a deferred debounce produces), gated against a cold build of the final text. -- `mainreindex`, `exact`, `split:N`, `editmid`, `restabilize`, `perfile`, `expandwhy` and `faithful` are bisect instruments, not gates: they run reduced or deliberately non-production paths, are expected to diverge, and only matter for localising a failure a gate already caught. Run the gates before and after a change — a change can make a stage identical by *degrading* the cold build rather than by fixing the re-index. `DET_TARGETS=gamemode/core/sh_data.lua` expands to 4 files and is far cheaper to iterate on than a 1300-file target. -- Performance changes require profiling or a targeted before/after benchmark. Use `GLUALS_PROFILE=1` for phase timings and `cargo run --release -p benchmark` for the large-workspace harness. -- For a sampling profile use `samply` (ETW-based on Windows, so it prompts for admin elevation on every run; the user has to approve it). Three things have to be right or you get a useless profile: build with `CARGO_PROFILE_RELEASE_DEBUG=1 cargo build --release -p benchmark` so the PDB exists, run the binary from `target/release` (samply resolves the PDB by the relative path recorded in the exe, so it only finds it from that directory), and do **not** pass `--main-thread-only` — the tools run analysis on a spawned big-stack thread, so the main thread only shows a join. A working invocation is `cd target/release && BENCH_CODEBASE= samply record --save-only --unstable-presymbolicate -o .json.gz ./benchmark.exe`. That writes `.json.gz` plus a `.json.syms.json` sidecar; the profile itself holds only addresses, so symbol names come from joining the two by `libs[].debugName` and the frame address against each module's `symbol_table` rva ranges. -- Performance is extremely important; the language server must be quick and responsive on large workspaces without loss of functionality. You are to always optimise at the root cause of performance issues. Things such as budgets, string based prefilters / guards and other similar "hacks" are unacceptable since they will regress functionality in large or complex codebases. +- Use `VirtualWorkspace` with realistic addon/gamemode paths; prefer existing GMod fixtures. Call-role tests must load relevant builtins. +- Tests: `cargo test -p glua_code_analysis ` | `cargo test -p glua_code_analysis` | `cargo test`. +- Corpus diffs: `glua_check` JSON. Benchmark is for performance only. +- Determinism (required for index/cache/unresolve changes): `cargo run --release -p determinism`. Requires `DET_CODEBASE` and `DET_ANNOTATIONS`; set `DET_EDIT_FIND`/`DET_EDIT_REPLACE` for edit gates or they skip. Every gate must be `+0` diagnostics and `+0` index. + Gates: `repeat`, `fresh`, `order`, `reindex`, `allreindex`, `mainexpand`, `noopedit`, `realedit`, `editrevert`, `indexrepeat`, `burst`. + Bisect/debug only (expected to diverge): `mainreindex`, `exact`, `split:N`, `editmid`, `restabilize`, `perfile`, `expandwhy`, `faithful`. + Use `DET_TARGETS=gamemode/core/sh_data.lua` by default for edit target, `sh_configuration` is good for performance related tests (many related files). +- Perf: `GLUALS_PROFILE=1` for phase timings; `cargo run --release -p benchmark` for large-workspace. For `samply` (ETW on Windows, needs elevation and therefore user permission first): build with `CARGO_PROFILE_RELEASE_DEBUG=1 cargo build --release -p benchmark`, run from `target/release`, do not use `--main-thread-only` (analysis runs on spawned thread). Example: `cd target/release && BENCH_CODEBASE= samply record --save-only --unstable-presymbolicate -o out.json.gz ./benchmark.exe`. ## Commands -- Format: `cargo fmt --all`. -- CI-equivalent lint: `cargo clippy --workspace --all-targets --all-features -- -D warnings`. -- Pre-commit hygiene: `pre-commit run --all --hook-stage manual`. -- Local release build: `cargo build --release`, optionally with `-p glua_ls`, `-p glua_check`, or `-p glua_doc_cli`. -- Shipped/CI optimized build: `cargo build --profile dist`. -- Docs commands run from `docs/mintlify`: `mint dev` and `mint broken-links`. +- `cargo fmt --all` +- `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- `pre-commit run --all-files` (mixed-line-ending hook is `manual` stage) +- `cargo build --release` [`-p glua_ls|glua_check|glua_doc_cli`] +- `cargo build --profile dist` (shipped/CI optimized, thin LTO) +- `docs/mintlify`: `mint dev` | `mint broken-links` From 56657d7d1e3ba60b4a435281c2e90b7405a022d8 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:04:27 +0100 Subject: [PATCH 061/159] fix: remap Element/TableConst offsets on incremental edits Anchor tables by Global / Local / Tree identity and migrate cross-file Element owners and TableConst caches in place when an edit shifts TextRange offsets. Stash old anchored maps before VFS mutation (update_file_by_uri, update_file_preparsed, update_file_preparsed_deferred, update_file_text_only, self_index) and after reindex remap member_current_owner / owner_members and type caches via remap_table_ranges_in_type. Handles _G/_ENV canonicalization, local IndexExpr (t.inner = {}), and ambiguous Tree anchors. Keeps expansion at 1309 files; DET realedit/burst are now IDENTICAL. Co-authored-by: internal --- .../src/db_index/member/mod.rs | 79 +++ .../src/db_index/type/mod.rs | 312 +++++++++++ crates/glua_code_analysis/src/lib.rs | 517 +++++++++++++++++- tools/benchmark/src/main.rs | 6 +- 4 files changed, 907 insertions(+), 7 deletions(-) diff --git a/crates/glua_code_analysis/src/db_index/member/mod.rs b/crates/glua_code_analysis/src/db_index/member/mod.rs index 5b6da8a2e..afd60e73a 100644 --- a/crates/glua_code_analysis/src/db_index/member/mod.rs +++ b/crates/glua_code_analysis/src/db_index/member/mod.rs @@ -1723,6 +1723,85 @@ impl LuaMemberIndex { self.function_scope_ranges.remove(&file_id); self.conditional_branch_ranges.remove(&file_id); } + + pub fn remap_elements( + &mut self, + map: &rustc_hash::FxHashMap, crate::InFiled>, + ) { + if map.is_empty() { + return; + } + let to_move: Vec<(LuaMemberId, LuaMemberOwner, LuaMemberOwner)> = self + .member_current_owner + .iter() + .filter_map(|(mid, owner)| { + if let LuaMemberOwner::Element(old) = owner { + if let Some(new) = map.get(old) { + return Some(( + *mid, + LuaMemberOwner::Element(old.clone()), + LuaMemberOwner::Element(new.clone()), + )); + } + } + None + }) + .collect(); + for (member_id, old_owner, new_owner) in to_move { + // Remove from old owner's structures + self.detach_member_from_owner(&old_owner, member_id); + // The detach already removed from owner_members and key indexes. + // Now attach to new owner using the established rehome pattern. + self.set_member_owner(new_owner.clone(), member_id.file_id, member_id); + self.add_member_to_owner(new_owner, member_id); + // Clean up old tombstone if now empty + if let LuaMemberOwner::Element(old_range) = &old_owner { + if self + .owner_members + .get(&old_owner) + .is_none_or(|m| m.is_empty()) + { + self.owner_members.remove(&old_owner); + if let Some(set) = self.in_filed.get_mut(&old_range.file_id) { + set.remove(&MemberOrOwner::Owner(old_owner.clone())); + if set.is_empty() { + self.in_filed.remove(&old_range.file_id); + } + } + } + } + } + } + + pub fn remove_deleted_element_owners(&mut self, deleted: &[crate::InFiled]) { + for range in deleted { + let owner = LuaMemberOwner::Element(range.clone()); + if let Some(member_items) = self.owner_members.remove(&owner) { + for item in member_items.get_member_items() { + match item { + LuaMemberIndexItem::One(id) => { + self.member_current_owner.remove(id); + self.remove_member_from_all_owner_key_indexes(&owner, *id); + self.remove_current_owner_member(&owner, *id); + } + LuaMemberIndexItem::Many(ids) => { + for id in ids { + self.member_current_owner.remove(id); + self.remove_member_from_all_owner_key_indexes(&owner, *id); + self.remove_current_owner_member(&owner, *id); + } + } + } + } + } + if let Some(set) = self.in_filed.get_mut(&range.file_id) { + set.remove(&MemberOrOwner::Owner(owner)); + if set.is_empty() { + self.in_filed.remove(&range.file_id); + } + } + } + } } impl LuaIndex for LuaMemberIndex { diff --git a/crates/glua_code_analysis/src/db_index/type/mod.rs b/crates/glua_code_analysis/src/db_index/type/mod.rs index ac56d1403..d2195908c 100644 --- a/crates/glua_code_analysis/src/db_index/type/mod.rs +++ b/crates/glua_code_analysis/src/db_index/type/mod.rs @@ -187,6 +187,293 @@ fn replace_table_consts_in_type( } } +fn remap_table_ranges_in_type( + typ: &LuaType, + map: &rustc_hash::FxHashMap, InFiled>, +) -> Option { + match typ { + LuaType::TableConst(old) => map.get(old).map(|new| LuaType::TableConst(new.clone())), + LuaType::Instance(inst) => { + let mut changed = false; + let mut new_base = inst.get_base().clone(); + if let Some(nb) = remap_table_ranges_in_type(inst.get_base(), map) { + new_base = nb; + changed = true; + } + let mut new_range = inst.get_range().clone(); + if let Some(mapped) = map.get(inst.get_range()) { + new_range = mapped.clone(); + changed = true; + } + if changed { + Some(LuaType::Instance(Arc::new( + crate::db_index::r#type::types::LuaInstanceType::new(new_base, new_range), + ))) + } else { + None + } + } + LuaType::Union(union) => { + let mut changed = false; + let new_types: Vec = union + .into_vec() + .into_iter() + .map(|sub| { + remap_table_ranges_in_type(&sub, map) + .inspect(|_| changed = true) + .unwrap_or(sub) + }) + .collect(); + changed.then(|| LuaType::from_vec(new_types)) + } + LuaType::Intersection(inter) => { + let mut changed = false; + let new_types: Vec = inter + .get_types() + .iter() + .map(|sub| { + remap_table_ranges_in_type(sub, map) + .inspect(|_| changed = true) + .unwrap_or_else(|| sub.clone()) + }) + .collect(); + changed.then(|| { + LuaType::Intersection(Arc::new(crate::LuaIntersectionType::new(new_types))) + }) + } + LuaType::MergedTable(merged) => { + let mut changed = false; + let new_types: Vec = merged + .get_types() + .iter() + .map(|sub| { + remap_table_ranges_in_type(sub, map) + .inspect(|_| changed = true) + .unwrap_or_else(|| sub.clone()) + }) + .collect(); + changed + .then(|| LuaType::MergedTable(Arc::new(crate::LuaMergedTableType::new(new_types)))) + } + LuaType::Array(arr) => remap_table_ranges_in_type(arr.get_base(), map).map(|new_base| { + LuaType::Array(Arc::new(crate::LuaArrayType::new( + new_base, + arr.get_len().clone(), + ))) + }), + LuaType::Tuple(tuple) => { + let mut changed = false; + let new_types: Vec = tuple + .get_types() + .iter() + .map(|sub| { + remap_table_ranges_in_type(sub, map) + .inspect(|_| changed = true) + .unwrap_or_else(|| sub.clone()) + }) + .collect(); + if changed { + Some(LuaType::Tuple(Arc::new(crate::LuaTupleType::new( + new_types, + tuple.status, + )))) + } else { + None + } + } + LuaType::Object(obj) => { + let mut changed = false; + let mut new_fields = std::collections::BTreeMap::new(); + for (k, v) in obj.get_fields() { + if let Some(nv) = remap_table_ranges_in_type(v, map) { + changed = true; + new_fields.insert(k.clone(), nv); + } else { + new_fields.insert(k.clone(), v.clone()); + } + } + let mut new_index_access = Vec::new(); + for (k, v) in obj.get_index_access() { + let nk = remap_table_ranges_in_type(k, map).unwrap_or_else(|| k.clone()); + let nv = remap_table_ranges_in_type(v, map).unwrap_or_else(|| v.clone()); + if &nk != k || &nv != v { + changed = true; + } + new_index_access.push((nk, nv)); + } + if changed { + Some(LuaType::Object(Arc::new( + crate::LuaObjectType::new_with_fields(new_fields, new_index_access), + ))) + } else { + None + } + } + LuaType::Generic(r#gen) => { + let mut changed = false; + let new_params: Vec = r#gen + .get_params() + .iter() + .map(|p| { + remap_table_ranges_in_type(p, map) + .inspect(|_| changed = true) + .unwrap_or_else(|| p.clone()) + }) + .collect(); + if changed { + Some(LuaType::Generic(Arc::new(crate::LuaGenericType::new( + r#gen.get_base_type_id(), + new_params, + )))) + } else { + None + } + } + LuaType::TableGeneric(params) => { + let mut changed = false; + let new_params: Vec = params + .iter() + .map(|p| { + remap_table_ranges_in_type(p, map) + .inspect(|_| changed = true) + .unwrap_or_else(|| p.clone()) + }) + .collect(); + changed.then(|| LuaType::TableGeneric(Arc::new(new_params))) + } + LuaType::DocFunction(func) => { + let mut changed = false; + let mut new_params = Vec::new(); + for (name, ty) in func.get_params() { + if let Some(ty) = ty { + if let Some(nt) = remap_table_ranges_in_type(ty, map) { + changed = true; + new_params.push((name.clone(), Some(nt))); + } else { + new_params.push((name.clone(), Some(ty.clone()))); + } + } else { + new_params.push((name.clone(), None)); + } + } + let new_ret = remap_table_ranges_in_type(func.get_ret(), map) + .inspect(|_| changed = true) + .unwrap_or_else(|| func.get_ret().clone()); + if &new_ret != func.get_ret() { + changed = true; + } + if changed { + let new_func = crate::LuaFunctionType::new( + func.get_async_state(), + func.is_colon_define(), + func.is_variadic(), + new_params, + new_ret, + ) + .with_optional_params(func.get_optional_params().to_vec()) + .with_call_arg_roles(func.get_call_arg_roles().to_vec()); + Some(LuaType::DocFunction(Arc::new(new_func))) + } else { + None + } + } + LuaType::Variadic(var) => match var.as_ref() { + crate::VariadicType::Multi(types) => { + let mut changed = false; + let new_types: Vec = types + .iter() + .map(|t| { + remap_table_ranges_in_type(t, map) + .inspect(|_| changed = true) + .unwrap_or_else(|| t.clone()) + }) + .collect(); + changed.then(|| LuaType::Variadic(Arc::new(crate::VariadicType::Multi(new_types)))) + } + crate::VariadicType::Base(base) => remap_table_ranges_in_type(base, map) + .map(|nb| LuaType::Variadic(Arc::new(crate::VariadicType::Base(nb)))), + }, + LuaType::MultiLineUnion(mlu) => { + let mut changed = false; + let new_unions: Vec<(LuaType, Option)> = mlu + .get_unions() + .iter() + .map(|(ty, doc)| { + if let Some(nt) = remap_table_ranges_in_type(ty, map) { + changed = true; + (nt, doc.clone()) + } else { + (ty.clone(), doc.clone()) + } + }) + .collect(); + changed.then(|| { + LuaType::MultiLineUnion(Arc::new(crate::LuaMultiLineUnion::new(new_unions))) + }) + } + LuaType::TypeGuard(inner) => { + remap_table_ranges_in_type(inner, map).map(|nt| LuaType::TypeGuard(Arc::new(nt))) + } + LuaType::Conditional(cond) => { + let mut changed = false; + let new_cond = remap_table_ranges_in_type(cond.get_condition(), map) + .inspect(|_| changed = true) + .unwrap_or_else(|| cond.get_condition().clone()); + let new_true = remap_table_ranges_in_type(cond.get_true_type(), map) + .inspect(|_| changed = true) + .unwrap_or_else(|| cond.get_true_type().clone()); + let new_false = remap_table_ranges_in_type(cond.get_false_type(), map) + .inspect(|_| changed = true) + .unwrap_or_else(|| cond.get_false_type().clone()); + if changed { + Some(LuaType::Conditional(Arc::new( + crate::LuaConditionalType::new( + new_cond, + new_true, + new_false, + cond.get_infer_params().to_vec(), + cond.has_new, + ), + ))) + } else { + None + } + } + LuaType::Mapped(mapped) => remap_table_ranges_in_type(&mapped.value, map).map(|nv| { + LuaType::Mapped(Arc::new(crate::LuaMappedType::new( + mapped.param.clone(), + nv, + mapped.is_readonly, + mapped.is_optional, + ))) + }), + LuaType::TableOf(inner) => { + remap_table_ranges_in_type(inner, map).map(|nt| LuaType::TableOf(Box::new(nt))) + } + LuaType::Call(call) => { + let mut changed = false; + let new_ops: Vec = call + .get_operands() + .iter() + .map(|op| { + remap_table_ranges_in_type(op, map) + .inspect(|_| changed = true) + .unwrap_or_else(|| op.clone()) + }) + .collect(); + if changed { + Some(LuaType::Call(Arc::new(crate::LuaAliasCallType::new( + call.get_call_kind(), + new_ops, + )))) + } else { + None + } + } + _ => None, + } +} + pub(crate) fn widen_literal_type_for_assignment(typ: &LuaType) -> LuaType { match typ { LuaType::IntegerConst(_) => LuaType::Integer, @@ -1318,6 +1605,31 @@ impl LuaTypeIndex { self.rebuild_inference_derived_state(&changed_files); } + pub fn remap_table_const( + &mut self, + map: &rustc_hash::FxHashMap, InFiled>, + ) { + if map.is_empty() { + return; + } + let mut updates = Vec::new(); + for (owner, cache) in self.types.iter() { + if let Some(new_type) = remap_table_ranges_in_type(cache.as_type(), map) { + let new_cache = match cache { + LuaTypeCache::DocType(_) => LuaTypeCache::DocType(new_type), + LuaTypeCache::InferType(_) => LuaTypeCache::InferType(new_type), + }; + updates.push((owner.clone(), new_cache)); + } + } + let mut changed_files = HashSet::default(); + for (owner, new_cache) in updates { + changed_files.insert(owner.get_file_id()); + self.insert_type_cache(owner, new_cache); + } + self.rebuild_inference_derived_state(&changed_files); + } + pub fn files_with_type_caches_referencing_files( &self, file_ids: &std::collections::HashSet, diff --git a/crates/glua_code_analysis/src/lib.rs b/crates/glua_code_analysis/src/lib.rs index c1a1dd970..801aa0435 100644 --- a/crates/glua_code_analysis/src/lib.rs +++ b/crates/glua_code_analysis/src/lib.rs @@ -29,8 +29,8 @@ pub use diagnostic::*; pub use gamemode_base::detect_gamemode_base_libraries; pub use glua_codestyle::*; use glua_parser::{ - LineIndex, LuaAstNode, LuaCallExpr, LuaExpr, LuaIndexKey, LuaLocalStat, LuaNameExpr, - LuaParenExpr, LuaParser, LuaSyntaxTree, + LineIndex, LuaAssignStat, LuaAstNode, LuaAstToken, LuaCallExpr, LuaExpr, LuaIndexKey, + LuaLocalStat, LuaNameExpr, LuaParenExpr, LuaParser, LuaSyntaxTree, LuaTableExpr, LuaTableField, }; pub use library_collision::LibraryDefinitionCollision; use lsp_types::Uri; @@ -900,6 +900,352 @@ fn lexically_normalize_path(path: &Path) -> PathBuf { normalized } +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +enum TableAnchor { + Global(String), + Local { + decl_name: String, + decl_pos: u32, + path: String, + }, + Tree { + parent_kind: String, + nth: usize, + }, +} + +#[allow(dead_code)] +fn collect_table_ranges(db: &DbIndex, file_id: FileId) -> Vec> { + let Some(tree) = db.get_vfs().get_syntax_tree(&file_id) else { + return Vec::new(); + }; + let chunk = tree.get_chunk_node(); + let mut ranges = Vec::new(); + for table_expr in chunk.descendants::() { + ranges.push(InFiled::new(file_id, table_expr.get_range())); + } + ranges.sort_by_key(|r| r.value.start()); + ranges +} + +fn expr_path_strings(expr: &LuaExpr) -> Option> { + match expr { + LuaExpr::NameExpr(name_expr) => Some(vec![name_expr.get_name_text()?.to_string()]), + LuaExpr::IndexExpr(index_expr) => { + if index_expr.get_index_token()?.is_colon() { + return None; + } + let mut path = expr_path_strings(&index_expr.get_prefix_expr()?)?; + let name = match index_expr.get_index_key()? { + LuaIndexKey::Name(n) => n.get_name_text().to_string(), + LuaIndexKey::String(s) => s.get_value().to_string(), + LuaIndexKey::Integer(i) => i.syntax().text().to_string(), + LuaIndexKey::Expr(_) | LuaIndexKey::Idx(_) => return None, + }; + path.push(name); + Some(path) + } + _ => None, + } +} + +fn var_path_strings(var: &glua_parser::LuaVarExpr) -> Option> { + match var { + glua_parser::LuaVarExpr::NameExpr(n) => Some(vec![n.get_name_text()?.to_string()]), + glua_parser::LuaVarExpr::IndexExpr(idx) => { + let expr: LuaExpr = LuaExpr::IndexExpr(idx.clone()); + expr_path_strings(&expr) + } + } +} + +#[allow(clippy::only_used_in_recursion)] +fn table_global_path_recursive( + db: &DbIndex, + file_id: FileId, + table: LuaTableExpr, +) -> Option { + if let Some(field) = table.get_parent::() { + let key = field.get_field_key()?; + let key_str = match key { + glua_parser::LuaIndexKey::Name(n) => n.get_name_text().to_string(), + glua_parser::LuaIndexKey::String(s) => s.get_value().to_string(), + _ => return None, + }; + let parent_table = field.get_parent::()?; + let parent_path = table_global_path_recursive(db, file_id, parent_table)?; + return Some(format!("{}.{}", parent_path, key_str)); + } + let mut current = table.syntax().clone(); + while let Some(parent) = current.parent() { + if let Some(assign) = LuaAssignStat::cast(parent.clone()) { + let (vars, exprs) = assign.get_var_and_expr_list(); + for (var, expr) in vars.iter().zip(exprs.iter()) { + if expr.get_range() == table.get_range() { + if let Some(mut path) = var_path_strings(var) { + // Canonicalize _G / _ENV prefix like global_path_for_expr + if path.len() > 1 && matches!(path[0].as_str(), "_G" | "_ENV") { + path.remove(0); + } + return Some(path.join(".")); + } + } + } + break; + } + if glua_parser::LuaLocalStat::can_cast(parent.kind().into()) { + break; + } + current = parent; + } + None +} + +fn table_local_anchor(db: &DbIndex, file_id: FileId, table: LuaTableExpr) -> Option { + let mut parts: Vec = Vec::new(); + let mut cur = table; + loop { + if let Some(field) = cur.get_parent::() { + let key = field.get_field_key()?; + let key_str = match key { + glua_parser::LuaIndexKey::Name(n) => n.get_name_text().to_string(), + glua_parser::LuaIndexKey::String(s) => s.get_value().to_string(), + _ => return None, + }; + parts.push(key_str); + if let Some(parent_table) = field.get_parent::() { + cur = parent_table; + continue; + } else { + return None; + } + } + let parent = cur.syntax().parent()?; + if let Some(local) = glua_parser::LuaLocalStat::cast(parent.clone()) { + let values = local.get_value_exprs().collect::>(); + let idx = values + .iter() + .position(|v| v.get_range() == cur.get_range())?; + let names = local.get_local_name_list().collect::>(); + let name = names.get(idx)?; + parts.reverse(); + let path = parts.join("."); + let name_text = name.get_name_token()?.get_name_text().to_string(); + return Some(TableAnchor::Local { + decl_name: name_text, + decl_pos: u32::from(name.get_position()), + path, + }); + } + if let Some(assign) = LuaAssignStat::cast(parent.clone()) { + let (vars, exprs) = assign.get_var_and_expr_list(); + let idx = exprs + .iter() + .position(|e| e.get_range() == cur.get_range())?; + let var = vars.get(idx)?; + match var { + glua_parser::LuaVarExpr::NameExpr(name_expr) => { + let decl_tree = db.get_decl_index().get_decl_tree(&file_id)?; + let name_text = name_expr.get_name_text()?; + let decl = + decl_tree.find_local_decl(name_text.as_str(), name_expr.get_position())?; + parts.reverse(); + let path = parts.join("."); + return Some(TableAnchor::Local { + decl_name: decl.get_name().to_string(), + decl_pos: u32::from(decl.get_id().position), + path, + }); + } + glua_parser::LuaVarExpr::IndexExpr(_) => { + // Handle `t.inner = {}` where `t` is a local + let var_path = var_path_strings(var)?; + let root_name = var_path.first()?.clone(); + // Find the leftmost NameExpr for the root to get its position + let root_name_expr = var + .syntax() + .descendants() + .find_map(glua_parser::LuaNameExpr::cast)?; + let decl_tree = db.get_decl_index().get_decl_tree(&file_id)?; + let decl = + decl_tree.find_local_decl(&root_name, root_name_expr.get_position())?; + // var_path is e.g. ["t","inner"] or ["t","a","b"] + let suffix = if var_path.len() > 1 { + var_path[1..].join(".") + } else { + String::new() + }; + parts.reverse(); + let inner = parts.join("."); + let mut combined = Vec::new(); + if !suffix.is_empty() { + combined.push(suffix); + } + if !inner.is_empty() { + combined.push(inner); + } + let final_path = combined.join("."); + return Some(TableAnchor::Local { + decl_name: decl.get_name().to_string(), + decl_pos: u32::from(decl.get_id().position), + path: final_path, + }); + } + } + } + return None; + } +} + +fn table_tree_anchor(table: LuaTableExpr) -> TableAnchor { + let parent = table + .syntax() + .parent() + .map(|n| format!("{:?}", n.kind())) + .unwrap_or_else(|| "root".to_string()); + let parent_node = table + .syntax() + .parent() + .unwrap_or_else(|| table.syntax().clone()); + let mut idx = 0usize; + let mut found = 0usize; + for child in parent_node.children() { + if LuaTableExpr::can_cast(child.kind().into()) { + if child.text_range() == table.get_range() { + found = idx; + } + idx += 1; + } + } + TableAnchor::Tree { + parent_kind: parent, + nth: found, + } +} + +fn table_anchor(db: &DbIndex, range: &InFiled) -> TableAnchor { + let file_id = range.file_id; + let Some(tree) = db.get_vfs().get_syntax_tree(&file_id) else { + return TableAnchor::Tree { + parent_kind: "missing".to_string(), + nth: 0, + }; + }; + let chunk = tree.get_chunk_node(); + for table in chunk.descendants::() { + if table.get_range() == range.value { + if let Some(global) = table_global_path_recursive(db, file_id, table.clone()) { + return TableAnchor::Global(global); + } + if let Some(local) = table_local_anchor(db, file_id, table.clone()) { + return local; + } + return table_tree_anchor(table); + } + } + TableAnchor::Tree { + parent_kind: "fallback".to_string(), + nth: 0, + } +} + +#[allow(dead_code)] +fn build_anchor_map( + old: Vec>, + new: Vec>, + db: &DbIndex, +) -> std::collections::HashMap< + InFiled, + InFiled, + rustc_hash::FxBuildHasher, +> { + use rustc_hash::FxHashMap; + if old.is_empty() || new.is_empty() { + return FxHashMap::default(); + } + let old_anchored: Vec<(InFiled, TableAnchor)> = old + .iter() + .map(|r| (r.clone(), table_anchor(db, r))) + .collect(); + // For new, we need db after reindex; table_anchor uses new db's tree + let new_anchored: Vec<(InFiled, TableAnchor)> = new + .iter() + .map(|r| (r.clone(), table_anchor(db, r))) + .collect(); + + if old.len() != new.len() { + let old_set: std::collections::HashSet = + old_anchored.iter().map(|(_, a)| a.clone()).collect(); + let new_set: std::collections::HashSet = + new_anchored.iter().map(|(_, a)| a.clone()).collect(); + if old_set != new_set { + eprintln!( + "[anchor] len mismatch old={} new={} anchor mismatch, skipping migration (old_set {} new_set {})", + old.len(), + new.len(), + old_set.len(), + new_set.len() + ); + return FxHashMap::default(); + } + } + + let mut new_by_anchor: FxHashMap> = FxHashMap::default(); + for (range, anchor) in new_anchored { + // Keep first occurrence if duplicate (should be unique) + new_by_anchor.entry(anchor).or_insert(range); + } + let mut map: FxHashMap, InFiled> = + FxHashMap::default(); + for (old_range, anchor) in old_anchored { + if let Some(new_range) = new_by_anchor.get(&anchor) { + map.insert(old_range, new_range.clone()); + } + } + if old.len() == new.len() && map.len() != old.len() { + eprintln!( + "[anchor] len equal but map incomplete {}/{} (likely TreePath collision)", + map.len(), + old.len() + ); + } + map +} + +fn collect_anchored_map( + db: &DbIndex, + file_id: FileId, +) -> rustc_hash::FxHashMap> { + use rustc_hash::{FxHashMap, FxHashSet}; + let Some(tree) = db.get_vfs().get_syntax_tree(&file_id) else { + return FxHashMap::default(); + }; + let chunk = tree.get_chunk_node(); + let mut map: FxHashMap> = FxHashMap::default(); + let mut ambiguous: FxHashSet = FxHashSet::default(); + for table in chunk.descendants::() { + let range = InFiled::new(file_id, table.get_range()); + let anchor = if let Some(global) = table_global_path_recursive(db, file_id, table.clone()) { + TableAnchor::Global(global) + } else if let Some(local) = table_local_anchor(db, file_id, table.clone()) { + local + } else { + table_tree_anchor(table) + }; + if ambiguous.contains(&anchor) { + continue; + } + #[allow(clippy::map_entry)] + if map.contains_key(&anchor) { + map.remove(&anchor); + ambiguous.insert(anchor); + } else { + map.insert(anchor, range); + } + } + map +} + #[derive(Debug)] pub struct EmmyLuaAnalysis { pub compilation: LuaCompilation, @@ -909,6 +1255,10 @@ pub struct EmmyLuaAnalysis { pub(crate) inferred_guard_propagation_stats: InferredGuardPropagationStats, #[cfg(test)] cross_file_stabilization_invocations: usize, + pending_table_ranges: rustc_hash::FxHashMap< + FileId, + rustc_hash::FxHashMap>, + >, } impl EmmyLuaAnalysis { @@ -922,6 +1272,7 @@ impl EmmyLuaAnalysis { inferred_guard_propagation_stats: InferredGuardPropagationStats::default(), #[cfg(test)] cross_file_stabilization_invocations: 0, + pending_table_ranges: rustc_hash::FxHashMap::default(), } } @@ -1075,6 +1426,12 @@ impl EmmyLuaAnalysis { .has_annotated_vgui_parent_calls(existing) }; if is_special { + let anchored = collect_anchored_map(self.compilation.get_db(), existing); + if !anchored.is_empty() { + self.pending_table_ranges + .entry(existing) + .or_insert(anchored); + } let file_id = self .compilation .get_db_mut() @@ -1086,6 +1443,40 @@ impl EmmyLuaAnalysis { expansion, old_guard_snapshot, ); + // Apply element/table remap if stashed, then clear + if let Some(old_map) = self.pending_table_ranges.remove(&existing) { + let new_map = collect_anchored_map(self.compilation.get_db(), file_id); + let mut global_remap: rustc_hash::FxHashMap< + InFiled, + InFiled, + > = rustc_hash::FxHashMap::default(); + let mut deleted: Vec> = Vec::new(); + for (anchor, old_range) in old_map { + if let Some(new_range) = new_map.get(&anchor) { + if &old_range != new_range { + global_remap.insert(old_range.clone(), new_range.clone()); + } + } else { + deleted.push(old_range); + } + } + if !global_remap.is_empty() { + self.compilation + .get_db_mut() + .get_member_index_mut() + .remap_elements(&global_remap); + self.compilation + .get_db_mut() + .get_type_index_mut() + .remap_table_const(&global_remap); + } + if !deleted.is_empty() { + self.compilation + .get_db_mut() + .get_member_index_mut() + .remove_deleted_element_owners(&deleted); + } + } profile::phase_report("update_file_by_uri"); return Some(file_id); } @@ -1191,6 +1582,12 @@ impl EmmyLuaAnalysis { } else { None }; + let anchored = collect_anchored_map(self.compilation.get_db(), existing); + if !anchored.is_empty() { + self.pending_table_ranges + .entry(existing) + .or_insert(anchored); + } let file_id = self .compilation .get_db_mut() @@ -1487,6 +1884,13 @@ impl EmmyLuaAnalysis { (None, InferredGuardSnapshot::default()) }; + if let Some(fid) = existing_file_id { + let anchored = collect_anchored_map(self.compilation.get_db(), fid); + if !anchored.is_empty() { + self.pending_table_ranges.entry(fid).or_insert(anchored); + } + } + let file_id = self .compilation .get_db_mut() @@ -1518,6 +1922,42 @@ impl EmmyLuaAnalysis { &incremental_source_file_ids, ); self.reindex_changed_inferred_param_consumers(&old_guard_facts, &reindex_file_ids); + // Remap Element/TableConst that shifted due to offset changes in the edited file. + if let Some(fid) = existing_file_id { + if let Some(old_map) = self.pending_table_ranges.remove(&fid) { + let new_map = collect_anchored_map(self.compilation.get_db(), file_id); + let mut global_remap: rustc_hash::FxHashMap< + InFiled, + InFiled, + > = rustc_hash::FxHashMap::default(); + let mut deleted: Vec> = Vec::new(); + for (anchor, old_range) in old_map { + if let Some(new_range) = new_map.get(&anchor) { + if &old_range != new_range { + global_remap.insert(old_range.clone(), new_range.clone()); + } + } else { + deleted.push(old_range); + } + } + if !global_remap.is_empty() { + self.compilation + .get_db_mut() + .get_member_index_mut() + .remap_elements(&global_remap); + self.compilation + .get_db_mut() + .get_type_index_mut() + .remap_table_const(&global_remap); + } + if !deleted.is_empty() { + self.compilation + .get_db_mut() + .get_member_index_mut() + .remove_deleted_element_owners(&deleted); + } + } + } } Some(file_id) @@ -1563,6 +2003,13 @@ impl EmmyLuaAnalysis { return None; } + if let Some(fid) = existing_file_id { + let anchored = collect_anchored_map(self.compilation.get_db(), fid); + if !anchored.is_empty() { + self.pending_table_ranges.entry(fid).or_insert(anchored); + } + } + self.compilation .get_db_mut() .get_vfs_mut() @@ -1586,6 +2033,12 @@ impl EmmyLuaAnalysis { return Some(file_id); } } + // Stash old table anchors before VFS mutation so self_index can remap + // Element owners and TableConst that shifted due to earlier edits. + let anchored = collect_anchored_map(self.compilation.get_db(), file_id); + if !anchored.is_empty() { + self.pending_table_ranges.entry(file_id).or_insert(anchored); + } } let file_id = self @@ -1729,8 +2182,66 @@ impl EmmyLuaAnalysis { /// entries are keyed by position, so an edit that shifts offsets is exactly /// what makes them stop matching the tree. pub fn self_index_files(&mut self, file_ids: Vec) { + // Capture old anchored tables. If the edit came through update_file_text_only + // or update_file_by_uri, the old anchors are already stashed in + // pending_table_ranges before the VFS mutation; otherwise collect from the + // current tree before we drop it. + let mut old_maps: rustc_hash::FxHashMap< + FileId, + rustc_hash::FxHashMap>, + > = rustc_hash::FxHashMap::default(); + for fid in &file_ids { + if let Some(pending) = self.pending_table_ranges.remove(fid) { + old_maps.insert(*fid, pending); + } else { + let m = collect_anchored_map(self.compilation.get_db(), *fid); + if !m.is_empty() { + old_maps.insert(*fid, m); + } + } + } + self.compilation.remove_index(file_ids.clone()); - self.compilation.update_index(file_ids); + self.compilation.update_index(file_ids.clone()); + + // Build global remap oldRange -> newRange by matching anchors. + let mut global_remap: rustc_hash::FxHashMap< + InFiled, + InFiled, + > = rustc_hash::FxHashMap::default(); + let mut deleted: Vec> = Vec::new(); + for fid in &file_ids { + let old_map = old_maps.remove(fid); + let new_map = collect_anchored_map(self.compilation.get_db(), *fid); + if let Some(old_map) = old_map { + for (anchor, old_range) in old_map { + if let Some(new_range) = new_map.get(&anchor) { + if &old_range != new_range { + global_remap.insert(old_range.clone(), new_range.clone()); + } + } else { + deleted.push(old_range); + } + } + } + } + + if !global_remap.is_empty() { + self.compilation + .get_db_mut() + .get_member_index_mut() + .remap_elements(&global_remap); + self.compilation + .get_db_mut() + .get_type_index_mut() + .remap_table_const(&global_remap); + } + if !deleted.is_empty() { + self.compilation + .get_db_mut() + .get_member_index_mut() + .remove_deleted_element_owners(&deleted); + } } pub fn self_index_files_and_get_ripple_with_changed( diff --git a/tools/benchmark/src/main.rs b/tools/benchmark/src/main.rs index af836a678..f4d986fc8 100644 --- a/tools/benchmark/src/main.rs +++ b/tools/benchmark/src/main.rs @@ -203,8 +203,7 @@ fn run_incremental_edits( // to wait for if it is gated on its own file rather than on the // whole ripple. let t = Instant::now(); - analysis.compilation.remove_index(vec![file_id]); - analysis.compilation.update_index(vec![file_id]); + analysis.self_index_files(vec![file_id]); let self_only = t.elapsed(); // `BENCH_EDIT_SELF_ONLY=1` stops after the edited file's own // entries, so a profile of the run contains nothing but the cost a @@ -249,8 +248,7 @@ fn run_incremental_edits( // revert has to match it. if std::env::var_os("BENCH_EDIT_SELF_ONLY").is_some() { analysis.update_file_text_only(&uri, text); - analysis.compilation.remove_index(vec![file_id]); - analysis.compilation.update_index(vec![file_id]); + analysis.self_index_files(vec![file_id]); } else { analysis.update_file_by_uri(&uri, Some(text)); } From 8fb9aa95e0195b78ac2a96536bdaba0251b0d5b4 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Fri, 28 Aug 2026 07:10:22 +0100 Subject: [PATCH 062/159] fix: make the edit fingerprint read what dependents can observe --- .../member/assignment_contribution.rs | 59 +- .../src/db_index/member/mod.rs | 91 +- .../src/db_index/type/mod.rs | 18 +- .../diagnostic/test/incremental_edit_test.rs | 419 ++++++ .../src/diagnostic/test/mod.rs | 1 + crates/glua_code_analysis/src/lib.rs | 1206 +++++------------ 6 files changed, 883 insertions(+), 911 deletions(-) create mode 100644 crates/glua_code_analysis/src/diagnostic/test/incremental_edit_test.rs diff --git a/crates/glua_code_analysis/src/db_index/member/assignment_contribution.rs b/crates/glua_code_analysis/src/db_index/member/assignment_contribution.rs index 58e9db156..70ab958ac 100644 --- a/crates/glua_code_analysis/src/db_index/member/assignment_contribution.rs +++ b/crates/glua_code_analysis/src/db_index/member/assignment_contribution.rs @@ -2,7 +2,8 @@ use rustc_hash::FxHashMap; use std::collections::HashSet; use super::{LuaMemberId, LuaMemberKey, LuaMemberOwner}; -use crate::{FileId, LuaType}; +use crate::{FileId, InFiled, LuaType}; +use rowan::TextRange; /// The group a member assignment contributes its evidence to. pub type MemberAssignmentContributionKey = (LuaMemberOwner, LuaMemberKey); @@ -112,6 +113,62 @@ impl MemberAssignmentContributionStore { keys } + /// Moves writer groups whose owner is a table literal that shifted offset + /// onto the literal's new range. + /// + /// Without this a group stays filed under the pre-edit range while the + /// member index has already re-homed the owner, so the widening merge for + /// the new range cannot see the writers other files contributed. + pub fn remap_element_owners( + &mut self, + map: &FxHashMap, InFiled>, + ) { + let moved: Vec<( + MemberAssignmentContributionKey, + MemberAssignmentContributionKey, + )> = self + .by_owner_key + .keys() + .filter_map(|store_key| { + let LuaMemberOwner::Element(old) = &store_key.0 else { + return None; + }; + let new = map.get(old)?; + Some(( + store_key.clone(), + (LuaMemberOwner::Element(new.clone()), store_key.1.clone()), + )) + }) + .collect(); + + for (old_key, new_key) in moved { + let Some(group) = self.by_owner_key.remove(&old_key) else { + continue; + }; + for member_id in group.keys() { + if let Some(entries) = self.by_file.get_mut(&member_id.file_id) { + entries.insert(*member_id, new_key.clone()); + } + } + self.by_owner_key.entry(new_key).or_default().extend(group); + } + } + + /// Drops one member's writer entry, for a member removed on its own + /// rather than as part of a file sweep. + pub fn remove_member(&mut self, member_id: LuaMemberId) { + let Some(entries) = self.by_file.get_mut(&member_id.file_id) else { + return; + }; + let Some(store_key) = entries.remove(&member_id) else { + return; + }; + if entries.is_empty() { + self.by_file.remove(&member_id.file_id); + } + self.detach(&store_key, member_id); + } + /// Number of stored writer entries, for the profile report. pub fn entry_count(&self) -> usize { self.by_owner_key.values().map(FxHashMap::len).sum() diff --git a/crates/glua_code_analysis/src/db_index/member/mod.rs b/crates/glua_code_analysis/src/db_index/member/mod.rs index afd60e73a..bac24f029 100644 --- a/crates/glua_code_analysis/src/db_index/member/mod.rs +++ b/crates/glua_code_analysis/src/db_index/member/mod.rs @@ -1731,22 +1731,53 @@ impl LuaMemberIndex { if map.is_empty() { return; } - let to_move: Vec<(LuaMemberId, LuaMemberOwner, LuaMemberOwner)> = self - .member_current_owner + // Driven off the map rather than a scan of every member in the + // workspace: `owner_members` already indexes members by owner, and the + // map holds only the literals one edited file shifted. + let to_move: Vec<(LuaMemberId, LuaMemberOwner, LuaMemberOwner)> = map .iter() - .filter_map(|(mid, owner)| { - if let LuaMemberOwner::Element(old) = owner { - if let Some(new) = map.get(old) { - return Some(( - *mid, - LuaMemberOwner::Element(old.clone()), - LuaMemberOwner::Element(new.clone()), - )); - } - } - None + .flat_map(|(old, new)| { + let old_owner = LuaMemberOwner::Element(old.clone()); + let new_owner = LuaMemberOwner::Element(new.clone()); + self.owner_members + .get(&old_owner) + .into_iter() + .flat_map(|items| items.get_member_items()) + .flat_map(|item| match item { + LuaMemberIndexItem::One(id) => vec![*id], + LuaMemberIndexItem::Many(ids) => ids.clone(), + }) + .map(move |id| (id, old_owner.clone(), new_owner.clone())) + .collect::>() + }) + .collect(); + self.assignment_contributions.remap_element_owners(map); + let moved_slots: Vec<( + (LuaMemberOwner, LuaMemberKey), + (LuaMemberOwner, LuaMemberKey), + )> = self + .alias_contributed_slots + .keys() + .filter_map(|slot| { + let LuaMemberOwner::Element(old) = &slot.0 else { + return None; + }; + let new = map.get(old)?; + Some(( + slot.clone(), + (LuaMemberOwner::Element(new.clone()), slot.1.clone()), + )) }) .collect(); + for (old_slot, new_slot) in moved_slots { + if let Some(ids) = self.alias_contributed_slots.remove(&old_slot) { + self.alias_contributed_slots + .entry(new_slot) + .or_default() + .extend(ids); + } + } + for (member_id, old_owner, new_owner) in to_move { // Remove from old owner's structures self.detach_member_from_owner(&old_owner, member_id); @@ -1777,21 +1808,29 @@ impl LuaMemberIndex { for range in deleted { let owner = LuaMemberOwner::Element(range.clone()); if let Some(member_items) = self.owner_members.remove(&owner) { - for item in member_items.get_member_items() { - match item { - LuaMemberIndexItem::One(id) => { - self.member_current_owner.remove(id); - self.remove_member_from_all_owner_key_indexes(&owner, *id); - self.remove_current_owner_member(&owner, *id); - } - LuaMemberIndexItem::Many(ids) => { - for id in ids { - self.member_current_owner.remove(id); - self.remove_member_from_all_owner_key_indexes(&owner, *id); - self.remove_current_owner_member(&owner, *id); - } + let member_ids: Vec = member_items + .get_member_items() + .flat_map(|item| match item { + LuaMemberIndexItem::One(id) => vec![*id], + LuaMemberIndexItem::Many(ids) => ids.clone(), + }) + .collect(); + for id in member_ids { + self.member_current_owner.remove(&id); + self.remove_member_from_all_owner_key_indexes(&owner, id); + self.remove_current_owner_member(&owner, id); + // The member itself has to go too. Leaving it in + // `members`/`in_filed` makes it reachable by id and by + // file while `get_member_owner` answers `None`, from a + // file no later re-index will visit. + self.members.remove(&id); + if let Some(set) = self.in_filed.get_mut(&id.file_id) { + set.remove(&MemberOrOwner::Member(id)); + if set.is_empty() { + self.in_filed.remove(&id.file_id); } } + self.assignment_contributions.remove_member(id); } } if let Some(set) = self.in_filed.get_mut(&range.file_id) { diff --git a/crates/glua_code_analysis/src/db_index/type/mod.rs b/crates/glua_code_analysis/src/db_index/type/mod.rs index d2195908c..3be33d10b 100644 --- a/crates/glua_code_analysis/src/db_index/type/mod.rs +++ b/crates/glua_code_analysis/src/db_index/type/mod.rs @@ -1612,8 +1612,24 @@ impl LuaTypeIndex { if map.is_empty() { return; } + // Only caches that actually name a table literal in one of the edited + // files can contain a range the map moves, and `cache_refs` already + // records which files those are. Scanning every cache in the workspace + // here would put a full-index walk on the per-keystroke path. + let source_files: HashSet = map.keys().map(|range| range.file_id).collect(); + let candidate_owners: HashSet<&LuaTypeOwner> = source_files + .iter() + .filter_map(|file_id| self.cache_refs.owners(&TypeCacheRef::File(*file_id))) + .flatten() + .filter_map(|owner_file_id| self.in_filed_type_owner.get(owner_file_id)) + .flatten() + .collect(); + let mut updates = Vec::new(); - for (owner, cache) in self.types.iter() { + for owner in candidate_owners { + let Some(cache) = self.types.get(owner) else { + continue; + }; if let Some(new_type) = remap_table_ranges_in_type(cache.as_type(), map) { let new_cache = match cache { LuaTypeCache::DocType(_) => LuaTypeCache::DocType(new_type), diff --git a/crates/glua_code_analysis/src/diagnostic/test/incremental_edit_test.rs b/crates/glua_code_analysis/src/diagnostic/test/incremental_edit_test.rs new file mode 100644 index 000000000..8456e8f7c --- /dev/null +++ b/crates/glua_code_analysis/src/diagnostic/test/incremental_edit_test.rs @@ -0,0 +1,419 @@ +#[cfg(test)] +mod tests { + use crate::{DiagnosticCode, Emmyrc, FileId, VirtualWorkspace, file_export_fingerprint}; + use googletest::prelude::*; + use lsp_types::Uri; + use tokio_util::sync::CancellationToken; + + fn workspace_with(codes: Vec) -> VirtualWorkspace { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let mut emmyrc = Emmyrc::default(); + emmyrc.diagnostics.enables = codes; + ws.update_emmyrc(emmyrc); + ws + } + + fn codes_in(ws: &VirtualWorkspace, file_id: FileId) -> Vec { + let diagnostics = ws + .analysis + .diagnose_file(file_id, CancellationToken::new()) + .unwrap_or_default(); + let mut codes: Vec = diagnostics + .into_iter() + .filter_map(|diagnostic| match diagnostic.code { + Some(lsp_types::NumberOrString::String(code)) => Some(code), + _ => None, + }) + .collect(); + codes.sort(); + codes + } + + fn write(ws: &mut VirtualWorkspace, uri: &Uri, text: &str) -> FileId { + ws.analysis + .update_file_by_uri(uri, Some(text.to_string())) + .expect("file id") + } + + /// A `@return` edit changes no arity, so a fingerprint that hashes only the + /// parameter count reports no export change and the reader of the call + /// keeps the type the old annotation gave it. + #[gtest] + fn return_annotation_change_reaches_the_caller() { + let mut ws = workspace_with(vec![DiagnosticCode::AssignTypeMismatch]); + let provider_uri = ws.virtual_url_generator.new_uri("lua/provider.lua"); + write( + &mut ws, + &provider_uri, + r#" + provider = provider or {} + ---@return string + function provider.Describe() end + "#, + ); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/consumer.lua"); + let consumer_id = write( + &mut ws, + &consumer_uri, + r#" + ---@type string + local described = provider.Describe() + "#, + ); + + expect_that!( + codes_in(&ws, consumer_id), + not(contains(eq(DiagnosticCode::AssignTypeMismatch.get_name()))) + ); + + write( + &mut ws, + &provider_uri, + r#" + provider = provider or {} + ---@return number + function provider.Describe() end + "#, + ); + + expect_that!( + codes_in(&ws, consumer_id), + contains(eq(DiagnosticCode::AssignTypeMismatch.get_name())) + ); + } + + /// A string literal is a value, not a shape. Collapsing it to `string` in + /// the fingerprint hides the change from every file that narrows on it. + #[gtest] + fn string_literal_export_change_reaches_the_caller() { + let mut ws = workspace_with(vec![DiagnosticCode::ParamTypeMismatch]); + let provider_uri = ws.virtual_url_generator.new_uri("lua/mode.lua"); + write( + &mut ws, + &provider_uri, + r#" + config = config or {} + config.Mode = "server" + "#, + ); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/reader.lua"); + let consumer_id = write( + &mut ws, + &consumer_uri, + r#" + ---@param mode "server" + local function takesServer(mode) end + takesServer(config.Mode) + "#, + ); + + expect_that!(codes_in(&ws, consumer_id), is_empty()); + + write( + &mut ws, + &provider_uri, + r#" + config = config or {} + config.Mode = "client" + "#, + ); + + expect_that!( + codes_in(&ws, consumer_id), + contains(eq(DiagnosticCode::ParamTypeMismatch.get_name())) + ); + } + + /// The fast path exists for this: an edit that shifts every offset below it + /// but changes nothing another file can read must not invalidate dependents. + /// Hashing any position-derived identity breaks it for every edit that is + /// not at the end of the file. + #[gtest] + fn comment_edit_above_a_declaration_keeps_dependents_settled() { + let mut ws = workspace_with(vec![DiagnosticCode::ParamTypeMismatch]); + let provider_uri = ws.virtual_url_generator.new_uri("lua/values.lua"); + write( + &mut ws, + &provider_uri, + r#" + -- leading note + values = values or {} + values.Count = 1 + "#, + ); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/counter.lua"); + let consumer_id = write( + &mut ws, + &consumer_uri, + r#" + ---@param count number + local function takesNumber(count) end + takesNumber(values.Count) + "#, + ); + let before = codes_in(&ws, consumer_id); + + write( + &mut ws, + &provider_uri, + r#" + -- leading note, now considerably longer than it was before + values = values or {} + values.Count = 1 + "#, + ); + + expect_that!(codes_in(&ws, consumer_id), eq(&before)); + } + + /// Two table literals in two different call argument lists sit at the same + /// index of the same parent kind. An anchor built from the parent's kind + /// alone collides, and a collision drops both from the remap, leaving their + /// members owned by a range the edit already moved. + #[gtest] + fn sibling_call_argument_tables_survive_an_offset_shift() { + let mut ws = workspace_with(vec![DiagnosticCode::UndefinedField]); + let provider_uri = ws.virtual_url_generator.new_uri("lua/register.lua"); + write( + &mut ws, + &provider_uri, + r#" + registry = registry or {} + ---@param name string + ---@param spec table + function registry.Add(name, spec) end + + registry.Add("first", { alpha = 1 }) + registry.Add("second", { beta = 2 }) + "#, + ); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/consume.lua"); + let consumer_id = write( + &mut ws, + &consumer_uri, + r#" + local one = { alpha = 1 } + local two = { beta = 2 } + local _ = one.alpha + local _ = two.beta + "#, + ); + let before = codes_in(&ws, consumer_id); + + write( + &mut ws, + &provider_uri, + r#" + -- a comment that shifts every offset below it + registry = registry or {} + ---@param name string + ---@param spec table + function registry.Add(name, spec) end + + registry.Add("first", { alpha = 1 }) + registry.Add("second", { beta = 2 }) + "#, + ); + + expect_that!(codes_in(&ws, consumer_id), eq(&before)); + } + + /// Deleting a file has no new text to fingerprint. Comparing fingerprints + /// lets a file that exported nothing return before the removal runs, and + /// its dependents keep resolving members it no longer defines. + #[gtest] + fn deleting_a_provider_invalidates_its_dependents() { + let mut ws = workspace_with(vec![ + DiagnosticCode::UndefinedField, + DiagnosticCode::UndefinedGlobal, + ]); + let provider_uri = ws.virtual_url_generator.new_uri("lua/provider.lua"); + write( + &mut ws, + &provider_uri, + r#" + shared = shared or {} + shared.Helper = function() end + "#, + ); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/uses_helper.lua"); + let consumer_id = write( + &mut ws, + &consumer_uri, + r#" + shared.Helper() + "#, + ); + expect_that!(codes_in(&ws, consumer_id), is_empty()); + + ws.analysis.update_file_by_uri(&provider_uri, None); + + expect_that!( + codes_in(&ws, consumer_id), + contains(eq(DiagnosticCode::UndefinedGlobal.get_name())) + ); + } + + /// The fingerprint decides whether an edit ripples to dependents. These + /// exercise it directly: a diagnostic-level assertion on a two-file + /// workspace can be satisfied by an unrelated re-analysis, so it does not + /// prove which dimension the fingerprint actually reads. + fn fingerprint_of(ws: &VirtualWorkspace, file_id: FileId) -> u64 { + file_export_fingerprint(ws.analysis.compilation.get_db(), file_id) + } + + fn fingerprint_after_edit(first: &str, second: &str) -> (u64, u64) { + let mut ws = workspace_with(vec![]); + let uri = ws.virtual_url_generator.new_uri("lua/subject.lua"); + let file_id = write(&mut ws, &uri, first); + let before = fingerprint_of(&ws, file_id); + let file_id = write(&mut ws, &uri, second); + (before, fingerprint_of(&ws, file_id)) + } + + #[gtest] + fn fingerprint_moves_when_a_return_annotation_changes() { + let (before, after) = fingerprint_after_edit( + r#" + provider = provider or {} + ---@return string + function provider.Describe() end + "#, + r#" + provider = provider or {} + ---@return number + function provider.Describe() end + "#, + ); + expect_that!(after, not(eq(before))); + } + + #[gtest] + fn fingerprint_moves_when_a_param_annotation_changes() { + let (before, after) = fingerprint_after_edit( + r#" + provider = provider or {} + ---@param value string + function provider.Accept(value) end + "#, + r#" + provider = provider or {} + ---@param value number + function provider.Accept(value) end + "#, + ); + expect_that!(after, not(eq(before))); + } + + #[gtest] + fn fingerprint_moves_when_an_overload_is_added() { + let (before, after) = fingerprint_after_edit( + r#" + provider = provider or {} + ---@param value string + function provider.Accept(value) end + "#, + r#" + provider = provider or {} + ---@overload fun(value: number, extra: boolean) + ---@param value string + function provider.Accept(value) end + "#, + ); + expect_that!(after, not(eq(before))); + } + + #[gtest] + fn fingerprint_moves_when_an_exported_string_literal_changes() { + let (before, after) = fingerprint_after_edit( + r#" + config = config or {} + config.Mode = "server" + "#, + r#" + config = config or {} + config.Mode = "client" + "#, + ); + expect_that!(after, not(eq(before))); + } + + /// A member key that is a path is still a member key. Skipping keys that + /// look like model paths hid the writer evidence for those entries, so a + /// dependent kept whatever type the previous write gave them. + #[gtest] + fn fingerprint_moves_when_a_path_shaped_entry_changes_type() { + let (before, after) = fingerprint_after_edit( + r#" + models = models or {} + models["models/vehicles/car.mdl"] = 100 + "#, + r#" + models = models or {} + models["models/vehicles/car.mdl"] = "expensive" + "#, + ); + expect_that!(after, not(eq(before))); + } + + /// The property the whole fast path rests on: an edit that shifts every + /// offset below it, without changing anything a dependent can read, must + /// leave the fingerprint alone. + #[gtest] + fn fingerprint_holds_across_a_comment_edit_above_every_declaration() { + let (before, after) = fingerprint_after_edit( + r#" + -- note + config = config or {} + config.Mode = "server" + ---@return string + function config.Describe() end + "#, + r#" + -- note, rewritten at greater length so every offset below moves + config = config or {} + config.Mode = "server" + ---@return string + function config.Describe() end + "#, + ); + expect_that!(after, eq(before)); + } + + /// Local state is not observable from another file, so changing it must + /// not cost a ripple. + /// + /// The signature section still reads a local function's *inferred* return + /// type, so an edit that changes what one returns does ripple. That is an + /// over-ripple, not a stale read, and narrowing it would mean hashing a + /// signature by content wherever an exported type names it. + #[gtest] + fn fingerprint_holds_across_a_local_only_edit() { + let (before, after) = fingerprint_after_edit( + r#" + config = config or {} + config.Mode = "server" + local function helper() + local scratch = 1 + return scratch + end + "#, + r#" + config = config or {} + config.Mode = "server" + local function helper() + local scratch = 1 + local unrelated = scratch + 1 + _ = unrelated + return scratch + end + "#, + ); + expect_that!(after, eq(before)); + } +} diff --git a/crates/glua_code_analysis/src/diagnostic/test/mod.rs b/crates/glua_code_analysis/src/diagnostic/test/mod.rs index b5ca7deac..9888a61a6 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/mod.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/mod.rs @@ -24,6 +24,7 @@ mod gmod_network_test; mod gmod_realm_misuse_test; mod gmod_systems_test; mod incomplete_signature_doc_test; +mod incremental_edit_test; mod inference_trust_test; mod inject_field_test; mod instance_type_test; diff --git a/crates/glua_code_analysis/src/lib.rs b/crates/glua_code_analysis/src/lib.rs index 801aa0435..85a713136 100644 --- a/crates/glua_code_analysis/src/lib.rs +++ b/crates/glua_code_analysis/src/lib.rs @@ -30,7 +30,8 @@ pub use gamemode_base::detect_gamemode_base_libraries; pub use glua_codestyle::*; use glua_parser::{ LineIndex, LuaAssignStat, LuaAstNode, LuaAstToken, LuaCallExpr, LuaExpr, LuaIndexKey, - LuaLocalStat, LuaNameExpr, LuaParenExpr, LuaParser, LuaSyntaxTree, LuaTableExpr, LuaTableField, + LuaLocalStat, LuaNameExpr, LuaParenExpr, LuaParser, LuaSyntaxKind, LuaSyntaxTree, LuaTableExpr, + LuaTableField, }; pub use library_collision::LibraryDefinitionCollision; use lsp_types::Uri; @@ -98,16 +99,13 @@ fn hash_member_owner_stable(owner: &LuaMemberOwner, hasher: &mut impl Hasher) { "Type".hash(hasher); tid.get_name().hash(hasher); } - LuaMemberOwner::Element(_) => { - // The concrete InFiled (file + range) is not stable - // under incremental re-index: the same logical table - // `cityrp.configuration` can be owned by different literal ranges - // depending on which file's literal the resolver picks, and that - // choice can swing when only one file is re-indexed. Hashing the - // literal's file_id/range would make a trailing comment look like - // an export change (observed: 2246 members all flipped owner from - // file 1236 to 679). Hash just the variant. + LuaMemberOwner::Element(range) => { + // The range moves whenever an edit shifts offsets and is re-homed + // by the remap pass, so only the file it lives in is hashed. That + // still distinguishes one file's literal from another's, which is + // what a dependent resolving the owner can observe. "Element".hash(hasher); + range.file_id.hash(hasher); } LuaMemberOwner::LocalUnresolve => { "LocalUnresolve".hash(hasher); @@ -115,7 +113,7 @@ fn hash_member_owner_stable(owner: &LuaMemberOwner, hasher: &mut impl Hasher) { } } -fn hash_lua_member_key_coarse(key: &LuaMemberKey, hasher: &mut impl Hasher) { +fn hash_lua_member_key_export(key: &LuaMemberKey, hasher: &mut impl Hasher) { match key { LuaMemberKey::Name(name) => { "Name".hash(hasher); @@ -128,127 +126,149 @@ fn hash_lua_member_key_coarse(key: &LuaMemberKey, hasher: &mut impl Hasher) { LuaMemberKey::None => { "None".hash(hasher); } - LuaMemberKey::ExprType(_) => { - // The inner type is the type of a computed key expression. - // Hashing the full union (e.g. Union of 5 specific strings vs - // generic String) made a trailing comment flip a member's key - // from Union([...5 strings...]) to String, which is not a real - // export change for `cityrp.configuration["vehicles"]`'s inner - // table. Hash just the variant. + LuaMemberKey::ExprType(typ) => { "ExprType".hash(hasher); + hash_lua_type_export(typ, hasher); } } } -#[allow(unreachable_patterns)] -fn hash_lua_type_coarse(typ: &LuaType, hasher: &mut impl Hasher) { - // Coarse export fingerprint: ignore literal values, collapse string/number - // consts to their base kind, and collapse unions of a single kind to that - // kind (so `Union("a","b","c")` hashes as `String`, matching generic - // `String` — otherwise a trailing comment that only wobbles inference - // precision would look like an export change and force a 1300-file ripple). +/// Hashes everything about a type that another file can observe. +/// +/// Only identity that is derived from a source position is normalised away: +/// a table literal's range, an instance's range and a signature's id all move +/// whenever an edit shifts offsets, and are re-homed by the remap pass rather +/// than by a re-index, so hashing them would report an export change for every +/// edit. Values, shapes and names are kept: they are what a dependent reads. +fn hash_lua_type_export(typ: &LuaType, hasher: &mut impl Hasher) { + // Arm order is not guaranteed for the set-like composites, so their arm + // hashes are sorted before they are folded in. + fn hash_unordered(tag: &str, arms: &[LuaType], hasher: &mut impl Hasher) { + tag.hash(hasher); + let mut arm_hashes: Vec = arms + .iter() + .map(|arm| { + let mut h = rustc_hash::FxHasher::default(); + hash_lua_type_export(arm, &mut h); + h.finish() + }) + .collect(); + arm_hashes.sort_unstable(); + arm_hashes.hash(hasher); + } + match typ { - LuaType::StringConst(_) - | LuaType::DocStringConst(_) - | LuaType::String - | LuaType::StrTplRef(_) => "String".hash(hasher), - LuaType::IntegerConst(_) - | LuaType::DocIntegerConst(_) - | LuaType::Integer - | LuaType::FloatConst(_) - | LuaType::Number => "Number".hash(hasher), - LuaType::BooleanConst(_) | LuaType::DocBooleanConst(_) | LuaType::Boolean => { - "Boolean".hash(hasher) - } - LuaType::TableConst(_) - | LuaType::Table - | LuaType::TableGeneric(_) - | LuaType::TableOf(_) - | LuaType::Object(_) - | LuaType::Array(_) - | LuaType::Tuple(_) - | LuaType::MergedTable(_) => "Table".hash(hasher), - LuaType::Function | LuaType::DocFunction(_) | LuaType::Signature(_) => { - "Function".hash(hasher) - } - LuaType::Nil => "Nil".hash(hasher), - LuaType::Any => "Any".hash(hasher), - LuaType::Unknown => "Unknown".hash(hasher), - LuaType::Never => "Never".hash(hasher), - LuaType::SelfInfer => "SelfInfer".hash(hasher), - LuaType::Global => "Global".hash(hasher), - LuaType::Userdata => "Userdata".hash(hasher), - LuaType::Thread => "Thread".hash(hasher), - LuaType::Io => "Io".hash(hasher), - LuaType::Namespace(_) => "Namespace".hash(hasher), - LuaType::Language(_) => "Language".hash(hasher), - LuaType::Union(u) => { - // Collapse Union of single kind to that kind. - let mut cats: Vec = u - .types() - .map(|arm| { - let mut h = rustc_hash::FxHasher::default(); - hash_lua_type_coarse(arm, &mut h); - format!("{:x}", h.finish()) - }) - .collect(); - cats.sort(); - cats.dedup(); - if cats.len() == 1 { - cats[0].hash(hasher); - } else { - "Union".hash(hasher); - for c in cats { - c.hash(hasher); - } - } + LuaType::StringConst(s) | LuaType::DocStringConst(s) => { + "StringConst".hash(hasher); + s.as_str().hash(hasher); } - LuaType::Intersection(i) => { - let mut cats: Vec = i - .get_types() - .iter() - .map(|arm| { - let mut h = rustc_hash::FxHasher::default(); - hash_lua_type_coarse(arm, &mut h); - format!("{:x}", h.finish()) - }) - .collect(); - cats.sort(); - cats.dedup(); - if cats.len() == 1 { - cats[0].hash(hasher); - } else { - "Intersection".hash(hasher); - for c in cats { - c.hash(hasher); - } - } + LuaType::IntegerConst(i) | LuaType::DocIntegerConst(i) => { + "IntegerConst".hash(hasher); + i.hash(hasher); } - LuaType::Ref(id) | LuaType::Def(id) => { - "Ref".hash(hasher); - id.get_name().hash(hasher); + LuaType::FloatConst(f) => { + "FloatConst".hash(hasher); + f.to_bits().hash(hasher); + } + LuaType::BooleanConst(b) | LuaType::DocBooleanConst(b) => { + "BooleanConst".hash(hasher); + b.hash(hasher); + } + // The range is the literal's identity, and it moves on any offset + // shift. The file it lives in does not, and is enough to tell one + // file's literal from another's. + LuaType::TableConst(range) => { + "TableConst".hash(hasher); + range.file_id.hash(hasher); } LuaType::Instance(inst) => { "Instance".hash(hasher); - hash_lua_type_coarse(inst.get_base(), hasher); + inst.get_range().file_id.hash(hasher); + hash_lua_type_export(inst.get_base(), hasher); + } + // The id is a file plus a position. The signature's own shape is + // hashed by the signature section of the file fingerprint. + LuaType::Signature(id) => { + "Signature".hash(hasher); + id.get_file_id().hash(hasher); } - LuaType::ModuleRef(fid) => { + LuaType::Ref(id) => { + "Ref".hash(hasher); + id.get_name().hash(hasher); + } + LuaType::Def(id) => { + "Def".hash(hasher); + id.get_name().hash(hasher); + } + LuaType::Union(union) => hash_unordered("Union", &union.into_vec(), hasher), + LuaType::Intersection(inter) => hash_unordered("Intersection", inter.get_types(), hasher), + LuaType::MergedTable(merged) => hash_unordered("MergedTable", merged.get_types(), hasher), + LuaType::Tuple(tuple) => { + "Tuple".hash(hasher); + tuple.status.hash(hasher); + for sub in tuple.get_types() { + hash_lua_type_export(sub, hasher); + } + } + LuaType::Array(arr) => { + "Array".hash(hasher); + format!("{:?}", arr.get_len()).hash(hasher); + hash_lua_type_export(arr.get_base(), hasher); + } + LuaType::Object(obj) => { + "Object".hash(hasher); + for (key, value) in obj.get_fields() { + format!("{:?}", key).hash(hasher); + hash_lua_type_export(value, hasher); + } + for (key, value) in obj.get_index_access() { + hash_lua_type_export(key, hasher); + hash_lua_type_export(value, hasher); + } + } + LuaType::TableGeneric(params) => { + "TableGeneric".hash(hasher); + for param in params.iter() { + hash_lua_type_export(param, hasher); + } + } + LuaType::TableOf(inner) => { + "TableOf".hash(hasher); + hash_lua_type_export(inner, hasher); + } + LuaType::TypeGuard(inner) => { + "TypeGuard".hash(hasher); + hash_lua_type_export(inner, hasher); + } + LuaType::Generic(generic) => { + "Generic".hash(hasher); + generic.get_base_type_id().get_name().hash(hasher); + for param in generic.get_params() { + hash_lua_type_export(param, hasher); + } + } + LuaType::DocFunction(func) => { + "DocFunction".hash(hasher); + func.is_colon_define().hash(hasher); + func.get_async_state().hash(hasher); + func.is_variadic().hash(hasher); + func.get_optional_params().hash(hasher); + for (name, param_type) in func.get_params() { + name.hash(hasher); + match param_type { + Some(param_type) => hash_lua_type_export(param_type, hasher), + None => "NoParamType".hash(hasher), + } + } + hash_lua_type_export(func.get_ret(), hasher); + } + LuaType::ModuleRef(file_id) => { "ModuleRef".hash(hasher); - fid.hash(hasher); - } - LuaType::Generic(_) => "Generic".hash(hasher), - LuaType::TplRef(_) | LuaType::ConstTplRef(_) => "TplRef".hash(hasher), - LuaType::Variadic(_) => "Variadic".hash(hasher), - LuaType::Call(_) => "Call".hash(hasher), - LuaType::MultiLineUnion(_) => "MultiLineUnion".hash(hasher), - LuaType::TypeGuard(_) => "TypeGuard".hash(hasher), - LuaType::Conditional(_) | LuaType::ConditionalInfer(_) => "Conditional".hash(hasher), - LuaType::Mapped(_) => "Mapped".hash(hasher), - LuaType::DocAttribute(_) => "DocAttribute".hash(hasher), - _ => { - // Fallback: just discriminant, no inner data. - format!("{:?}", std::mem::discriminant(typ)).hash(hasher); + file_id.hash(hasher); } + // The remaining variants carry no source position, so their `Debug` + // form is a precise and stable description of them. + other => format!("{:?}", other).hash(hasher), } } @@ -259,7 +279,7 @@ fn hash_lua_type_coarse(typ: &LuaType, hasher: &mut impl Hasher) { /// excluded - those are not observable cross-file, so an edit that only touches /// them does not require a dependency ripple, no matter how large that file's /// fan-in would be under the old file-level expansion. -fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { +pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { let mut hasher = rustc_hash::FxHasher::default(); // --- Members directly declared in this file (owner, key, feature) --- @@ -267,7 +287,7 @@ fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { let mut members = member_index.get_file_members(file_id); members.sort_by_key(|m| crate::db_index::member_id_sort_key(m.get_id())); for member in members { - hash_lua_member_key_coarse(member.get_key(), &mut hasher); + hash_lua_member_key_export(member.get_key(), &mut hasher); if let Some(owner) = member_index.get_member_owner(&member.get_id()) { hash_member_owner_stable(owner, &mut hasher); } @@ -275,25 +295,14 @@ fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- Per-writer assignment contributions --- - // Filtered for stability: owner collapsed to constant "Owner" to hide G vs E - // flip, and model-path keys (contain "/" or ".mdl") are skipped - they are - // nested vehicle model entries whose presence wobbles nondeterministically - // (observed trailing comment adds one spurious `ford_f350_ambu` entry). The - // top-level config fields like `Advert Cost` are still captured via their - // Name keys and coarse types. { let store = member_index.member_assignment_contributions(); let keys = store.keys_for_files(&HashSet::from([file_id])); let mut bucket_hashes = Vec::new(); for (owner, key) in keys { - if let LuaMemberKey::Name(name) = &key { - if name.contains('/') || name.contains(".mdl") { - continue; - } - } let mut bh = rustc_hash::FxHasher::default(); - "Owner".hash(&mut bh); - hash_lua_member_key_coarse(&key, &mut bh); + hash_member_owner_stable(&owner, &mut bh); + hash_lua_member_key_export(&key, &mut bh); if let Some(contribs) = store.contributions(&(owner.clone(), key.clone())) { let mut contribs_vec: Vec<_> = contribs .iter() @@ -301,10 +310,10 @@ fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { .collect(); contribs_vec.sort_by_key(|(mid, _)| crate::db_index::member_id_sort_key(**mid)); for (_mid, contrib) in contribs_vec { - hash_lua_type_coarse(&contrib.bound_type, &mut bh); - hash_lua_type_coarse(&contrib.source_type, &mut bh); + hash_lua_type_export(&contrib.bound_type, &mut bh); + hash_lua_type_export(&contrib.source_type, &mut bh); if let Some(doc) = &contrib.doc_type { - hash_lua_type_coarse(doc, &mut bh); + hash_lua_type_export(doc, &mut bh); } contrib.guarded_bootstrap.hash(&mut bh); contrib.preserve_table_literals.hash(&mut bh); @@ -326,14 +335,14 @@ fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { decl_id.get_name().hash(&mut hasher); if let Some(supers) = db.get_type_index().get_super_type_entries(&decl_id) { for sup in supers.iter().filter(|s| s.file_id == file_id) { - hash_lua_type_coarse(&sup.value.typ, &mut hasher); + hash_lua_type_export(&sup.value.typ, &mut hasher); } } if let Some(params) = db.get_type_index().get_generic_params(&decl_id) { for param in params { param.name.hash(&mut hasher); if let Some(constraint) = ¶m.type_constraint { - hash_lua_type_coarse(constraint, &mut hasher); + hash_lua_type_export(constraint, &mut hasher); } } } @@ -341,48 +350,48 @@ fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- Exported type caches (global/member decls, not locals) --- + // Keyed by the name a dependent resolves rather than by the owner's + // offset. Every `LuaTypeOwner` variant carries a source position, and an + // edit anywhere above one shifts it, so hashing the position reports an + // export change for every edit that is not at the very end of the file. if let Some(owners) = db.get_type_index().file_type_owners(file_id) { - let mut owners_vec: Vec<_> = owners.iter().collect(); - owners_vec.sort_by(|a, b| { - let key_a = match a { - LuaTypeOwner::Decl(did) => format!( - "D:{}:{}:{}", - did.file_id.id, - u32::from(did.position), - did.file_id.id - ), - _ => format!("{:?}", a), - }; - let key_b = match b { - LuaTypeOwner::Decl(did) => format!( - "D:{}:{}:{}", - did.file_id.id, - u32::from(did.position), - did.file_id.id - ), - _ => format!("{:?}", b), - }; - key_a.cmp(&key_b) - }); - for owner in owners_vec { - if let LuaTypeOwner::Decl(decl_id) = owner { - if let Some(decl) = db.get_decl_index().get_decl(decl_id) { + let mut entries: Vec<(String, u64)> = Vec::new(); + for owner in owners.iter() { + let key = match owner { + LuaTypeOwner::Decl(decl_id) => { + let Some(decl) = db.get_decl_index().get_decl(decl_id) else { + continue; + }; if decl.is_local() { continue; } + format!("D:{}", decl.get_name()) } - } - if let Some(cache) = db.get_type_index().get_type_cache(owner) { - match owner { - LuaTypeOwner::Decl(did) => { - did.file_id.hash(&mut hasher); - did.position.hash(&mut hasher); + LuaTypeOwner::Member(member_id) => { + let member_index = db.get_member_index(); + let Some(member) = member_index.get_member(member_id) else { + continue; + }; + let mut h = rustc_hash::FxHasher::default(); + hash_lua_member_key_export(member.get_key(), &mut h); + if let Some(owner) = member_index.get_member_owner(member_id) { + hash_member_owner_stable(owner, &mut h); } - _ => format!("{:?}", owner).hash(&mut hasher), + format!("M:{:x}", h.finish()) } - hash_lua_type_coarse(cache.as_type(), &mut hasher); - } + // The cached type of a bare expression. No other file can name + // one, so it is local memoisation rather than an export. + LuaTypeOwner::SyntaxId(_) => continue, + }; + let Some(cache) = db.get_type_index().get_type_cache(owner) else { + continue; + }; + let mut h = rustc_hash::FxHasher::default(); + hash_lua_type_export(cache.as_type(), &mut h); + entries.push((key, h.finish())); } + entries.sort_unstable(); + entries.hash(&mut hasher); } // --- Signatures defined in this file --- @@ -391,14 +400,46 @@ fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { sig_ids_sorted.sort_by_key(|id| id.get_position()); for sig_id in sig_ids_sorted { if let Some(sig) = db.get_signature_index().get(sig_id) { - sig.get_type_params().len().hash(&mut hasher); sig.is_vararg.hash(&mut hasher); sig.is_colon_define.hash(&mut hasher); sig.async_state.hash(&mut hasher); + sig.resolve_return.hash(&mut hasher); + format!("{:?}", sig.nodiscard).hash(&mut hasher); + sig.params.hash(&mut hasher); + for param in &sig.generic_params { + param.name.hash(&mut hasher); + if let Some(constraint) = ¶m.constraint { + hash_lua_type_export(constraint, &mut hasher); + } + } + // A caller reads the declared parameter and return types, so a + // `@param`/`@return`/`@overload` edit is an export change even + // though it leaves the arity alone. + let mut param_indices: Vec<&usize> = sig.param_docs.keys().collect(); + param_indices.sort_unstable(); + for idx in param_indices { + idx.hash(&mut hasher); + let doc = &sig.param_docs[idx]; + doc.name.hash(&mut hasher); + doc.nullable.hash(&mut hasher); + doc.description.hash(&mut hasher); + hash_lua_type_export(&doc.type_ref, &mut hasher); + } + for ret in &sig.return_docs { + ret.name.hash(&mut hasher); + ret.description.hash(&mut hasher); + hash_lua_type_export(&ret.type_ref, &mut hasher); + } + for overload in &sig.overloads { + hash_lua_type_export(&LuaType::DocFunction(overload.clone()), &mut hasher); + } + for out_param in &sig.out_params { + format!("{:?}", out_param).hash(&mut hasher); + } } if let Some(guard) = db.get_signature_index().inferred_positive_guard(sig_id) { guard.param_idx.hash(&mut hasher); - hash_lua_type_coarse(&guard.narrowed_type, &mut hasher); + hash_lua_type_export(&guard.narrowed_type, &mut hasher); } } } @@ -413,7 +454,7 @@ fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { for (owner, guard) in guard_vec { owner.path().hash(&mut hasher); guard.param_idx.hash(&mut hasher); - hash_lua_type_coarse(&guard.narrowed_type, &mut hasher); + hash_lua_type_export(&guard.narrowed_type, &mut hasher); } } @@ -430,192 +471,6 @@ fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { hasher.finish() } -fn file_export_fingerprint_detailed(db: &DbIndex, file_id: FileId) -> Vec<(&'static str, u64)> { - let mut out = Vec::new(); - // members - { - let mut hasher = rustc_hash::FxHasher::default(); - let member_index = db.get_member_index(); - let mut members = member_index.get_file_members(file_id); - members.sort_by_key(|m| crate::db_index::member_id_sort_key(m.get_id())); - for member in members { - hash_lua_member_key_coarse(member.get_key(), &mut hasher); - if let Some(owner) = member_index.get_member_owner(&member.get_id()) { - hash_member_owner_stable(owner, &mut hasher); - } - member.get_feature().hash(&mut hasher); - } - out.push(("members", hasher.finish())); - } - // contributions - filtered for stability (see file_export_fingerprint) - { - let mut hasher = rustc_hash::FxHasher::default(); - let member_index = db.get_member_index(); - let store = member_index.member_assignment_contributions(); - let keys = store.keys_for_files(&HashSet::from([file_id])); - let mut bucket_hashes = Vec::new(); - for (owner, key) in keys { - if let LuaMemberKey::Name(name) = &key { - if name.contains('/') || name.contains(".mdl") { - continue; - } - } - let mut bh = rustc_hash::FxHasher::default(); - "Owner".hash(&mut bh); - hash_lua_member_key_coarse(&key, &mut bh); - if let Some(contribs) = store.contributions(&(owner.clone(), key.clone())) { - let mut contribs_vec: Vec<_> = contribs - .iter() - .filter(|(mid, _)| mid.file_id == file_id) - .collect(); - contribs_vec.sort_by_key(|(mid, _)| crate::db_index::member_id_sort_key(**mid)); - for (_mid, contrib) in contribs_vec { - hash_lua_type_coarse(&contrib.bound_type, &mut bh); - hash_lua_type_coarse(&contrib.source_type, &mut bh); - if let Some(doc) = &contrib.doc_type { - hash_lua_type_coarse(doc, &mut bh); - } - contrib.guarded_bootstrap.hash(&mut bh); - contrib.preserve_table_literals.hash(&mut bh); - } - } - bucket_hashes.push(bh.finish()); - } - bucket_hashes.sort_unstable(); - for bh in bucket_hashes { - bh.hash(&mut hasher); - } - out.push(("contributions", hasher.finish())); - } - // type decls - { - let mut hasher = rustc_hash::FxHasher::default(); - if let Some(decl_ids) = db.get_type_index().get_file_type_decl_ids(file_id) { - let mut decl_ids_sorted = decl_ids.clone(); - decl_ids_sorted.sort_by(|a, b| a.get_name().cmp(b.get_name())); - for decl_id in decl_ids_sorted { - decl_id.get_name().hash(&mut hasher); - if let Some(supers) = db.get_type_index().get_super_type_entries(&decl_id) { - for sup in supers.iter().filter(|s| s.file_id == file_id) { - hash_lua_type_coarse(&sup.value.typ, &mut hasher); - } - } - if let Some(params) = db.get_type_index().get_generic_params(&decl_id) { - for param in params { - param.name.hash(&mut hasher); - if let Some(constraint) = ¶m.type_constraint { - hash_lua_type_coarse(constraint, &mut hasher); - } - } - } - } - } - out.push(("type_decl", hasher.finish())); - } - // exported type caches - { - let mut hasher = rustc_hash::FxHasher::default(); - if let Some(owners) = db.get_type_index().file_type_owners(file_id) { - let mut owners_vec: Vec<_> = owners.iter().collect(); - owners_vec.sort_by(|a, b| { - let key_a = match a { - LuaTypeOwner::Decl(did) => { - format!("D:{}:{}", did.file_id.id, u32::from(did.position)) - } - _ => format!("{:?}", a), - }; - let key_b = match b { - LuaTypeOwner::Decl(did) => { - format!("D:{}:{}", did.file_id.id, u32::from(did.position)) - } - _ => format!("{:?}", b), - }; - key_a.cmp(&key_b) - }); - for owner in owners_vec { - if let LuaTypeOwner::Decl(decl_id) = owner { - if let Some(decl) = db.get_decl_index().get_decl(decl_id) { - if decl.is_local() { - continue; - } - } - } - if let Some(cache) = db.get_type_index().get_type_cache(owner) { - match owner { - LuaTypeOwner::Decl(did) => { - did.file_id.hash(&mut hasher); - did.position.hash(&mut hasher); - } - _ => format!("{:?}", owner).hash(&mut hasher), - } - hash_lua_type_coarse(cache.as_type(), &mut hasher); - } - } - } - out.push(("type_cache", hasher.finish())); - } - // signatures - { - let mut hasher = rustc_hash::FxHasher::default(); - if let Some(sig_ids) = db.get_signature_index().get_file_signature_ids(file_id) { - let mut sig_ids_sorted: Vec<_> = sig_ids.iter().collect(); - sig_ids_sorted.sort_by_key(|id| id.get_position()); - for sig_id in sig_ids_sorted { - if let Some(sig) = db.get_signature_index().get(sig_id) { - sig.get_type_params().len().hash(&mut hasher); - sig.is_vararg.hash(&mut hasher); - sig.is_colon_define.hash(&mut hasher); - sig.async_state.hash(&mut hasher); - } - if let Some(guard) = db.get_signature_index().inferred_positive_guard(sig_id) { - guard.param_idx.hash(&mut hasher); - hash_lua_type_coarse(&guard.narrowed_type, &mut hasher); - } - } - } - out.push(("signature", hasher.finish())); - } - // guard facts - { - let mut hasher = rustc_hash::FxHasher::default(); - let guard_facts = db - .get_signature_index() - .inferred_guard_facts_for_files(&HashSet::from([file_id])); - if !guard_facts.is_empty() { - let mut guard_vec: Vec<_> = guard_facts.iter().collect(); - guard_vec.sort_by(|a, b| a.0.path().cmp(b.0.path())); - for (owner, guard) in guard_vec { - owner.path().hash(&mut hasher); - guard.param_idx.hash(&mut hasher); - hash_lua_type_coarse(&guard.narrowed_type, &mut hasher); - } - } - out.push(("guard", hasher.finish())); - } - // namespace - { - let mut hasher = rustc_hash::FxHasher::default(); - if let Some(ns) = db.get_type_index().get_file_namespace(&file_id) { - ns.hash(&mut hasher); - } - if let Some(using) = db.get_type_index().get_file_using_namespace(&file_id) { - for ns in using { - ns.hash(&mut hasher); - } - } - out.push(("namespace", hasher.finish())); - } - out -} - -#[allow(dead_code)] -fn file_export_fingerprints(db: &DbIndex, file_ids: &HashSet) -> HashMap { - file_ids - .iter() - .map(|fid| (*fid, file_export_fingerprint(db, *fid))) - .collect() -} - fn global_path_for_expr(expr: &LuaExpr) -> Option> { let mut path = match expr { LuaExpr::NameExpr(name_expr) => { @@ -908,26 +763,18 @@ enum TableAnchor { decl_pos: u32, path: String, }, + /// Path of `(node kind, index among same-kind siblings)` from the chunk + /// root down to the table, for literals that no name reaches. + /// + /// The whole path is needed, not just the parent's kind: two table + /// literals passed to two different calls both sit at index 0 of a + /// `CallArgList`, and a shared anchor makes both ambiguous, so neither is + /// remapped nor reported deleted and both keep a stale range. Tree { - parent_kind: String, - nth: usize, + path: Vec<(LuaSyntaxKind, usize)>, }, } -#[allow(dead_code)] -fn collect_table_ranges(db: &DbIndex, file_id: FileId) -> Vec> { - let Some(tree) = db.get_vfs().get_syntax_tree(&file_id) else { - return Vec::new(); - }; - let chunk = tree.get_chunk_node(); - let mut ranges = Vec::new(); - for table_expr in chunk.descendants::() { - ranges.push(InFiled::new(file_id, table_expr.get_range())); - } - ranges.sort_by_key(|r| r.value.start()); - ranges -} - fn expr_path_strings(expr: &LuaExpr) -> Option> { match expr { LuaExpr::NameExpr(name_expr) => Some(vec![name_expr.get_name_text()?.to_string()]), @@ -1098,119 +945,24 @@ fn table_local_anchor(db: &DbIndex, file_id: FileId, table: LuaTableExpr) -> Opt } fn table_tree_anchor(table: LuaTableExpr) -> TableAnchor { - let parent = table - .syntax() - .parent() - .map(|n| format!("{:?}", n.kind())) - .unwrap_or_else(|| "root".to_string()); - let parent_node = table - .syntax() - .parent() - .unwrap_or_else(|| table.syntax().clone()); - let mut idx = 0usize; - let mut found = 0usize; - for child in parent_node.children() { - if LuaTableExpr::can_cast(child.kind().into()) { - if child.text_range() == table.get_range() { - found = idx; - } - idx += 1; - } - } - TableAnchor::Tree { - parent_kind: parent, - nth: found, - } + let mut path = Vec::new(); + let mut node = table.syntax().clone(); + while let Some(parent) = node.parent() { + let kind = node.kind(); + let nth = parent + .children() + .filter(|sibling| sibling.kind() == kind) + .position(|sibling| sibling == node) + .unwrap_or(0); + path.push((kind.into(), nth)); + node = parent; + } + path.reverse(); + TableAnchor::Tree { path } } -fn table_anchor(db: &DbIndex, range: &InFiled) -> TableAnchor { - let file_id = range.file_id; - let Some(tree) = db.get_vfs().get_syntax_tree(&file_id) else { - return TableAnchor::Tree { - parent_kind: "missing".to_string(), - nth: 0, - }; - }; - let chunk = tree.get_chunk_node(); - for table in chunk.descendants::() { - if table.get_range() == range.value { - if let Some(global) = table_global_path_recursive(db, file_id, table.clone()) { - return TableAnchor::Global(global); - } - if let Some(local) = table_local_anchor(db, file_id, table.clone()) { - return local; - } - return table_tree_anchor(table); - } - } - TableAnchor::Tree { - parent_kind: "fallback".to_string(), - nth: 0, - } -} - -#[allow(dead_code)] -fn build_anchor_map( - old: Vec>, - new: Vec>, - db: &DbIndex, -) -> std::collections::HashMap< - InFiled, - InFiled, - rustc_hash::FxBuildHasher, -> { - use rustc_hash::FxHashMap; - if old.is_empty() || new.is_empty() { - return FxHashMap::default(); - } - let old_anchored: Vec<(InFiled, TableAnchor)> = old - .iter() - .map(|r| (r.clone(), table_anchor(db, r))) - .collect(); - // For new, we need db after reindex; table_anchor uses new db's tree - let new_anchored: Vec<(InFiled, TableAnchor)> = new - .iter() - .map(|r| (r.clone(), table_anchor(db, r))) - .collect(); - - if old.len() != new.len() { - let old_set: std::collections::HashSet = - old_anchored.iter().map(|(_, a)| a.clone()).collect(); - let new_set: std::collections::HashSet = - new_anchored.iter().map(|(_, a)| a.clone()).collect(); - if old_set != new_set { - eprintln!( - "[anchor] len mismatch old={} new={} anchor mismatch, skipping migration (old_set {} new_set {})", - old.len(), - new.len(), - old_set.len(), - new_set.len() - ); - return FxHashMap::default(); - } - } - - let mut new_by_anchor: FxHashMap> = FxHashMap::default(); - for (range, anchor) in new_anchored { - // Keep first occurrence if duplicate (should be unique) - new_by_anchor.entry(anchor).or_insert(range); - } - let mut map: FxHashMap, InFiled> = - FxHashMap::default(); - for (old_range, anchor) in old_anchored { - if let Some(new_range) = new_by_anchor.get(&anchor) { - map.insert(old_range, new_range.clone()); - } - } - if old.len() == new.len() && map.len() != old.len() { - eprintln!( - "[anchor] len equal but map incomplete {}/{} (likely TreePath collision)", - map.len(), - old.len() - ); - } - map -} +type AnchorMaps = + rustc_hash::FxHashMap>>; fn collect_anchored_map( db: &DbIndex, @@ -1399,7 +1151,13 @@ impl EmmyLuaAnalysis { // changed. A trailing comment or a local-only edit keeps the same // fingerprint, so the ripple collapses to empty and the edit costs only // the file's own analysis instead of seconds for a hub file. - if let Some(existing) = existing_file_id { + // + // A deletion (`text: None`) is excluded: it has no new text to index, + // and comparing fingerprints would let a file that exported nothing + // return before the removal seeds run, leaving dependents pointing at + // a file that is gone. It takes the full path below, which filters + // removed files out of `update_index` and seeds VGUI forwarding removal. + if let Some(existing) = existing_file_id.filter(|_| text.is_some()) { // Capture fingerprint and expansion before the VFS mutation. // Expansion must be captured before reindexing the edited file, as // in the original `update_file_by_uri` path: dependents are those @@ -1426,162 +1184,21 @@ impl EmmyLuaAnalysis { .has_annotated_vgui_parent_calls(existing) }; if is_special { - let anchored = collect_anchored_map(self.compilation.get_db(), existing); - if !anchored.is_empty() { - self.pending_table_ranges - .entry(existing) - .or_insert(anchored); - } + let old_maps = self.take_old_anchor_maps(&[existing]); let file_id = self .compilation .get_db_mut() .get_vfs_mut() .set_file_content(uri, text); - let expansion = before_expansion; self.reindex_expanded_files_with_old_snapshot( vec![file_id], - expansion, + before_expansion, old_guard_snapshot, ); - // Apply element/table remap if stashed, then clear - if let Some(old_map) = self.pending_table_ranges.remove(&existing) { - let new_map = collect_anchored_map(self.compilation.get_db(), file_id); - let mut global_remap: rustc_hash::FxHashMap< - InFiled, - InFiled, - > = rustc_hash::FxHashMap::default(); - let mut deleted: Vec> = Vec::new(); - for (anchor, old_range) in old_map { - if let Some(new_range) = new_map.get(&anchor) { - if &old_range != new_range { - global_remap.insert(old_range.clone(), new_range.clone()); - } - } else { - deleted.push(old_range); - } - } - if !global_remap.is_empty() { - self.compilation - .get_db_mut() - .get_member_index_mut() - .remap_elements(&global_remap); - self.compilation - .get_db_mut() - .get_type_index_mut() - .remap_table_const(&global_remap); - } - if !deleted.is_empty() { - self.compilation - .get_db_mut() - .get_member_index_mut() - .remove_deleted_element_owners(&deleted); - } - } + self.apply_table_remap(old_maps); profile::phase_report("update_file_by_uri"); return Some(file_id); } - let before_detailed = if std::env::var_os("GLUALS_DEBUG_FINGERPRINT").is_some() { - Some(file_export_fingerprint_detailed( - self.compilation.get_db(), - existing, - )) - } else { - None - }; - let before_members_debug = if std::env::var_os("GLUALS_DEBUG_FINGERPRINT").is_some() { - let db = self.compilation.get_db(); - let mut members = db.get_member_index().get_file_members(existing); - members.sort_by_key(|m| crate::db_index::member_id_sort_key(m.get_id())); - let strs: Vec = members - .iter() - .map(|m| { - let owner_str = db - .get_member_index() - .get_member_owner(&m.get_id()) - .map(|o| match o { - LuaMemberOwner::GlobalPath(g) => { - format!("GlobalPath({})", g.get_name()) - } - LuaMemberOwner::Type(t) => format!("Type({})", t.get_name()), - LuaMemberOwner::Element(_) => "Element".to_string(), - LuaMemberOwner::LocalUnresolve => "LocalUnresolve".to_string(), - }) - .unwrap_or_else(|| "None".to_string()); - format!( - "{:?} key={:?} feat={:?} owner={}", - m.get_id(), - m.get_key(), - m.get_feature(), - owner_str - ) - }) - .collect(); - Some(strs) - } else { - None - }; - let before_contribs_debug = if std::env::var_os("GLUALS_DEBUG_FINGERPRINT").is_some() { - let db = self.compilation.get_db(); - let member_index = db.get_member_index(); - let store = member_index.member_assignment_contributions(); - let keys = store.keys_for_files(&HashSet::from([existing])); - let mut keys_vec: Vec<_> = keys.into_iter().collect(); - keys_vec.sort_by(|(_, ka), (_, kb)| { - let key_str = |k: &LuaMemberKey| match k { - LuaMemberKey::Name(n) => n.to_string(), - LuaMemberKey::Integer(i) => i.to_string(), - LuaMemberKey::None => "".to_string(), - LuaMemberKey::ExprType(_) => "".to_string(), - }; - key_str(ka).cmp(&key_str(kb)) - }); - let mut out = Vec::new(); - for (owner, key) in keys_vec { - if let LuaMemberKey::Name(name) = &key { - if name.contains('/') || name.contains(".mdl") { - continue; - } - } - if let Some(contribs) = store.contributions(&(owner.clone(), key.clone())) { - let mut contribs_vec: Vec<_> = contribs - .iter() - .filter(|(mid, _)| mid.file_id == existing) - .collect(); - contribs_vec - .sort_by_key(|(mid, _)| crate::db_index::member_id_sort_key(**mid)); - for (mid, contrib) in contribs_vec { - let owner_str = "Owner".to_string(); - let key_str = match &key { - LuaMemberKey::Name(n) => format!("N:{}", n), - LuaMemberKey::Integer(i) => format!("I:{}", i), - LuaMemberKey::None => "None".to_string(), - LuaMemberKey::ExprType(t) => { - let mut h = rustc_hash::FxHasher::default(); - hash_lua_type_coarse(t, &mut h); - format!("E:{:x}", h.finish()) - } - }; - let mut h1 = rustc_hash::FxHasher::default(); - hash_lua_type_coarse(&contrib.bound_type, &mut h1); - let mut h2 = rustc_hash::FxHasher::default(); - hash_lua_type_coarse(&contrib.source_type, &mut h2); - out.push(format!( - "owner={} key={} mid={:?} bound={:x} source={:x} guarded={} preserve={}", - owner_str, - key_str, - mid, - h1.finish(), - h2.finish(), - contrib.guarded_bootstrap, - contrib.preserve_table_literals - )); - } - } - } - Some(out) - } else { - None - }; let anchored = collect_anchored_map(self.compilation.get_db(), existing); if !anchored.is_empty() { self.pending_table_ranges @@ -1598,182 +1215,30 @@ impl EmmyLuaAnalysis { // be taken from the new index. profile::phase("edit/self-index", || { self.self_index_files(vec![file_id]); + // The self-index derives this file's cross-file reads in + // isolation. Settling them here is what the ripple used to do + // for the whole expansion, and it is also what makes the + // after-fingerprint comparable to the before-fingerprint, + // which was taken from an already settled index. + self.stabilize_cross_file_type_caches(&[file_id]); }); let after_fp = file_export_fingerprint(self.compilation.get_db(), file_id); if before_fp == after_fp { profile::phase_report("update_file_by_uri (no-ripple)"); return Some(file_id); } - if std::env::var_os("GLUALS_DEBUG_FINGERPRINT").is_some() { - if let Some(before_detailed) = before_detailed { - let after_detailed = - file_export_fingerprint_detailed(self.compilation.get_db(), file_id); - if let Some(path) = self.compilation.get_db().get_vfs().get_file_path(&file_id) - { - eprintln!( - "[fingerprint] changed {} before={:x} after={:x}", - path.display(), - before_fp, - after_fp - ); - for ((name, before_cat), (_, after_cat)) in - before_detailed.iter().zip(after_detailed.iter()) - { - if before_cat != after_cat { - eprintln!( - " category {} before={:x} after={:x}", - name, before_cat, after_cat - ); - } - } - // Detailed member diff (stable owner) - if let Some(before_members) = before_members_debug { - let db = self.compilation.get_db(); - let mut after_members = db.get_member_index().get_file_members(file_id); - after_members - .sort_by_key(|m| crate::db_index::member_id_sort_key(m.get_id())); - let after_strs: Vec = after_members - .iter() - .map(|m| { - let owner_str = db - .get_member_index() - .get_member_owner(&m.get_id()) - .map(|o| match o { - LuaMemberOwner::GlobalPath(g) => { - format!("GlobalPath({})", g.get_name()) - } - LuaMemberOwner::Type(t) => { - format!("Type({})", t.get_name()) - } - LuaMemberOwner::Element(_) => "Element".to_string(), - LuaMemberOwner::LocalUnresolve => { - "LocalUnresolve".to_string() - } - }) - .unwrap_or_else(|| "None".to_string()); - format!( - "{:?} key={:?} feat={:?} owner={}", - m.get_id(), - m.get_key(), - m.get_feature(), - owner_str - ) - }) - .collect(); - eprintln!( - " members before={} after={}", - before_members.len(), - after_strs.len() - ); - let before_set: std::collections::HashSet<_> = - before_members.iter().collect(); - let after_set: std::collections::HashSet<_> = - after_strs.iter().collect(); - for b in &before_members { - if !after_set.contains(b) { - eprintln!(" - {}", b); - } - } - for a in &after_strs { - if !before_set.contains(a) { - eprintln!(" + {}", a); - } - } - } - if let Some(before_contribs) = before_contribs_debug { - let db = self.compilation.get_db(); - let member_index = db.get_member_index(); - let store = member_index.member_assignment_contributions(); - let keys = store.keys_for_files(&HashSet::from([file_id])); - let mut keys_vec: Vec<_> = keys.into_iter().collect(); - keys_vec.sort_by(|(_, ka), (_, kb)| { - let key_str = |k: &LuaMemberKey| match k { - LuaMemberKey::Name(n) => n.to_string(), - LuaMemberKey::Integer(i) => i.to_string(), - LuaMemberKey::None => "".to_string(), - LuaMemberKey::ExprType(_) => "".to_string(), - }; - key_str(ka).cmp(&key_str(kb)) - }); - let mut after_contribs = Vec::new(); - for (owner, key) in keys_vec { - if let LuaMemberKey::Name(name) = &key { - if name.contains('/') || name.contains(".mdl") { - continue; - } - } - if let Some(contribs) = - store.contributions(&(owner.clone(), key.clone())) - { - let mut contribs_vec: Vec<_> = contribs - .iter() - .filter(|(mid, _)| mid.file_id == file_id) - .collect(); - contribs_vec.sort_by_key(|(mid, _)| { - crate::db_index::member_id_sort_key(**mid) - }); - for (mid, contrib) in contribs_vec { - let owner_str = "Owner".to_string(); - let key_str = match &key { - LuaMemberKey::Name(n) => format!("N:{}", n), - LuaMemberKey::Integer(i) => format!("I:{}", i), - LuaMemberKey::None => "None".to_string(), - LuaMemberKey::ExprType(t) => { - let mut h = rustc_hash::FxHasher::default(); - hash_lua_type_coarse(t, &mut h); - format!("E:{:x}", h.finish()) - } - }; - let mut h1 = rustc_hash::FxHasher::default(); - hash_lua_type_coarse(&contrib.bound_type, &mut h1); - let mut h2 = rustc_hash::FxHasher::default(); - hash_lua_type_coarse(&contrib.source_type, &mut h2); - after_contribs.push(format!( - "owner={} key={} mid={:?} bound={:x} source={:x} guarded={} preserve={}", - owner_str, - key_str, - mid, - h1.finish(), - h2.finish(), - contrib.guarded_bootstrap, - contrib.preserve_table_literals - )); - } - } - } - eprintln!( - " contribs before={} after={}", - before_contribs.len(), - after_contribs.len() - ); - let before_set: std::collections::HashSet<_> = - before_contribs.iter().collect(); - let after_set: std::collections::HashSet<_> = - after_contribs.iter().collect(); - for b in &before_contribs { - if !after_set.contains(b) { - eprintln!(" - {}", b); - } - } - for a in &after_contribs { - if !before_set.contains(a) { - eprintln!(" + {}", a); - } - } - } - } - } - } - // Export changed - pay the ripple. Use the pre-computed expansion + // Export changed - pay the ripple. The edited file is re-indexed a + // second time here, as part of the expansion: its entries have to be + // derived in the same batch as its dependents' for the pass to + // converge, and it is one file out of an expansion in the thousands. + // + // Use the pre-computed expansion // (before the edit) so that call-site dependents that already // reference the old exports are included. Computing after // `self_index_files` missed them (observed: guard consumer went from 2 // to 1 and stayed `Entity`). Use the old guard snapshot for the // guard propagation, which must be captured before `self_index` overwrites it. let expansion = before_expansion; - if std::env::var_os("GLUALS_DEBUG_FINGERPRINT").is_some() { - eprintln!("[fingerprint] expansion {} files", expansion.len()); - } profile::phase("edit/ripple", || { self.reindex_expanded_files_with_old_snapshot( vec![file_id], @@ -1884,12 +1349,21 @@ impl EmmyLuaAnalysis { (None, InferredGuardSnapshot::default()) }; - if let Some(fid) = existing_file_id { - let anchored = collect_anchored_map(self.compilation.get_db(), fid); - if !anchored.is_empty() { - self.pending_table_ranges.entry(fid).or_insert(anchored); + // The anchors have to be read before the VFS mutation drops the old + // tree. When this call also re-indexes, they are consumed below; + // otherwise they are stashed for whichever pass does index the file, + // because until then the index still holds the pre-edit ranges. + let old_maps = match existing_file_id { + Some(fid) if trigger_reindex => self.take_old_anchor_maps(&[fid]), + Some(fid) => { + let anchored = collect_anchored_map(self.compilation.get_db(), fid); + if !anchored.is_empty() { + self.pending_table_ranges.entry(fid).or_insert(anchored); + } + AnchorMaps::default() } - } + None => AnchorMaps::default(), + }; let file_id = self .compilation @@ -1922,42 +1396,7 @@ impl EmmyLuaAnalysis { &incremental_source_file_ids, ); self.reindex_changed_inferred_param_consumers(&old_guard_facts, &reindex_file_ids); - // Remap Element/TableConst that shifted due to offset changes in the edited file. - if let Some(fid) = existing_file_id { - if let Some(old_map) = self.pending_table_ranges.remove(&fid) { - let new_map = collect_anchored_map(self.compilation.get_db(), file_id); - let mut global_remap: rustc_hash::FxHashMap< - InFiled, - InFiled, - > = rustc_hash::FxHashMap::default(); - let mut deleted: Vec> = Vec::new(); - for (anchor, old_range) in old_map { - if let Some(new_range) = new_map.get(&anchor) { - if &old_range != new_range { - global_remap.insert(old_range.clone(), new_range.clone()); - } - } else { - deleted.push(old_range); - } - } - if !global_remap.is_empty() { - self.compilation - .get_db_mut() - .get_member_index_mut() - .remap_elements(&global_remap); - self.compilation - .get_db_mut() - .get_type_index_mut() - .remap_table_const(&global_remap); - } - if !deleted.is_empty() { - self.compilation - .get_db_mut() - .get_member_index_mut() - .remove_deleted_element_owners(&deleted); - } - } - } + self.apply_table_remap(old_maps); } Some(file_id) @@ -2084,12 +1523,6 @@ impl EmmyLuaAnalysis { let mut file_ids = expansion.clone(); self.add_vgui_forwarding_removal_seed(&removed_file_ids, &mut file_ids); - if std::env::var_os("GLUALS_DEBUG_FINGERPRINT").is_some() && !removed_file_ids.is_empty() { - eprintln!( - "[vgui] removed {:?} expansion before {:?} after {:?}", - removed_file_ids, expansion, file_ids - ); - } let guard_fact_file_ids = file_ids.iter().copied().collect::>(); let old_guard_facts = self.inferred_guard_snapshot(&guard_fact_file_ids); self.compilation.remove_index(file_ids.clone()); @@ -2138,12 +1571,6 @@ impl EmmyLuaAnalysis { let mut file_ids = expansion.clone(); self.add_vgui_forwarding_removal_seed(&removed_file_ids, &mut file_ids); - if std::env::var_os("GLUALS_DEBUG_FINGERPRINT").is_some() && !removed_file_ids.is_empty() { - eprintln!( - "[vgui] (old_snapshot) removed {:?} expansion before {:?} after {:?}", - removed_file_ids, expansion, file_ids - ); - } let guard_fact_file_ids = file_ids.iter().copied().collect::>(); let old_guard_facts = old_snapshot; self.compilation.remove_index(file_ids.clone()); @@ -2181,16 +1608,15 @@ impl EmmyLuaAnalysis { /// which is all a request positioned *inside that file* needs — the index /// entries are keyed by position, so an edit that shifts offsets is exactly /// what makes them stop matching the tree. - pub fn self_index_files(&mut self, file_ids: Vec) { - // Capture old anchored tables. If the edit came through update_file_text_only - // or update_file_by_uri, the old anchors are already stashed in - // pending_table_ranges before the VFS mutation; otherwise collect from the - // current tree before we drop it. - let mut old_maps: rustc_hash::FxHashMap< - FileId, - rustc_hash::FxHashMap>, - > = rustc_hash::FxHashMap::default(); - for fid in &file_ids { + /// The anchor map the index's stored `Element` ranges correspond to. + /// + /// An edit stashes this before mutating the VFS, because the tree those + /// ranges came from is gone once the new text is parsed. Files re-indexed + /// without an intervening edit have no stash, so their current tree is + /// still the one the index was built from. + fn take_old_anchor_maps(&mut self, file_ids: &[FileId]) -> AnchorMaps { + let mut old_maps = AnchorMaps::default(); + for fid in file_ids { if let Some(pending) = self.pending_table_ranges.remove(fid) { old_maps.insert(*fid, pending); } else { @@ -2200,41 +1626,47 @@ impl EmmyLuaAnalysis { } } } + old_maps + } - self.compilation.remove_index(file_ids.clone()); - self.compilation.update_index(file_ids.clone()); - - // Build global remap oldRange -> newRange by matching anchors. + /// Re-homes index entries that name a re-indexed file's table literals by + /// range, from the range the old tree gave them to the range the new tree + /// does. Entries whose literal no longer exists are dropped. + /// + /// Only the edited file's own members are rebuilt by a re-index; every + /// other file's reference to one of its `Element` owners keeps the old + /// offset, so without this they point into the wrong table after any edit + /// that shifts offsets. + fn apply_table_remap(&mut self, mut old_maps: AnchorMaps) { + if old_maps.is_empty() { + return; + } let mut global_remap: rustc_hash::FxHashMap< InFiled, InFiled, > = rustc_hash::FxHashMap::default(); let mut deleted: Vec> = Vec::new(); - for fid in &file_ids { - let old_map = old_maps.remove(fid); - let new_map = collect_anchored_map(self.compilation.get_db(), *fid); - if let Some(old_map) = old_map { - for (anchor, old_range) in old_map { - if let Some(new_range) = new_map.get(&anchor) { - if &old_range != new_range { - global_remap.insert(old_range.clone(), new_range.clone()); - } - } else { - deleted.push(old_range); + let file_ids: Vec = old_maps.keys().copied().collect(); + for fid in file_ids { + let Some(old_map) = old_maps.remove(&fid) else { + continue; + }; + let new_map = collect_anchored_map(self.compilation.get_db(), fid); + for (anchor, old_range) in old_map { + match new_map.get(&anchor) { + Some(new_range) if &old_range != new_range => { + global_remap.insert(old_range, new_range.clone()); } + Some(_) => {} + None => deleted.push(old_range), } } } if !global_remap.is_empty() { - self.compilation - .get_db_mut() - .get_member_index_mut() - .remap_elements(&global_remap); - self.compilation - .get_db_mut() - .get_type_index_mut() - .remap_table_const(&global_remap); + let db = self.compilation.get_db_mut(); + db.get_member_index_mut().remap_elements(&global_remap); + db.get_type_index_mut().remap_table_const(&global_remap); } if !deleted.is_empty() { self.compilation @@ -2244,6 +1676,13 @@ impl EmmyLuaAnalysis { } } + pub fn self_index_files(&mut self, file_ids: Vec) { + let old_maps = self.take_old_anchor_maps(&file_ids); + self.compilation.remove_index(file_ids.clone()); + self.compilation.update_index(file_ids); + self.apply_table_remap(old_maps); + } + pub fn self_index_files_and_get_ripple_with_changed( &mut self, file_ids: Vec, @@ -2277,6 +1716,7 @@ impl EmmyLuaAnalysis { // reference the old exports are missed. let before_expansion = self.expand_reindex_file_ids(file_ids.clone()); self.self_index_files(file_ids.clone()); + self.stabilize_cross_file_type_caches(&file_ids); let mut changed = Vec::new(); for fid in &file_ids { let after = file_export_fingerprint(self.compilation.get_db(), *fid); From 707f4f33f65f641cdbcd6a5684dea019d39c7d9d Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:13:27 +0100 Subject: [PATCH 063/159] fix: various issues with cross-file caches --- AGENTS.md | 8 +- .../src/db_index/accessor_func/mod.rs | 24 + .../src/db_index/call_site_param.rs | 1 - .../src/db_index/dynamic_field/mod.rs | 90 + .../member/assignment_contribution.rs | 87 +- .../src/db_index/member/mod.rs | 193 +- .../src/db_index/metatable/mod.rs | 7 +- .../src/db_index/operators/lua_operator.rs | 1 - .../src/db_index/operators/mod.rs | 8 + .../src/db_index/property/mod.rs | 14 + .../src/db_index/signature/signature.rs | 4 + .../src/db_index/type/mod.rs | 44 +- .../src/db_index/type/type_decl.rs | 32 + .../diagnostic/test/incremental_edit_test.rs | 1594 ++++++++++++++++- crates/glua_code_analysis/src/lib.rs | 1275 ++++++++++--- 15 files changed, 3017 insertions(+), 365 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a29afc614..a087d1c28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ - Network diagnostics compare send/receive flows and order; be conservative with dynamic names, branches, and loops. - Annotation metadata changes need ingestion coverage plus a downstream behavior test via real builtins/fixtures. - Sort any output derived from hash maps or parallel collection before diagnostics/completions/snapshots. -- No budgets, caps, or fragile prefilters for performance. Profile first, then index/cache/optimize/parallelize. +- No budgets, caps, or fragile prefilters for performance: they regress functionality on exactly the large or complex workspaces the server exists for. Profile first, then index/cache/optimize/parallelize. Fix performance at the root cause. - Config changes must update structs, `crates/glua_code_analysis/resources/schema.json`, and docs together. Run `cargo run --bin schema_json_gen` and commit the diff. - `.gluarc.json` is exclusive when present; otherwise consider `.luarc.json`, `.emmyrc.json`, `.emmyrc.lua` in order. Gamemode-base detection scans workspace roots. - Annotations are external library workspaces: `glua_check --gmod-annotations`, `glua_ls --gmod-annotations-path` (or `gmod.annotationsPath` / `gmod.autoLoadAnnotations` in config). @@ -48,17 +48,17 @@ - Use `VirtualWorkspace` with realistic addon/gamemode paths; prefer existing GMod fixtures. Call-role tests must load relevant builtins. - Tests: `cargo test -p glua_code_analysis ` | `cargo test -p glua_code_analysis` | `cargo test`. - Corpus diffs: `glua_check` JSON. Benchmark is for performance only. -- Determinism (required for index/cache/unresolve changes): `cargo run --release -p determinism`. Requires `DET_CODEBASE` and `DET_ANNOTATIONS`; set `DET_EDIT_FIND`/`DET_EDIT_REPLACE` for edit gates or they skip. Every gate must be `+0` diagnostics and `+0` index. +- Determinism (required for index/cache/unresolve changes): `cargo run --release -p determinism`. The harness module docs say what each gate proves and which stages are expected to diverge; read them before interpreting a result. Requires `DET_CODEBASE` and `DET_ANNOTATIONS`; set `DET_EDIT_FIND`/`DET_EDIT_REPLACE` for edit gates or they skip. Every gate must be `+0` diagnostics and `+0` index. Gates: `repeat`, `fresh`, `order`, `reindex`, `allreindex`, `mainexpand`, `noopedit`, `realedit`, `editrevert`, `indexrepeat`, `burst`. Bisect/debug only (expected to diverge): `mainreindex`, `exact`, `split:N`, `editmid`, `restabilize`, `perfile`, `expandwhy`, `faithful`. Use `DET_TARGETS=gamemode/core/sh_data.lua` by default for edit target, `sh_configuration` is good for performance related tests (many related files). -- Perf: `GLUALS_PROFILE=1` for phase timings; `cargo run --release -p benchmark` for large-workspace. For `samply` (ETW on Windows, needs elevation and therefore user permission first): build with `CARGO_PROFILE_RELEASE_DEBUG=1 cargo build --release -p benchmark`, run from `target/release`, do not use `--main-thread-only` (analysis runs on spawned thread). Example: `cd target/release && BENCH_CODEBASE= samply record --save-only --unstable-presymbolicate -o out.json.gz ./benchmark.exe`. +- Perf: `GLUALS_PROFILE=1` for phase timings; `cargo run --release -p benchmark` for large-workspace. For `samply` (ETW on Windows, needs elevation and therefore user permission first) three things have to be right or the profile is useless: build with `CARGO_PROFILE_RELEASE_DEBUG=1 cargo build --release -p benchmark` so the PDB exists; run from `target/release`, because samply resolves the PDB by the relative path recorded in the exe; and do not pass `--main-thread-only`, because analysis runs on a spawned big-stack thread and the main thread only shows a join. Example: `cd target/release && BENCH_CODEBASE= samply record --save-only --unstable-presymbolicate -o out.json.gz ./benchmark.exe`. That writes `out.json.gz` plus an `out.json.syms.json` sidecar; the profile holds only addresses, so symbol names come from joining the two by `libs[].debugName` and the frame address against each module's `symbol_table` rva ranges. ## Commands - `cargo fmt --all` - `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- `pre-commit run --all-files` (mixed-line-ending hook is `manual` stage) +- `pre-commit run --all-files`, and `pre-commit run --all --hook-stage manual` to include the manual-stage hooks (mixed-line-ending) - `cargo build --release` [`-p glua_ls|glua_check|glua_doc_cli`] - `cargo build --profile dist` (shipped/CI optimized, thin LTO) - `docs/mintlify`: `mint dev` | `mint broken-links` diff --git a/crates/glua_code_analysis/src/db_index/accessor_func/mod.rs b/crates/glua_code_analysis/src/db_index/accessor_func/mod.rs index aab64daf6..0d960060d 100644 --- a/crates/glua_code_analysis/src/db_index/accessor_func/mod.rs +++ b/crates/glua_code_analysis/src/db_index/accessor_func/mod.rs @@ -32,6 +32,30 @@ impl AccessorFuncAnnotationIndex { self.by_file.entry(file_id).or_default().push(name); } + /// The `@accessorfunc` annotations this file declares, as + /// `(function name, name parameter index)`. + /// + /// The index is consulted by name while analysing calls in *any* file, and + /// decides which argument names the accessor - so which `Get*`/`Set*` + /// members get synthesized on the owner. + #[cfg(test)] + pub fn annotations_in_file(&self, file_id: FileId) -> Vec<(&SmolStr, usize)> { + let Some(names) = self.by_file.get(&file_id) else { + return Vec::new(); + }; + names + .iter() + .filter_map(|name| { + let annotation = self + .by_name + .get(name)? + .iter() + .find(|annotation| annotation.file_id == file_id)?; + Some((name, annotation.name_param_index)) + }) + .collect() + } + pub fn contains_name(&self, name: &str) -> bool { self.by_name.contains_key(name) } diff --git a/crates/glua_code_analysis/src/db_index/call_site_param.rs b/crates/glua_code_analysis/src/db_index/call_site_param.rs index 863d7fb1f..34ebb72dd 100644 --- a/crates/glua_code_analysis/src/db_index/call_site_param.rs +++ b/crates/glua_code_analysis/src/db_index/call_site_param.rs @@ -321,7 +321,6 @@ impl CallSiteParamIndex { } out } - /// Every call-site-inferred parameter type currently indexed. pub fn iter_inferred_params( &self, diff --git a/crates/glua_code_analysis/src/db_index/dynamic_field/mod.rs b/crates/glua_code_analysis/src/db_index/dynamic_field/mod.rs index 4cce13297..e64832549 100644 --- a/crates/glua_code_analysis/src/db_index/dynamic_field/mod.rs +++ b/crates/glua_code_analysis/src/db_index/dynamic_field/mod.rs @@ -189,6 +189,96 @@ impl DynamicFieldIndex { } } + /// Re-keys owners whose table literal shifted offset. + /// + /// A dynamic field is filed under the literal's range, and a write from + /// another file is not re-collected when this one is re-indexed, so a + /// stale key makes the field unreachable from the type the literal has. + pub fn remap_table_ranges( + &mut self, + map: &rustc_hash::FxHashMap, InFiled>, + ) { + fn remap_owner( + owner: &DynamicFieldOwner, + map: &rustc_hash::FxHashMap, InFiled>, + ) -> Option { + match owner { + DynamicFieldOwner::Table(range) => map + .get(range) + .map(|new| DynamicFieldOwner::Table(new.clone())), + DynamicFieldOwner::Type(_) => None, + } + } + + macro_rules! remap_owner_keyed { + ($field:expr) => {{ + let moved: Vec<(DynamicFieldOwner, DynamicFieldOwner)> = $field + .keys() + .filter_map(|owner| Some((owner.clone(), remap_owner(owner, map)?))) + .collect(); + // Detached before any is re-filed: one literal's new range can + // be another's old one. + let detached: Vec<_> = moved + .into_iter() + .filter_map(|(old, new)| Some((new, $field.remove(&old)?))) + .collect(); + for (new, value) in detached { + $field.entry(new).or_default().extend(value); + } + }}; + } + + remap_owner_keyed!(self.field_definitions); + remap_owner_keyed!(self.direct_field_definitions); + remap_owner_keyed!(self.finite_named_members); + remap_owner_keyed!(self.wildcard_definitions); + + for entries in self.file_contributions.values_mut() { + for (owner, _, _) in entries.iter_mut() { + if let Some(new) = remap_owner(owner, map) { + *owner = new; + } + } + } + for entries in self.wildcard_file_contributions.values_mut() { + for (owner, _) in entries.iter_mut() { + if let Some(new) = remap_owner(owner, map) { + *owner = new; + } + } + } + } + + /// Every table-literal range this index is keyed by. + #[cfg(test)] + pub(crate) fn table_ranges(&self) -> Vec> { + fn owner_range(owner: &DynamicFieldOwner) -> Option> { + match owner { + DynamicFieldOwner::Table(range) => Some(range.clone()), + DynamicFieldOwner::Type(_) => None, + } + } + self.field_definitions + .keys() + .chain(self.direct_field_definitions.keys()) + .chain(self.finite_named_members.keys()) + .chain(self.wildcard_definitions.keys()) + .filter_map(owner_range) + .chain( + self.file_contributions + .values() + .flatten() + .filter_map(|(owner, _, _)| owner_range(owner)), + ) + .chain( + self.wildcard_file_contributions + .values() + .flatten() + .filter_map(|(owner, _)| owner_range(owner)), + ) + .collect() + } + /// Whether the index has finished being built for the current analysis /// round. A read taken before that answers from however far the batch walk /// happened to get, so an absent field is not yet known to be absent. diff --git a/crates/glua_code_analysis/src/db_index/member/assignment_contribution.rs b/crates/glua_code_analysis/src/db_index/member/assignment_contribution.rs index 70ab958ac..c6eb3980d 100644 --- a/crates/glua_code_analysis/src/db_index/member/assignment_contribution.rs +++ b/crates/glua_code_analysis/src/db_index/member/assignment_contribution.rs @@ -56,7 +56,6 @@ impl MemberAssignmentContributionStore { .or_default() .insert(member_id, contribution); } - /// Drops every entry the removed files contributed, in one sweep keyed by /// file rather than a whole-store scan per file. pub fn remove_files(&mut self, removed: &HashSet) { @@ -141,10 +140,23 @@ impl MemberAssignmentContributionStore { }) .collect(); - for (old_key, new_key) in moved { - let Some(group) = self.by_owner_key.remove(&old_key) else { - continue; - }; + // Every group is detached before any is re-filed. One literal's new + // range can be another's old range, and a remove-then-insert loop + // would then merge the first group into the second's key and carry + // both along on the second move. + let detached: Vec<( + MemberAssignmentContributionKey, + MemberAssignmentContributionKey, + _, + )> = moved + .into_iter() + .filter_map(|(old_key, new_key)| { + let group = self.by_owner_key.remove(&old_key)?; + Some((old_key, new_key, group)) + }) + .collect(); + + for (_old_key, new_key, group) in detached { for member_id in group.keys() { if let Some(entries) = self.by_file.get_mut(&member_id.file_id) { entries.insert(*member_id, new_key.clone()); @@ -169,6 +181,71 @@ impl MemberAssignmentContributionStore { self.detach(&store_key, member_id); } + /// Rewrites table-literal ranges inside stored writer evidence. + /// + /// A contribution's `bound_type`/`source_type`/`doc_type` can name a table + /// literal in the edited file. The member index re-homes the *owner*, but + /// the evidence the widening merge reads is here, and a stale range in it + /// resolves to a literal that no longer exists. + pub fn remap_table_ranges(&mut self, map: &FxHashMap, InFiled>) { + let keys: Vec = + self.by_owner_key.keys().cloned().collect(); + for key in keys { + let Some(group) = self.by_owner_key.get_mut(&key) else { + continue; + }; + for contribution in group.values_mut() { + if let Some(bound) = + crate::db_index::remap_table_ranges_in_type(&contribution.bound_type, map) + { + contribution.bound_type = bound; + } + if let Some(source) = + crate::db_index::remap_table_ranges_in_type(&contribution.source_type, map) + { + contribution.source_type = source; + } + if let Some(doc) = contribution + .doc_type + .as_ref() + .and_then(|doc| crate::db_index::remap_table_ranges_in_type(doc, map)) + { + contribution.doc_type = Some(doc); + } + } + } + } + + /// Every table-literal range this store is keyed by or whose evidence + /// names one. + #[cfg(test)] + pub(crate) fn table_ranges(&self) -> Vec> { + self.by_owner_key + .iter() + .flat_map(|(store_key, group)| { + let owner = match &store_key.0 { + LuaMemberOwner::Element(range) => Some(range.clone()), + _ => None, + }; + owner + .into_iter() + .chain(group.values().flat_map(|contribution| { + crate::db_index::table_ranges_in_type(&contribution.bound_type) + .into_iter() + .chain(crate::db_index::table_ranges_in_type( + &contribution.source_type, + )) + .chain( + contribution + .doc_type + .iter() + .flat_map(crate::db_index::table_ranges_in_type), + ) + })) + }) + .collect() + } + /// Number of stored writer entries, for the profile report. pub fn entry_count(&self) -> usize { self.by_owner_key.values().map(FxHashMap::len).sum() diff --git a/crates/glua_code_analysis/src/db_index/member/mod.rs b/crates/glua_code_analysis/src/db_index/member/mod.rs index bac24f029..00cb7c6cb 100644 --- a/crates/glua_code_analysis/src/db_index/member/mod.rs +++ b/crates/glua_code_analysis/src/db_index/member/mod.rs @@ -1020,6 +1020,41 @@ impl LuaMemberIndex { global_ids } + /// Every table-literal range in `file_id` that currently owns a member. + /// + /// Used when the file is removed: its literals are all gone, and members + /// other files own on them are not reachable from any file the removal + /// sweep visits. + pub fn element_owner_ranges_in_file(&self, file_id: FileId) -> Vec> { + let mut ranges: Vec> = self + .owner_members + .keys() + .filter_map(|owner| match owner { + LuaMemberOwner::Element(range) if range.file_id == file_id => Some(range.clone()), + _ => None, + }) + .collect(); + ranges.sort_unstable_by_key(|range| (range.value.start(), range.value.end())); + ranges + } + + /// Every table-literal range that currently owns at least one member. + #[cfg(test)] + pub(crate) fn element_owner_ranges(&self) -> Vec> { + let mut ranges: Vec> = self + .owner_members + .iter() + .filter(|(_, members)| !members.is_empty()) + .filter_map(|(owner, _)| match owner { + LuaMemberOwner::Element(range) => Some(range.clone()), + _ => None, + }) + .collect(); + ranges + .sort_unstable_by_key(|range| (range.file_id, range.value.start(), range.value.end())); + ranges + } + pub fn get_members(&self, owner: &LuaMemberOwner) -> Option> { let owner_members = self.owner_members.get(owner)?; if owner_members.get_member_len() == 0 { @@ -1637,7 +1672,7 @@ fn stable_member_sort_key(member: &LuaMember) -> (u32, u32, u32, u16) { // The owner-level sorted member-id cache depends on these file id, position, // range end, and kind components remaining immutable for a member's lifetime. -pub fn member_id_sort_key(member_id: LuaMemberId) -> (u32, u32, u32, u16) { +pub(crate) fn member_id_sort_key(member_id: LuaMemberId) -> (u32, u32, u32, u16) { let syntax_id = member_id.get_syntax_id(); ( member_id.file_id.id, @@ -1656,6 +1691,35 @@ fn sorted_member_pair(first: LuaMemberId, second: LuaMemberId) -> Vec { - if let Some(owner) = self.member_current_owner.get(&member_id).cloned() { - self.remove_member_from_all_owner_key_indexes(&owner, member_id); - self.remove_current_owner_member(&owner, member_id); - self.remove_current_member_key(member_id); - } - self.members.remove(&member_id); - self.member_current_owner.remove(&member_id); - self.non_overwriting_assignment_members.remove(&member_id); - self.conditional_branch_assignment_members - .remove(&member_id); - self.synthesized_owner_members.remove(&member_id); - self.deferred_index_expr_members.remove(&member_id); - self.member_function_scope_ranges.remove(&member_id); - } + MemberOrOwner::Member(member_id) => self.forget_member(member_id), MemberOrOwner::Owner(owner) => { owners.insert(owner); } @@ -1724,6 +1774,18 @@ impl LuaMemberIndex { self.conditional_branch_ranges.remove(&file_id); } + /// Drops an owner entry from every file that registered it. + /// + /// `set_member_owner` files it under the contributing member's file, so a + /// single Element owner can be registered by several files at once. + fn remove_owner_from_all_files(&mut self, owner: &LuaMemberOwner) { + let entry = MemberOrOwner::Owner(owner.clone()); + self.in_filed.retain(|_, set| { + set.remove(&entry); + !set.is_empty() + }); + } + pub fn remap_elements( &mut self, map: &rustc_hash::FxHashMap, crate::InFiled>, @@ -1734,8 +1796,14 @@ impl LuaMemberIndex { // Driven off the map rather than a scan of every member in the // workspace: `owner_members` already indexes members by owner, and the // map holds only the literals one edited file shifted. - let to_move: Vec<(LuaMemberId, LuaMemberOwner, LuaMemberOwner)> = map - .iter() + // Applied in a fixed order. Where one literal moves onto the range + // another is vacating, the resulting state depends on which move ran + // first, and hash-map iteration order is not stable. + let mut moves: Vec<(&crate::InFiled, &crate::InFiled)> = + map.iter().collect(); + moves.sort_unstable_by_key(|(old, _)| (old.file_id, old.value.start(), old.value.end())); + let to_move: Vec<(LuaMemberId, LuaMemberOwner, LuaMemberOwner)> = moves + .into_iter() .flat_map(|(old, new)| { let old_owner = LuaMemberOwner::Element(old.clone()); let new_owner = LuaMemberOwner::Element(new.clone()); @@ -1752,6 +1820,7 @@ impl LuaMemberIndex { }) .collect(); self.assignment_contributions.remap_element_owners(map); + self.assignment_contributions.remap_table_ranges(map); let moved_slots: Vec<( (LuaMemberOwner, LuaMemberKey), (LuaMemberOwner, LuaMemberKey), @@ -1769,42 +1838,52 @@ impl LuaMemberIndex { )) }) .collect(); - for (old_slot, new_slot) in moved_slots { - if let Some(ids) = self.alias_contributed_slots.remove(&old_slot) { - self.alias_contributed_slots - .entry(new_slot) - .or_default() - .extend(ids); - } + // Detached before any is re-filed, for the same reason the + // contribution store is: one literal's new range can be another's old. + let detached_slots: Vec<((LuaMemberOwner, LuaMemberKey), Vec)> = moved_slots + .into_iter() + .filter_map(|(old_slot, new_slot)| { + Some((new_slot, self.alias_contributed_slots.remove(&old_slot)?)) + }) + .collect(); + for (new_slot, ids) in detached_slots { + self.alias_contributed_slots + .entry(new_slot) + .or_default() + .extend(ids); } for (member_id, old_owner, new_owner) in to_move { - // Remove from old owner's structures self.detach_member_from_owner(&old_owner, member_id); - // The detach already removed from owner_members and key indexes. - // Now attach to new owner using the established rehome pattern. self.set_member_owner(new_owner.clone(), member_id.file_id, member_id); self.add_member_to_owner(new_owner, member_id); - // Clean up old tombstone if now empty - if let LuaMemberOwner::Element(old_range) = &old_owner { - if self + if matches!(old_owner, LuaMemberOwner::Element(_)) + && self .owner_members .get(&old_owner) .is_none_or(|m| m.is_empty()) - { - self.owner_members.remove(&old_owner); - if let Some(set) = self.in_filed.get_mut(&old_range.file_id) { - set.remove(&MemberOrOwner::Owner(old_owner.clone())); - if set.is_empty() { - self.in_filed.remove(&old_range.file_id); - } - } - } + { + self.owner_members.remove(&old_owner); + // `set_member_owner` files the owner entry under the *member's* + // file, not the range's. For the cross-file case this pass + // exists for they are different files, and clearing the wrong + // one leaves a dead owner in the member file's set for the next + // sweep to act on. + self.remove_owner_from_all_files(&old_owner); } } } - pub fn remove_deleted_element_owners(&mut self, deleted: &[crate::InFiled]) { + /// Drops the owners for table literals an edit removed, and every member + /// filed under them. + /// + /// Those members can belong to files the edit does not re-index, so + /// nothing else will clean them up. + pub fn remove_deleted_element_owners( + &mut self, + deleted: &[crate::InFiled], + ) -> Vec { + let mut forgotten = Vec::new(); for range in deleted { let owner = LuaMemberOwner::Element(range.clone()); if let Some(member_items) = self.owner_members.remove(&owner) { @@ -1816,30 +1895,18 @@ impl LuaMemberIndex { }) .collect(); for id in member_ids { - self.member_current_owner.remove(&id); - self.remove_member_from_all_owner_key_indexes(&owner, id); - self.remove_current_owner_member(&owner, id); - // The member itself has to go too. Leaving it in - // `members`/`in_filed` makes it reachable by id and by - // file while `get_member_owner` answers `None`, from a - // file no later re-index will visit. - self.members.remove(&id); - if let Some(set) = self.in_filed.get_mut(&id.file_id) { - set.remove(&MemberOrOwner::Member(id)); - if set.is_empty() { - self.in_filed.remove(&id.file_id); - } - } - self.assignment_contributions.remove_member(id); + self.forget_member(id); + forgotten.push(id); } } - if let Some(set) = self.in_filed.get_mut(&range.file_id) { - set.remove(&MemberOrOwner::Owner(owner)); - if set.is_empty() { - self.in_filed.remove(&range.file_id); - } - } - } + self.member_owner_key_index.remove(&owner); + self.member_owner_key_history_index.remove(&owner); + self.current_owner_member_history.remove(&owner); + self.alias_contributed_slots + .retain(|(slot_owner, _), _| slot_owner != &owner); + self.remove_owner_from_all_files(&owner); + } + forgotten } } diff --git a/crates/glua_code_analysis/src/db_index/metatable/mod.rs b/crates/glua_code_analysis/src/db_index/metatable/mod.rs index 9eadd71b4..89f56d104 100644 --- a/crates/glua_code_analysis/src/db_index/metatable/mod.rs +++ b/crates/glua_code_analysis/src/db_index/metatable/mod.rs @@ -40,11 +40,16 @@ impl LuaMetatableIndex { pub fn add(&mut self, table: InFiled, metatable: InFiled) { self.metatables.insert(table, metatable); } + /// Number of recorded `setmetatable` bindings, so a test can show its + /// fixture actually produced one. + #[cfg(test)] + pub fn metatable_count(&self) -> usize { + self.metatables.len() + } pub fn get(&self, table: &InFiled) -> Option<&InFiled> { self.metatables.get(table) } - pub fn add_factory_binding(&mut self, binding: SetmetatableFactoryBinding) { self.factory_bindings .entry(binding.file_id) diff --git a/crates/glua_code_analysis/src/db_index/operators/lua_operator.rs b/crates/glua_code_analysis/src/db_index/operators/lua_operator.rs index 6f956cf66..e22ce7296 100644 --- a/crates/glua_code_analysis/src/db_index/operators/lua_operator.rs +++ b/crates/glua_code_analysis/src/db_index/operators/lua_operator.rs @@ -46,7 +46,6 @@ impl LuaOperator { func, } } - pub fn get_owner(&self) -> &LuaOperatorOwner { &self.owner } diff --git a/crates/glua_code_analysis/src/db_index/operators/mod.rs b/crates/glua_code_analysis/src/db_index/operators/mod.rs index e4ae317ba..761b6f1f2 100644 --- a/crates/glua_code_analysis/src/db_index/operators/mod.rs +++ b/crates/glua_code_analysis/src/db_index/operators/mod.rs @@ -59,6 +59,14 @@ impl LuaOperatorIndex { .and_then(|map| map.get(&meta_method)) } + /// Every metamethod this file declares. Another file's inference reads + /// them whenever it applies an operator to the owning type. + pub fn operators_in_file(&self, file_id: FileId) -> Vec<&LuaOperator> { + self.in_filed_operator_map + .get(&file_id) + .map(|ids| ids.iter().filter_map(|id| self.get_operator(id)).collect()) + .unwrap_or_default() + } pub fn get_operator(&self, id: &LuaOperatorId) -> Option<&LuaOperator> { self.operators.get(id) } diff --git a/crates/glua_code_analysis/src/db_index/property/mod.rs b/crates/glua_code_analysis/src/db_index/property/mod.rs index 7170c08f2..1e6f8ce1d 100644 --- a/crates/glua_code_analysis/src/db_index/property/mod.rs +++ b/crates/glua_code_analysis/src/db_index/property/mod.rs @@ -304,6 +304,20 @@ impl LuaPropertyIndex { Some(()) } + /// Every documented symbol this file declares, with its property. + pub fn properties_in_file( + &self, + file_id: FileId, + ) -> Vec<(&LuaSemanticDeclId, &LuaCommonProperty)> { + let Some(owners) = self.in_filed_owner.get(&file_id) else { + return Vec::new(); + }; + owners + .iter() + .filter_map(|owner| Some((owner, self.get_property(owner)?))) + .collect() + } + pub fn get_property(&self, owner_id: &LuaSemanticDeclId) -> Option<&LuaCommonProperty> { self.property_owners_map .get(owner_id) diff --git a/crates/glua_code_analysis/src/db_index/signature/signature.rs b/crates/glua_code_analysis/src/db_index/signature/signature.rs index a5fd5e0c3..a38368b24 100644 --- a/crates/glua_code_analysis/src/db_index/signature/signature.rs +++ b/crates/glua_code_analysis/src/db_index/signature/signature.rs @@ -112,6 +112,10 @@ impl LuaSignature { } } + pub fn return_correlations(&self) -> &[LuaReturnCorrelation] { + &self.return_correlations + } + pub fn set_return_correlations(&mut self, correlations: Vec) { self.return_correlations = correlations; } diff --git a/crates/glua_code_analysis/src/db_index/type/mod.rs b/crates/glua_code_analysis/src/db_index/type/mod.rs index 3be33d10b..91767de60 100644 --- a/crates/glua_code_analysis/src/db_index/type/mod.rs +++ b/crates/glua_code_analysis/src/db_index/type/mod.rs @@ -187,7 +187,22 @@ fn replace_table_consts_in_type( } } -fn remap_table_ranges_in_type( +/// Every table-literal range a type names, directly or nested. +/// +/// The mirror of [`remap_table_ranges_in_type`]: a test can use it to assert +/// that no store was left holding a range the remap should have moved. +#[cfg(test)] +pub(crate) fn table_ranges_in_type(typ: &LuaType) -> Vec> { + let mut ranges = Vec::new(); + TypeVisitTrait::visit_type(typ, &mut |inner| match inner { + LuaType::TableConst(range) => ranges.push(range.clone()), + LuaType::Instance(instance) => ranges.push(instance.get_range().clone()), + _ => {} + }); + ranges +} + +pub(crate) fn remap_table_ranges_in_type( typ: &LuaType, map: &rustc_hash::FxHashMap, InFiled>, ) -> Option { @@ -470,6 +485,8 @@ fn remap_table_ranges_in_type( None } } + // The remaining variants nest no type that can carry a table literal's + // range, so there is nothing under them to move. _ => None, } } @@ -1605,6 +1622,28 @@ impl LuaTypeIndex { self.rebuild_inference_derived_state(&changed_files); } + /// Drops the cached types of members that were removed on their own, + /// rather than as part of a file sweep. + /// + /// A member whose owning table literal is gone leaves a cache entry no + /// re-index will reach, because the file it belongs to is not being + /// re-analysed. + pub fn remove_member_type_caches(&mut self, member_ids: &[crate::LuaMemberId]) { + for member_id in member_ids { + let owner = LuaTypeOwner::Member(*member_id); + if let Some(set) = self.in_filed_type_owner.get_mut(&member_id.file_id) { + set.remove(&owner); + } + // `cache_refs` has to come off with the cache, the way + // `insert_type_cache` and the file sweep both keep them in step. + if let Some(previous) = self.types.remove(&owner) { + self.cache_refs + .remove(member_id.file_id, previous.as_type()); + } + self.fact_metadata.remove(&owner); + } + } + pub fn remap_table_const( &mut self, map: &rustc_hash::FxHashMap, InFiled>, @@ -1638,6 +1677,9 @@ impl LuaTypeIndex { updates.push((owner.clone(), new_cache)); } } + // `candidate_owners` comes out of a hash set, so the writes are ordered + // before they are applied. + updates.sort_unstable_by_key(|(owner, _)| format!("{:?}", owner)); let mut changed_files = HashSet::default(); for (owner, new_cache) in updates { changed_files.insert(owner.get_file_id()); diff --git a/crates/glua_code_analysis/src/db_index/type/type_decl.rs b/crates/glua_code_analysis/src/db_index/type/type_decl.rs index 144663106..bbe00c21a 100644 --- a/crates/glua_code_analysis/src/db_index/type/type_decl.rs +++ b/crates/glua_code_analysis/src/db_index/type/type_decl.rs @@ -99,6 +99,38 @@ impl LuaTypeDecl { matches!(self.extra, LuaTypeExtra::Attribute { .. }) } + /// The declaration's kind plus the flags each of its locations carries. + /// + /// Both live only on the declaration - they touch no member, signature or + /// type cache - yet `(exact)` decides whether another file's write creates + /// a member on this type, and `(partial)`/`(private)` gate diagnostics that + /// other files report. + pub fn kind_and_flags(&self) -> (LuaDeclTypeKind, Vec<(FileId, u8)>) { + let kind = match &self.extra { + LuaTypeExtra::Enum { .. } => LuaDeclTypeKind::Enum, + LuaTypeExtra::Class => LuaDeclTypeKind::Class, + LuaTypeExtra::Alias { .. } => LuaDeclTypeKind::Alias, + LuaTypeExtra::Attribute { .. } => LuaDeclTypeKind::Attribute, + }; + let mut flags: Vec<(FileId, u8)> = self + .locations + .iter() + .map(|location| (location.file_id, location.flag.bits())) + .collect(); + flags.sort_unstable(); + (kind, flags) + } + + /// The enum's base type and flatness, or the attribute's type. `None` for + /// a class; an alias's origin has its own accessor. + pub fn extra_type(&self) -> (Option<&LuaType>, bool) { + match &self.extra { + LuaTypeExtra::Enum { base, flat } => (base.as_ref(), *flat), + LuaTypeExtra::Attribute { typ } => (typ.as_ref(), false), + LuaTypeExtra::Class | LuaTypeExtra::Alias { .. } => (None, false), + } + } + pub fn is_exact(&self) -> bool { self.locations .iter() diff --git a/crates/glua_code_analysis/src/diagnostic/test/incremental_edit_test.rs b/crates/glua_code_analysis/src/diagnostic/test/incremental_edit_test.rs index 8456e8f7c..aef9a8018 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/incremental_edit_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/incremental_edit_test.rs @@ -127,12 +127,12 @@ mod tests { } /// The fast path exists for this: an edit that shifts every offset below it - /// but changes nothing another file can read must not invalidate dependents. - /// Hashing any position-derived identity breaks it for every edit that is - /// not at the end of the file. + /// but changes nothing another file can read must not invalidate + /// dependents. Hashing any position-derived identity breaks it for every + /// edit that is not at the end of the file. #[gtest] fn comment_edit_above_a_declaration_keeps_dependents_settled() { - let mut ws = workspace_with(vec![DiagnosticCode::ParamTypeMismatch]); + let mut ws = workspace_with(vec![DiagnosticCode::AssignTypeMismatch]); let provider_uri = ws.virtual_url_generator.new_uri("lua/values.lua"); write( &mut ws, @@ -149,12 +149,18 @@ mod tests { &mut ws, &consumer_uri, r#" - ---@param count number - local function takesNumber(count) end - takesNumber(values.Count) + ---@type string + local wrong = values.Count "#, ); + // A baseline of "no diagnostics" would let the assertion below pass + // with the whole cross-file read broken, so the consumer reports one + // that only survives while that read still resolves. let before = codes_in(&ws, consumer_id); + expect_that!( + before, + contains(eq(DiagnosticCode::AssignTypeMismatch.get_name())) + ); write( &mut ws, @@ -170,56 +176,78 @@ mod tests { } /// Two table literals in two different call argument lists sit at the same - /// index of the same parent kind. An anchor built from the parent's kind - /// alone collides, and a collision drops both from the remap, leaving their - /// members owned by a range the edit already moved. + /// index of the same parent kind, and inserting another shifts every later + /// ordinal. Either mistake re-homes one literal's members onto the other's + /// range, so the anchor has to identify the literal, not just its position. #[gtest] - fn sibling_call_argument_tables_survive_an_offset_shift() { - let mut ws = workspace_with(vec![DiagnosticCode::UndefinedField]); - let provider_uri = ws.virtual_url_generator.new_uri("lua/register.lua"); - write( - &mut ws, - &provider_uri, - r#" - registry = registry or {} + fn sibling_call_argument_tables_keep_their_own_ranges() { + let register = |extra: &str| { + format!( + r#" + registry = registry or {{}} ---@param name string ---@param spec table function registry.Add(name, spec) end +{extra} + registry.Add("first", {{ alpha = 1 }}) + registry.Add("second", {{ beta = 2 }}) + "# + ) + }; - registry.Add("first", { alpha = 1 }) - registry.Add("second", { beta = 2 }) - "#, - ); + let mut ws = workspace_with(vec![]); + let uri = ws.virtual_url_generator.new_uri("lua/register.lua"); + let file_id = write(&mut ws, &uri, ®ister("")); - let consumer_uri = ws.virtual_url_generator.new_uri("lua/consume.lua"); - let consumer_id = write( - &mut ws, - &consumer_uri, - r#" - local one = { alpha = 1 } - local two = { beta = 2 } - local _ = one.alpha - local _ = two.beta - "#, - ); - let before = codes_in(&ws, consumer_id); + // The anchor whose literal declares `alpha`, found by reading the text + // its range covers. + fn anchor_for( + ws: &VirtualWorkspace, + file_id: FileId, + field: &str, + ) -> Option { + let db = ws.analysis.compilation.get_db(); + let text = db.get_vfs().get_file_content(&file_id)?.clone(); + crate::collect_anchored_map(db, file_id) + .into_iter() + .find(|(_, range)| text[range.value].contains(field)) + .map(|(anchor, _)| anchor) + } - write( - &mut ws, - &provider_uri, - r#" - -- a comment that shifts every offset below it - registry = registry or {} - ---@param name string - ---@param spec table - function registry.Add(name, spec) end + fn text_at(ws: &VirtualWorkspace, file_id: FileId, anchor: &crate::TableAnchor) -> String { + let db = ws.analysis.compilation.get_db(); + let text = db + .get_vfs() + .get_file_content(&file_id) + .expect("file content") + .clone(); + let range = crate::collect_anchored_map(db, file_id) + .get(anchor) + .expect("anchor still resolves") + .value; + text[range].to_string() + } - registry.Add("first", { alpha = 1 }) - registry.Add("second", { beta = 2 }) - "#, + let alpha_anchor = anchor_for(&ws, file_id, "alpha").expect("alpha literal"); + let beta_anchor = anchor_for(&ws, file_id, "beta").expect("beta literal"); + expect_that!(alpha_anchor, not(eq(&beta_anchor))); + + // Insert a third registration above the pair. A positional anchor + // renumbers here and maps alpha's members onto the new literal. + let file_id = write( + &mut ws, + &uri, + ®ister(" registry.Add(\"zeroth\", { zeta = 0 })"), ); - expect_that!(codes_in(&ws, consumer_id), eq(&before)); + expect_that!( + text_at(&ws, file_id, &alpha_anchor), + contains_substring("alpha") + ); + expect_that!( + text_at(&ws, file_id, &beta_anchor), + contains_substring("beta") + ); } /// Deleting a file has no new text to fingerprint. Comparing fingerprints @@ -259,6 +287,32 @@ mod tests { ); } + /// Every `LuaType::Signature` reachable from a stored type cache, whose id + /// the signature index no longer holds. + fn dangling_signature_references(ws: &VirtualWorkspace) -> Vec { + let db = ws.analysis.compilation.get_db(); + let mut dangling = Vec::new(); + for file_id in db.get_vfs().get_all_file_ids() { + let Some(owners) = db.get_type_index().file_type_owners(file_id) else { + continue; + }; + for owner in owners.iter() { + let Some(cache) = db.get_type_index().get_type_cache(owner) else { + continue; + }; + crate::db_index::TypeVisitTrait::visit_type(cache.as_type(), &mut |inner| { + if let crate::LuaType::Signature(id) = inner + && db.get_signature_index().get(id).is_none() + { + dangling.push(format!("{owner:?} -> {id:?}")); + } + }); + } + } + dangling.sort(); + dangling + } + /// The fingerprint decides whether an edit ripples to dependents. These /// exercise it directly: a diagnostic-level assertion on a two-file /// workspace can be satisfied by an unrelated re-analysis, so it does not @@ -416,4 +470,1450 @@ mod tests { ); expect_that!(after, eq(before)); } + + /// Every anchor must survive an edit that shifts the file, and must still + /// name the same literal afterwards. An anchor that resolves to a + /// *different* literal is worse than one that stops resolving: the remap + /// then re-homes members onto the wrong table instead of leaving them. + #[gtest] + fn every_anchor_keeps_naming_its_own_literal_across_an_offset_shift() { + let body = r#" + local Reg = {} + Reg.Existing = 1 + Registry = Reg + + Direct = { alpha = 1 } + + local Nested = {} + Nested.inner = { beta = 2 } + + local Config = { section = { epsilon = 5 } } + + register("first", { gamma = 3 }) + register("second", { delta = 4 }) + "#; + + let mut ws = workspace_with(vec![]); + let uri = ws.virtual_url_generator.new_uri("lua/anchors.lua"); + + fn anchored_text( + ws: &VirtualWorkspace, + file_id: FileId, + ) -> std::collections::BTreeMap { + let db = ws.analysis.compilation.get_db(); + let text = db + .get_vfs() + .get_file_content(&file_id) + .expect("file content") + .clone(); + crate::collect_anchored_map(db, file_id) + .into_iter() + .map(|(anchor, range)| { + ( + format!("{anchor:?}"), + text[range.value] + .split_whitespace() + .collect::>() + .join(" "), + ) + }) + .collect() + } + + let file_id = write(&mut ws, &uri, body); + let before = anchored_text(&ws, file_id); + expect_that!(before.len(), gt(5)); + + let file_id = write( + &mut ws, + &uri, + &format!( + "-- a comment that shifts every offset below it +{body}" + ), + ); + + // Same anchors, and each still covering the same source text. + expect_that!(anchored_text(&ws, file_id), eq(&before)); + } + + /// A dependent caches `Signature(file, position)`. Moving the function + /// changes that position, but the signature's own shape is what the + /// fingerprint reads, so nothing ripples. If nothing re-homes the id, the + /// dependent is left naming a signature the index no longer holds. + #[gtest] + fn moving_a_function_leaves_no_dangling_signature_reference() { + let mut ws = workspace_with(vec![]); + let provider_uri = ws.virtual_url_generator.new_uri("lua/provider.lua"); + let provider = r#" + provider = provider or {} + ---@return string + function provider.Describe() end + "#; + write(&mut ws, &provider_uri, provider); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/alias.lua"); + write( + &mut ws, + &consumer_uri, + r#" + local describe = provider.Describe + local described = describe() + "#, + ); + + let provider_id = ws + .analysis + .compilation + .get_db() + .get_vfs() + .get_file_id(&provider_uri) + .expect("provider file id"); + let before_fingerprint = fingerprint_of(&ws, provider_id); + + write( + &mut ws, + &provider_uri, + &format!( + "-- a comment that moves the function below it +{provider}" + ), + ); + + // Without this the test is vacuous: a changed fingerprint pays the + // ripple, which re-derives the dependent's cache anyway. + expect_that!(fingerprint_of(&ws, provider_id), eq(before_fingerprint)); + expect_that!(dangling_signature_references(&ws), is_empty()); + } + + /// A call argument is the only evidence an unannotated parameter in + /// another file has, and an argument edit changes no member, type decl or + /// signature in the editing file. Without a call-site section the + /// fingerprint calls it local and the callee keeps the type the previous + /// argument gave it. + #[gtest] + fn fingerprint_moves_when_a_call_site_changes_an_inferred_receiver() { + let consumer = |argument: &str| { + format!( + r#" + local PANEL = {{}} + local OTHER = {{}} + function PANEL:ProvidedByReceiver() end + function OTHER:SomethingElse() end + function PANEL:Load() + self.Mixin = include("mixins/shared.lua") + end + function PANEL:Dispatch(name) + local callback = self.Mixin[name] + callback({argument}) + end + "# + ) + }; + + let mut ws = workspace_with(vec![]); + ws.def_file( + "lua/mixins/shared.lua", + r#" + local MIXIN = {} + function MIXIN.Run(self) + self:ProvidedByReceiver() + end + return MIXIN + "#, + ); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/autorun/consumer.lua"); + let consumer_id = write(&mut ws, &consumer_uri, &consumer("self")); + let before = fingerprint_of(&ws, consumer_id); + + write(&mut ws, &consumer_uri, &consumer("OTHER")); + + expect_that!(fingerprint_of(&ws, consumer_id), not(eq(before))); + } + + /// Adding an `include` changes which files load this one and in what + /// order, which realm and load-order analysis both read. It moves no + /// member, type or signature in the editing file. + #[gtest] + fn fingerprint_moves_when_a_load_edge_is_added() { + let mut ws = workspace_with(vec![]); + ws.def_file( + "lua/shared/helper.lua", + r#" + helper = helper or {} + function helper.Run() end + "#, + ); + + let loader_uri = ws.virtual_url_generator.new_uri("lua/autorun/loader.lua"); + let loader_id = write( + &mut ws, + &loader_uri, + r#" + local ready = true + "#, + ); + let before = fingerprint_of(&ws, loader_id); + + write( + &mut ws, + &loader_uri, + r#" + include("shared/helper.lua") + local ready = true + "#, + ); + + expect_that!(fingerprint_of(&ws, loader_id), not(eq(before))); + } + + /// A `@deprecated` on an exported symbol changes the diagnostics every + /// call site in every other file reports. + #[gtest] + fn fingerprint_moves_when_an_annotation_other_files_act_on_changes() { + let (before, after) = fingerprint_after_edit( + r#" + provider = provider or {} + function provider.Doc() end + "#, + r#" + provider = provider or {} + ---@deprecated + function provider.Doc() end + "#, + ); + expect_that!(after, not(eq(before))); + } + + /// A description is read from this file's index when a hover in another + /// file asks for it, so no dependent caches one. Rippling a hub file for + /// prose nothing stores would cost seconds for nothing. + #[gtest] + fn fingerprint_holds_across_a_description_edit() { + let (before, after) = fingerprint_after_edit( + r#" + provider = provider or {} + --- first description + function provider.Doc() end + "#, + r#" + provider = provider or {} + --- second description, at greater length + function provider.Doc() end + "#, + ); + expect_that!(after, eq(before)); + } + + /// A metamethod is read by any file that applies the operator to the + /// owning type. + #[gtest] + fn fingerprint_moves_when_an_operator_is_declared() { + let (before, after) = fingerprint_after_edit( + r#" + ---@class Vec + Vec = {} + "#, + r#" + ---@class Vec + ---@operator add(Vec): Vec + Vec = {} + "#, + ); + expect_that!(after, not(eq(before))); + } + + /// Network diagnostics compare a message's writes against its reads across + /// files, so changing either half is an export change. + #[gtest] + fn fingerprint_moves_when_a_net_write_changes() { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + // Net ops are recognised through signature metadata, so the annotated + // builtins have to be present or no flows are collected at all. + ws.def_gmod_call_arg_builtins(); + + let uri = ws + .virtual_url_generator + .new_uri("lua/autorun/client/sender.lua"); + let file_id = write( + &mut ws, + &uri, + r#" + net.Start("Msg") + net.WriteString("payload") + net.SendToServer() + "#, + ); + expect_that!( + ws.analysis + .compilation + .get_db() + .get_gmod_network_index() + .get_file_data(file_id) + .map(|data| data.send_flows.len()), + some(gt(0)) + ); + let before = fingerprint_of(&ws, file_id); + + let file_id = write( + &mut ws, + &uri, + r#" + net.Start("Msg") + net.WriteInt(1, 8) + net.SendToServer() + "#, + ); + + expect_that!(fingerprint_of(&ws, file_id), not(eq(before))); + } + + /// Realm is first-class. Wrapping an existing definition in `if SERVER` + /// changes which callers may reach it and which realm-mismatch + /// diagnostics other files report, while leaving its name, type and + /// signature alone. + #[gtest] + fn fingerprint_moves_when_a_declaration_changes_realm() { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + ws.def_gmod_call_arg_builtins(); + + let uri = ws.virtual_url_generator.new_uri("lua/autorun/subject.lua"); + let file_id = write( + &mut ws, + &uri, + "function Shared() end +", + ); + let before = fingerprint_of(&ws, file_id); + + let file_id = write( + &mut ws, + &uri, + "if SERVER then +function Shared() end +end +", + ); + + expect_that!(fingerprint_of(&ws, file_id), not(eq(before))); + } + + /// Repointing an exported alias at a different function in the same file + /// changes no member key, no owner and no signature shape. Only which + /// signature the alias names moves, so an identity that keeps just the + /// file cannot see it. + #[gtest] + fn fingerprint_moves_when_an_export_is_repointed_at_another_function() { + let (before, after) = fingerprint_after_edit( + r#" + provider = provider or {} + ---@return string + function provider.A() end + ---@return number + function provider.B() end + provider.Dispatch = provider.A + "#, + r#" + provider = provider or {} + ---@return string + function provider.A() end + ---@return number + function provider.B() end + provider.Dispatch = provider.B + "#, + ); + expect_that!(after, not(eq(before))); + } + + /// The same for a table literal: the export names a different literal in + /// the same file, and nothing else about the file changes. + #[gtest] + fn fingerprint_moves_when_an_export_is_repointed_at_another_table() { + let (before, after) = fingerprint_after_edit( + r#" + local first = { alpha = 1 } + local second = { beta = 2 } + Exported = first + _ = second + "#, + r#" + local first = { alpha = 1 } + local second = { beta = 2 } + Exported = second + _ = first + "#, + ); + expect_that!(after, not(eq(before))); + } + + /// The property the whole fast path rests on, swept across every shape the + /// fingerprint reads: an edit that only shifts byte offsets must not move + /// it. A source position reaching the hash through any section - often via + /// `Debug` on a struct that embeds a range - defeats the optimisation for + /// every edit that is not at the end of a file. + #[gtest] + fn fingerprint_holds_across_an_offset_shift_for_every_hashed_shape() { + let bodies: Vec<(&str, &str)> = vec![ + ( + "global number", + "A = 1 +", + ), + ( + "global string", + "B = \"two\" +", + ), + ( + "global function", + "function C() end +", + ), + ( + "annotated function", + "P = P or {} +---@param x string +---@return integer +function P.F(x) end +", + ), + ( + "class and field", + "---@class K +---@field a string +K = {} +", + ), + ( + "alias", + "---@alias M string +", + ), + ( + "enum", + "---@enum E +E = { X = 1 } +", + ), + ( + "operator", + "---@class V +---@operator add(V): V +V = {} +", + ), + ( + "metatable operator", + "Obj = setmetatable({ v = 1 }, { __add = function(a, b) return a end }) +", + ), + ( + "table literals", + "T = { a = 1, b = { c = 2 } } +local L = { d = 3 } +U = L +", + ), + ( + "realm branches", + "if SERVER then +S = 1 +else +S = 2 +end +", + ), + ( + "vgui panel", + "local PANEL = {} +AccessorFunc(PANEL, \"m_a\", \"A\") +vgui.Register(\"W\", PANEL, \"Panel\") +", + ), + ( + "include", + "include(\"shared/other.lua\") +R = 1 +", + ), + ( + "local function", + "local function helper() + return 1 +end +G = helper() +", + ), + ( + "deprecated", + "P = P or {} +---@deprecated +function P.Old() end +", + ), + ]; + + // A net flow needs a realm path and the annotated builtins, so it gets + // its own fixture below rather than a shared one that would silently + // record no flows and make the sweep vacuous for it. + let mut moved: Vec<&str> = Vec::new(); + for (name, body) in bodies { + // Both a comment and a blank line: a comment directly above a + // declaration also becomes its doc comment, which must not count + // as an export change either. + for prefix in [ + "-- padding above everything +", + " +", + ] { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + ws.def_gmod_call_arg_builtins(); + ws.def_file( + "lua/shared/other.lua", + "other = 1 +", + ); + let uri = ws.virtual_url_generator.new_uri("lua/autorun/subject.lua"); + let file_id = write(&mut ws, &uri, body); + let before = fingerprint_of(&ws, file_id); + let file_id = write(&mut ws, &uri, &format!("{prefix}{body}")); + if fingerprint_of(&ws, file_id) != before { + moved.push(name); + } + } + } + + expect_that!(moved, is_empty()); + } + + /// The net-flow half of the sweep. Kept separate because it only records + /// flows on a realm path with the annotated builtins loaded, and a fixture + /// that records none would pass whatever the network section hashed. + #[gtest] + fn fingerprint_holds_across_an_offset_shift_for_network_flows() { + let body = |note: &str| { + format!( + "-- {note} +net.Start(\"M\") +net.WriteString(\"x\") +net.SendToServer() +" + ) + }; + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + ws.def_gmod_call_arg_builtins(); + + let uri = ws + .virtual_url_generator + .new_uri("lua/autorun/client/sender.lua"); + let file_id = write(&mut ws, &uri, &body("note")); + expect_that!( + ws.analysis + .compilation + .get_db() + .get_gmod_network_index() + .get_file_data(file_id) + .map(|data| data.send_flows.len()), + some(gt(0)) + ); + let before = fingerprint_of(&ws, file_id); + + let file_id = write(&mut ws, &uri, &body("note, rewritten at greater length")); + + expect_that!(fingerprint_of(&ws, file_id), eq(before)); + } + + /// The editor writes the text first and re-indexes later, so by the time + /// the ripple decision is made the VFS already holds the new tree while the + /// index still holds the old entries. A fingerprint taken at that moment + /// compares the old index against the new tree and moves for any edit that + /// shifts a table literal - which is most files. + #[gtest] + fn the_editors_write_then_index_sequence_keeps_a_shifted_file_settled() { + let body = |note: &str| { + format!( + r#" + -- {note} + MYLIB = MYLIB or {{}} + MYLIB.Config = {{ enabled = true }} + function MYLIB.Run() end + "# + ) + }; + + let mut ws = workspace_with(vec![]); + let uri = ws.virtual_url_generator.new_uri("lua/autorun/mylib.lua"); + let file_id = write(&mut ws, &uri, &body("note")); + + // Exactly what the editor does: text first, index after. + ws.analysis + .update_file_text_only(&uri, body("note, rewritten at greater length")); + let (changed, expansion) = ws + .analysis + .self_index_files_and_get_ripple_with_changed(vec![file_id]); + + expect_that!(changed, is_empty()); + expect_that!(expansion, is_empty()); + } + + /// The same sequence, for an edit that does change an export: it has to + /// still report the ripple. + #[gtest] + fn the_editors_write_then_index_sequence_still_reports_a_real_change() { + let mut ws = workspace_with(vec![]); + let uri = ws.virtual_url_generator.new_uri("lua/autorun/mylib.lua"); + let file_id = write( + &mut ws, + &uri, + r#" + MYLIB = MYLIB or {} + MYLIB.Mode = "server" + "#, + ); + + ws.analysis.update_file_text_only( + &uri, + r#" + MYLIB = MYLIB or {} + MYLIB.Mode = "client" + "# + .to_string(), + ); + let (changed, _) = ws + .analysis + .self_index_files_and_get_ripple_with_changed(vec![file_id]); + + expect_that!(changed, contains(eq(&file_id))); + } + + /// Two literals with the *same* field names, and a literal with no fields + /// at all, cannot be told apart by their fields. Neither may fall back to + /// a sibling ordinal: inserting a third registration renumbers those, and + /// the old anchor would then resolve to a different literal and re-home its + /// members onto the wrong table. + #[gtest] + fn indistinguishable_literals_are_never_remapped_onto_each_other() { + let source = |extra: &str| { + format!( + r#" + registry = registry or {{}} + ---@param name string + ---@param spec table + function registry.Add(name, spec) end +{extra} + registry.Add("first", {{ name = "a" }}) + registry.Add("second", {{ name = "b" }}) + registry.Add("third", {{}}) + "# + ) + }; + + let mut ws = workspace_with(vec![]); + let uri = ws.virtual_url_generator.new_uri("lua/registry.lua"); + let file_id = write(&mut ws, &uri, &source("")); + + let text_by_anchor = |ws: &VirtualWorkspace, file_id: FileId| { + let db = ws.analysis.compilation.get_db(); + let text = db + .get_vfs() + .get_file_content(&file_id) + .expect("file content") + .clone(); + crate::collect_anchored_map(db, file_id) + .into_iter() + .map(|(anchor, range)| (format!("{anchor:?}"), text[range.value].to_string())) + .collect::>() + }; + + let before = text_by_anchor(&ws, file_id); + let file_id = write( + &mut ws, + &uri, + &source(" registry.Add(\"zeroth\", { name = \"z\" })"), + ); + let after = text_by_anchor(&ws, file_id); + + // Any anchor that survives the insertion must still cover the same + // literal. An anchor that cannot promise that must not be emitted. + let survivors: Vec<&String> = before.keys().filter(|a| after.contains_key(*a)).collect(); + // Without this the loop below would be satisfied by nothing surviving. + expect_that!(survivors.len(), gt(0)); + for (anchor, text) in &before { + if let Some(after_text) = after.get(anchor) { + expect_that!(after_text, eq(text), "anchor {anchor} moved literal"); + } + } + } + + /// An alias is read by name and resolved to its target, so changing the + /// target changes what every file that names it infers. The alias body + /// lives on the type declaration, not among its supertypes. + #[gtest] + fn fingerprint_moves_when_an_alias_target_changes() { + let (before, after) = fingerprint_after_edit( + r#" + ---@alias Mode string + "#, + r#" + ---@alias Mode integer + "#, + ); + expect_that!(after, not(eq(before))); + } + + /// A supertype is part of what a dependent resolves through the class. + #[gtest] + fn fingerprint_moves_when_a_supertype_is_added() { + let (before, after) = fingerprint_after_edit( + r#" + ---@class Base + Base = {} + ---@class Derived + Derived = {} + "#, + r#" + ---@class Base + Base = {} + ---@class Derived : Base + Derived = {} + "#, + ); + expect_that!(after, not(eq(before))); + } + + /// A namespace changes how every name in the file resolves for a + /// dependent, without moving any member or signature. + #[gtest] + fn fingerprint_moves_when_a_namespace_is_declared() { + let (before, after) = fingerprint_after_edit( + r#" + ---@class Thing + Thing = {} + "#, + r#" + ---@namespace Shared + ---@class Thing + Thing = {} + "#, + ); + expect_that!(after, not(eq(before))); + } + + /// A writer's own evidence, not the merged result: whether the write was + /// guarded decides how the widening merge treats it, and the merge runs + /// for every file that contributes to the same slot. + #[gtest] + fn fingerprint_moves_when_a_writers_guard_changes() { + let (before, after) = fingerprint_after_edit( + r#" + config = config or {} + config.Values = {} + "#, + r#" + config = config or {} + config.Values = config.Values or {} + "#, + ); + expect_that!(after, not(eq(before))); + } + + /// The end-to-end invariant the remap exists for: no member may be left + /// owned by a range that is no longer a table literal. + /// + /// A dependent's members are not re-derived by the edited file's + /// re-index, so if the remap does not move them they point into the wrong + /// text. Repeated edits matter: a stash that is never consumed makes the + /// remap a no-op from the second edit onwards. + #[gtest] + fn no_member_is_left_owned_by_a_range_that_is_no_longer_a_literal() { + let provider = |note: &str| { + format!( + r#" + -- {note} + Registry = {{ existing = 1 }} + Extra = {{ other = 2 }} + "# + ) + }; + + let mut ws = workspace_with(vec![]); + let provider_uri = ws.virtual_url_generator.new_uri("lua/registry.lua"); + write(&mut ws, &provider_uri, &provider("note")); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/adds_handler.lua"); + write( + &mut ws, + &consumer_uri, + r#" + Registry.Handlers = {} + Extra.More = {} + "#, + ); + + /// Element owners that no table literal in the current text occupies. + fn orphaned_owners(ws: &VirtualWorkspace) -> Vec { + let db = ws.analysis.compilation.get_db(); + let mut live: std::collections::HashSet> = + std::collections::HashSet::new(); + for file_id in db.get_vfs().get_all_file_ids() { + let Some(tree) = db.get_vfs().get_syntax_tree(&file_id) else { + continue; + }; + for table in glua_parser::LuaAstNode::descendants::( + &tree.get_chunk_node(), + ) { + live.insert(crate::InFiled::new( + file_id, + glua_parser::LuaAstNode::get_range(&table), + )); + } + } + db.get_member_index() + .element_owner_ranges() + .into_iter() + .filter(|range| !live.contains(range)) + .map(|range| format!("{range:?}")) + .collect() + } + + expect_that!(orphaned_owners(&ws), is_empty()); + + for note in ["note, rewritten once", "note, rewritten a second time"] { + write(&mut ws, &provider_uri, &provider(note)); + expect_that!(orphaned_owners(&ws), is_empty(), "after edit: {note}"); + } + } + + /// `AccessorFunc` synthesizes getter and setter members on the owning + /// class, which any file can then call. They are synthesized into this + /// file's member set, so the members section is what carries them - there + /// is no separate call-index section to keep in step. + #[gtest] + fn fingerprint_moves_when_an_accessor_func_is_renamed() { + let panel = |accessor: &str| { + format!( + "local PANEL = {{}} +AccessorFunc(PANEL, \"m_name\", \"{accessor}\") +vgui.Register(\"MyPanel\", PANEL, \"Panel\") +" + ) + }; + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + ws.def_gmod_call_arg_builtins(); + + let uri = ws.virtual_url_generator.new_uri("lua/autorun/panel.lua"); + let file_id = write(&mut ws, &uri, &panel("Name")); + // The synthesized accessors have to actually be there, or the + // assertion below would hold for a file that declares nothing. + let member_keys: Vec = ws + .analysis + .compilation + .get_db() + .get_member_index() + .get_file_members(file_id) + .iter() + .map(|member| format!("{:?}", member.get_key())) + .collect(); + expect_that!(member_keys, contains(contains_substring("GetName"))); + let before = fingerprint_of(&ws, file_id); + + let file_id = write(&mut ws, &uri, &panel("Title")); + + expect_that!(fingerprint_of(&ws, file_id), not(eq(before))); + } + + /// A `setmetatable` binding is read by every file that resolves a member + /// through the table. Repointing it at a different literal in the same + /// file moves no member, type or signature. + #[gtest] + fn fingerprint_moves_when_a_metatable_binding_is_repointed() { + let source = |metatable: &str| { + format!( + r#" + local mtA = {{ alpha = 1 }} + local mtB = {{ beta = 2 }} + _ = mtA + _ = mtB + Foo = setmetatable({{}}, {metatable}) + "# + ) + }; + + let mut ws = workspace_with(vec![]); + let uri = ws.virtual_url_generator.new_uri("lua/meta.lua"); + let file_id = write(&mut ws, &uri, &source("mtA")); + // Without a recorded binding the assertion below would hold whatever + // the section hashed. + expect_that!( + ws.analysis + .compilation + .get_db() + .get_metatable_index() + .metatable_count(), + gt(0) + ); + let before = fingerprint_of(&ws, file_id); + + let file_id = write(&mut ws, &uri, &source("mtB")); + + expect_that!(fingerprint_of(&ws, file_id), not(eq(before))); + } + + /// A `@field` default gates whether that field counts as required, and the + /// missing-field diagnostic is reported by the file that builds the table. + #[gtest] + fn fingerprint_moves_when_a_field_default_is_added() { + let (before, after) = fingerprint_after_edit( + r#" + ---@class Config + ---@field timeout number + Config = {} + "#, + r#" + ---@class Config + ---@field timeout number + ---@field retries? number + Config = {} + "#, + ); + expect_that!(after, not(eq(before))); + } + + /// A guard inferred from a function body narrows the parameter for every + /// caller, in any file. A file that already has one takes the full path, + /// so the case the fingerprint has to catch is a file whose guard changes + /// what it narrows to. + #[gtest] + fn fingerprint_moves_when_an_inferred_guard_narrows_differently() { + let mut ws = workspace_with(vec![]); + ws.def_file( + "lua/shared/entity_meta.lua", + r#" + ---@class Entity + ---@class NULL: Entity + ---@class Player: Entity + ---@class NPC: Entity + ---@param value any + ---@return TypeGuard + ---@return_cast value -NULL + function IsValid(value) end + ---@return boolean + ---@return_cast self Player + function Entity:IsPlayer() end + ---@return boolean + ---@return_cast self NPC + function Entity:IsNPC() end + "#, + ); + + fn guard_count(ws: &VirtualWorkspace, file_id: FileId) -> usize { + ws.analysis + .compilation + .get_db() + .get_signature_index() + .inferred_guard_facts_for_files(&std::collections::HashSet::from([file_id])) + .len() + } + + let uri = ws.virtual_url_generator.new_uri("lua/shared/guard.lua"); + let file_id = write( + &mut ws, + &uri, + "function GuardA(ent) return IsValid(ent) and ent:IsPlayer() end", + ); + // Without a recorded guard the assertion below would hold whatever the + // section hashed. + expect_that!(guard_count(&ws, file_id), gt(0)); + let before = fingerprint_of(&ws, file_id); + + let file_id = write( + &mut ws, + &uri, + "function GuardA(ent) return IsValid(ent) and ent:IsNPC() end", + ); + expect_that!(guard_count(&ws, file_id), gt(0)); + + expect_that!(fingerprint_of(&ws, file_id), not(eq(before))); + } + + /// The table a module returns is its export type, which every consumer of + /// `require`/`include` reads. The returned local is skipped by the + /// type-cache section, so returning a different table moves nothing else. + #[gtest] + fn fingerprint_moves_when_a_module_returns_a_different_table() { + let (before, after) = fingerprint_after_edit( + r#" + local M = { a = 1 } + local N = { b = 2 } + _ = N + return M + "#, + r#" + local M = { a = 1 } + local N = { b = 2 } + _ = M + return N + "#, + ); + expect_that!(after, not(eq(before))); + } + + /// Two table literals in one file are different owners. Collapsing both to + /// their file makes moving a field from one to the other invisible, even + /// though every dependent resolving through either literal sees it. + #[gtest] + fn fingerprint_moves_when_a_field_moves_between_two_literals() { + let (before, after) = fingerprint_after_edit( + r#" + Shared = { alpha = 1 } + Other = { beta = 2 } + "#, + r#" + Shared = { alpha = 1, beta = 2 } + Other = {} + "#, + ); + expect_that!(after, not(eq(before))); + } + + /// `@accessorfunc` registers the annotated function in a workspace-wide + /// index by name, and every other file's call analysis consults it to + /// decide which argument names the accessor. Retargeting it changes what + /// gets synthesized there, and moves nothing in this file. + #[gtest] + fn fingerprint_moves_when_an_accessorfunc_annotation_is_retargeted() { + let declaration = |param_index: &str| { + format!( + r#" + ---@class base_item + ITEM = {{}} + + ---@accessorfunc {param_index} + function ITEM:AutoFunction(name, key) + end + "# + ) + }; + + let mut ws = workspace_with(vec![]); + let uri = ws.virtual_url_generator.new_uri("lua/items/base_item.lua"); + let file_id = write(&mut ws, &uri, &declaration("1")); + // Without a registered annotation the assertion below would hold + // whatever the section hashed. + expect_that!( + ws.analysis + .compilation + .get_db() + .get_accessor_func_index() + .annotations_in_file(file_id) + .len(), + gt(0) + ); + let before = fingerprint_of(&ws, file_id); + + let file_id = write(&mut ws, &uri, &declaration("2")); + + expect_that!(fingerprint_of(&ws, file_id), not(eq(before))); + } + + /// A computed-key or unresolved-receiver write creates no member, which is + /// why the dynamic field index exists, but every other file reads it by + /// name to decide whether a field is known. + #[gtest] + fn fingerprint_moves_when_a_dynamic_field_contribution_is_removed() { + let mut ws = workspace_with(vec![]); + ws.def_file( + "lua/shared/player_meta.lua", + r#" + ---@class Player + Player = {} + "#, + ); + + let uri = ws.virtual_url_generator.new_uri("lua/shared/writes.lua"); + let with_write = r#" + ---@type Player + local ply = Player + ply.myCustomField = 1 + "#; + let file_id = write(&mut ws, &uri, with_write); + let before = fingerprint_of(&ws, file_id); + + let file_id = write( + &mut ws, + &uri, + r#" + ---@type Player + local ply = Player + "#, + ); + + expect_that!(fingerprint_of(&ws, file_id), not(eq(before))); + } + + /// Retargeting a metatable lookup moves the method onto a different class, + /// which every file that calls it resolves through. The member's key and + /// this file's text length are unchanged; only its owner moves. + #[gtest] + fn fingerprint_moves_when_a_method_is_attached_to_a_different_class() { + let mut ws = workspace_with(vec![]); + ws.def_file( + "lua/shared/meta.lua", + r#" + ---@class Entity + Entity = {} + ---@class Player : Entity + Player = {} + ---@generic T: string + ---@param name `T` + ---@return T + function FindMetaTable(name) end + "#, + ); + + let uri = ws.virtual_url_generator.new_uri("lua/autorun/extend.lua"); + let owner_of_custom = |ws: &VirtualWorkspace, file_id: FileId| { + let db = ws.analysis.compilation.get_db(); + db.get_member_index() + .get_file_members(file_id) + .iter() + .find(|member| member.get_key() == &crate::LuaMemberKey::Name("Custom".into())) + .and_then(|member| db.get_member_index().get_member_owner(&member.get_id())) + .map(|owner| format!("{owner:?}")) + }; + + let file_id = write( + &mut ws, + &uri, + "local meta = FindMetaTable(\"Player\") +function meta:Custom() end +", + ); + // The lookup has to actually resolve, or both versions would produce an + // ownerless member and the assertion below would hold for the wrong + // reason. + expect_that!( + owner_of_custom(&ws, file_id), + some(contains_substring("Player")) + ); + let before = fingerprint_of(&ws, file_id); + + let file_id = write( + &mut ws, + &uri, + "local meta = FindMetaTable(\"Entity\") +function meta:Custom() end +", + ); + expect_that!( + owner_of_custom(&ws, file_id), + some(contains_substring("Entity")) + ); + + expect_that!(fingerprint_of(&ws, file_id), not(eq(before))); + } + + /// The remap has to reach every store keyed by a table literal's range, + /// not just the member index. A fingerprint test only proves the hash + /// moved; this proves the entries survived the move. + /// + /// The consumer writes into literals the provider declares, so those + /// entries belong to a file the provider's re-index never revisits. + /// + /// These are the only stores that hold another file's literal range. The + /// metatable, operator and call-site-param indexes were checked and do not: + /// their entries resolve to a range in the file that writes them, so that + /// file's own re-index re-derives them. + #[gtest] + fn every_store_keyed_by_a_literal_still_points_at_the_same_code() { + let provider = |note: &str| { + format!( + r#" + -- {note} + Registry = {{ existing = 1 }} + Meta = setmetatable({{ value = 1 }}, {{ __add = function(a, b) return a end }}) + ---@param name string + ---@param spec table + function Registry.Add(name, spec) end + Registry.Add("first", {{ alpha = 1 }}) + "# + ) + }; + + let mut ws = workspace_with(vec![]); + let provider_uri = ws.virtual_url_generator.new_uri("lua/provider.lua"); + write(&mut ws, &provider_uri, &provider("note")); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/consumer.lua"); + // Every write here keys a store by a literal declared in the provider, + // so the entries belong to a file the provider's re-index never + // revisits. Without the remap they keep the pre-edit range. + write( + &mut ws, + &consumer_uri, + r#" + Registry.Handlers = {} + local key = "computed" + Registry[key] = 1 + setmetatable(Registry, { __add = function(a, b) return a end }) + Registry.Add("second", Registry) + "#, + ); + + /// What every remapped store currently points at, as the source text + /// its range covers. + /// + /// Checking the text rather than "is this still a literal" is what the + /// remap actually promises: not every range these stores hold is a + /// table literal, but each must keep covering the same code. + fn held_text(ws: &VirtualWorkspace) -> Vec { + let db = ws.analysis.compilation.get_db(); + let member_index = db.get_member_index(); + let mut held: Vec<(&'static str, crate::InFiled)> = Vec::new(); + let mut push = |label, ranges: Vec>| { + held.extend(ranges.into_iter().map(move |range| (label, range))); + }; + push("member owner", member_index.element_owner_ranges()); + push( + "contribution", + member_index + .member_assignment_contributions() + .table_ranges(), + ); + push("dynamic field", db.get_dynamic_field_index().table_ranges()); + + let mut out: Vec = held + .into_iter() + .map(|(store, range)| { + let text = db + .get_vfs() + .get_file_content(&range.file_id) + .and_then(|text| text.get(std::ops::Range::::from(range.value))) + .map(|slice| slice.split_whitespace().collect::>().join(" ")) + .unwrap_or_else(|| "".to_string()); + format!("{store}: {text}") + }) + .collect(); + // Not deduped: two entries rendering the same text are distinct + // entries, and dropping one would hide a lost entry whose text + // happens to match a survivor's. + out.sort(); + out + } + + let before = held_text(&ws); + // A fixture that fills none of these stores would satisfy the loop + // below with an empty set. + expect_that!(before.len(), gt(5)); + + for note in ["note, rewritten once", "note, rewritten a second time"] { + write(&mut ws, &provider_uri, &provider(note)); + expect_that!(held_text(&ws), eq(&before), "after edit: {note}"); + } + } + + /// Deleting a file purges every `Element` owner in it, including literals + /// no anchor could name. Recreating it must leave the workspace exactly as + /// it was - an over-eager purge would take members belonging to files the + /// deletion never touched. + #[gtest] + fn deleting_and_recreating_a_file_restores_the_workspace() { + let provider_source = r#" + Registry = { existing = 1 } + Anonymous = { {}, {} } + "#; + + let mut ws = workspace_with(vec![ + DiagnosticCode::UndefinedField, + DiagnosticCode::UndefinedGlobal, + ]); + let provider_uri = ws.virtual_url_generator.new_uri("lua/registry.lua"); + write(&mut ws, &provider_uri, provider_source); + + let unrelated_uri = ws.virtual_url_generator.new_uri("lua/unrelated.lua"); + let unrelated_id = write( + &mut ws, + &unrelated_uri, + r#" + Other = { kept = 1 } + Other.Added = {} + local _ = Other.kept + local _ = Other.Added + "#, + ); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/consumer.lua"); + let consumer_id = write( + &mut ws, + &consumer_uri, + r#" + Registry.Handlers = {} + local _ = Registry.existing + "#, + ); + + let baseline_consumer = codes_in(&ws, consumer_id); + let baseline_unrelated = codes_in(&ws, unrelated_id); + expect_that!(baseline_unrelated, is_empty()); + + ws.analysis.update_file_by_uri(&provider_uri, None); + // A file that shares no literal with the deleted one must be untouched. + expect_that!(codes_in(&ws, unrelated_id), eq(&baseline_unrelated)); + + write(&mut ws, &provider_uri, provider_source); + + expect_that!(codes_in(&ws, consumer_id), eq(&baseline_consumer)); + expect_that!(codes_in(&ws, unrelated_id), eq(&baseline_unrelated)); + } + + /// Reopening a file re-sends its unchanged text. The semantic-no-op gate + /// should skip the work, and skipping must not leave the index behind. + #[gtest] + fn reopening_a_file_with_unchanged_text_keeps_dependents_settled() { + let mut ws = workspace_with(vec![DiagnosticCode::AssignTypeMismatch]); + let provider_uri = ws.virtual_url_generator.new_uri("lua/values.lua"); + let provider_source = r#" + values = values or {} + values.Count = 1 + values.Table = { nested = true } + "#; + write(&mut ws, &provider_uri, provider_source); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/counter.lua"); + let consumer_id = write( + &mut ws, + &consumer_uri, + r#" + ---@type string + local wrong = values.Count + "#, + ); + let before = codes_in(&ws, consumer_id); + expect_that!( + before, + contains(eq(DiagnosticCode::AssignTypeMismatch.get_name())) + ); + + // Twice, because the first reopen and every one after take different + // branches of the unchanged-text gate. + for _ in 0..2 { + write(&mut ws, &provider_uri, provider_source); + expect_that!(codes_in(&ws, consumer_id), eq(&before)); + } + } + + /// `(exact)` decides whether another file's write creates a member on the + /// class. The flag lives only on the declaration, so nothing else in this + /// file moves when it is added. + #[gtest] + fn fingerprint_moves_when_a_class_becomes_exact() { + let (before, after) = fingerprint_after_edit( + r#" + ---@class Config + ---@field known string + Config = {} + "#, + r#" + ---@class (exact) Config + ---@field known string + Config = {} + "#, + ); + expect_that!(after, not(eq(before))); + } + + /// A flag on the declaration lives on each of its locations, not on the + /// declaration itself, so it is a separate dimension from the base type. + #[gtest] + fn fingerprint_moves_when_an_enum_gains_a_flag() { + let (before, after) = fingerprint_after_edit( + r#" + ---@enum Colours + Colours = { Red = 1 } + "#, + r#" + ---@enum (key) Colours + Colours = { Red = 1 } + "#, + ); + expect_that!(after, not(eq(before))); + } + + /// An attribute's type is the other half of `extra_type()`, alongside the + /// enum base, and a consumer resolves it by name. + #[gtest] + fn fingerprint_moves_when_an_attribute_type_changes() { + let (before, after) = fingerprint_after_edit( + r#" + ---@class Holder + ---@field value string + Holder = {} + "#, + r#" + ---@class Holder + ---@field value integer + Holder = {} + "#, + ); + expect_that!(after, not(eq(before))); + } + + /// A member's owner is hashed by its literal's anchor, so that the same + /// logical table declared in several files hashes the same whichever + /// literal the resolver happens to pick. That normalisation must not hide + /// a member genuinely moving between two literals that share a path - + /// `collect_anchored_map` drops a duplicated anchor as ambiguous, so those + /// literals fall back to file plus ordinal and stay distinguishable. + #[gtest] + fn anchor_keyed_owners_still_see_a_member_move_between_shared_paths() { + let (before, after) = fingerprint_after_edit( + r#" + Cfg = { a = 1 } + if SERVER then + Cfg = { b = 2 } + end + "#, + r#" + Cfg = { a = 1, b = 2 } + if SERVER then + Cfg = {} + end + "#, + ); + expect_that!(after, not(eq(before))); + } + + /// The same for two literals reached by distinct paths, and for a nested + /// path shared by two roots. + #[gtest] + fn anchor_keyed_owners_still_see_a_member_move_between_distinct_paths() { + let (before, after) = fingerprint_after_edit( + r#" + Root = { inner = { a = 1 } } + Other = { inner = { b = 2 } } + "#, + r#" + Root = { inner = { a = 1, b = 2 } } + Other = { inner = {} } + "#, + ); + expect_that!(after, not(eq(before))); + } } diff --git a/crates/glua_code_analysis/src/lib.rs b/crates/glua_code_analysis/src/lib.rs index 85a713136..cf04d2b6b 100644 --- a/crates/glua_code_analysis/src/lib.rs +++ b/crates/glua_code_analysis/src/lib.rs @@ -30,8 +30,7 @@ pub use gamemode_base::detect_gamemode_base_libraries; pub use glua_codestyle::*; use glua_parser::{ LineIndex, LuaAssignStat, LuaAstNode, LuaAstToken, LuaCallExpr, LuaExpr, LuaIndexKey, - LuaLocalStat, LuaNameExpr, LuaParenExpr, LuaParser, LuaSyntaxKind, LuaSyntaxTree, LuaTableExpr, - LuaTableField, + LuaLocalStat, LuaNameExpr, LuaParenExpr, LuaParser, LuaSyntaxTree, LuaTableExpr, LuaTableField, }; pub use library_collision::LibraryDefinitionCollision; use lsp_types::Uri; @@ -49,10 +48,9 @@ use tokio_util::sync::CancellationToken; use url::Url; pub use vfs::*; -#[derive(Default)] /// The cross-file facts an edit can invalidate, captured before /// re-analysis. -#[derive(Clone)] +#[derive(Clone, Debug, Default)] pub(crate) struct InferredGuardSnapshot { facts: HashMap, consumers: HashMap>, @@ -89,7 +87,11 @@ fn sort_inferred_guard_owners(owners: &mut [LuaInferredGuardOwner]) { }); } -fn hash_member_owner_stable(owner: &LuaMemberOwner, hasher: &mut impl Hasher) { +fn hash_member_owner_stable( + ids: &ExportIdentities, + owner: &LuaMemberOwner, + hasher: &mut impl Hasher, +) { match owner { LuaMemberOwner::GlobalPath(gid) => { "GlobalPath".hash(hasher); @@ -100,12 +102,15 @@ fn hash_member_owner_stable(owner: &LuaMemberOwner, hasher: &mut impl Hasher) { tid.get_name().hash(hasher); } LuaMemberOwner::Element(range) => { - // The range moves whenever an edit shifts offsets and is re-homed - // by the remap pass, so only the file it lives in is hashed. That - // still distinguishes one file's literal from another's, which is - // what a dependent resolving the owner can observe. + // Named by its anchor where it has one, and by its file plus + // ordinal otherwise - see `ExportIdentities::table_identity`. + // + // Deliberately *not* the identity `TableConst` uses. A member's + // owner is the one place where the resolver's choice between two + // files' literals for a single logical table would otherwise read + // as an export change on every edit. "Element".hash(hasher); - range.file_id.hash(hasher); + ids.table_identity(range).hash(hasher); } LuaMemberOwner::LocalUnresolve => { "LocalUnresolve".hash(hasher); @@ -113,7 +118,11 @@ fn hash_member_owner_stable(owner: &LuaMemberOwner, hasher: &mut impl Hasher) { } } -fn hash_lua_member_key_export(key: &LuaMemberKey, hasher: &mut impl Hasher) { +fn hash_lua_member_key_export( + ids: &ExportIdentities, + key: &LuaMemberKey, + hasher: &mut impl Hasher, +) { match key { LuaMemberKey::Name(name) => { "Name".hash(hasher); @@ -128,9 +137,128 @@ fn hash_lua_member_key_export(key: &LuaMemberKey, hasher: &mut impl Hasher) { } LuaMemberKey::ExprType(typ) => { "ExprType".hash(hasher); - hash_lua_type_export(typ, hasher); + hash_lua_type_export(ids, typ, hasher); + } + } +} + +/// Offset-free identities for the things a type can point at. +/// +/// A signature id and a table literal's range are both a file plus a position, +/// and the position moves whenever an edit shifts the file. Hashing the +/// position reports an export change for every edit; hashing only the file +/// makes *repointing* an export at a different function or literal in the same +/// file invisible. The index among the file's signatures, or among its table +/// literals, is stable under a shift and still tells the two apart. +/// +/// Built per file on first use, because a fingerprint usually reaches only a +/// handful of files. +struct ExportIdentities<'a> { + db: &'a DbIndex, + signature_ordinals: std::cell::RefCell>>, + table_ordinals: std::cell::RefCell>>, + table_anchors: std::cell::RefCell< + rustc_hash::FxHashMap>, + >, +} + +impl<'a> ExportIdentities<'a> { + fn new(db: &'a DbIndex) -> Self { + Self { + db, + signature_ordinals: std::cell::RefCell::new(rustc_hash::FxHashMap::default()), + table_ordinals: std::cell::RefCell::new(rustc_hash::FxHashMap::default()), + table_anchors: std::cell::RefCell::new(rustc_hash::FxHashMap::default()), } } + + fn signature_ordinal(&self, id: &LuaSignatureId) -> Option { + let file_id = id.get_file_id(); + let mut cache = self.signature_ordinals.borrow_mut(); + let positions = cache.entry(file_id).or_insert_with(|| { + let mut positions: Vec = self + .db + .get_signature_index() + .get_file_signature_ids(file_id) + .map(|ids| ids.iter().map(|id| id.get_position()).collect()) + .unwrap_or_default(); + positions.sort_unstable(); + positions + }); + positions.binary_search(&id.get_position()).ok() + } + + /// What a table literal is called, for the purpose of deciding whether an + /// export changed. + /// + /// The anchor, when the literal has one: a name like `cityrp.configuration` + /// identifies the *logical* table, and several files can declare a literal + /// for it. Which of those the resolver picks as a member's owner is not + /// stable across a partial re-index, so keying on the literal's file and + /// position makes an unrelated edit look like an export change. The anchor + /// is the same whichever literal wins. + /// + /// Falls back to file plus ordinal for a literal no name reaches - still + /// enough to tell two literals in one file apart, which is what a member + /// moving between them needs. + fn table_identity(&self, range: &InFiled) -> String { + let file_id = range.file_id; + let mut cache = self.table_anchors.borrow_mut(); + let anchors = cache.entry(file_id).or_insert_with(|| { + collect_anchored_map(self.db, file_id) + .into_iter() + .filter_map(|(anchor, anchored)| match anchor { + // Only a name identifies the logical table across files. + TableAnchor::Global(path) => Some((anchored.value, format!("G:{path}"))), + _ => None, + }) + .collect() + }); + match anchors.get(&range.value) { + Some(anchor) => anchor.clone(), + None => { + drop(cache); + format!("{}:{:?}", file_id.id, self.table_ordinal(range)) + } + } + } + + fn table_ordinal(&self, range: &InFiled) -> Option { + let file_id = range.file_id; + let mut cache = self.table_ordinals.borrow_mut(); + let ranges = cache.entry(file_id).or_insert_with(|| { + let Some(tree) = self.db.get_vfs().get_syntax_tree(&file_id) else { + return Vec::new(); + }; + let mut ranges: Vec = tree + .get_chunk_node() + .descendants::() + .map(|table| table.get_range()) + .collect(); + ranges.sort_unstable_by_key(|range| (range.start(), range.end())); + ranges + }); + ranges + .binary_search_by_key(&(range.value.start(), range.value.end()), |range| { + (range.start(), range.end()) + }) + .ok() + } +} + +/// Hashes a generic parameter, recursing into its constraint so a table +/// literal's range inside one is normalised the same way it is elsewhere. +fn hash_generic_param_export( + ids: &ExportIdentities, + param: &GenericParam, + hasher: &mut impl Hasher, +) { + param.name.hash(hasher); + format!("{:?}", param.attributes).hash(hasher); + match ¶m.type_constraint { + Some(constraint) => hash_lua_type_export(ids, constraint, hasher), + None => "NoConstraint".hash(hasher), + } } /// Hashes everything about a type that another file can observe. @@ -140,16 +268,21 @@ fn hash_lua_member_key_export(key: &LuaMemberKey, hasher: &mut impl Hasher) { /// whenever an edit shifts offsets, and are re-homed by the remap pass rather /// than by a re-index, so hashing them would report an export change for every /// edit. Values, shapes and names are kept: they are what a dependent reads. -fn hash_lua_type_export(typ: &LuaType, hasher: &mut impl Hasher) { +fn hash_lua_type_export(ids: &ExportIdentities, typ: &LuaType, hasher: &mut impl Hasher) { // Arm order is not guaranteed for the set-like composites, so their arm // hashes are sorted before they are folded in. - fn hash_unordered(tag: &str, arms: &[LuaType], hasher: &mut impl Hasher) { + fn hash_unordered( + ids: &ExportIdentities, + tag: &str, + arms: &[LuaType], + hasher: &mut impl Hasher, + ) { tag.hash(hasher); let mut arm_hashes: Vec = arms .iter() .map(|arm| { let mut h = rustc_hash::FxHasher::default(); - hash_lua_type_export(arm, &mut h); + hash_lua_type_export(ids, arm, &mut h); h.finish() }) .collect(); @@ -180,17 +313,20 @@ fn hash_lua_type_export(typ: &LuaType, hasher: &mut impl Hasher) { LuaType::TableConst(range) => { "TableConst".hash(hasher); range.file_id.hash(hasher); + ids.table_ordinal(range).hash(hasher); } LuaType::Instance(inst) => { "Instance".hash(hasher); inst.get_range().file_id.hash(hasher); - hash_lua_type_export(inst.get_base(), hasher); + ids.table_ordinal(inst.get_range()).hash(hasher); + hash_lua_type_export(ids, inst.get_base(), hasher); } // The id is a file plus a position. The signature's own shape is // hashed by the signature section of the file fingerprint. LuaType::Signature(id) => { "Signature".hash(hasher); id.get_file_id().hash(hasher); + ids.signature_ordinal(id).hash(hasher); } LuaType::Ref(id) => { "Ref".hash(hasher); @@ -200,51 +336,55 @@ fn hash_lua_type_export(typ: &LuaType, hasher: &mut impl Hasher) { "Def".hash(hasher); id.get_name().hash(hasher); } - LuaType::Union(union) => hash_unordered("Union", &union.into_vec(), hasher), - LuaType::Intersection(inter) => hash_unordered("Intersection", inter.get_types(), hasher), - LuaType::MergedTable(merged) => hash_unordered("MergedTable", merged.get_types(), hasher), + LuaType::Union(union) => hash_unordered(ids, "Union", &union.into_vec(), hasher), + LuaType::Intersection(inter) => { + hash_unordered(ids, "Intersection", inter.get_types(), hasher) + } + LuaType::MergedTable(merged) => { + hash_unordered(ids, "MergedTable", merged.get_types(), hasher) + } LuaType::Tuple(tuple) => { "Tuple".hash(hasher); tuple.status.hash(hasher); for sub in tuple.get_types() { - hash_lua_type_export(sub, hasher); + hash_lua_type_export(ids, sub, hasher); } } LuaType::Array(arr) => { "Array".hash(hasher); format!("{:?}", arr.get_len()).hash(hasher); - hash_lua_type_export(arr.get_base(), hasher); + hash_lua_type_export(ids, arr.get_base(), hasher); } LuaType::Object(obj) => { "Object".hash(hasher); for (key, value) in obj.get_fields() { - format!("{:?}", key).hash(hasher); - hash_lua_type_export(value, hasher); + hash_lua_member_key_export(ids, key, hasher); + hash_lua_type_export(ids, value, hasher); } for (key, value) in obj.get_index_access() { - hash_lua_type_export(key, hasher); - hash_lua_type_export(value, hasher); + hash_lua_type_export(ids, key, hasher); + hash_lua_type_export(ids, value, hasher); } } LuaType::TableGeneric(params) => { "TableGeneric".hash(hasher); for param in params.iter() { - hash_lua_type_export(param, hasher); + hash_lua_type_export(ids, param, hasher); } } LuaType::TableOf(inner) => { "TableOf".hash(hasher); - hash_lua_type_export(inner, hasher); + hash_lua_type_export(ids, inner, hasher); } LuaType::TypeGuard(inner) => { "TypeGuard".hash(hasher); - hash_lua_type_export(inner, hasher); + hash_lua_type_export(ids, inner, hasher); } LuaType::Generic(generic) => { "Generic".hash(hasher); generic.get_base_type_id().get_name().hash(hasher); for param in generic.get_params() { - hash_lua_type_export(param, hasher); + hash_lua_type_export(ids, param, hasher); } } LuaType::DocFunction(func) => { @@ -256,22 +396,144 @@ fn hash_lua_type_export(typ: &LuaType, hasher: &mut impl Hasher) { for (name, param_type) in func.get_params() { name.hash(hasher); match param_type { - Some(param_type) => hash_lua_type_export(param_type, hasher), + Some(param_type) => hash_lua_type_export(ids, param_type, hasher), None => "NoParamType".hash(hasher), } } - hash_lua_type_export(func.get_ret(), hasher); + hash_lua_type_export(ids, func.get_ret(), hasher); } LuaType::ModuleRef(file_id) => { "ModuleRef".hash(hasher); file_id.hash(hasher); } - // The remaining variants carry no source position, so their `Debug` - // form is a precise and stable description of them. + LuaType::Variadic(variadic) => { + "Variadic".hash(hasher); + match variadic.as_ref() { + VariadicType::Base(base) => { + "Base".hash(hasher); + hash_lua_type_export(ids, base, hasher); + } + VariadicType::Multi(types) => { + "Multi".hash(hasher); + for sub in types { + hash_lua_type_export(ids, sub, hasher); + } + } + } + } + LuaType::Call(call) => { + "Call".hash(hasher); + format!("{:?}", call.get_call_kind()).hash(hasher); + for operand in call.get_operands() { + hash_lua_type_export(ids, operand, hasher); + } + } + LuaType::MultiLineUnion(union) => { + "MultiLineUnion".hash(hasher); + for (arm, description) in union.get_unions() { + description.hash(hasher); + hash_lua_type_export(ids, arm, hasher); + } + } + LuaType::Conditional(cond) => { + "Conditional".hash(hasher); + cond.has_new.hash(hasher); + for param in cond.get_infer_params() { + hash_generic_param_export(ids, param, hasher); + } + hash_lua_type_export(ids, cond.get_condition(), hasher); + hash_lua_type_export(ids, cond.get_true_type(), hasher); + hash_lua_type_export(ids, cond.get_false_type(), hasher); + } + LuaType::Mapped(mapped) => { + "Mapped".hash(hasher); + format!("{:?}", mapped.param.0).hash(hasher); + hash_generic_param_export(ids, &mapped.param.1, hasher); + mapped.is_readonly.hash(hasher); + mapped.is_optional.hash(hasher); + hash_lua_type_export(ids, &mapped.value, hasher); + } + LuaType::DocAttribute(attribute) => { + "DocAttribute".hash(hasher); + for (name, param_type) in attribute.get_params() { + name.hash(hasher); + match param_type { + Some(param_type) => hash_lua_type_export(ids, param_type, hasher), + None => "NoParamType".hash(hasher), + } + } + } + LuaType::StrTplRef(tpl) => { + "StrTplRef".hash(hasher); + tpl.get_prefix().hash(hasher); + tpl.get_name().hash(hasher); + tpl.get_suffix().hash(hasher); + format!("{:?}", tpl.get_tpl_id()).hash(hasher); + if let Some(constraint) = tpl.get_constraint() { + hash_lua_type_export(ids, constraint, hasher); + } + } + LuaType::TplRef(tpl) | LuaType::ConstTplRef(tpl) => { + match typ { + LuaType::ConstTplRef(_) => "ConstTplRef".hash(hasher), + _ => "TplRef".hash(hasher), + } + format!("{:?}", tpl.get_tpl_id()).hash(hasher); + tpl.get_name().hash(hasher); + match tpl.get_constraint() { + Some(constraint) => hash_lua_type_export(ids, constraint, hasher), + None => "NoConstraint".hash(hasher), + } + } + // The remaining variants hold no nested type and no source position, + // so their `Debug` form describes them precisely and stably. other => format!("{:?}", other).hash(hasher), } } +/// A name another file can resolve for a documented symbol, or `None` when +/// nothing outside this file can name it. +/// +/// Every `LuaSemanticDeclId` variant is a file plus a position, and the +/// position moves on any edit above it, so the name is what gets hashed. +fn semantic_decl_export_key(ids: &ExportIdentities, id: &LuaSemanticDeclId) -> Option { + let db = ids.db; + match id { + LuaSemanticDeclId::TypeDecl(type_decl_id) => Some(format!("T:{}", type_decl_id.get_name())), + LuaSemanticDeclId::LuaDecl(decl_id) => { + let decl = db.get_decl_index().get_decl(decl_id)?; + (!decl.is_local()).then(|| format!("D:{}", decl.get_name())) + } + LuaSemanticDeclId::Member(member_id) => { + let member_index = db.get_member_index(); + let member = member_index.get_member(member_id)?; + let mut hasher = rustc_hash::FxHasher::default(); + hash_lua_member_key_export(ids, member.get_key(), &mut hasher); + if let Some(owner) = member_index.get_member_owner(member_id) { + hash_member_owner_stable(ids, owner, &mut hasher); + } + Some(format!("M:{:x}", hasher.finish())) + } + // A signature has no name of its own; it is reached through the decl + // or member that holds it, and its own shape is hashed by the + // signature section. Its index among the file's signatures identifies + // it without a byte position, which would move on any edit above it. + LuaSemanticDeclId::Signature(signature_id) => { + let mut file_signatures: Vec<_> = db + .get_signature_index() + .get_file_signature_ids(signature_id.get_file_id())? + .iter() + .map(|id| id.get_position()) + .collect(); + file_signatures.sort_unstable(); + let ordinal = file_signatures + .binary_search(&signature_id.get_position()) + .ok()?; + Some(format!("S:{ordinal}")) + } + } +} + /// Hash of the cross-file-visible exports a single file contributes. /// /// Used to decide whether a re-index of this file can affect any other file. @@ -281,15 +543,16 @@ fn hash_lua_type_export(typ: &LuaType, hasher: &mut impl Hasher) { /// fan-in would be under the old file-level expansion. pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { let mut hasher = rustc_hash::FxHasher::default(); + let ids = &ExportIdentities::new(db); // --- Members directly declared in this file (owner, key, feature) --- let member_index = db.get_member_index(); let mut members = member_index.get_file_members(file_id); members.sort_by_key(|m| crate::db_index::member_id_sort_key(m.get_id())); for member in members { - hash_lua_member_key_export(member.get_key(), &mut hasher); + hash_lua_member_key_export(ids, member.get_key(), &mut hasher); if let Some(owner) = member_index.get_member_owner(&member.get_id()) { - hash_member_owner_stable(owner, &mut hasher); + hash_member_owner_stable(ids, owner, &mut hasher); } member.get_feature().hash(&mut hasher); } @@ -301,8 +564,8 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { let mut bucket_hashes = Vec::new(); for (owner, key) in keys { let mut bh = rustc_hash::FxHasher::default(); - hash_member_owner_stable(&owner, &mut bh); - hash_lua_member_key_export(&key, &mut bh); + hash_member_owner_stable(ids, &owner, &mut bh); + hash_lua_member_key_export(ids, &key, &mut bh); if let Some(contribs) = store.contributions(&(owner.clone(), key.clone())) { let mut contribs_vec: Vec<_> = contribs .iter() @@ -310,10 +573,11 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { .collect(); contribs_vec.sort_by_key(|(mid, _)| crate::db_index::member_id_sort_key(**mid)); for (_mid, contrib) in contribs_vec { - hash_lua_type_export(&contrib.bound_type, &mut bh); - hash_lua_type_export(&contrib.source_type, &mut bh); - if let Some(doc) = &contrib.doc_type { - hash_lua_type_export(doc, &mut bh); + hash_lua_type_export(ids, &contrib.bound_type, &mut bh); + hash_lua_type_export(ids, &contrib.source_type, &mut bh); + match &contrib.doc_type { + Some(doc) => hash_lua_type_export(ids, doc, &mut bh), + None => "NoDocType".hash(&mut bh), } contrib.guarded_bootstrap.hash(&mut bh); contrib.preserve_table_literals.hash(&mut bh); @@ -333,16 +597,40 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { decl_ids_sorted.sort_by(|a, b| a.get_name().cmp(b.get_name())); for decl_id in decl_ids_sorted { decl_id.get_name().hash(&mut hasher); + if let Some(type_decl) = db.get_type_index().get_type_decl(&decl_id) { + // An alias is read by name and resolved to its target, so + // changing the target changes what every file that names it + // infers. + match type_decl.get_alias_ref() { + Some(alias_ref) => hash_lua_type_export(ids, alias_ref, &mut hasher), + None => "NoAlias".hash(&mut hasher), + } + // Kind and flags live only on the declaration, so nothing else + // in this file moves when one changes - yet `(exact)` decides + // whether another file's write creates a member on this type, + // and `(partial)`/`(private)` gate diagnostics other files + // report. + let (kind, flags) = type_decl.kind_and_flags(); + format!("{kind:?}").hash(&mut hasher); + flags.hash(&mut hasher); + let (extra_type, flat) = type_decl.extra_type(); + flat.hash(&mut hasher); + match extra_type { + Some(extra_type) => hash_lua_type_export(ids, extra_type, &mut hasher), + None => "NoExtra".hash(&mut hasher), + } + } if let Some(supers) = db.get_type_index().get_super_type_entries(&decl_id) { for sup in supers.iter().filter(|s| s.file_id == file_id) { - hash_lua_type_export(&sup.value.typ, &mut hasher); + hash_lua_type_export(ids, &sup.value.typ, &mut hasher); } } if let Some(params) = db.get_type_index().get_generic_params(&decl_id) { for param in params { param.name.hash(&mut hasher); - if let Some(constraint) = ¶m.type_constraint { - hash_lua_type_export(constraint, &mut hasher); + match ¶m.type_constraint { + Some(constraint) => hash_lua_type_export(ids, constraint, &mut hasher), + None => "NoConstraint".hash(&mut hasher), } } } @@ -355,7 +643,11 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { // edit anywhere above one shifts it, so hashing the position reports an // export change for every edit that is not at the very end of the file. if let Some(owners) = db.get_type_index().file_type_owners(file_id) { - let mut entries: Vec<(String, u64)> = Vec::new(); + // Sorted by name then source order. The position orders the entries but + // is never hashed: two declarations of the same name in one file are + // distinguished by which type each holds, and swapping them has to be + // visible, but the offsets themselves move on any edit above. + let mut entries: Vec<(String, u32, u64)> = Vec::new(); for owner in owners.iter() { let key = match owner { LuaTypeOwner::Decl(decl_id) => { @@ -373,9 +665,9 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { continue; }; let mut h = rustc_hash::FxHasher::default(); - hash_lua_member_key_export(member.get_key(), &mut h); + hash_lua_member_key_export(ids, member.get_key(), &mut h); if let Some(owner) = member_index.get_member_owner(member_id) { - hash_member_owner_stable(owner, &mut h); + hash_member_owner_stable(ids, owner, &mut h); } format!("M:{:x}", h.finish()) } @@ -383,15 +675,23 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { // one, so it is local memoisation rather than an export. LuaTypeOwner::SyntaxId(_) => continue, }; + let owner_position = match owner { + LuaTypeOwner::Decl(decl_id) => u32::from(decl_id.position), + LuaTypeOwner::Member(member_id) => u32::from(member_id.get_position()), + LuaTypeOwner::SyntaxId(_) => continue, + }; let Some(cache) = db.get_type_index().get_type_cache(owner) else { continue; }; let mut h = rustc_hash::FxHasher::default(); - hash_lua_type_export(cache.as_type(), &mut h); - entries.push((key, h.finish())); + hash_lua_type_export(ids, cache.as_type(), &mut h); + entries.push((key, owner_position, h.finish())); } entries.sort_unstable(); - entries.hash(&mut hasher); + for (key, _, type_hash) in entries { + key.hash(&mut hasher); + type_hash.hash(&mut hasher); + } } // --- Signatures defined in this file --- @@ -408,8 +708,9 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { sig.params.hash(&mut hasher); for param in &sig.generic_params { param.name.hash(&mut hasher); - if let Some(constraint) = ¶m.constraint { - hash_lua_type_export(constraint, &mut hasher); + match ¶m.constraint { + Some(constraint) => hash_lua_type_export(ids, constraint, &mut hasher), + None => "NoConstraint".hash(&mut hasher), } } // A caller reads the declared parameter and return types, so a @@ -423,41 +724,361 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { doc.name.hash(&mut hasher); doc.nullable.hash(&mut hasher); doc.description.hash(&mut hasher); - hash_lua_type_export(&doc.type_ref, &mut hasher); + format!("{:?}", doc.default_value).hash(&mut hasher); + format!("{:?}", doc.attributes).hash(&mut hasher); + hash_lua_type_export(ids, &doc.type_ref, &mut hasher); } for ret in &sig.return_docs { ret.name.hash(&mut hasher); ret.description.hash(&mut hasher); - hash_lua_type_export(&ret.type_ref, &mut hasher); + format!("{:?}", ret.default_value).hash(&mut hasher); + format!("{:?}", ret.attributes).hash(&mut hasher); + format!("{:?}", ret.return_kind).hash(&mut hasher); + hash_lua_type_export(ids, &ret.type_ref, &mut hasher); } for overload in &sig.overloads { - hash_lua_type_export(&LuaType::DocFunction(overload.clone()), &mut hasher); + hash_lua_type_export(ids, &LuaType::DocFunction(overload.clone()), &mut hasher); } + // Caller-side narrowing facts derived from the body. A caller + // in another file reads them, and an edit can change one while + // leaving the declared parameters and returns alone. + format!("{:?}", sig.require_guard_param()).hash(&mut hasher); + sig.nil_return_guard_params().hash(&mut hasher); + format!("{:?}", sig.return_correlations()).hash(&mut hasher); + format!("{:?}", sig.direct_param_return_alias()).hash(&mut hasher); + format!("{:?}", sig.class_name_param_return_alias()).hash(&mut hasher); + format!("{:?}", sig.falsy_param_nil_free_return_slots()).hash(&mut hasher); + format!("{:?}", sig.falsy_param_return_aliases()).hash(&mut hasher); for out_param in &sig.out_params { - format!("{:?}", out_param).hash(&mut hasher); + format!("{:?}", out_param.root).hash(&mut hasher); + out_param.field_path.hash(&mut hasher); + hash_lua_type_export(ids, &out_param.type_ref, &mut hasher); } } if let Some(guard) = db.get_signature_index().inferred_positive_guard(sig_id) { guard.param_idx.hash(&mut hasher); - hash_lua_type_export(&guard.narrowed_type, &mut hasher); + hash_lua_type_export(ids, &guard.narrowed_type, &mut hasher); } } } + // --- Parameter types this file's call sites are evidence for --- + // A call argument here is the only evidence an unannotated parameter in + // another file has, and `expand_reindex_file_ids` already treats call + // sites as producing dependents. Without this the fingerprint would call + // an argument change local and never ripple it to the callee. + { + let contributed = db + .get_call_site_param_index() + .inferred_params_for_contributor_files(&HashSet::from([file_id])); + let mut param_hashes: Vec = contributed + .iter() + .map(|((signature_id, param_idx), typ)| { + let mut h = rustc_hash::FxHasher::default(); + // The signature's position moves on any edit to its own file; + // the file it lives in and the parameter index do not. + signature_id.get_file_id().hash(&mut h); + param_idx.hash(&mut h); + hash_lua_type_export(ids, typ, &mut h); + h.finish() + }) + .collect(); + param_hashes.sort_unstable(); + param_hashes.hash(&mut hasher); + } + // --- Inferred guard facts produced by this file --- let guard_facts = db .get_signature_index() .inferred_guard_facts_for_files(&HashSet::from([file_id])); if !guard_facts.is_empty() { - let mut guard_vec: Vec<_> = guard_facts.iter().collect(); - guard_vec.sort_by(|a, b| a.0.path().cmp(b.0.path())); - for (owner, guard) in guard_vec { + // Sorted on the same total key the rest of the analyzer uses. A path + // alone is not total: the standard `if SERVER` pattern gives one path + // two owners that differ only by realm, and ordering them by path + // leaves the fold order to hash-map iteration. + let mut guard_owners: Vec<_> = guard_facts.keys().cloned().collect(); + sort_inferred_guard_owners(&mut guard_owners); + for owner in guard_owners { owner.path().hash(&mut hasher); - guard.param_idx.hash(&mut hasher); - hash_lua_type_export(&guard.narrowed_type, &mut hasher); + // The realm the guard applies in is part of what a caller reads, + // and the same path can hold a different guard per realm. + format!("{:?}", owner.state_mask()).hash(&mut hasher); + owner.source_file_id().hash(&mut hasher); + if let Some(guard) = guard_facts.get(&owner) { + guard.param_idx.hash(&mut hasher); + hash_lua_type_export(ids, &guard.narrowed_type, &mut hasher); + } + } + } + + // --- Annotations on this file's symbols that other files act on --- + // A `@deprecated`, `@private` or `@export` on an exported symbol changes + // the diagnostics every call site in every other file reports. + // + // The free-text description and source are deliberately excluded: a hover + // in another file reads them from this index when the request arrives, so + // no dependent holds a copy that could go stale, and a doc-comment edit on + // a hub file would otherwise pay a full ripple for text nothing caches. + { + let default = LuaCommonProperty::new(); + let default_property = format!( + "{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}", + default.visibility, + default.deprecated, + default.export, + default.decl_features, + default.version_conds, + default.attribute_uses, + default.default_value, + default.tag_content, + ); + let mut properties: Vec<(String, String)> = db + .get_property_index() + .properties_in_file(file_id) + .into_iter() + .filter_map(|(owner, property)| { + let key = semantic_decl_export_key(ids, owner)?; + // None of these hold a source position, so their `Debug` form + // describes them precisely and stably. + let acted_on = format!( + "{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}", + property.visibility, + property.deprecated, + property.export, + property.decl_features, + property.version_conds, + property.attribute_uses, + // Gates whether a field counts as required, and the + // missing-field diagnostic is reported by the file that + // builds the table, not the one that declares the class. + property.default_value, + // Carries the GMod tag payloads (`@accessorfunc` and + // friends) that other files' call analysis reads. + property.tag_content, + ); + // Writing a doc comment creates a property whose acted-on + // fields are all still default. Registering it would make + // documenting a symbol an export change, which is the case + // excluding the description is meant to avoid. + (acted_on != default_property).then_some((key, acted_on)) + }) + .collect(); + properties.sort_unstable(); + properties.hash(&mut hasher); + } + + // --- Metamethods this file declares --- + // Another file's inference reads these whenever it applies an operator to + // the owning type. + { + let mut operators: Vec = db + .get_operator_index() + .operators_in_file(file_id) + .into_iter() + .map(|operator| { + let mut h = rustc_hash::FxHasher::default(); + // A table owner is a literal's range, which moves on any edit + // above it, so it is identified the same way a `TableConst` is. + match operator.get_owner() { + LuaOperatorOwner::Table(range) => { + "Table".hash(&mut h); + range.file_id.hash(&mut h); + ids.table_ordinal(&range).hash(&mut h); + } + LuaOperatorOwner::Type(type_decl_id) => { + "Type".hash(&mut h); + type_decl_id.get_name().hash(&mut h); + } + } + format!("{:?}", operator.get_op()).hash(&mut h); + // The operator's own range moves on any edit above it; what a + // dependent reads is the function it resolves to. + hash_lua_type_export(ids, &operator.get_operator_func(db), &mut h); + h.finish() + }) + .collect(); + operators.sort_unstable(); + operators.hash(&mut hasher); + } + + // --- Network flows this file declares --- + // Network diagnostics compare a message's writes against its reads across + // files, so changing either half is an export change. + // + // Field by field: `NetSendFlow`, `NetReceiveFlow` and `NetOpEntry` all + // carry the source range of the call they came from, and those move on + // every edit above them. What the peer file's diagnostic reads is the + // message name and the ordered sequence of operations. + if let Some(network) = db.get_gmod_network_index().get_file_data(file_id) { + fn hash_ops(ops: &[NetOpEntry], hasher: &mut impl Hasher) { + for entry in ops { + format!("{:?}", entry.op).hash(hasher); + entry.display_name.hash(hasher); + entry.dynamic.hash(hasher); + format!("{:?}", entry.bits).hash(hasher); + } + } + let mut flows: Vec = Vec::new(); + for flow in &network.send_flows { + let mut h = rustc_hash::FxHasher::default(); + "Send".hash(&mut h); + flow.message_name.hash(&mut h); + format!("{:?}", flow.send_kind).hash(&mut h); + flow.send_display_name.hash(&mut h); + flow.send_target.hash(&mut h); + flow.is_wrapped.hash(&mut h); + hash_ops(&flow.writes, &mut h); + flows.push(h.finish()); + } + for flow in &network.receive_flows { + let mut h = rustc_hash::FxHasher::default(); + "Receive".hash(&mut h); + flow.message_name.hash(&mut h); + flow.reads_opaque.hash(&mut h); + hash_ops(&flow.reads, &mut h); + flows.push(h.finish()); + } + flows.sort_unstable(); + flows.hash(&mut hasher); + } + + // --- Metatable bindings this file declares --- + // `setmetatable(t, mt)` is read by every file that resolves a member + // through `t`. Both halves are table literals, and repointing one at a + // different literal in the same file moves no member, type or signature. + { + let metatable_index = db.get_metatable_index(); + let mut bindings: Vec<(usize, u32, usize)> = Vec::new(); + if let Some(tree) = db.get_vfs().get_syntax_tree(&file_id) { + for table in tree.get_chunk_node().descendants::() { + let range = InFiled::new(file_id, table.get_range()); + let Some(metatable) = metatable_index.get(&range) else { + continue; + }; + let Some(table_ordinal) = ids.table_ordinal(&range) else { + continue; + }; + bindings.push(( + table_ordinal, + metatable.file_id.id, + ids.table_ordinal(metatable).unwrap_or(usize::MAX), + )); + } + } + bindings.sort_unstable(); + bindings.hash(&mut hasher); + } + + // --- The realm each exported symbol is declared in --- + // Realm is first-class: wrapping an existing definition in `if SERVER` + // changes which callers may reach it and which realm-mismatch diagnostics + // other files report, while leaving its name, type and signature alone. + // The offset is used only to look the realm up, never hashed. + { + let gmod_infer = db.get_gmod_infer_index(); + let mut realms: Vec<(String, String)> = Vec::new(); + if let Some(decl_tree) = db.get_decl_index().get_decl_tree(&file_id) { + for (decl_id, decl) in decl_tree.get_decls() { + if decl.is_local() { + continue; + } + let realm = gmod_infer.get_realm_at_offset(&file_id, decl_id.position); + realms.push((format!("D:{}", decl.get_name()), format!("{realm:?}"))); + } + } + for member in db.get_member_index().get_file_members(file_id) { + let mut h = rustc_hash::FxHasher::default(); + hash_lua_member_key_export(ids, member.get_key(), &mut h); + if let Some(owner) = db.get_member_index().get_member_owner(&member.get_id()) { + hash_member_owner_stable(ids, owner, &mut h); + } + let realm = gmod_infer.get_realm_at_offset(&file_id, member.get_id().get_position()); + realms.push((format!("M:{:x}", h.finish()), format!("{realm:?}"))); + } + realms.sort_unstable(); + realms.dedup(); + realms.hash(&mut hasher); + + if let Some(metadata) = gmod_infer.get_realm_file_metadata(&file_id) { + // Field by field, because `branch_realm_ranges` carries the source + // ranges of the `if CLIENT`/`if SERVER` blocks, and those move on + // every edit above them. Which realms the file narrows to is the + // part another file can observe; where the braces sit is not. + format!( + "{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}", + metadata.inferred_realm, + metadata.load_realm, + metadata.load_status, + metadata.load_state_mask, + metadata.filename_hint, + metadata.dependency_hints, + metadata.annotation_realm, + ) + .hash(&mut hasher); + let branch_realms: Vec = metadata + .branch_realm_ranges + .iter() + .map(|range| format!("{:?}", range.realm)) + .collect(); + branch_realms.hash(&mut hasher); } } + // --- What this file exports as a module --- + // `local M = {} ... return M` makes the returned table the module's export + // type, which every `require`/`include` consumer reads. The local itself is + // skipped by the type-cache section, so returning a different table moves + // nothing else. + if let Some(module) = db.get_module_index().get_module(file_id) { + module.full_module_name.hash(&mut hasher); + module.visible.hash(&mut hasher); + module.is_meta.hash(&mut hasher); + format!("{:?}", module.workspace_id).hash(&mut hasher); + format!("{:?}", module.version_conds).hash(&mut hasher); + match &module.export_type { + Some(export_type) => hash_lua_type_export(ids, export_type, &mut hasher), + None => "NoExport".hash(&mut hasher), + } + match module + .semantic_id + .as_ref() + .and_then(|id| semantic_decl_export_key(ids, id)) + { + Some(key) => key.hash(&mut hasher), + None => "NoSemanticId".hash(&mut hasher), + } + } + + // --- Load edges this file declares --- + // Adding or removing an `include`/`require` changes which files load this + // one and in what order, which realm and load-order analysis both read. + // No member, type or signature moves when it happens. + { + let dependency_index = db.get_file_dependencies_index(); + let mut sites: Vec = dependency_index + .get_dependency_sites(&file_id) + .unwrap_or_default() + .iter() + .map(|site| { + // The call's range is left out: it moves on any edit above it, + // and the edge is identified by its target and kind. + format!( + "{:?}|{:?}|{:?}|{}", + site.kind, site.target_file_id, site.path, site.original_expr + ) + }) + .collect(); + sites.sort_unstable(); + sites.hash(&mut hasher); + + let mut required: Vec = dependency_index + .get_required_files(&file_id) + .map(|files| files.iter().map(|file| file.id).collect()) + .unwrap_or_default(); + required.sort_unstable(); + required.hash(&mut hasher); + } + // --- Namespace / using (affects type resolution) --- if let Some(ns) = db.get_type_index().get_file_namespace(&file_id) { ns.hash(&mut hasher); @@ -756,23 +1377,38 @@ fn lexically_normalize_path(path: &Path) -> PathBuf { } #[derive(Debug, Clone, Hash, PartialEq, Eq)] -enum TableAnchor { +pub(crate) enum TableAnchor { Global(String), Local { decl_name: String, - decl_pos: u32, path: String, + /// The field names the literal declares. + /// + /// The declaration's byte position cannot identify it - that is the + /// case the anchor has to survive - and neither can its index among + /// the file's same-named locals, because inserting another `local cfg` + /// above renumbers it and the old anchor would then resolve to the new + /// literal. Duplicate names are routine in Lua, so the field names are + /// the tie-break, and two that still collide are left unanchored. + fields: Vec, }, - /// Path of `(node kind, index among same-kind siblings)` from the chunk - /// root down to the table, for literals that no name reaches. + /// A literal passed to a call that also takes string literals, keyed by + /// the call's path and those strings. /// - /// The whole path is needed, not just the parent's kind: two table - /// literals passed to two different calls both sit at index 0 of a - /// `CallArgList`, and a shared anchor makes both ambiguous, so neither is - /// remapped nor reported deleted and both keep a stale range. - Tree { - path: Vec<(LuaSyntaxKind, usize)>, + /// `registry.Add("first", { ... })` is the dominant shape for a literal no + /// name reaches, and the call's own string arguments identify it without a + /// sibling ordinal - inserting another registration above does not + /// renumber it. + CallArgument { + path: String, + labels: Vec, + arg_index: usize, }, + /// A literal no name and no call reaches, keyed by the field names it + /// declares. Those survive an edit to a field's *value*, which a sibling + /// ordinal would not: inserting another literal above renumbers ordinals, + /// and the old anchor would then resolve to a different literal. + Fields(Vec), } fn expr_path_strings(expr: &LuaExpr) -> Option> { @@ -806,12 +1442,7 @@ fn var_path_strings(var: &glua_parser::LuaVarExpr) -> Option> { } } -#[allow(clippy::only_used_in_recursion)] -fn table_global_path_recursive( - db: &DbIndex, - file_id: FileId, - table: LuaTableExpr, -) -> Option { +fn table_global_path_recursive(table: LuaTableExpr) -> Option { if let Some(field) = table.get_parent::() { let key = field.get_field_key()?; let key_str = match key { @@ -820,7 +1451,7 @@ fn table_global_path_recursive( _ => return None, }; let parent_table = field.get_parent::()?; - let parent_path = table_global_path_recursive(db, file_id, parent_table)?; + let parent_path = table_global_path_recursive(parent_table)?; return Some(format!("{}.{}", parent_path, key_str)); } let mut current = table.syntax().clone(); @@ -830,7 +1461,6 @@ fn table_global_path_recursive( for (var, expr) in vars.iter().zip(exprs.iter()) { if expr.get_range() == table.get_range() { if let Some(mut path) = var_path_strings(var) { - // Canonicalize _G / _ENV prefix like global_path_for_expr if path.len() > 1 && matches!(path[0].as_str(), "_G" | "_ENV") { path.remove(0); } @@ -848,7 +1478,26 @@ fn table_global_path_recursive( None } +/// The field names a table literal declares, sorted. +/// +/// They survive an edit to a field's *value*, which is what makes them usable +/// as identity, and they distinguish literals that no name singles out. +fn table_field_names(table: &LuaTableExpr) -> Vec { + let mut fields: Vec = table + .get_fields() + .filter_map(|field| match field.get_field_key()? { + glua_parser::LuaIndexKey::Name(name) => Some(name.get_name_text().to_string()), + glua_parser::LuaIndexKey::String(text) => Some(text.get_value().to_string()), + glua_parser::LuaIndexKey::Integer(number) => Some(format!("{number:?}")), + _ => None, + }) + .collect(); + fields.sort_unstable(); + fields +} + fn table_local_anchor(db: &DbIndex, file_id: FileId, table: LuaTableExpr) -> Option { + let fields = table_field_names(&table); let mut parts: Vec = Vec::new(); let mut cur = table; loop { @@ -880,7 +1529,7 @@ fn table_local_anchor(db: &DbIndex, file_id: FileId, table: LuaTableExpr) -> Opt let name_text = name.get_name_token()?.get_name_text().to_string(); return Some(TableAnchor::Local { decl_name: name_text, - decl_pos: u32::from(name.get_position()), + fields, path, }); } @@ -900,15 +1549,13 @@ fn table_local_anchor(db: &DbIndex, file_id: FileId, table: LuaTableExpr) -> Opt let path = parts.join("."); return Some(TableAnchor::Local { decl_name: decl.get_name().to_string(), - decl_pos: u32::from(decl.get_id().position), + fields, path, }); } glua_parser::LuaVarExpr::IndexExpr(_) => { - // Handle `t.inner = {}` where `t` is a local let var_path = var_path_strings(var)?; let root_name = var_path.first()?.clone(); - // Find the leftmost NameExpr for the root to get its position let root_name_expr = var .syntax() .descendants() @@ -916,7 +1563,6 @@ fn table_local_anchor(db: &DbIndex, file_id: FileId, table: LuaTableExpr) -> Opt let decl_tree = db.get_decl_index().get_decl_tree(&file_id)?; let decl = decl_tree.find_local_decl(&root_name, root_name_expr.get_position())?; - // var_path is e.g. ["t","inner"] or ["t","a","b"] let suffix = if var_path.len() > 1 { var_path[1..].join(".") } else { @@ -934,7 +1580,7 @@ fn table_local_anchor(db: &DbIndex, file_id: FileId, table: LuaTableExpr) -> Opt let final_path = combined.join("."); return Some(TableAnchor::Local { decl_name: decl.get_name().to_string(), - decl_pos: u32::from(decl.get_id().position), + fields, path: final_path, }); } @@ -944,56 +1590,101 @@ fn table_local_anchor(db: &DbIndex, file_id: FileId, table: LuaTableExpr) -> Opt } } -fn table_tree_anchor(table: LuaTableExpr) -> TableAnchor { - let mut path = Vec::new(); - let mut node = table.syntax().clone(); - while let Some(parent) = node.parent() { - let kind = node.kind(); - let nth = parent - .children() - .filter(|sibling| sibling.kind() == kind) - .position(|sibling| sibling == node) - .unwrap_or(0); - path.push((kind.into(), nth)); - node = parent; - } - path.reverse(); - TableAnchor::Tree { path } +/// The call a table literal is an argument to, described by the call's path +/// and its literal string arguments. +/// +/// `registry.Add("first", { ... })` is the dominant shape for a table literal +/// no name reaches, and this identifies it without a sibling ordinal, so +/// inserting another registration above does not renumber it. +fn table_call_argument_anchor(table: &LuaTableExpr) -> Option { + let arg_list = table.syntax().parent()?; + let call = LuaCallExpr::cast(arg_list.parent()?)?; + let path = var_path_strings(&glua_parser::LuaVarExpr::cast( + call.get_prefix_expr()?.syntax().clone(), + )?)? + .join("."); + + let mut arg_index = 0; + let mut labels = Vec::new(); + for (index, arg) in call.get_args_list()?.get_args().enumerate() { + match &arg { + LuaExpr::LiteralExpr(literal) => { + if let Some(glua_parser::LuaLiteralToken::String(text)) = literal.get_literal() { + labels.push(format!("{index}:{}", text.get_value())); + } + } + _ => { + if arg.get_range() == table.get_range() { + arg_index = index; + } + } + } + } + (!labels.is_empty()).then_some(TableAnchor::CallArgument { + path, + labels, + arg_index, + }) } type AnchorMaps = rustc_hash::FxHashMap>>; -fn collect_anchored_map( +pub(crate) fn collect_anchored_map( db: &DbIndex, file_id: FileId, ) -> rustc_hash::FxHashMap> { - use rustc_hash::{FxHashMap, FxHashSet}; + use rustc_hash::FxHashMap; let Some(tree) = db.get_vfs().get_syntax_tree(&file_id) else { return FxHashMap::default(); }; let chunk = tree.get_chunk_node(); + + // Two passes, because an anchor is only usable if it singles its literal + // out. Both the name a literal is reached by and the field names it + // declares survive an edit elsewhere in the file; the sibling ordinals in + // a tree path do not. So the most durable unique key wins, and the + // ordinals are added only to break a tie between literals that are + // otherwise indistinguishable. + let candidates: Vec<(Option, LuaTableExpr)> = chunk + .descendants::() + .map(|table| { + let named = table_global_path_recursive(table.clone()) + .map(TableAnchor::Global) + .or_else(|| table_local_anchor(db, file_id, table.clone())) + .or_else(|| table_call_argument_anchor(&table)) + .or_else(|| { + let fields = table_field_names(&table); + (!fields.is_empty()).then_some(TableAnchor::Fields(fields)) + }); + (named, table) + }) + .collect(); + + let mut counts: FxHashMap<&TableAnchor, usize> = FxHashMap::default(); + for (anchor, _) in &candidates { + if let Some(anchor) = anchor { + *counts.entry(anchor).or_default() += 1; + } + } + let ambiguous: rustc_hash::FxHashSet = counts + .into_iter() + .filter(|(_, count)| *count > 1) + .map(|(anchor, _)| anchor.clone()) + .collect(); + + // A literal with no unique durable key is left out entirely. The only key + // left for it is the sibling ordinals, and those renumber when anything is + // inserted above, so an old anchor would resolve to a *different* literal + // and the remap would re-home its members onto the wrong table. Leaving it + // out costs a stale range, which a later re-index corrects; re-homing it + // writes a wrong one that nothing does. let mut map: FxHashMap> = FxHashMap::default(); - let mut ambiguous: FxHashSet = FxHashSet::default(); - for table in chunk.descendants::() { - let range = InFiled::new(file_id, table.get_range()); - let anchor = if let Some(global) = table_global_path_recursive(db, file_id, table.clone()) { - TableAnchor::Global(global) - } else if let Some(local) = table_local_anchor(db, file_id, table.clone()) { - local - } else { - table_tree_anchor(table) - }; - if ambiguous.contains(&anchor) { + for (anchor, table) in candidates { + let Some(anchor) = anchor.filter(|anchor| !ambiguous.contains(anchor)) else { continue; - } - #[allow(clippy::map_entry)] - if map.contains_key(&anchor) { - map.remove(&anchor); - ambiguous.insert(anchor); - } else { - map.insert(anchor, range); - } + }; + map.insert(anchor, InFiled::new(file_id, table.get_range())); } map } @@ -1007,6 +1698,16 @@ pub struct EmmyLuaAnalysis { pub(crate) inferred_guard_propagation_stats: InferredGuardPropagationStats, #[cfg(test)] cross_file_stabilization_invocations: usize, + /// Guard facts as they stood before a self-index overwrote them. + /// + /// The LSP splits one edit across two calls: it self-indexes the edited + /// files to answer requests inside them, then pays the ripple later. + /// Guard propagation has to diff against the facts from before the + /// self-index, so they are carried across the gap. + pending_guard_snapshot: Option, + /// Export fingerprints taken before a VFS mutation, for the paths that + /// write the text and re-index later. See [`Self::stash_pre_edit_state`]. + pending_export_fingerprints: rustc_hash::FxHashMap, pending_table_ranges: rustc_hash::FxHashMap< FileId, rustc_hash::FxHashMap>, @@ -1024,6 +1725,8 @@ impl EmmyLuaAnalysis { inferred_guard_propagation_stats: InferredGuardPropagationStats::default(), #[cfg(test)] cross_file_stabilization_invocations: 0, + pending_guard_snapshot: None, + pending_export_fingerprints: rustc_hash::FxHashMap::default(), pending_table_ranges: rustc_hash::FxHashMap::default(), } } @@ -1158,22 +1861,24 @@ impl EmmyLuaAnalysis { // a file that is gone. It takes the full path below, which filters // removed files out of `update_index` and seeds VGUI forwarding removal. if let Some(existing) = existing_file_id.filter(|_| text.is_some()) { - // Capture fingerprint and expansion before the VFS mutation. - // Expansion must be captured before reindexing the edited file, as - // in the original `update_file_by_uri` path: dependents are those - // that reference the file's *old* exports (e.g. a call site that - // already references `Predicates.IsPlayer`), and computing it after - // `self_index_files` would miss them (observed: guard addition - // expansion went from 2 to 1 and the consumer stayed `Entity`). - let before_fp = file_export_fingerprint(self.compilation.get_db(), existing); + // Both are taken before the VFS mutation. A dependent is a file + // that references this file's *old* exports, so an expansion + // computed after the re-index would not contain it. + let before_fp = self.take_pre_edit_fingerprint(existing); let before_expansion = self.expand_reindex_file_ids(vec![existing]); let old_guard_snapshot = self .inferred_guard_snapshot(&before_expansion.iter().copied().collect::>()); - // For files that define inferred guards or VGUI forwarding, the - // `self_index` shortcut would clobber the `old_guard_snapshot` and - // VGUI metadata needed for correct ripple. Fall back to the original - // full reindex path for those (observed: guard addition stayed - // `Entity` and VGUI deletion left stale parent chain). + // Inferred guards and VGUI forwarding are derived from state the + // self-index clears and the ripple then rebuilds from, so for a + // file that carries either, "did the exports change" is not a + // question the fingerprint can answer: the facts it would compare + // are gone by the time it looks. Those files take the full path. + // + // This is a correctness requirement rather than a performance + // prefilter - removing it makes + // `test_fact_preserving_guard_reindex_keeps_full_incremental_consumer_chain` + // fail, because the consumer chain is rebuilt from facts the + // self-index has already dropped. let is_special = { let db = self.compilation.get_db(); !db.get_signature_index() @@ -1185,6 +1890,7 @@ impl EmmyLuaAnalysis { }; if is_special { let old_maps = self.take_old_anchor_maps(&[existing]); + self.pending_export_fingerprints.remove(&existing); let file_id = self .compilation .get_db_mut() @@ -1195,16 +1901,11 @@ impl EmmyLuaAnalysis { before_expansion, old_guard_snapshot, ); - self.apply_table_remap(old_maps); + self.apply_table_remap(old_maps, &[file_id]); profile::phase_report("update_file_by_uri"); return Some(file_id); } - let anchored = collect_anchored_map(self.compilation.get_db(), existing); - if !anchored.is_empty() { - self.pending_table_ranges - .entry(existing) - .or_insert(anchored); - } + self.stash_pre_edit_anchors(existing); let file_id = self .compilation .get_db_mut() @@ -1232,17 +1933,13 @@ impl EmmyLuaAnalysis { // derived in the same batch as its dependents' for the pass to // converge, and it is one file out of an expansion in the thousands. // - // Use the pre-computed expansion - // (before the edit) so that call-site dependents that already - // reference the old exports are included. Computing after - // `self_index_files` missed them (observed: guard consumer went from 2 - // to 1 and stayed `Entity`). Use the old guard snapshot for the - // guard propagation, which must be captured before `self_index` overwrites it. - let expansion = before_expansion; + // The expansion and the guard snapshot are the ones taken before + // the edit, so guard propagation diffs against the facts the + // self-index has already overwritten. profile::phase("edit/ripple", || { self.reindex_expanded_files_with_old_snapshot( vec![file_id], - expansion, + before_expansion, old_guard_snapshot, ) }); @@ -1250,7 +1947,11 @@ impl EmmyLuaAnalysis { return Some(file_id); } - // New file - no fingerprint to compare, fall back to full expansion. + // A new file, or a deletion. Neither has a useful before-fingerprint, + // so both take the full expansion. + let old_maps = existing_file_id + .map(|file_id| self.take_old_anchor_maps(&[file_id])) + .unwrap_or_default(); let file_id = self .compilation .get_db_mut() @@ -1260,6 +1961,11 @@ impl EmmyLuaAnalysis { profile::phase("edit/reindex", || { self.reindex_expanded_files(vec![file_id], expansion) }); + // A deleted file has no tree, so every one of its literals is gone and + // its Element owners are purged. That is what has to happen: the + // members other files own on them are not reachable from any file the + // re-index visited. + self.apply_table_remap(old_maps, &[file_id]); profile::phase_report("update_file_by_uri"); Some(file_id) @@ -1311,8 +2017,13 @@ impl EmmyLuaAnalysis { } if trigger_reindex { - self.compilation.remove_index(vec![file_id]); - self.compilation.update_index(vec![file_id]); + // Through `self_index_files`, so the anchor stash an + // earlier text-only write left is consumed and applied. + // Re-indexing without it leaves the stash describing a tree + // two edits back, and the next edit would then remap from + // ranges the index no longer holds. + self.self_index_files(vec![file_id]); + self.pending_export_fingerprints.remove(&file_id); } self.compilation @@ -1356,10 +2067,7 @@ impl EmmyLuaAnalysis { let old_maps = match existing_file_id { Some(fid) if trigger_reindex => self.take_old_anchor_maps(&[fid]), Some(fid) => { - let anchored = collect_anchored_map(self.compilation.get_db(), fid); - if !anchored.is_empty() { - self.pending_table_ranges.entry(fid).or_insert(anchored); - } + self.stash_pre_edit_state(fid); AnchorMaps::default() } None => AnchorMaps::default(), @@ -1396,7 +2104,12 @@ impl EmmyLuaAnalysis { &incremental_source_file_ids, ); self.reindex_changed_inferred_param_consumers(&old_guard_facts, &reindex_file_ids); - self.apply_table_remap(old_maps); + self.apply_table_remap(old_maps, &[file_id]); + // Settled against the current text now, so any stashed fingerprint + // describes a state that no longer exists. + for reindexed in &reindex_file_ids { + self.pending_export_fingerprints.remove(reindexed); + } } Some(file_id) @@ -1443,10 +2156,7 @@ impl EmmyLuaAnalysis { } if let Some(fid) = existing_file_id { - let anchored = collect_anchored_map(self.compilation.get_db(), fid); - if !anchored.is_empty() { - self.pending_table_ranges.entry(fid).or_insert(anchored); - } + self.stash_pre_edit_state(fid); } self.compilation @@ -1472,12 +2182,7 @@ impl EmmyLuaAnalysis { return Some(file_id); } } - // Stash old table anchors before VFS mutation so self_index can remap - // Element owners and TableConst that shifted due to earlier edits. - let anchored = collect_anchored_map(self.compilation.get_db(), file_id); - if !anchored.is_empty() { - self.pending_table_ranges.entry(file_id).or_insert(anchored); - } + self.stash_pre_edit_state(file_id); } let file_id = self @@ -1508,46 +2213,7 @@ impl EmmyLuaAnalysis { /// 8 and the workspace ended up with 18 diagnostics that a cold build does /// not produce. pub fn reindex_expanded_files(&mut self, file_ids: Vec, expansion: Vec) { - let incremental_source_file_ids = file_ids.iter().copied().collect::>(); - let removed_file_ids = file_ids - .iter() - .copied() - .filter(|file_id| { - self.compilation - .get_db() - .get_vfs() - .get_syntax_tree(file_id) - .is_none() - }) - .collect::>(); - - let mut file_ids = expansion.clone(); - self.add_vgui_forwarding_removal_seed(&removed_file_ids, &mut file_ids); - let guard_fact_file_ids = file_ids.iter().copied().collect::>(); - let old_guard_facts = self.inferred_guard_snapshot(&guard_fact_file_ids); - self.compilation.remove_index(file_ids.clone()); - let update_file_ids = file_ids - .iter() - .copied() - .filter(|file_id| !removed_file_ids.contains(file_id)) - .collect::>(); - if !update_file_ids.is_empty() { - self.compilation.update_index(update_file_ids.clone()); - self.stabilize_cross_file_type_caches(&update_file_ids); - } - for file_id in &incremental_source_file_ids { - self.compilation - .get_db_mut() - .get_call_site_param_index_mut() - .refresh_file_source_dependencies(*file_id); - } - self.reindex_changed_inferred_guard_references( - &guard_fact_file_ids, - &old_guard_facts, - &file_ids, - &incremental_source_file_ids, - ); - self.reindex_changed_inferred_param_consumers(&old_guard_facts, &file_ids); + self.reindex_expanded_files_inner(file_ids, expansion, None); } pub(crate) fn reindex_expanded_files_with_old_snapshot( @@ -1555,6 +2221,20 @@ impl EmmyLuaAnalysis { file_ids: Vec, expansion: Vec, old_snapshot: InferredGuardSnapshot, + ) { + self.reindex_expanded_files_inner(file_ids, expansion, Some(old_snapshot)); + } + + /// Re-analyses `expansion` with `file_ids` as the files that changed. + /// + /// `old_snapshot` is the guard facts to diff propagation against. Pass the + /// snapshot taken before a self-index overwrote them; `None` takes one now, + /// which is only correct when nothing has re-indexed since. + fn reindex_expanded_files_inner( + &mut self, + file_ids: Vec, + expansion: Vec, + old_snapshot: Option, ) { let incremental_source_file_ids = file_ids.iter().copied().collect::>(); let removed_file_ids = file_ids @@ -1569,10 +2249,15 @@ impl EmmyLuaAnalysis { }) .collect::>(); - let mut file_ids = expansion.clone(); + let mut file_ids = expansion; self.add_vgui_forwarding_removal_seed(&removed_file_ids, &mut file_ids); let guard_fact_file_ids = file_ids.iter().copied().collect::>(); - let old_guard_facts = old_snapshot; + // A self-index may already have overwritten the guard facts this + // ripple has to diff against, in which case the snapshot from before + // it was stashed for us. + let old_guard_facts = old_snapshot + .or_else(|| self.pending_guard_snapshot.take()) + .unwrap_or_else(|| self.inferred_guard_snapshot(&guard_fact_file_ids)); self.compilation.remove_index(file_ids.clone()); let update_file_ids = file_ids .iter() @@ -1583,6 +2268,11 @@ impl EmmyLuaAnalysis { self.compilation.update_index(update_file_ids.clone()); self.stabilize_cross_file_type_caches(&update_file_ids); } + // These files are settled against their current text now, so a + // fingerprint stashed for one describes a state that no longer exists. + for file_id in &file_ids { + self.pending_export_fingerprints.remove(file_id); + } for file_id in &incremental_source_file_ids { self.compilation .get_db_mut() @@ -1598,16 +2288,47 @@ impl EmmyLuaAnalysis { self.reindex_changed_inferred_param_consumers(&old_guard_facts, &file_ids); } - /// Rebuilds only these files' own index entries. + /// Records the anchors the index's stored `Element` ranges correspond to, + /// unless an earlier edit already recorded some. /// - /// Nothing cross-file is settled: dependents keep whatever they inferred - /// before, and the caller still owes them a - /// [`reindex_expanded_files`](Self::reindex_expanded_files) against an - /// expansion captured beforehand. What this does buy is that the edited - /// file's declarations, members and signatures line up with its text again, - /// which is all a request positioned *inside that file* needs — the index - /// entries are keyed by position, so an edit that shifts offsets is exactly - /// what makes them stop matching the tree. + /// The oldest stash is the one that matches the index: a write that does + /// not re-index leaves the index describing the text from before it, so + /// that is the state the stored ranges belong to. + fn stash_pre_edit_anchors(&mut self, file_id: FileId) { + if self.pending_table_ranges.contains_key(&file_id) { + return; + } + let anchored = collect_anchored_map(self.compilation.get_db(), file_id); + if !anchored.is_empty() { + self.pending_table_ranges.insert(file_id, anchored); + } + } + + /// The same, plus the export fingerprint, for the paths that write the text + /// now and re-index later. + /// + /// The fingerprint has to be taken here for the same reason the anchors do: + /// once the new text is parsed it would read the old index against the new + /// tree, and report a change for every edit that shifts a table literal. + fn stash_pre_edit_state(&mut self, file_id: FileId) { + self.stash_pre_edit_anchors(file_id); + // Independent of the anchors: a file with no table literals stashes no + // anchors, and one stash must not suppress the other. + if !self.pending_export_fingerprints.contains_key(&file_id) { + let fingerprint = file_export_fingerprint(self.compilation.get_db(), file_id); + self.pending_export_fingerprints + .insert(file_id, fingerprint); + } + } + + /// The file's export fingerprint as it stood before the edit: the stashed + /// one when a write has already landed, otherwise one taken now. + fn take_pre_edit_fingerprint(&mut self, file_id: FileId) -> u64 { + self.pending_export_fingerprints + .remove(&file_id) + .unwrap_or_else(|| file_export_fingerprint(self.compilation.get_db(), file_id)) + } + /// The anchor map the index's stored `Element` ranges correspond to. /// /// An edit stashes this before mutating the VFS, because the tree those @@ -1637,20 +2358,48 @@ impl EmmyLuaAnalysis { /// other file's reference to one of its `Element` owners keeps the old /// offset, so without this they point into the wrong table after any edit /// that shifts offsets. - fn apply_table_remap(&mut self, mut old_maps: AnchorMaps) { - if old_maps.is_empty() { - return; - } + fn apply_table_remap(&mut self, mut old_maps: AnchorMaps, file_ids: &[FileId]) { let mut global_remap: rustc_hash::FxHashMap< InFiled, InFiled, > = rustc_hash::FxHashMap::default(); let mut deleted: Vec> = Vec::new(); - let file_ids: Vec = old_maps.keys().copied().collect(); - for fid in file_ids { - let Some(old_map) = old_maps.remove(&fid) else { + // Driven off the files being re-indexed, not off the stashed anchors: a + // removed file whose literals were all unnameable stashes nothing, and + // its owners still have to go. + for fid in file_ids.iter().copied() { + let old_map = old_maps.remove(&fid).unwrap_or_default(); + // A file with no tree has been removed. Only then does an anchor + // that no longer resolves mean the literal is gone: while the file + // is still there, a mismatch can equally be a heuristic the anchor + // did not survive, and purging on that basis destroys members other + // files own with nothing left to rebuild them. Leaving the entry + // stale is recoverable; deleting it is not. + let file_removed = self + .compilation + .get_db() + .get_vfs() + .get_syntax_tree(&fid) + .is_none(); + if old_map.is_empty() && !file_removed { + // Nothing stashed and the file is still there, so there is no + // range to move and none to purge. Skipping here avoids a + // `collect_anchored_map` tree walk per file, which the batch + // path would otherwise pay for every file it touches. continue; - }; + } + if file_removed { + // Every literal in it is gone, not just the ones an anchor + // reached: `collect_anchored_map` leaves out literals it cannot + // name uniquely, and members other files own on those are not + // reachable from any file the removal sweeps. + deleted.extend( + self.compilation + .get_db() + .get_member_index() + .element_owner_ranges_in_file(fid), + ); + } let new_map = collect_anchored_map(self.compilation.get_db(), fid); for (anchor, old_range) in old_map { match new_map.get(&anchor) { @@ -1658,7 +2407,8 @@ impl EmmyLuaAnalysis { global_remap.insert(old_range, new_range.clone()); } Some(_) => {} - None => deleted.push(old_range), + None if file_removed => deleted.push(old_range), + None => {} } } } @@ -1667,20 +2417,39 @@ impl EmmyLuaAnalysis { let db = self.compilation.get_db_mut(); db.get_member_index_mut().remap_elements(&global_remap); db.get_type_index_mut().remap_table_const(&global_remap); + // Beyond the type cache and the member owner, the one store that + // can hold *another* file's literal range: a write registers a + // dynamic field on a table it does not declare. + db.get_dynamic_field_index_mut() + .remap_table_ranges(&global_remap); } if !deleted.is_empty() { - self.compilation - .get_db_mut() + let db = self.compilation.get_db_mut(); + let forgotten = db .get_member_index_mut() .remove_deleted_element_owners(&deleted); + // Their cached types would otherwise outlive them: the files those + // members belong to are not the ones being re-analysed. + db.get_type_index_mut() + .remove_member_type_caches(&forgotten); } } + /// Rebuilds only these files' own index entries. + /// + /// Nothing cross-file is settled: dependents keep whatever they inferred + /// before, and the caller still owes them a + /// [`reindex_expanded_files`](Self::reindex_expanded_files) against an + /// expansion captured beforehand. What this does buy is that the edited + /// file's declarations, members and signatures line up with its text again, + /// which is all a request positioned *inside that file* needs — the index + /// entries are keyed by position, so an edit that shifts offsets is exactly + /// what makes them stop matching the tree. pub fn self_index_files(&mut self, file_ids: Vec) { let old_maps = self.take_old_anchor_maps(&file_ids); self.compilation.remove_index(file_ids.clone()); - self.compilation.update_index(file_ids); - self.apply_table_remap(old_maps); + self.compilation.update_index(file_ids.clone()); + self.apply_table_remap(old_maps, &file_ids); } pub fn self_index_files_and_get_ripple_with_changed( @@ -1701,20 +2470,29 @@ impl EmmyLuaAnalysis { }); if has_special { let expansion = self.expand_reindex_file_ids(file_ids.clone()); + let snapshot = + self.inferred_guard_snapshot(&expansion.iter().copied().collect::>()); + self.pending_guard_snapshot.get_or_insert(snapshot); self.self_index_files(file_ids.clone()); return (file_ids, expansion); } + // The text is already written by the time the editor path reaches + // here, so a fingerprint taken now would read the old index against the + // new tree. Whoever wrote the text stashed one taken before it. let mut before_fps = HashMap::new(); for fid in &file_ids { - before_fps.insert( - *fid, - file_export_fingerprint(self.compilation.get_db(), *fid), - ); + before_fps.insert(*fid, self.take_pre_edit_fingerprint(*fid)); } // Expansion must be captured before self_index, or dependents that // reference the old exports are missed. let before_expansion = self.expand_reindex_file_ids(file_ids.clone()); + // The oldest snapshot in a burst is the one the ripple has to diff + // against: later batches see facts the earlier self-indexes already + // overwrote. + let snapshot = + self.inferred_guard_snapshot(&before_expansion.iter().copied().collect::>()); + self.pending_guard_snapshot.get_or_insert(snapshot); self.self_index_files(file_ids.clone()); self.stabilize_cross_file_type_caches(&file_ids); let mut changed = Vec::new(); @@ -1725,14 +2503,14 @@ impl EmmyLuaAnalysis { } } if changed.is_empty() { + // The guard snapshot is left alone: an earlier batch in this burst + // may still owe a ripple that has to diff against it. return (Vec::new(), Vec::new()); } - // For the common non-special case the before expansion already - // contains the dependents of the changed files; filtering it to the - // changed subset would require per-file tracking, but the over-ripple - // is at most the same as the full edit and still <1s for a single-file - // hub edit when fingerprints are stable (observed sh_configuration 0.45s). - // Keep the before expansion for now. + // The before expansion already contains the dependents of the changed + // files. Narrowing it to just those would need per-file dependent + // tracking, and over-rippling here costs at most what the edit would + // have cost without the fingerprint at all. (changed, before_expansion) } @@ -2371,6 +3149,13 @@ impl EmmyLuaAnalysis { .filter(|(_, text)| text.is_none()) .filter_map(|(uri, _)| self.compilation.get_db().get_vfs().get_file_id(uri)) .collect::>(); + // Taken before the writes below, as on every other edit path: the + // expansion re-derives each dependent's *type caches*, but a member + // another file owns on a literal here is not reached by that, so the + // ranges still have to be re-homed. + let mut remap_source_file_ids: Vec = old_source_file_ids.iter().copied().collect(); + remap_source_file_ids.sort_unstable(); + let old_anchor_maps = self.take_old_anchor_maps(&remap_source_file_ids); let mut old_guard_fact_file_ids = self.expand_reindex_file_ids(old_source_file_ids.iter().copied().collect()); self.add_vgui_forwarding_removal_seed( @@ -2517,6 +3302,7 @@ impl EmmyLuaAnalysis { let _p = Profile::new("post: stabilize_cross_file_type_caches"); self.stabilize_cross_file_type_caches(&updated_files); } + self.apply_table_remap(old_anchor_maps, &remap_source_file_ids); { let _p = Profile::new("post: refresh_file_source_dependencies"); for file_id in &old_source_file_ids { @@ -2656,6 +3442,9 @@ impl EmmyLuaAnalysis { self.add_vgui_forwarding_removal_seed(&removed_file_ids, &mut reindex_file_ids); let guard_fact_file_ids = reindex_file_ids.iter().copied().collect::>(); let old_guard_facts = self.inferred_guard_snapshot(&guard_fact_file_ids); + // Members other files own on this file's table literals are filed + // under *their* file, so `remove_index` never reaches them. + let old_maps = self.take_old_anchor_maps(&[file_id]); self.compilation .get_db_mut() .get_vfs_mut() @@ -2684,6 +3473,8 @@ impl EmmyLuaAnalysis { &reindex_file_ids, &HashSet::new(), ); + self.apply_table_remap(old_maps, &[file_id]); + self.pending_export_fingerprints.remove(&file_id); return Some(file_id); } From d8fef95e995fa3a1dbef7648b97d4191f3f54163 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:59:34 +0100 Subject: [PATCH 064/159] chore: cleanup --- .../src/compilation/analyzer/gmod/mod.rs | 585 +----------------- .../analyzer/lua/for_range_stat.rs | 21 +- .../lua/member_write_policy/scalar.rs | 7 - .../src/compilation/analyzer/lua/mod.rs | 3 - .../src/compilation/analyzer/lua/stats.rs | 259 +------- crates/glua_code_analysis/src/lib.rs | 410 +----------- crates/glua_doc_cli/src/cmd_args.rs | 2 - .../glua_ls/src/context/debounced_analysis.rs | 142 +---- .../glua_ls/src/handlers/test/hover_test.rs | 39 -- crates/glua_parser/src/grammar/lua/expr.rs | 2 - crates/glua_parser/src/grammar/lua/test.rs | 2 - crates/glua_parser/src/lexer/mod.rs | 3 - crates/glua_parser/src/syntax/mod.rs | 5 - .../glua_parser/src/syntax/node/lua/expr.rs | 26 - .../src/syntax/node/lua/path_trait.rs | 14 - .../src/syntax/node/token/number_analyzer.rs | 3 - crates/glua_parser/src/text/reader.rs | 32 - tools/benchmark/src/main.rs | 47 +- tools/lsp_latency.js | 112 +--- 19 files changed, 59 insertions(+), 1655 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 b2df99c31..19c18f31d 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs @@ -140,11 +140,6 @@ fn scan_gmod_keywords( } /// Pre-analysis phase: runs BEFORE lua_analyze. -/// Collects purely syntactic metadata (hooks, network, realm, scripted class -/// type declarations) so that lua_analyze has correct realm keys and scripted -/// class types available from the start. Without them lua_analyze would see -/// `GmodRealm::Unknown` and the unresolve phase would have to recompute every -/// flow once the realm became known. pub struct GmodPreAnalysisPipeline; impl AnalysisPipeline for GmodPreAnalysisPipeline { @@ -171,19 +166,8 @@ impl AnalysisPipeline for GmodPreAnalysisPipeline { let mut t_scoped = std::time::Duration::ZERO; let mut profile = do_profile.then(GmodPreProfile::default); - // The registry is derived by scanning the entire signature index, so the - // cache key has to cover how much of that index existed when it was - // built — not just VFS content. Keying on content alone let a registry - // built during an earlier workspace group (with fewer files indexed) be - // served to a later group that could see more. + // Cache key covers signature index size. let helper_revision = helper_registry_revision(db); - // `collect_gmod_call_sites` already built this pair for every group - // before any group entered resolution, so reuse it whenever the - // signature index has not grown since — deriving it again means - // another fold over the whole signature index. - // - // On a miss the rebuild is served from the per-file scan cache, so it - // only re-derives the files that changed. let reusable_roles = context .gmod_global_call_roles .as_ref() @@ -220,11 +204,6 @@ impl AnalysisPipeline for GmodPreAnalysisPipeline { } // Per-file metadata collection is read-only against `&DbIndex` (it only - // reads the reference/decl indexes built by earlier passes plus each - // file's own AST), so it runs in parallel across files. The collected - // results are merged into the db sequentially afterward in file order to - // preserve identical behavior. The scoped-class (`is_in_scope`) work - // mutates the db and stays in the sequential merge loop. let s_collect = do_profile.then(std::time::Instant::now); let collect_file_ids: Vec = tree_list.iter().map(|tree| tree.file_id).collect(); let collected = crate::profile::phase("gmodpre/collect_file_metadata", || { @@ -461,22 +440,6 @@ impl GmodPreProfile { } } -/// Post-analysis phase: runs AFTER lua_analyze. -/// Synthesizes members that depend on metadata collected during lua_analyze -/// (gmod_class_metadata_index: AccessorFunc, NetworkVar, VGUI register calls). -/// Collects GMod `net` message flows. -/// -/// This runs at the very end of the batch, after declaration, doc, lua and -/// unresolve analysis, because flow collection *reads* what those produce: -/// resolving `net.Start`/`net.Send` reached through a wrapper needs the -/// wrapper's signature, its receiver's type, and the members those depend on. -/// -/// Collecting any earlier would let the collector see a poorer index on a cold -/// build than on a re-index, which makes `gmod-net-*` diagnostics change across -/// the workspace after the first edit. Nothing in the analysis pipeline reads -/// the network index (only diagnostics do), so collecting it last costs nothing -/// and is the only point at which the input state is the same for a cold build -/// and a partial re-index. pub struct GmodNetworkAnalysisPipeline; impl AnalysisPipeline for GmodNetworkAnalysisPipeline { @@ -491,10 +454,6 @@ impl AnalysisPipeline for GmodNetworkAnalysisPipeline { } let _p = Profile::cond_new("gmod net-analyze", tree_list.len() > 1); - // The gmod pre-pass already built these for this batch; reuse them - // unless the signature index has grown since (the revision covers that). - // On a miss the rebuild is served from the per-file scan cache, so it - // only re-derives the files that changed. let helper_revision = helper_registry_revision(db); let reusable_roles = context .gmod_global_call_roles @@ -562,8 +521,6 @@ fn collect_file_network_flows( let mut local_fns = LocalFnCache::default(); let mut net = NetCallResolver::default(); // One memo for both walks: the receive walk and the three send walks start - // from the same call expressions and reach the same helpers, so a shared - // memo resolves each of them once. let mut resolve_memo = ResolveMemo::default(); let (_, _, _, receive_flows) = crate::profile::phase("gmodnet/receive_walk", || { @@ -616,8 +573,6 @@ impl AnalysisPipeline for GmodPostAnalysisPipeline { }); // Resolve scripted_ents.GetMember delegations BEFORE synthesizing - // members so that NetworkVar calls copied from target entities are - // picked up by synthesize_scripted_class_members. let t_deleg = do_profile.then(std::time::Instant::now); crate::profile::phase("gmodpost/getmember_delegations", || { resolve_getmember_network_var_delegations(db, &scripted_scope_files, context) @@ -631,8 +586,6 @@ impl AnalysisPipeline for GmodPostAnalysisPipeline { let t_class = do_profile.then(std::time::Instant::now); // Same per-file cached scan the net pass uses. Folding the signature - // index directly here took its `HashMap` iteration order, so a call path - // defined by two files resolved differently between processes. let (_, annotated_global_call_roles) = crate::profile::phase("gmodpost/call_roles_and_registry", || { build_call_roles_and_registry(db) @@ -741,10 +694,6 @@ fn collect_annotated_scripted_class_calls( } /// A db write produced by the per-file annotated call-site scan. -/// -/// The scan itself reads only the file's own AST plus immutable db state; the -/// writes are buffered so the scan can run off the caller's thread and be -/// applied afterwards in the original file-then-call order. enum PendingCallSite { VguiParent(GmodVguiParentCallMetadata), ScriptedClass(GmodScriptedClassCallKind, GmodScriptedClassCallMetadata), @@ -832,12 +781,6 @@ fn collect_annotated_call_sites_with( } } -/// Scripted-class registration and `load`-style call sites for one -/// workspace group, collected before *any* group resolves. -/// -/// The role map is stashed on the context because `GmodPreAnalysisPipeline` -/// needs the same one; rebuilding it there would repeat a full signature-index -/// fold per group. pub(crate) fn collect_gmod_call_sites(db: &mut DbIndex, context: &mut AnalyzeContext) { if !db.get_emmyrc().gmod.enabled { return; @@ -889,19 +832,11 @@ fn collect_annotated_load_dependency_site( } /// Workspace-global registry of helper function definitions, stored as -/// `(FileId, LuaSyntaxId)` rather than live red-tree nodes so the registry is -/// `Send + Sync` and can be shared across the parallel per-file collection -/// workers. Each entry is resolved back to a `(LuaBlock, LuaChunk)` on demand by -/// rebuilding the owning file's red tree from the (Send) green tree in the VFS. #[derive(Default)] pub(crate) struct HelperRegistry { /// Function bodies keyed by the language server's canonical global symbol - /// identity. This is a call-graph lookup, not a net-op recognizer: the body - /// is still inspected through annotated signatures only. globals: HashMap, /// Unique method names provide a conservative fallback for dynamic Lua - /// receivers whose class cannot be inferred. Ambiguous method names are - /// deliberately removed during construction. methods: HashMap, signatures: HashMap, } @@ -909,10 +844,6 @@ pub(crate) struct HelperRegistry { type IndexedHelperDefinition = (LuaSignatureId, FileId, LuaSyntaxId, Option); /// Cache key for the net-helper registry. -/// -/// The registry is a pure function of the syntax trees reachable through the -/// signature index, so both the VFS content revision and the size of that index -/// have to take part in the key. Both reads are `O(1)`. fn helper_registry_revision(db: &DbIndex) -> u64 { let content_revision = db.get_vfs().content_revision(); let signature_count = db.get_signature_index().indexed_signature_count() as u64; @@ -947,10 +878,6 @@ fn build_call_roles_and_registry( map }); // Merge order decides which file wins a call path both define, so it has to - // be a property of the source, not of the session. `FileId`s are handed out - // in workspace-collection order and shift when a file is removed and - // re-added, so order by normalized path — the same policy - // `HelperRegistryBuilder::build` already uses for its definitions. let scan_files = crate::profile::phase("ccs/sort_scan_files", || { let vfs = db.get_vfs(); let mut scan_files = signatures_by_file.keys().copied().collect::>(); @@ -968,11 +895,6 @@ fn build_call_roles_and_registry( scan_files }); - // A file's scan reads only its own signatures plus immutable db state, so - // the uncached ones are derived concurrently. On a cold index that is every - // file in the workspace, and the per-file syntax-tree walk behind - // `has_calls` dominates. Results are stored and merged below in exactly the - // previous fixed file order, so the fold is unchanged. let uncached = scan_files .iter() .copied() @@ -1048,8 +970,6 @@ fn build_call_roles_and_registry( struct HelperRegistryBuilder { definitions: Vec, /// Whether the scanned file contains any call expression at all. Computed - /// once per file so annotation libraries full of empty stubs are rejected - /// before resolving every signature back to a red-tree closure. file_has_calls: bool, } @@ -1067,9 +987,6 @@ impl HelperRegistryBuilder { return; }; // Annotation libraries contain thousands of empty function stubs. - // They can carry net metadata, but they cannot be wrapper bodies. - // Checking for a first statement is constant-time and avoids walking - // every empty stub's red subtree during the signature scan. if block.get_stats().next().is_none() { return; } @@ -1152,14 +1069,6 @@ fn expr_written_name(expr: &LuaExpr) -> Option { } /// The names the shipped net operations are declared under (`Start`, -/// `Receive`, and any annotated wrapper of them). -/// -/// Read straight out of the annotated call-role map the pre-analysis pass -/// already built, which is keyed by access path and already tags these calls -/// `NetStart`/`NetReceive`. The pre-pass records op *call sites* by path and so -/// cannot see `local recv = net.Receive`; carrying the op names lets the same -/// reference lookup and per-file binding closure that finds helper calls follow -/// that alias to its call sites. fn net_operation_names(annotated_roles: &AnnotatedGmodGlobalCallRoleMap) -> HashSet { annotated_roles .roles_by_path @@ -1236,26 +1145,10 @@ fn var_expr_written_name(var_expr: &LuaVarExpr) -> Option { } /// The names a call has to be written with for it to expand into a helper that -/// can reach a `net.Start`. -/// -/// The helpers that can answer yes are a small fixed set, and resolution -/// matches declarations by written name, so a call written with a name no such -/// helper carries cannot expand into one. -/// -/// Seeded from the net operations' own references, then grown outward: each -/// site is walked *up* to the function containing it, and that function's own -/// call sites come from the reference index, whose enclosing functions are the -/// next level. The set settles when a round adds no new site. -/// -/// Nothing is scanned. The cost is proportional to how much net code the -/// workspace actually has, not to its size. fn net_producing_function_names(db: &DbIndex, op_names: &HashSet) -> HashSet { let mut names: HashSet = HashSet::new(); let mut visited_decls: HashSet = HashSet::new(); // Seeded from the net operations' own references rather than from the - // pre-pass's recorded call sites: that record is only written for files - // that also need hook metadata, so it is not a complete list of net ops. - // The reference index records every reference unconditionally. let mut frontier: Vec> = op_names .iter() .flat_map(|name| name_reference_sites(db, name)) @@ -1290,8 +1183,6 @@ fn net_producing_function_names(db: &DbIndex, op_names: &HashSet) -> Ha continue; }; // A local enters neither name-keyed reference table, so a chain - // through local wrappers only continues if the next level comes - // from the declaration's own references. if let Some(decl_id) = closure_local_decl_id(file_id, &closure) && visited_decls.insert(decl_id) { @@ -1353,9 +1244,6 @@ fn decl_reference_sites(db: &DbIndex, decl_id: LuaDeclId) -> Vec Option { if let Some(local_func_stat) = closure.get_parent::() { return Some(LuaDeclId::new( @@ -1374,17 +1262,11 @@ fn closure_local_decl_id(file_id: FileId, closure: &LuaClosureExpr) -> Option>, /// The helper names themselves, needed per file to pick up locals: a - /// `local function send()` never enters the global reference table, so its - /// call sites are only reachable through its declaration's own references. names: HashSet, } @@ -1411,9 +1293,6 @@ fn net_helper_call_sites(db: &DbIndex, names: HashSet) -> NetHelperCall } } // Source order, so a file's flows are collected in the same order the walk - // produced them. The key is the whole identity rather than just the start - // offset: sites arrive in hash order, and sorting on a partial key leaves - // equal ids non-adjacent, which silently defeats the dedup. for sites in by_file.values_mut() { sites.sort_by_key(|syntax_id| { let range = syntax_id.get_range(); @@ -1431,8 +1310,6 @@ struct FileFunctionMap { /// `local f = function() end`, `f = function() end`. bare: HashMap, /// All top-level function-defining blocks in source order, including - /// duplicates and unnamed closures. Lets callers that need to scan every - /// function body in the file skip running 4 separate `descendants` walks. all_blocks: Vec, } @@ -1537,8 +1414,6 @@ impl FileFunctionMap { } /// Lazy cache of per-file function maps, keyed by file identity and chunk -/// range. Used so cross-file helper recursion doesn't rebuild the same map -/// repeatedly or alias equal-sized chunks from different files. #[derive(Default)] struct LocalFnCache { cache: HashMap<(FileId, TextRange), FileFunctionMap>, @@ -1554,13 +1429,9 @@ impl LocalFnCache { } /// All per-file gmod pre-analysis metadata collected off-thread for one file. -/// Produced by [`collect_file_gmod_metadata`] (read-only against `&DbIndex`) and -/// merged into the db sequentially by the pipeline in file order. struct GmodFileMetadataResult { keywords: GmodKeywords, /// `Some` when hook metadata was collected (file had hook-relevant - /// keywords): (hook sites, system metadata, gm-method realm annotations). - /// `None` means the hook walk was skipped for this file. hook_metadata: Option<( Vec, GmodSystemFileMetadata, @@ -1577,10 +1448,6 @@ struct GmodFileMetadataResult { } /// Collect all per-file gmod pre-analysis metadata for `file_id`. Read-only -/// against `&DbIndex`: reads the file's own AST (rebuilt locally from the Send -/// green tree) plus pre-existing immutable index state, so this is safe to run -/// concurrently across files. The returned [`GmodFileMetadataResult`] is merged -/// into the db sequentially by the caller. fn collect_file_gmod_metadata( db: &DbIndex, file_id: FileId, @@ -1618,8 +1485,6 @@ fn collect_file_gmod_metadata( let mut local_fns = LocalFnCache::default(); // One resolver per file: it memoizes signature resolution per call site and - // holds an infer cache per file touched, including helper bodies expanded - // from other files. let mut net = NetCallResolver::default(); // Hook metadata collection never expands wrapper chains for send flows, so @@ -1628,8 +1493,6 @@ fn collect_file_gmod_metadata( let collect_non_net_metadata = keywords.needs_hook_metadata(); // Network flows are collected later, by `GmodNetworkAnalysisPipeline`; see - // that pipeline for why. Receive-flow collection therefore no longer rides - // along here, so a file that needs no hook metadata skips the walk whole. let hook_metadata = collect_non_net_metadata.then(|| { let (hook_sites, system_metadata, gm_method_realms, _receive_flows) = collect_hook_and_receive_metadata( @@ -1714,9 +1577,6 @@ fn collect_hook_and_receive_metadata( file_id, }; // Built once for the whole walk rather than per call expression, so the - // memo carries across the walk instead of being reallocated empty each - // time. `resolve_memo` is a pure function of `(file_id, call range)` for a - // fixed registry and index — see the field's own doc. let mut net_ctx = NetCollectCtx { db, helper_registry, @@ -1728,8 +1588,6 @@ fn collect_hook_and_receive_metadata( }; // Collecting receive flows alone needs no walk: the `net.Receive` sites are - // already recorded by annotation, and the calls that can expand into a - // wrapper that reaches one come from the reference index. if !collect_non_net_metadata { if collect_receive_flows { for call_expr in net_candidate_call_exprs(db, &net_site, helper_call_sites) { @@ -2185,9 +2043,6 @@ fn collect_wrapped_net_send_flows_in_function_block( } // Wrapped helper flows can start a net message in one function and send at call-site. - // Keep a conservative stub so counterpart diagnostics can still resolve by message name. - // The realm is a placeholder: `is_wrapped` flows are used for counterpart - // presence only and are skipped by every realm-sensitive check. flows.push(NetSendFlow { message_name, start_range: call_expr.get_range(), @@ -2207,14 +2062,6 @@ fn collect_wrapped_net_send_flows_in_function_block( } /// Collect complete send flows performed by ordinary, unannotated helpers. -/// -/// The helper itself is found through the metadata-derived helper registry, and -/// every operation inside it is still classified by [`NetCallResolver`] from -/// the shipped signature annotations. The only extra work here is propagating -/// literal string arguments from the call site into the helper's parameters so -/// `net.Start(messageName)` can become concrete at -/// `MyLib.SendString("Message", value)`. Static message names take this same -/// path, which lets a no-argument helper produce a complete call-site flow. fn collect_unannotated_net_wrapper_send_flows( ctx: &mut NetCollectCtx<'_>, site: &NetWalkSite, @@ -2243,10 +2090,6 @@ fn collect_unannotated_net_wrapper_send_flows( } /// The calls in a file that can take part in net-flow collection. -/// -/// A lookup rather than a walk: the call sites that can expand into a -/// net-producing helper come from the reference index, which already records -/// every reference to those helpers' names. fn net_candidate_call_exprs( db: &DbIndex, site: &NetWalkSite, @@ -2254,16 +2097,10 @@ fn net_candidate_call_exprs( ) -> Vec { let root_syntax = site.root.syntax().clone(); // Locals never enter the global reference table, so a helper declared - // `local function send()` is reached through its own declaration's - // references instead. let mut local_sites: Vec = Vec::new(); if let Some(decl_tree) = db.get_decl_index().get_decl_tree(&site.file_id) { let names = &helper_call_sites.names; // `local sendString = MyLib.SendString` calls the helper under a name - // the reference index files under the local binding rather than under - // the helper, so this file's own bindings of a helper name count as - // call sites too. Chains settle by iterating, bounded by the number of - // bindings in the file. let mut aliases: HashSet = HashSet::new(); let bindings = decl_tree .get_decls() @@ -2333,8 +2170,6 @@ fn net_candidate_call_exprs( .collect::>(); // Source order. The key is the whole range so that equal calls reached - // through both the name and the local-declaration lookup end up adjacent - // and the dedup can see them. calls.sort_by_key(|call_expr| { let range = call_expr.get_range(); (range.start(), range.end()) @@ -2368,9 +2203,6 @@ fn collect_send_flows_from_helper_call( }; // A send flow always starts at a `net.Start` somewhere in the expansion, so - // a helper that cannot reach one contributes nothing however it is called. - // The answer depends only on the helper, so it is cached per helper rather - // than recomputed for each calling file. let helper_id = (helper_file_id, helper_key.clone()); let (reaches_start, _) = helper_reaches_net_role( ctx, @@ -2529,8 +2361,6 @@ fn collect_net_receive_flow( call_expr: &LuaCallExpr, ) -> Option { // Same ordering as the send collector: this runs for every call expression - // in the file, so the cheap literal-string check gates the far more - // expensive signature resolution. if !call_has_literal_string_arg(call_expr) { return None; } @@ -2567,8 +2397,6 @@ fn build_net_receive_flow( let mut reads = Vec::new(); // No annotated `callback` role means we cannot know which argument holds the - // receiver, so treat the reads as unknown rather than as none — asserting an - // empty read list here would invent count mismatches against every send. let mut reads_opaque = callback_idx.is_none(); if let Some(callback_expr) = callback_idx.and_then(|idx| { call_expr @@ -2581,10 +2409,6 @@ fn build_net_receive_flow( } None => { // Inline closure that can't yield a block is malformed — but a - // bare name reference we couldn't resolve in the file is the - // common case (callback defined elsewhere). Mark opaque so the - // mismatch checker skips this flow without losing the - // counterpart record. if !matches!(callback_expr, LuaExpr::ClosureExpr(_)) { reads_opaque = true; } @@ -2643,10 +2467,6 @@ fn collect_receive_flows_from_helper_call( }; // Mirror of the send walk's prune: a receive flow always originates at a - // `net.Receive` somewhere in the expansion, so a helper that cannot reach - // one contributes nothing however it is called. Without this, every call - // with a literal string argument re-walked the full body of whatever it - // resolved to, once per calling site. let helper_id = (helper_file_id, helper_key.clone()); let (reaches_receive, _) = helper_reaches_net_role( ctx, @@ -2676,8 +2496,6 @@ fn collect_receive_flows_from_helper_call( callback_idx, }) => { // Literal registrations are already indexed in the helper's - // defining file. Only materialize a call-site flow when the - // wrapper call makes a dynamic message parameter concrete. if extract_static_string_arg_value(&nested_call, message_idx).is_some() { continue; } @@ -2710,11 +2528,6 @@ fn collect_receive_flows_from_helper_call( } /// Resolve the callback block for a `net.Receive` second argument. Handles -/// inline closures (`function() ... end`) and same-file local/global function -/// references (`net.Receive("Msg", doRetrieve)` paired with -/// `local function doRetrieve() ... end` or `local doRetrieve = function() ... end`). -/// Cross-file references are out of scope — those resolve at semantic-model -/// time and are not part of the per-file collection pass. fn resolve_callback_block( file_id: FileId, root: &LuaChunk, @@ -2738,14 +2551,6 @@ fn resolve_callback_block( } /// Resolve a call expression to a function definition, returning a -/// stable string key (used for cycle detection), the function body block, -/// and the chunk that owns the body (which becomes the new `root` for -/// further nested helper resolution within that body). -/// -/// Resolve a `(FileId, LuaSyntaxId)` helper-registry entry back to its -/// `(LuaBlock, LuaChunk)` by rebuilding the owning file's red tree on demand. -/// Returns an owned `LuaChunk` (cheap clone of a red node) which becomes the new -/// `root` for further nested helper resolution within that body. fn resolve_registry_entry( db: &DbIndex, file_id: &FileId, @@ -2768,9 +2573,6 @@ fn resolve_call_to_function_block( db: &DbIndex, ) -> Option<(String, LuaBlock, LuaChunk, FileId)> { // Direct global member calls have a stable symbol identity even when - // duplicate declarations make the inferred signature winner dependent on - // index order. Resolve that identity through the deterministic registry. - // Aliases and locals take the signature-identity path below. if !call_expr_has_shadowing_local_root(db, root_file_id, call_expr) && let Some(call_path) = call_expr.get_access_path() { @@ -2801,8 +2603,6 @@ fn resolve_call_to_function_block( } // Pre-analysis can run before every local/global callable type cache is - // available. Preserve lexical same-file wrapper expansion only when the - // written bare name identifies exactly one function body in that file. if let Some(LuaExpr::NameExpr(name_expr)) = call_expr.get_prefix_expr() && let Some(name) = name_expr.get_name_text() && let Some(block) = local_fns @@ -2820,8 +2620,6 @@ fn resolve_call_to_function_block( } // Dynamic receivers sometimes have no inferable class in Lua source. Keep - // existing wrapper support only when the method name maps to exactly one - // indexed function body workspace-wide; ambiguity is a hard stop. if call_expr.is_colon_call() && let Some(LuaExpr::IndexExpr(index_expr)) = call_expr.get_prefix_expr() && let Some(LuaIndexKey::Name(method_token)) = index_expr.get_index_key() @@ -2843,14 +2641,6 @@ fn resolve_call_to_function_block( } /// Shared state for a file's net collection walk. Bundled so the recursive -/// helpers stay readable: they already carried 11 positional arguments before -/// `file_id` and the call resolver had to be threaded for annotation lookup. -/// -/// The three `&mut` fields are pure memo state: `local_fns`, `net` and -/// `resolve_memo` are all keyed by syntax position and each entry is a function -/// of the file's own text and the index, so a walk can only ever fill them in a -/// different order — never with a different answer, and never with one that -/// makes a later lookup depend on the walk that preceded it. struct NetCollectCtx<'a> { db: &'a DbIndex, helper_registry: &'a HelperRegistry, @@ -2868,8 +2658,6 @@ struct NetCollectCtx<'a> { type ResolvedHelperFn = (String, LuaBlock, LuaChunk, FileId); /// See [`NetCollectCtx::resolve_memo`]. Owned per file by the collector that -/// drives both the receive walk and the send walks, so one file's helper -/// resolutions are computed once instead of once per walk. type ResolveMemo = FxHashMap<(FileId, TextRange), Option>; type HelperId = (FileId, String); @@ -3015,8 +2803,6 @@ fn resolve_call_to_function_block_cached( } /// Position of the walk within a file, which changes when helper expansion -/// crosses into a function body defined elsewhere. `file_id` must travel with -/// `root` so signature resolution runs against the owning file. #[derive(Clone)] struct NetWalkSite { root: LuaChunk, @@ -3065,10 +2851,6 @@ fn collect_net_write_ops_from_stat( } /// Walk `subtree` for net payload call expressions, treating non-net -/// calls that resolve to a same-file function as helper expansions: we recurse -/// into the helper body so writes/reads it performs participate in the -/// outer flow. Cycles are guarded via `visited`, and dynamic-context propagates -/// from the call site into the helper body. #[allow(clippy::too_many_arguments)] fn collect_net_ops_recursive( ctx: &mut NetCollectCtx<'_>, @@ -3082,8 +2864,6 @@ fn collect_net_ops_recursive( flow_prefix: &[NetFlowFrame], ) { // Keep the public helper name used by read/write collection call sites, - // while the implementation below documents the call-argument evaluation - // ordering needed for nested reads such as `net.ReadData(net.ReadUInt(16))`. collect_net_ops_eval_order( ctx, site, @@ -3209,8 +2989,6 @@ fn collect_net_ops_from_call_expr( let helper_force_dynamic = force_dynamic || is_call_expr_in_dynamic_control_flow(enclosing_block, call_expr); // Carry the call-site's flow context into the helper so reads/writes - // performed inside the helper appear under the correct outer - // `for`/`if`/`while` frames in hover. let local_path = extract_flow_path(enclosing_block, call_expr); let mut nested_prefix = Vec::with_capacity(flow_prefix.len() + local_path.len()); nested_prefix.extend_from_slice(flow_prefix); @@ -3280,24 +3058,9 @@ fn is_call_expr_in_dynamic_control_flow(block: &LuaBlock, call_expr: &LuaCallExp } /// Walks ancestors from `call_expr` up to (but not including) `block`, -/// collecting one `NetFlowFrame` per enclosing if/while/for/repeat. Frames -/// are returned outer-to-inner so the renderer can nest them naturally. -/// -/// `if`/`elseif`/`else` are folded into a single frame per if-chain branch: -/// when the op lives inside an `elseif cond then ... end` clause, that frame -/// records `elseif cond then` (instead of the outer `if cond then`) so the -/// developer sees the actual branch the op is gated by. Same for `else`. The -/// frame's id is the clause's source range so two ops in different branches -/// of the same if are distinct frames (different patterns can result). -/// -/// The header text is a single-line trimmed summary of the statement opener -/// (e.g. `if cond then`, `for i = 1, #items do`). Multi-line headers and -/// excessively long ones are stored as `None` to keep hover popups compact. fn extract_flow_path(block: &LuaBlock, call_expr: &LuaCallExpr) -> Vec { let mut frames: Vec = Vec::new(); // When set, the next ancestor (which we know is the parent LuaIfStat of - // an elseif/else clause we just captured) should be skipped so we don't - // double-count the if-chain. let mut skip_parent_if = false; for node in call_expr .syntax() @@ -3363,13 +3126,6 @@ enum BranchKind { } /// The node's text up to its first newline. -/// -/// `node.text().to_string()` walks and concatenates every token underneath, so -/// asking an `if` statement for its header used to materialise the statement's -/// whole body — thousands of lines for a large branch — to read one line of it. -/// Network flow analysis does that for every branch around every `net` call in -/// every re-analysed file, which made it one of the more expensive things a -/// keystroke paid for. fn first_line_text(node: &LuaSyntaxNode) -> String { let mut line = String::new(); for token in node @@ -3421,9 +3177,6 @@ fn extract_branch_header(node: &LuaSyntaxNode, kind: BranchKind) -> Option 0 then`, `for i = 1, n do`. -/// Returns `None` for multi-line or oversized headers; the renderer falls -/// back to a generic label in that case. fn extract_flow_header(stat_node: &LuaSyntaxNode, kind: NetFlowKind) -> Option { const MAX_HEADER_LEN: usize = 80; // Only the opener is ever read, and it bails on a multi-line one, so there @@ -3443,13 +3196,9 @@ fn extract_flow_header(stat_node: &LuaSyntaxNode, kind: NetFlowKind) -> Option Option { } /// What a call does in the net subsystem, resolved purely from the callee's -/// signature metadata. Because resolution goes through the type layer, aliases -/// (`local netStart = net.Start`), cross-file globals, and annotated replacement -/// APIs are all recognized identically to the builtins. Ordinary wrappers are -/// expanded through their bodies and need no annotations of their own. #[derive(Debug, Clone)] enum NetCallRole { /// Begins a message: `call_arg("gmod.net_message", "start")`. Carries the - /// index of the parameter holding the message name, so a wrapper that takes - /// it somewhere other than first is read correctly. Start { message_idx: usize }, /// Registers a receiver: `call_arg("gmod.net_message", "receive")`, with the /// message-name index and the `callback` role's index when annotated. @@ -3513,22 +3256,11 @@ enum NetCallRole { } /// Resolves [`NetCallRole`] for call expressions, memoizing per call site. -/// -/// Signature resolution runs type inference, which is far more expensive than -/// the syntax match it replaces, so results are cached by syntax id — the send -/// and wrapped-send passes both scan the same statements, and helper expansion -/// can revisit a body. One [`LuaInferCache`] is kept per file so expansion into -/// a helper defined in another file still resolves against that file. #[derive(Default)] struct NetCallResolver { caches: HashMap, memo: HashMap<(FileId, LuaSyntaxId), Option>, /// `role` memoises the *role*, but the signature behind it is asked for - /// twice per call site: once here and once by - /// [`resolve_call_to_function_block`]'s signature path. Resolving a call's - /// signature means resolving its prefix to a semantic decl, which is the - /// single most expensive operation in this pipeline, so the answer is - /// memoised on the same key the role is. signature_memo: HashMap<(FileId, LuaSyntaxId), Option>, } @@ -3646,10 +3378,6 @@ impl NetCallResolver { } // Some same-file member declarations do not yet have a semantic owner - // edge at this pre-analysis phase, while their inferred callable type - // is already available. Read that type instead of falling back to the - // source spelling of the member. Ambiguous callable unions are left - // unresolved rather than choosing an arbitrary body. let prefix = call_expr.get_prefix_expr()?; let typ = crate::semantic::infer_expr(db, cache, prefix).ok()?; unique_signature_id_from_type(&typ) @@ -3679,8 +3407,6 @@ fn unique_signature_id_from_type(typ: &LuaType) -> Option { } /// Source text of the called function, used for display in diagnostics, hover -/// and code lens. Prefers what the developer actually wrote so an aliased or -/// wrapped call reports its own name. fn call_display_name(call_expr: &LuaCallExpr) -> SmolStr { call_expr .get_prefix_expr() @@ -3699,11 +3425,6 @@ fn call_display_name(call_expr: &LuaCallExpr) -> SmolStr { } /// Captures a short snippet of the value-arg source text for a write op so -/// hover can display *what* is being written (e.g. `net.WriteString("hi")` -/// instead of just `net.WriteString`). Returns `None` for read ops, when the -/// arg is missing, when it spans multiple lines, or when it's too long to -/// render inline — robustness over completeness; we'd rather show the bare -/// op name than blow up the hover popup with a 200-char expression. fn extract_write_value_text(call_expr: &LuaCallExpr, op: &NetOpDescriptor) -> Option { if !op.is_write() { return None; @@ -3727,10 +3448,6 @@ fn extract_write_value_text(call_expr: &LuaCallExpr, op: &NetOpDescriptor) -> Op } /// Extracts the static bit-width literal from a payload op that declares a -/// `gmod.net_payload`/`bits` parameter. Returns `None` for ops with no such -/// parameter, or when the argument is not an integer literal (variable, -/// expression, runtime computation) — anything else is unknowable at index time -/// and would produce false-positive mismatches if compared. fn extract_bit_width_arg(call_expr: &LuaCallExpr, bits_arg_idx: usize) -> Option { let arg_expr = call_expr.get_args_list()?.get_args().nth(bits_arg_idx)?; let LuaExpr::LiteralExpr(literal_expr) = arg_expr else { @@ -3757,14 +3474,6 @@ fn extract_static_string_arg_value(call_expr: &LuaCallExpr, arg_idx: usize) -> O } /// Cheap syntactic gate for the flow collectors, which run over every -/// statement-level call in a candidate file. A tracked flow always names its -/// message with a literal string, so a call carrying none can never start or -/// receive one, and is rejected here before the far more expensive signature -/// resolution runs. -/// -/// Deliberately index-agnostic: the message parameter's position comes from the -/// annotation and is not fixed at zero. Ordering only — every call that would -/// have produced a flow still reaches the resolver. fn call_has_literal_string_arg(call_expr: &LuaCallExpr) -> bool { let Some(args_list) = call_expr.get_args_list() else { return false; @@ -3778,10 +3487,6 @@ fn call_has_literal_string_arg(call_expr: &LuaCallExpr) -> bool { } /// Captures the recipient argument of a send terminator as a single-line snippet -/// for display in code lens. The argument position comes from the -/// `gmod.net_payload`/`target` call-arg role, so terminators with no recipient -/// (`net.Broadcast`, `net.SendToServer`) yield `None` without a name check. -/// Returns `None` when the source is multi-line or too long to render inline. fn extract_send_target_text(call_expr: &LuaCallExpr, send_kind: NetSendKind) -> Option { const MAX_INLINE_LEN: usize = 40; @@ -3806,8 +3511,6 @@ pub(crate) struct GmodScopedClassMatch { pub aliases: Vec, pub super_types: Vec, /// The scope's `classNamePrefix` (if any). Used to derive the stripped - /// short name for parent-alias synthesis (e.g. `gamemode_sandbox` → - /// `sandbox` → `Sandbox`). pub class_name_prefix: Option, } @@ -4007,9 +3710,6 @@ fn resolve_vgui_parent_relations( batch_file_ids: &[FileId], ) { // This group's files have had their calls re-collected by the passes - // before this one, so their removal marks come off: whatever relations - // they still contribute are resolved below. A marked file with no syntax - // tree left was deleted outright, and its relations are legitimately gone. let mut settled_pending = db .get_gmod_class_metadata_index() .pending_vgui_parent_relation_file_ids() @@ -4090,9 +3790,6 @@ fn resolve_vgui_parent_relations( continue; } // Every call already resolved means this file was not rebuilt, so - // walking its syntax tree would reproduce what is cached. Skipping it is - // the whole point: only a handful of the workspace's vgui files are - // touched by any one edit. if let Some(cached) = calls .iter() .map(|call| { @@ -4990,9 +4687,6 @@ fn scoped_class_uses_global_namespace(global_name: &str) -> bool { } /// Scopes whose authoring table is conventionally declared as a `local` -/// (e.g. `local PLUGIN = {}`, `local PLAYER = {}`) rather than a bare global. -/// For these, an explicit local declaration with the scope's global name is -/// treated as the scoped class table even without the synthetic seed. pub(crate) fn scoped_class_authored_as_local(global_name: &str) -> bool { matches!(global_name, "PLUGIN" | "PLAYER") } @@ -5003,11 +4697,6 @@ fn scoped_class_super_types( configured: &[String], ) -> Vec { // PLAYER is special: the runtime authoring table is named `PLAYER`, but that - // identifier is already a GMod enum alias (`PLAYER_IDLE`, ... in enums.lua), - // so the authoring-class annotation cannot use it. The shared player-class - // fields live on the `PlayerClass` annotation class instead. The player-class - // table is NOT itself a Player entity (methods use `self.Player:...`), so it - // inherits only `PlayerClass`. if global_name == "PLAYER" { return vec![LuaType::Ref(LuaTypeDeclId::global("PlayerClass"))]; } @@ -5066,26 +4755,12 @@ pub(crate) fn ensure_scoped_class_type_decl_for_file( } /// Resolve scripted_ents.GetMember("class", "method") delegation patterns. -/// -/// Detects patterns like: -/// ```lua -/// function ENT:SetupDataTables() -/// local f = scripted_ents.GetMember("target_class", "SetupDataTables") -/// f(self) -/// end -/// ``` -/// -/// When such a delegation is found, NetworkVar calls from the target entity's -/// metadata are copied into the current entity's metadata so that -/// `synthesize_scripted_class_members` will produce Get/Set members for them. fn resolve_getmember_network_var_delegations( db: &mut DbIndex, scripted_scope_files: &HashSet, context: &AnalyzeContext, ) { // Collect files to process: only scripted scope files whose source - // contains "scripted_ents.GetMember". Collect into owned structures - // so we can drop the immutable VFS borrow before mutable db access. let candidate_files: Vec<(FileId, LuaChunk, LuaTypeDeclId)> = { let vfs = db.get_vfs(); context @@ -5149,8 +4824,6 @@ fn build_class_file_map(db: &DbIndex) -> HashMap> { } /// Walk a scripted class file's AST looking for `scripted_ents.GetMember` delegation -/// patterns. When found, copy NetworkVar calls from the target class into this file's -/// metadata. fn find_and_resolve_getmember_delegations( db: &mut DbIndex, current_file_id: FileId, @@ -5221,9 +4894,6 @@ fn find_and_resolve_getmember_delegations( }; // Also check as a statement: f(self) as a statement - // Actually the descendant walk will hit both LuaCallExpr and - // LuaCallExprStat, and the LuaCallExpr inside a LuaCallExprStat - // will match either way. // Look up the target class if let Some(target_file_ids) = class_file_map.get(target_class) { @@ -5510,14 +5180,10 @@ fn synthesize_vgui_registrations( let mut vgui_registration_regions: Vec = Vec::new(); let mut synthesis_cache = VguiSynthesisCache::default(); // Tracks local table regions that have already been registered via - // `vgui.RegisterTable` so that subsequent `vgui.CreateFromTable` calls - // referencing the same region do not trigger a second class synthesis. let mut registered_table_regions: HashSet<(LuaDeclId, TextSize)> = HashSet::new(); for file_id in file_ids.iter().copied() { // Borrow first and skip files with no VGUI-relevant calls before paying - // for the (multi-Vec) metadata clone. The vast majority of files have - // class metadata but no VGUI register/derma calls. let has_vgui_work = match db .get_gmod_class_metadata_index() .get_file_metadata(&file_id) @@ -5611,21 +5277,6 @@ fn synthesize_vgui_registrations( resolve_local_registration_region(db, file_id, table_var, register_position) { // Skip synthesis when this table is already registered via a - // prior `vgui.RegisterTable` call. `vgui.CreateFromTable` uses - // the same `register_table` call_arg kind, which means it also - // lands in `vgui_register_table_calls`. Without this guard, the - // `CreateFromTable` call synthesizes a SECOND class at its own - // position, overwriting the first registration's binding and - // producing false-positive `undefined-field` / - // `unchecked-nil-access` on the original panel's `self.Field` - // accesses. - // - // Only actual `vgui.RegisterTable` calls populate the dedup - // set. A `CreateFromTable` call that appears before the real - // `RegisterTable` must not insert a key, otherwise the later - // `RegisterTable` can lose its base/type synthesis. The key is - // region-specific so reused locals can register later table - // regions without being blocked by earlier registrations. let registration_key = (decl_id, region_start); if registered_table_regions.contains(®istration_key) { continue; @@ -5720,8 +5371,6 @@ fn synthesize_vgui_registrations( flush_vgui_table_const_replacements(db, &mut synthesis_cache); // Synthesize AccessorFunc members for VGUI-registered classes. Group by - // file so each accessor target is resolved once instead of once per - // registration in that file. let mut registrations_by_file: HashMap> = HashMap::new(); for registration in &vgui_registration_regions { registrations_by_file @@ -5970,11 +5619,6 @@ fn synthesize_scripted_ent_registration( } // Inject extra super-types based on `ENT.Type`. The `Type` field selects - // the engine-side entity framework (e.g. `"nextbot"` provides `NextBot` - // methods like `StartActivity`, `loco`, `MoveToPos` via C++ metatable - // injection). Without this, `self:StartActivity()` on a `base_nextbot` - // entity produces false-positive `undefined-field` diagnostics because - // the synthesized `base_nextbot` class doesn't inherit from `NextBot`. if let Some((type_name, source_range)) = resolve_registered_scripted_ent_type(&table_expr) && let Some(super_name) = super_type_for_entity_type(&type_name) { @@ -6035,8 +5679,6 @@ fn resolve_registered_scripted_ent_type(table_expr: &LuaTableExpr) -> Option<(St } /// Maps `ENT.Type` values to the annotation class that provides the -/// engine-side framework methods. C++ metatable injection makes these -/// methods available at runtime; we model them via super-types. fn super_type_for_entity_type(type_name: &str) -> Option<&'static str> { match type_name { "nextbot" => Some("NextBot"), @@ -6163,8 +5805,6 @@ fn synthesize_scoped_base_assignments_with( let expected_base_path = format!("{}.Base", scope_match.global_name); // ENT.Type selects the engine-side entity framework (e.g. "nextbot" - // provides NextBot methods). Only entities use this field — SWEP, TOOL, - // PLAYER, etc. have their own Type field with different semantics. let expected_type_path = if scope_match.global_name == "ENT" { Some(format!("{}.Type", scope_match.global_name)) } else { @@ -6204,8 +5844,6 @@ fn synthesize_scoped_base_assignments_with( && access_path.eq_ignore_ascii_case(type_path) { // ENT.Type = "nextbot" → inject NextBot as a super-type so - // engine-side framework methods (StartActivity, loco, etc.) - // are visible on the synthesized class. let Some(type_name) = extract_scoped_base_name(value_expr) else { continue; }; @@ -6267,12 +5905,6 @@ fn extract_scoped_base_name(expr: &LuaExpr) -> Option { } /// A wrapper function that internally calls NetworkVar or NetworkVarElement. -/// For example: -/// ```lua -/// function ENT:SetupNW(type, name) -/// self:NetworkVar(type, 0, name) -/// end -/// ``` #[derive(Debug, Clone)] struct NetworkVarWrapper { /// The method name of the wrapper (e.g. "SetupNW") @@ -6489,10 +6121,6 @@ fn find_networkvar_in_closure( resolve_wrapper_arg_mapping(&inner_args, 0, param_names); // Determine the name argument — find the last string-like argument - // For 3-arg NetworkVar: name is at index 2 - // For 2-arg NetworkVar: name is at index 1 - // For 4-arg NetworkVarElement: name is at index 3 - // Try from the end to find the name position let name_indices: &[usize] = if is_element { &[3, 2, 1] } else { &[2, 1] }; let mut fixed_name = None; @@ -6530,8 +6158,6 @@ fn find_networkvar_in_closure( } /// Given a call argument expression and the wrapper's parameter names, -/// determine if the argument is a fixed string literal or a reference to -/// one of the wrapper's parameters. fn resolve_wrapper_arg_mapping( inner_args: &[LuaExpr], arg_index: usize, @@ -6564,8 +6190,6 @@ fn resolve_wrapper_arg_mapping( } /// Given a call to a known wrapper method and the wrapper's parameter mapping, -/// resolve the concrete type and name from the call arguments and synthesize -/// Get/Set members. fn synthesize_from_wrapper_call( db: &mut DbIndex, file_id: FileId, @@ -6796,22 +6420,6 @@ fn resolve_effective_inheritance_base( } /// Synthesize a parent-name alias member on a derived scripted class. -/// -/// In Garry's Mod, derived gamemodes can access their inherited base via a -/// field named after the parent's short (prefix-stripped) folder name. For -/// example, a DarkRP gamemode inheriting from Sandbox uses `self.Sandbox` to -/// reach the base gamemode table. The runtime exposes this field, but the -/// analyzer would otherwise have no type for it, which breaks hover, goto, -/// and completion on `self..`. -/// -/// Rules (mirroring the oracle-approved design): -/// - Only applies when the scope declares a non-empty `classNamePrefix`. -/// - The parent class name must start with that prefix, and the remainder -/// must be non-empty (otherwise we skip silently to avoid bogus aliases -/// on malformed or cross-scope base names). -/// - If the derived class already has a member with the alias name (for -/// example, because the user wrote `GM.Sandbox = BaseClass` themselves), -/// the explicit field wins and we do not synthesize a duplicate. fn synthesize_define_baseclass_parent_alias( db: &mut DbIndex, file_id: FileId, @@ -6876,8 +6484,6 @@ fn synthesize_define_baseclass_parent_alias( } /// Uppercase the first ASCII letter of `s`, leaving the rest untouched. -/// Non-ASCII leading bytes are preserved as-is (GMod class names are ASCII -/// in practice, so this keeps the implementation simple and allocation-light). fn capitalize_ascii_first(s: &str) -> String { let mut chars = s.chars(); match chars.next() { @@ -6900,10 +6506,6 @@ fn synthesize_accessor_func( call: &GmodScriptedClassCallMetadata, ) { // AccessorFunc(target, "m_VarKey", "Name", forceType) - // args[0] = target (ENT etc) - non-literal name ref - // args[1] = backing field name (string) - // args[2] = accessor name (string) - // args[3] = force type (FORCE_STRING, number, bool, etc) let accessor_name = match call.literal_args.get(2) { Some(Some(GmodClassCallLiteral::String(name))) => name.clone(), @@ -6998,10 +6600,6 @@ fn synthesize_network_var( call: &GmodScriptedClassCallMetadata, ) { // ENT:NetworkVar("Type", slot, "Name") — 3-arg form - // ENT:NetworkVar("Type", "Name") — 2-arg form (slot omitted) - // args[0] = type name (string) - // args[1] = slot (integer) OR name (string, if 2-arg form) - // args[2] = name (string, if 3-arg form) let type_arg_idx = call.network_var_type_arg_idx().unwrap_or(0); let type_name = match call.literal_args.get(type_arg_idx) { @@ -7089,13 +6687,6 @@ fn synthesize_network_var_element( call: &GmodScriptedClassCallMetadata, ) { // ENT:NetworkVarElement("Type", slot, element, "Name") — 4-arg form - // ENT:NetworkVarElement("Type", slot, "Name") — 3-arg form - // ENT:NetworkVarElement("Type", "Name") — 2-arg form - // The value type is always `number` for element access. - // args[0] = type name (string) — used only for validation, not for type - // args[1] = slot or name - // args[2] = element or name - // args[3] = name (if 4-arg form) let type_arg_idx = call.network_var_type_arg_idx().unwrap_or(0); if call @@ -7195,9 +6786,6 @@ fn synthesize_vgui_register( resolved_registration: Option, ) { // vgui.Register("PanelName", TABLE, "BasePanel") - // args[0] = panel name (string) - // args[1] = table variable (name ref) - // args[2] = base panel name (string) let table_source = call.vgui_panel_table_arg_source(1); let base_source = call.vgui_panel_base_arg_source(Some(2)); @@ -7236,10 +6824,6 @@ fn synthesize_derma_define_control( resolved_registration: Option, ) { // derma.DefineControl("ControlName", "description", TABLE, "BasePanel") - // args[0] = control name (string) - // args[1] = description (string, ignored) - // args[2] = table variable (name ref) - // args[3] = base panel name (string) let table_source = call.vgui_panel_table_arg_source(2); let base_source = call.vgui_panel_base_arg_source(Some(3)); @@ -7280,8 +6864,6 @@ fn synthesize_vgui_register_table( resolved_registration: Option, ) { // vgui.RegisterTable(TABLE, "BasePanel") - // args[0] = table variable (name ref) - // args[1] = base panel name (string) let table_source = call.vgui_panel_table_arg_source(0); let base_source = call.vgui_panel_base_arg_source(Some(1)); @@ -7320,8 +6902,6 @@ fn synthesize_vgui_register_file_target( call: &GmodScriptedClassCallMetadata, ) -> Option<(FileId, LuaDeclId, LuaTypeDeclId, String, TextSize, TextSize)> { // vgui.RegisterFile("path/to/panel.lua") includes a file with a temporary - // global PANEL table. The file itself is not a named VGUI class, but its - // methods should still see PANEL.Base inheritance while it is being loaded. let panel_source = call.vgui_panel_define_arg_source(); let GmodClassCallLiteral::String(path) = call.value_for_arg_source(&panel_source)? else { return None; @@ -7348,8 +6928,6 @@ fn synthesize_vgui_register_file_target( let class_type = LuaType::Def(class_decl_id.clone()); // `vgui.RegisterFile` returns the temporary PANEL table it loaded. Bind - // that call expression to the synthesized class so a subsequent - // `vgui.CreateFromTable(result)` preserves the file's PANEL members. write_type_cache( db, LuaTypeOwner::SyntaxId(InFiled::new(source_file_id, call.syntax_id)), @@ -7603,25 +7181,8 @@ fn register_global_panel( } // 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, @@ -7629,27 +7190,11 @@ fn find_registered_table_expr( register_position: TextSize, ) -> 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) .unwrap_or(decl_id.position); find_registered_table_expr_at_write_position(db, file_id, write_position).or_else(|| { - // When the latest write is a reassignment whose RHS is not a table - // literal (e.g. `PANEL = vgui.RegisterTable(PANEL, "DPanel")`), - // the table constructor still lives at the original `local PANEL = - // {...}` declaration. - // - // Only fall back when the registration call is the reassignment RHS - // itself — i.e. `register_position` is within the write statement's - // range. This avoids mis-modeling unrelated reassignments such as - // `PANEL = MakePanel()` followed by a separate `vgui.RegisterTable` - // call, where the stale initializer should NOT be used. if write_position == decl_id.position { return None; } @@ -7664,10 +7209,6 @@ fn find_registered_table_expr( } /// Checks whether `register_position` falls within the RHS expression -/// corresponding to the LHS at `write_position`. This identifies the -/// self-assignment registration pattern `PANEL = vgui.RegisterTable(PANEL, ...)` -/// while rejecting multi-assignments where the registration call is on a -/// different LHS (e.g. `PANEL, OTHER = MakePanel(), vgui.RegisterTable(...)`). fn write_position_contains_register( db: &DbIndex, file_id: FileId, @@ -7689,9 +7230,6 @@ fn write_position_contains_register( return false; }; // Find the specific RHS expression for the LHS at write_position. - // In a simple assignment `PANEL = expr`, there is one RHS at index 0. - // In a multi-assignment `A, B = expr1, expr2`, each LHS maps to its - // corresponding RHS by position index. let (lhs_list, rhs_list) = assign_stat.get_var_and_expr_list(); let Some(lhs_idx) = lhs_list .iter() @@ -7707,10 +7245,6 @@ fn write_position_contains_register( } /// Checks whether the call at the given metadata is `vgui.RegisterTable` -/// (not `vgui.CreateFromTable`). Both use the `register_table` call_arg -/// kind and land in `vgui_register_table_calls`, but only `RegisterTable` -/// actually registers a panel class. `CreateFromTable` instantiates from -/// an already-registered table and should not populate the dedup set. pub(crate) fn is_vgui_register_table_call( db: &DbIndex, file_id: FileId, @@ -7912,16 +7446,6 @@ fn synthesize_panel_class_with_id( } // 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 register_position = call.syntax_id.get_range().start(); let Some(resolved_registration) = resolved_registration.or_else(|| { @@ -7971,10 +7495,6 @@ fn synthesize_panel_class_with_id( if !cached_decl_has_reassignment(cache, db, file_id, decl_id) { // For single-panel files the `PANEL` local has one stable identity. - // Bind the decl slot too so method-self collection during the Lua - // pass sees the synthesized class before it caches member values. - // Reassigned locals remain table-literal-only to avoid collapsing - // distinct registration regions onto one class. write_type_cache( db, decl_id.into(), @@ -7984,27 +7504,11 @@ fn synthesize_panel_class_with_id( } // 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()); // 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(cache, db, file_id, decl_id, &table_range); @@ -8024,10 +7528,6 @@ fn synthesize_panel_class_with_id( .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, @@ -8045,12 +7545,6 @@ fn synthesize_panel_class_with_id( } // A derma file conventionally uses the *global* `PANEL` scratch - // table (`PANEL = {}` … `function PANEL:Paint()` … - // `vgui.Register("X", PANEL, "DButton")`). At runtime that - // table is consumed by the register call and the next file - // overwrites the global, so each file's `PANEL` is a separate - // class — exactly like `ENT`/`SWEP`, which are modelled as - // scoped class globals. for global_owner in global_panel_member_owners(db, var_name) { let members = db .get_member_index() @@ -8085,8 +7579,6 @@ fn synthesize_panel_class_with_id( } // Backfill persistent type caches that still hold this exact - // table-const identity (scoped to the current range only — never - // carried forward across registrations). cache .table_const_replacements .insert(table_range, class_type.clone()); @@ -8180,26 +7672,6 @@ fn bind_inline_vgui_panel_table( } /// 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. -/// Owners a *global* panel-table variable's members can be sitting on. -/// -/// Decl analysis parks `PANEL.Field` / `function PANEL:Method()` under -/// `GlobalPath("PANEL")`; the global-member migration then re-homes them onto -/// whatever the `PANEL` declaration resolved to, which for GMod workspaces is -/// the annotation `@class PANEL`. Both are checked so the transfer works -/// whichever stage the member reached. fn global_panel_member_owners(db: &DbIndex, var_name: &str) -> Vec { let mut owners = vec![LuaMemberOwner::GlobalPath(GlobalId::new(var_name))]; let type_decl_id = LuaTypeDeclId::global(var_name); @@ -8220,11 +7692,6 @@ fn collect_panel_member_source_ranges( 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: VGUI - // synthesis rewrites table-const caches after collecting region members, so - // cache state is intentionally not the source of truth here. if let Some(initializer_range) = cached_decl_initializer_table_range(cache, db, file_id, decl_id) && !ranges.iter().any(|existing| existing == &initializer_range) @@ -8254,8 +7721,6 @@ fn cached_decl_initializer_table_range( } /// 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, @@ -8285,9 +7750,6 @@ fn find_decl_initializer_table_range( } /// 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() @@ -8302,12 +7764,6 @@ fn decl_has_reassignment(db: &DbIndex, file_id: FileId, decl_id: LuaDeclId) -> b } /// 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, @@ -8512,16 +7968,11 @@ fn detect_scoped_class_from_path(db: &DbIndex, file_id: FileId) -> Option Option<(String, String)> { get_scripted_class_info_with_prefix(db, file_id).map(|(c, g, _)| (c, g)) } /// Like [`get_scripted_class_info_for_file`] but also returns the scope's -/// `class_name_prefix`, so callers can correctly strip it to recover the -/// folder short-name (used for parent-alias synthesis on inherited classes). pub(crate) fn get_scripted_class_info_with_prefix( db: &DbIndex, file_id: FileId, @@ -8573,9 +8024,6 @@ pub(crate) struct AnnotatedGmodGlobalCallRoleMap { candidate_call_path_kinds: Vec, environment_role_source_files: HashSet, /// Canonical function metadata per `(wire_format, direction)`, published to - /// the network index for features that must emit a net call. Values keep - /// their precedence rank so the winner does not depend on the signature - /// index's iteration order. canonical_net_ops: HashMap<(SmolStr, NetOpDirection), (CanonicalNetOpRank, crate::db_index::CanonicalNetOp)>, } @@ -9317,8 +8765,6 @@ fn match_bool(matches: bool) -> StaticArgTypeMatch { } /// Total precedence of canonical net metadata, lowest wins. Workspace class is -/// the meaningful preference; path and position make equal-name duplicates -/// deterministic even when their metadata conflicts. #[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] struct CanonicalNetOpRank { workspace: u8, @@ -9353,9 +8799,6 @@ fn canonical_net_op_rank(db: &DbIndex, signature_id: LuaSignatureId) -> Canonica } impl AnnotatedGmodGlobalCallRoleMap { - /// One file's contribution to the role map, plus the helper definitions its - /// signatures produced. Cached on the db and re-derived only when the file - /// is re-analysed — see `DbIndex::get_cached_file_helper_scan`. fn build_for_file( db: &DbIndex, signature_ids: &[LuaSignatureId], @@ -9389,9 +8832,6 @@ impl AnnotatedGmodGlobalCallRoleMap { } /// Folds another file's fragment in. Files are merged in a fixed order and - /// the first file to define a call path wins, so a path that two files both - /// define resolves the same way every run — the previous whole-index fold - /// took the signature index's `HashMap` order, which varies per process. fn merge_from(&mut self, other: &Self) { for (path, roles) in &other.roles_by_path { self.roles_by_path @@ -9435,9 +8875,6 @@ impl AnnotatedGmodGlobalCallRoleMap { if let Some(descriptor) = descriptor { // Wrappers are expected to share a wire format with the builtin they - // wrap, so collisions here are normal rather than exceptional. The - // signature index is a `HashMap`, so its iteration order varies per - // process; ranking the candidates keeps the published name stable. let candidate = ( canonical_net_op_rank(db, signature_id), crate::db_index::CanonicalNetOp { @@ -9926,8 +9363,6 @@ fn roles_from_inferred_receiver_method( call_path: &str, ) -> Option { // A local access path such as self.tabContainer:AddPanel cannot match the - // annotated DHorizontalScroller.AddPanel path, but its member signature can. - // Most calls have no VGUI parent role, so avoid semantic inference for them. if !matches!(call_expr.get_prefix_expr(), Some(LuaExpr::IndexExpr(_))) || !matches!( call_path.rsplit('.').next(), @@ -11353,8 +10788,6 @@ fn collect_member_realm_ranges(root: &LuaChunk) -> Vec { } /// Extract realm narrowing from a single if-statement, handling if/elseif/else clauses. -/// Also handles early-return guards like `if not CLIENT then return end` which narrows -/// the realm of code after the if-statement to the complementary realm. fn collect_if_realm_ranges(if_stat: &LuaIfStat, ranges: &mut Vec) { let condition_realm = if_stat .get_condition_expr() @@ -11367,8 +10800,6 @@ fn collect_if_realm_ranges(if_stat: &LuaIfStat, ranges: &mut Vec ranges.push(GmodRealmRange { range, realm }); } else { // Empty block (e.g., comment-only if-body): still record the realm - // so that realm-awareness checks (like AddCSLuaFile CLIENT detection) work. - // Use a zero-width range at the start of the if-statement as a marker. let pos = if_stat.syntax().text_range().start(); ranges.push(GmodRealmRange { range: TextRange::new(pos, pos), @@ -12477,10 +11908,6 @@ fn collect_dynamic_loaders( } // Every file is content-scanned for a `file_find` candidate before almost - // all of them bail out, and the whole walk is read-only against `&DbIndex`, - // so it runs across files in parallel. Results stay index-aligned and are - // flattened in file order, so the pattern list is identical to the previous - // sequential build. let per_file = super::parallel::map_files_collect(db, file_ids, |db, source_file_id| { let mut patterns = Vec::new(); let Some(tree) = db.get_vfs().get_syntax_tree(&source_file_id) else { @@ -13658,10 +13085,6 @@ fn dynamic_file_find_targets( relative_paths_by_parent: &HashMap>, ) -> Vec<(FileId, String)> { // `targets` is consumed in order by `apply_dynamic_loaders`, which feeds the - // load-graph fixpoint, so the order has to be a property of the source. The - // directory branch walks a `HashSet` of suffixes and a `HashMap` keyed by - // parent path — doubly hash-random per process. Same policy as - // `build_call_roles_and_registry`: order by normalized path, then file id. let mut targets = dynamic_file_find_targets_unordered(glob, result_kind, usage, relative_paths_by_parent); targets.sort_by_cached_key(|(file_id, target_path)| { @@ -14671,10 +14094,6 @@ fn infer_realm_from_filename(db: &DbIndex, file_id: FileId) -> Option } // 2. Check parent directory names for realm hints SECOND - // Prefer the path segment after the last `/lua/` anchor to avoid false realm hints - // from unrelated parent directory names (e.g. a user home directory named "server"). - // If there is no `/lua/` anchor, still allow inference for known GMod workspace layouts - // such as addon-root (`lua/...`) and gamemode-root (`gamemode/...`, `entities/...`). let path_str = file_path.to_string_lossy().to_ascii_lowercase(); let path_str = path_str.replace('\\', "/"); let components = file_path @@ -14722,8 +14141,6 @@ fn infer_realm_from_filename(db: &DbIndex, file_id: FileId) -> Option } // 3. Check GMod special directory patterns (engine-defined realm behavior per GMod loading order) - // These MUST come before the init.lua/shared.lua filename checks because e.g. - // effects/init.lua should be Shared (effects load on both realms), not Server. if search_str.contains("/effects/") { return Some(GmodRealm::Shared); } diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/for_range_stat.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/for_range_stat.rs index 7f0e804b5..e17803d10 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/for_range_stat.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/for_range_stat.rs @@ -47,11 +47,7 @@ pub fn analyze_for_range_stat( } if iter_var_types.contain_tpl() || enumerates_member_map { - // Either nothing bound the generic, so the vars hold raw - // template refs, or the types came from enumerating a - // table's member map. Either way the answer only covers the - // members indexed when this ran, and which those are - // depends on the order files were analysed in. + // Defer when iter type depends on incomplete enumeration. let unresolved = UnResolveIterVar { file_id: analyzer.file_id, iter_exprs: iter_exprs.clone(), @@ -96,9 +92,7 @@ pub fn analyze_for_range_stat( Some(()) } -/// Whether this loop's variable types come from enumerating a table's -/// member map, the union [`try_infer_pairs_iter_types_from_table_members`] -/// builds. +/// Whether loop iter types come from table member enumeration. pub fn iterates_table_member_map( db: &DbIndex, file_id: FileId, @@ -254,10 +248,7 @@ fn try_infer_pairs_iter_types_from_table_members( let table_type = infer_expr(db, cache, table_arg)?; if matches!(table_type, LuaType::Global) { - // The global table has no enumerable answer: its member types are the very - // thing analysis is computing, and a loop over it can declare further - // globals whose types are then part of the same union. Any snapshot is a - // record of how far inference had progressed, not a fact about the program. + // Global table has no stable enumerable answer. return Ok(Some(VariadicType::Multi(vec![ LuaType::String, LuaType::Any, @@ -290,11 +281,7 @@ fn try_infer_pairs_iter_types_from_table_members( .collect::>(); member_entries.sort_by_key(|(key, _)| member_key_stable_key(key)); - // A dynamic key aliases every access whose key infers to the same type - // rather than naming a member, so once one is present the literal keys - // beside it are a sample of the indices some file happened to write, not - // the table's key domain. Which samples are in the map depends on how far - // inference had progressed, so the keys are reported by kind. + // Dynamic keys alias by type; literal keys are incomplete samples. let keys_are_sampled = member_entries .iter() .any(|(key, _)| matches!(key, LuaMemberKey::ExprType(_))); diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/scalar.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/scalar.rs index e3fc2c5a4..fe9cf608c 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/scalar.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/scalar.rs @@ -166,13 +166,6 @@ fn should_merge_table_literals( .all(|state| state.all_table_assignment_merge_types) } -/// The merge of every writer's table type. -/// -/// Answering bare `table` here -- which is what widening each writer to -/// `table_literal_widen_type` and unioning amounts to -- throws away the only -/// thing the writers carry, and every field of the slot then reads as nil-able. -/// The writers name one runtime table, so merging them is both more precise and -/// independent of which writer the batch happened to reach first. fn merged_table_assignment_type( db: &DbIndex, incoming_type: &LuaType, diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs index 6def389cd..1e2bc0e65 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs @@ -146,8 +146,6 @@ impl AnalysisPipeline for LuaAnalysisPipeline { } // Detailed per-file logging is intentionally reserved for explicit profiling. - // Info logging can be enabled in normal server sessions, and logging every - // >1ms file turns large workspace analysis into a log-I/O hotspot. let should_log_file = if stderr_profile_enabled { file_elapsed.as_millis() > 1 } else { @@ -213,7 +211,6 @@ impl AnalysisPipeline for LuaAnalysisPipeline { } /// Measures how much of `lua analyze` could overlap if each dependency level ran -/// concurrently: the critical path is the sum of each level's slowest file. /// Profiling only (`GLUALS_PROFILE=1`). #[derive(Default)] struct LevelShape { diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index aac7c10b3..3e3ba5ab2 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -63,8 +63,6 @@ pub fn analyze_local_stat(analyzer: &mut LuaAnalyzer, local_stat: LuaLocalStat) return Some(()); } // Skip Nil binding for mutable locals (those with subsequent write-assignments). - // This prevents false "cannot assign X to never" diagnostics when a local is used - // as an upvalue inside a closure and assigned before the closure is first called. if is_local_mutable(analyzer, decl_id) { continue; } @@ -88,9 +86,6 @@ pub fn analyze_local_stat(analyzer: &mut LuaAnalyzer, local_stat: LuaLocalStat) break; }; let decl_id = LuaDeclId::new(analyzer.file_id, position); - // A copy of a loop variable holds whatever the variable held when the - // copy landed, and the settled re-derivation moves those, so it needs - // re-reading for the same reason a call or index read does. if is_call_or_index_expr(&expr) || reads_settling_iter_var(analyzer.db, analyzer.file_id, &expr) { @@ -98,10 +93,6 @@ pub fn analyze_local_stat(analyzer: &mut LuaAnalyzer, local_stat: LuaLocalStat) .context .request_uninformative_local_decl_reinfer(decl_id); } - // A read through a multi-declaration global answers from whichever backing - // tables the walk had reached; a decl deferred to the unresolve wave never - // reaches the settled-global-read recording below, so record it here - // before it can branch off. Re-derived once every backing table has landed. if initializer_reads_through_multi_decl_global(analyzer, &expr) { analyzer .context @@ -212,10 +203,6 @@ pub fn analyze_local_stat(analyzer: &mut LuaAnalyzer, local_stat: LuaLocalStat) continue; } - // A global's type is the merge of every file that writes it, and - // a batch that retains some of those writers while its own are - // still empty answers this read from a smaller set than a cold - // build sees. Re-derived once they have all landed. if reads_global_name(analyzer, &expr) { analyzer .context @@ -569,10 +556,6 @@ fn set_index_expr_owner(analyzer: &mut LuaAnalyzer, var_expr: LuaVarExpr) -> Opt let Some((member_owner, set_owner_only)) = resolve_index_expr_member_owner_for_file(&prefix_type, Some(analyzer.file_id)) else { - // The prefix inferred, but to nothing that names an owner. That - // is not a property of the source: the prefix may simply not - // have settled yet, and nothing revisits this attach. Record it - // so the settled pass can retry it once the batch is done. if prefix_carries_no_owner_information(&prefix_type) { analyzer .context @@ -594,11 +577,7 @@ fn set_index_expr_owner(analyzer: &mut LuaAnalyzer, var_expr: LuaVarExpr) -> Opt )); } Err(reason) => { - // Every other branch above reaches - // `apply_index_expr_member_owner`, which *creates* the - // `LuaMember` and then attaches it. This branch cannot: the - // prefix is not inferable yet, so there is no owner to attach - // to. + // Defer member with unresolvable prefix via unresolve. let unresolve_member = UnResolveMember { file_id: analyzer.file_id, member_id: LuaMemberId::new(var_expr.get_syntax_id(), analyzer.file_id), @@ -615,17 +594,9 @@ fn set_index_expr_owner(analyzer: &mut LuaAnalyzer, var_expr: LuaVarExpr) -> Opt Some(()) } -/// Whether a prefix type says nothing about which table a member write lands on. -/// -/// Distinguishes "this prefix has no owner" (a number, a string — a real answer) -/// from "this prefix has not settled yet", which is the only case worth retrying -/// after the batch is done. +/// Whether prefix type carries no owner information. fn prefix_carries_no_owner_information(prefix_type: &LuaType) -> bool { match prefix_type { - // `table` belongs here for the same reason `any` does: it names no - // element, so nothing can attach through it. It is also what a slot - // collapses to while a writer's literal is still being widened against - // siblings the walk has not reached, which is a property of the batch. LuaType::Unknown | LuaType::Any | LuaType::Table => true, LuaType::Union(union) => union.types().all(|arm| { matches!( @@ -837,10 +808,7 @@ fn apply_index_expr_member_owner_with_guarded( } let member_index = analyzer.db.get_member_index_mut(); member_index.add_member(member_owner, member); - // `add_member` already records the enclosing function scope for - // `FileDefine` index-expr members (via - // `assignment_file_define_scope_for_member`). For other features - // (e.g. `MetaDefine`) it stores `None`, so set the real scope here. + // Set scope for non-FileDefine members. if !matches!(decl_feature, LuaMemberFeature::FileDefine) { let function_scope = member_index .enclosing_function_scope_range(analyzer.file_id, member_id.get_position()); @@ -926,10 +894,6 @@ pub fn analyze_assign_stat(analyzer: &mut LuaAnalyzer, assign_stat: LuaAssignSta let type_owner = get_var_owner(analyzer, var.clone()); - // A local reassigned from a multi-declaration global field read has the - // same batch-order exposure as a local *initialized* from one: the walk - // answers it from whichever backing tables it had reached. Record it so - // the settled pass re-derives it against the complete set. if let LuaTypeOwner::Decl(decl_id) = &type_owner && initializer_reads_through_multi_decl_global(analyzer, expr) { @@ -1046,8 +1010,6 @@ pub fn analyze_assign_stat(analyzer: &mut LuaAnalyzer, assign_stat: LuaAssignSta } } // Reading an undefined global yields `nil` at runtime, so the - // assignment target's value is `nil` (not unknown). This mirrors - // the local-stat path above so hover/inference stays consistent. Err(InferFailReason::None) => { if should_defer_none_infer_expr(expr) { add_unresolve_for_assignment( @@ -1395,11 +1357,6 @@ fn should_skip_nil_table_shape_assignment( return false; }; - // A prefix that has not settled yet cannot answer this, and the write is a - // delete either way: `t[k] = nil` removes an entry, it never adds a member - // typed `nil`. A receiver typed by a `fun(self: T)` callback slot is still - // `unknown` while its file is walked, so a member attached here would land - // on the slot every closure filling it shares. if matches!(prefix_type, LuaType::Unknown | LuaType::Never) { return true; } @@ -1571,21 +1528,9 @@ fn reads_global_name(analyzer: &LuaAnalyzer, expr: &LuaExpr) -> bool { .is_none() } -/// Whether the initializer reads through a global whose root name has more than -/// one declaration — the `X = X or {}` per-realm bootstrap whose backing tables -/// the walk merges incrementally. Recurses index/call prefixes and operator -/// operands so a member path (`cityrp.presidential.Taxes`) or an arithmetic read -/// (`... / 100`) is caught, not only a bare `local x = cityrp`. -/// Record the current file if any `panel:GetParent()` read in it fell back to -/// the broad `Panel` type because the vgui parent chain was not complete. The -/// chains finish in the gmod-post pass; the fallback set accumulates over the -/// file walk, so a later statement is enough to flag the file for re-derivation. fn note_vgui_parent_fallback_file(analyzer: &mut LuaAnalyzer) { let file_id = analyzer.file_id; let cache = analyzer.context.infer_manager.get_infer_cache(file_id); - // Chain-derived successes are as batch-sensitive as fallbacks: the chain a - // read went through can be one the final chain state contradicts, so both - // kinds flag the file for the settled re-derivation. let has_chain_read = !cache.vgui_parent_fallback_calls.is_empty() || !cache.vgui_parent_chain_calls.is_empty(); if has_chain_read { @@ -1604,11 +1549,6 @@ fn initializer_reads_through_multi_decl_global(analyzer: &LuaAnalyzer, expr: &Lu .is_some_and(|decl_ids| decl_ids.len() > 1) } -/// The root global name a *field read* is rooted at (`cityrp` for -/// `cityrp.presidential.Taxes`), or `None` if it is not a field read rooted at a -/// global. A call is deliberately not followed: `cityrp.player.get(x)` returns -/// whatever the callee returns, not a field off the merged backing tables, so -/// re-deriving it against the complete set is neither needed nor sound. fn global_read_root_name(analyzer: &LuaAnalyzer, expr: &LuaExpr) -> Option { match expr { LuaExpr::NameExpr(name_expr) => reads_global_name(analyzer, expr) @@ -1739,15 +1679,6 @@ fn should_defer_pending_local_alias( } /// Whether `expr` reads out of `decl_id` itself: the `x = x.field` shape, -/// and the same read buried in an operand or call argument (`width = -/// bit.bor(bit.lshift(width:byte(1), 24), ...)`). Depth does not change the -/// self-contradiction — the value still cannot be the decl's lifetime type, -/// because it was computed from a read that type would reject. -/// Whether `expr` is the default-value idiom for `decl_id` — `p = p or DEFAULT`. -/// -/// The result always includes the declaration's own type, so unlike a plain -/// reassignment it refines the declaration rather than replacing it, and is the -/// one body write a parameter may take its type from. pub(crate) fn expr_fills_own_default( db: &DbIndex, file_id: crate::FileId, @@ -1849,10 +1780,6 @@ fn add_unresolve_for_assignment( match type_owner { LuaTypeOwner::Decl(decl_id) => { // A read out of the decl being assigned (`limit = - // limit.maximum`) must not queue a deferred write. The decl - // slot is empty until one of the file's deferred writes - // resolves, and `bind_type` has no acceptance rule for an empty - // slot, so whichever lands first owns the decl's lifetime type. if expr_reads_out_of_decl(analyzer.db, analyzer.file_id, decl_id, &expr) { return; } @@ -1886,13 +1813,6 @@ fn add_unresolve_for_assignment( prefix, ret_idx: 0, }; - // The deferred write resolves against whatever the index held - // when its retry ran, and an attempt that reaches `unknown` - // succeeds: the item retires and the placeholder becomes the - // member's final type. Whether the retry was early or late is a - // property of batch order, not of the source, so record the - // member for the settled re-infer in - // `refresh_member_initializer_caches`. analyzer .context .request_member_initializer_reinfer(member_id); @@ -1917,12 +1837,6 @@ fn assign_merge_type_owner_and_expr_type( expr_type = multi.get_type(idx).unwrap_or(&LuaType::Nil).clone(); } - // A self-referential guarded bootstrap (`x.y = x.y or {}`) assigns its - // own `{}` whatever the self-read resolves to, which is what - // `special_or_rule` folds it to. The inferred expression type can still - // carry that self-read when it was memoised before the fold ran, and - // what the read resolved to is whichever sibling file the batch - // analysed first. if let LuaTypeOwner::Member(member_id) = &type_owner && let Some(bootstrap_type) = guarded_table_bootstrap_member_type(analyzer.db, *member_id, true) @@ -1930,11 +1844,7 @@ fn assign_merge_type_owner_and_expr_type( expr_type = bootstrap_type; } - // Where every writer of this member is a `x.y = x.y or {}` guard they all - // name one table, so there are no competing writes to merge — each writer - // resolves to the earliest one's literal and the sibling widening is - // skipped. Widening them against each other unions two literals into a bare - // `table`, which drops the members another file attached to the namespace. + // Skip widening when all writers are guarded bootstraps. let canonical_guarded_bootstrap = match &type_owner { LuaTypeOwner::Member(member_id) => { canonical_guarded_table_bootstrap_type(analyzer.db, *member_id) @@ -1942,21 +1852,12 @@ fn assign_merge_type_owner_and_expr_type( _ => None, }; - // A repeated `x.y = x.y or {}` guard names one table, however many files - // open with it: each writer means "reuse it if it is there". Widening those - // literals against each other answers `table`, which drops whatever another - // file attached to it — so the guard has to preserve them here too, the same - // way the contribution record below already reads it. + // Preserve table literals for guarded bootstrap members. let preserve_table_literals = preserve_table_literals || matches!(&type_owner, LuaTypeOwner::Member(member_id) if is_guarded_table_assignment_member(analyzer.db, *member_id)); - // A plain writer that shares the slot has to preserve them too: widening - // `self.x = {}` against a `self.x = self.x or {}` in another file answers - // `table`, and the guard's literal is then gone for every reader — - // including the writes that attach members through it. This says nothing - // about *this* write being a guarded one, so it feeds the widening decision - // alone and not the classification below. + // Preserve literals for plain writers sharing guarded slot. let preserve_sibling_table_literals = preserve_table_literals || matches!(&type_owner, LuaTypeOwner::Member(member_id) if slot_has_guarded_table_bootstrap(analyzer.db, *member_id)); @@ -1988,10 +1889,6 @@ fn assign_merge_type_owner_and_expr_type( } Some(None) => {} None => { - // Whether every sibling writer already carried a type is a - // property of how far the batch has run, not of the source. - // Where one did not, the merge below is provisional and the - // settled pass re-derives it against the complete writer set. let mut skipped_uncached_sibling = false; let widened = get_widened_member_assignment_type( analyzer.db, @@ -2000,10 +1897,6 @@ fn assign_merge_type_owner_and_expr_type( preserve_sibling_table_literals, &mut skipped_uncached_sibling, ); - // Recorded on the skip, not on the answer: a walk that read no - // sibling type declines to widen at all, and that write needs - // the settled re-derivation just as much as one that widened - // from a partial set. if skipped_uncached_sibling && let LuaTypeOwner::Member(member_id) = &type_owner { analyzer.context.record_settled_member_widening_candidate( *member_id, @@ -2016,11 +1909,6 @@ fn assign_merge_type_owner_and_expr_type( } } } - // A table literal written into a slot another file bootstraps with - // `x.y = x.y or {}` keeps its own literal — but whether that sibling had - // been indexed when the walk asked is a property of the walk order. Where - // the literal was widened away, queue it so the settled pass can ask - // again against the whole writer set. if let LuaTypeOwner::Member(member_id) = &type_owner && matches!(source_type, Some(LuaType::TableConst(_))) && matches!(expr_type, LuaType::Table) @@ -2061,11 +1949,6 @@ fn assign_merge_type_owner_and_expr_type( let guarded_table_assignment = preserve_table_literals || is_guarded_table_assignment_member(analyzer.db, *member_id); if guarded_table_assignment { - // Whichever canonical writer this found — including none at all — it - // read the sibling set off a half-built owner index, and which - // writers are visible there is a property of how far the batch has - // got. Re-derived once they have all landed and been migrated to - // their final owner. See `resettle_guarded_table_bootstraps`. analyzer .context .record_settled_guarded_bootstrap_candidate(*member_id); @@ -2251,14 +2134,6 @@ fn get_cached_widened_member_assignment_type( visible_count, ) { WideningCacheLookup::FirstSighting => { - // Being the only writer the owner can currently see is a statement - // about how far the batch has run: until the global this member - // hangs off resolves, its siblings sit on the global path instead - // and are invisible here. Re-derived once they have all been - // migrated to their owner. - // - // Only a named slot: a key the source writes as an expression names - // one entry of a collection, and those never migrate as a group. if matches!(cache_key.key, LuaMemberKey::Name(_)) { analyzer.context.record_settled_member_widening_candidate( *member_id, @@ -2297,8 +2172,7 @@ fn get_cached_widened_member_assignment_type( } } -/// Stores this write's own evidence so the settled re-derivation can merge the -/// complete writer set. See [`MemberAssignmentContribution`]. +/// Record member assignment contribution. fn record_member_assignment_contribution( analyzer: &mut LuaAnalyzer, member_id: LuaMemberId, @@ -2341,15 +2215,7 @@ fn record_member_assignment_contribution_in( .record_member_assignment_contribution(member_id, contribution); } -/// Records the evidence of an assignment whose value only resolved after the -/// walk had moved on. -/// -/// The walk records a contribution as it binds each write, so a write whose -/// right-hand side deferred contributes nothing and the settled merge never -/// sees it. Whether a write deferred is a fact about how far the batch had -/// run - a re-index keeps out-of-batch types standing and resolves inline what -/// a cold build had to defer - so the writer set the merge reads would -/// otherwise differ between the two. +/// Record contribution for resolved member assignment. pub(in crate::compilation::analyzer) fn record_resolved_member_assignment_contribution( db: &mut DbIndex, member_id: LuaMemberId, @@ -2377,16 +2243,7 @@ pub(in crate::compilation::analyzer) fn record_resolved_member_assignment_contri ); } -/// Applies the visibility marks a write earns from its own syntax, for a write -/// the walk did not get to classify. -/// -/// The walk marks each assignment as it binds it — guarded bootstrap, or -/// conditional branch — and those marks decide which writers stay visible for -/// the slot. A write whose right-hand side could not be inferred in time is -/// finished by the unresolve pass instead, which binds the type and stops, so -/// the marks are never applied. Both tests read syntax alone, so the answer is -/// the same either way; only whether anything asks was in question, and that is -/// a fact about how far the batch had run. +/// Apply visibility marks for resolved member assignment. pub(in crate::compilation::analyzer) fn mark_resolved_member_assignment( db: &mut DbIndex, member_id: LuaMemberId, @@ -2469,22 +2326,6 @@ pub(in crate::compilation::analyzer) fn preserve_guarded_table_assignment_member } /// Returns true when the assignment that introduced this member sits inside a -/// branching construct (if / while / repeat / for). In those cases we must not -/// collapse to a single "latest write" member, because the assignments in -/// sibling branches (or earlier loop iterations) are not dominated by this one -/// and their types must remain available so reads can union them. -/// -/// Without this guard, a pattern like -/// -/// ```lua -/// if cond then -/// obj.field = Vector(...) -/// else -/// obj.field = nil -/// end -/// ``` -/// -/// would silently drop the `Vector` branch and hover `obj.field` as just `nil`. fn is_member_assignment_in_conditional_branch(db: &DbIndex, member_id: LuaMemberId) -> bool { let Some(tree) = db.get_vfs().get_syntax_tree(&member_id.file_id) else { return false; @@ -2563,14 +2404,6 @@ fn assigns_bare_table_literal(db: &DbIndex, member_id: LuaMemberId) -> bool { } /// Every writer of this member's slot, when at least one of them bootstraps it -/// with `x.y = x.y or {}`. -/// -/// The guard means "reuse it if it is there", and a plain `x.y = {}` resets that -/// same table, so every writer names one runtime table however many literals the -/// source spells. Returning them together is what lets the slot hold a single -/// identity: treating a plain writer as a rival definition forks it, the merge -/// of the forks answers bare `table`, and `table` names no element — so every -/// member attached through the slot is lost. fn guarded_table_assignment_member_ids_for_owner_key( db: &DbIndex, member_id: LuaMemberId, @@ -2587,8 +2420,6 @@ fn guarded_table_assignment_member_ids_for_owner_key( bootstrapped = true; } else if !assigns_bare_table_literal(db, related_member_id) { // This writer contributes something that is not a fresh table -- a - // class, a call result -- so the slot really can hold more than one - // thing and there is no single identity to resolve to. return None; } @@ -2637,14 +2468,6 @@ pub(in crate::compilation::analyzer) fn get_widened_member_assignment_type( if related_member_id == *member_id { continue; } - // Only writers that come before this one are evidence for it. The walk - // otherwise settles that with "the sibling already has a type cache", - // which reports how far the batch has run rather than anything about - // the source: a re-index clears the batch's caches and leaves the rest - // standing, so the same sibling counts on one run and not on another. - // Reading the order off the source makes the set identical on both, - // and it is the rule the settled re-derivation already applies - a - // later write must not widen the type it is itself checked against. if !preserve_table_literals && member_id_sort_key(related_member_id) >= member_id_sort_key(*member_id) { @@ -2698,12 +2521,6 @@ pub(in crate::compilation::analyzer) fn get_widened_member_assignment_type( previous_states.iter(), ) } - // Only reachable once a preceding writer has been seen but every one of - // them was skipped for having no type yet: siblings exist, and not one - // of them is evidence. Widening the literal here guesses at writers this - // pass has not read, and how many it has read is how far the batch has - // run, not anything about the source. Leave the write as it stands and - // let the settled re-derivation decide against the complete set. MemberAssignmentWideningDecision::NoPreviousAssignments => return None, }; @@ -2735,17 +2552,6 @@ pub(super) fn flush_pending_dynamic_key_collection_widenings(analyzer: &mut LuaA } /// Whether a runtime write may give the class it names a field that class never -/// declares. -/// -/// A write through a reference only *names* the class; the value it runs on is -/// one instance of it. When a subclass already declares the same field, the -/// write is evidence about that subclass, not about the class it was typed -/// through -- and giving the base the field hands it to every subclass, which -/// hides the declarations and stops any receiver narrowing to the subclass that -/// really owns it. -/// -/// The subtype walk is not cheap, so it only runs for a write that would open a -/// key the owner does not already hold. fn write_may_declare_on_owner(db: &crate::DbIndex, member_id: LuaMemberId) -> bool { let member_index = db.get_member_index(); let Some(LuaMemberOwner::Type(owner_id)) = member_index.get_member_owner(&member_id).cloned() @@ -2765,9 +2571,6 @@ fn write_may_declare_on_owner(db: &crate::DbIndex, member_id: LuaMemberId) -> bo return true; } // Asked from the declaring side rather than by enumerating subtypes: - // collecting the subtypes of a base rescans the type index once per level - // of the hierarchy, while the types that declare this key at all are few and - // each answers with one walk up its own supers. let type_index = db.get_type_index(); !type_index.get_all_types().into_iter().any(|type_decl| { let candidate_id = type_decl.get_id(); @@ -2840,12 +2643,6 @@ fn guarded_bootstrap_range_for_node( } /// Range of the table a field of a guarded table literal names. -/// -/// `x = x or { y = {} }` creates `x.y` on exactly the condition `x.y = x.y or -/// {}` does — the namespace not existing yet — so the two shapes bootstrap the -/// same slot and have to resolve to one literal between them. Treating only the -/// second as a guarded writer leaves the first looking like a plain write, which -/// makes the whole slot ineligible for canonicalisation. fn guarded_table_literal_field_range( table_field: &LuaTableField, empty_only: bool, @@ -2872,8 +2669,6 @@ fn guarded_table_literal_field_range( } /// Range of the table arm of a self-referential guarded bootstrap (`x.y = -/// x.y or {}`), which is what the assignment's type is when the guard falls -/// through. fn guarded_table_assignment_bootstrap_range( index_expr: &LuaIndexExpr, empty_only: bool, @@ -2982,20 +2777,6 @@ fn guarded_table_bootstrap_range( ) } -/// The one table a repeated `x.y = x.y or {}` guard names. -/// -/// Every such writer means "reuse it if it is there", so at runtime they are all -/// the same table and only the first to run creates it. Giving each writer its -/// own literal instead makes a file that re-guards the namespace read its own -/// empty table and lose whatever another file attached, so they resolve to the -/// earliest writer's literal — the one that would have won at runtime. -/// Re-derives the literal each `x.y = x.y or {}` writer names, now that every -/// writer of the slot has landed. -/// -/// The canonical pick is the lowest-sorting writer, so a writer analysed before -/// its siblings existed either found no canonical at all (fewer than two were -/// indexed) or picked one that a later, lower-sorting writer displaces. Which of -/// those happened is a property of the batch, not of the source. pub(in crate::compilation::analyzer) fn resettle_guarded_table_bootstraps( db: &mut DbIndex, candidates: Vec, @@ -3036,9 +2817,6 @@ pub(in crate::compilation::analyzer) fn resettle_guarded_table_bootstraps( continue; }; // Every writer of the slot, not only the ones queued as candidates: the - // slot holds one table, so a writer left on its own literal forks the - // identity again, and whether it was queued depends on how far the walk - // had got when it ran. let members = guarded_table_assignment_member_ids_for_owner_key(db, first).unwrap_or(members); for member_id in members { @@ -3194,10 +2972,6 @@ fn register_expr_key_member(analyzer: &mut LuaAnalyzer, field: &LuaTableField) { } /// Whether this value-field (positional `{ expr }`) belongs to a shaped -/// sequential table literal whose integer members were registered in the -/// declaration pass (see `analyze_table_expr`). Such members need their value -/// types inferred and bound here, exactly like keyed/assign fields, otherwise -/// the registered `[n]` member has no type cache and dynamic indexing degrades. fn is_shaped_array_value_field(field: &LuaTableField) -> bool { field.is_value_field() && field @@ -3303,10 +3077,6 @@ fn special_assign_pattern( } // Register inferred string default for `x = x or "literal"`. - // This is a SIBLING branch to the table-guard path: only fires - // when the RHS is NOT a TableExpr and IS a string literal, - // and the type_owner is a plain Decl. Completely disjoint from - // the table-guard path. if !guarded_table_expr { if let LuaTypeOwner::Decl(decl_id) = &type_owner { if let Some(string_value) = extract_string_literal_from_expr(&right) { @@ -3515,12 +3285,6 @@ fn get_delayed_definition_decl_id( } /// Returns `true` when `expr` is a bare `NameExpr` that resolves to neither a -/// local declaration nor a registered global. Such reads evaluate to `nil` at -/// runtime, but `infer_expr` reports them as `Unknown` (see -/// `semantic/infer/mod.rs` where `InferFailReason::None` is collapsed to -/// `Ok(LuaType::Unknown)`). Callers use this to substitute `Nil` when binding -/// the LHS of a local/assign/table-field declaration so hover and downstream -/// inference reflect the runtime value. fn is_undefined_global_name_expr(analyzer: &LuaAnalyzer, expr: &LuaExpr) -> bool { let LuaExpr::NameExpr(name_expr) = expr else { return false; @@ -3542,9 +3306,6 @@ fn is_undefined_global_name_expr(analyzer: &LuaAnalyzer, expr: &LuaExpr) -> bool return false; } // Workspace-scoped lookup matches the diagnostic's own visibility check - // (see `diagnostic/checker/undefined_global.rs`). With multi-workspace - // isolation enabled, a global declared in another root must not "rescue" - // an undefined read in the current root. let module_index = analyzer.db.get_module_index(); let global_index = analyzer.db.get_global_index(); let has_global = if let Some(ws_id) = module_index.get_workspace_id(analyzer.file_id) { @@ -3572,8 +3333,6 @@ mod tests { } /// A sibling assignment that has not been analysed carries no type cache, so - /// the cross-file merge can only keep it by deriving its type from syntax. - /// Only the self-referential bootstrap has a syntax-determined type. #[test] fn guarded_table_bootstrap_range_names_only_the_self_referential_arm() { let source = "lib.store = lib.store or {}\nlib.other = fetch() or {}\n"; diff --git a/crates/glua_code_analysis/src/lib.rs b/crates/glua_code_analysis/src/lib.rs index cf04d2b6b..4f02adb71 100644 --- a/crates/glua_code_analysis/src/lib.rs +++ b/crates/glua_code_analysis/src/lib.rs @@ -102,13 +102,6 @@ fn hash_member_owner_stable( tid.get_name().hash(hasher); } LuaMemberOwner::Element(range) => { - // Named by its anchor where it has one, and by its file plus - // ordinal otherwise - see `ExportIdentities::table_identity`. - // - // Deliberately *not* the identity `TableConst` uses. A member's - // owner is the one place where the resolver's choice between two - // files' literals for a single logical table would otherwise read - // as an export change on every edit. "Element".hash(hasher); ids.table_identity(range).hash(hasher); } @@ -142,17 +135,7 @@ fn hash_lua_member_key_export( } } -/// Offset-free identities for the things a type can point at. -/// -/// A signature id and a table literal's range are both a file plus a position, -/// and the position moves whenever an edit shifts the file. Hashing the -/// position reports an export change for every edit; hashing only the file -/// makes *repointing* an export at a different function or literal in the same -/// file invisible. The index among the file's signatures, or among its table -/// literals, is stable under a shift and still tells the two apart. -/// -/// Built per file on first use, because a fingerprint usually reaches only a -/// handful of files. +/// Offset-free identities for types. struct ExportIdentities<'a> { db: &'a DbIndex, signature_ordinals: std::cell::RefCell>>, @@ -188,19 +171,7 @@ impl<'a> ExportIdentities<'a> { positions.binary_search(&id.get_position()).ok() } - /// What a table literal is called, for the purpose of deciding whether an - /// export changed. - /// - /// The anchor, when the literal has one: a name like `cityrp.configuration` - /// identifies the *logical* table, and several files can declare a literal - /// for it. Which of those the resolver picks as a member's owner is not - /// stable across a partial re-index, so keying on the literal's file and - /// position makes an unrelated edit look like an export change. The anchor - /// is the same whichever literal wins. - /// - /// Falls back to file plus ordinal for a literal no name reaches - still - /// enough to tell two literals in one file apart, which is what a member - /// moving between them needs. + /// Returns stable identity for a table literal. fn table_identity(&self, range: &InFiled) -> String { let file_id = range.file_id; let mut cache = self.table_anchors.borrow_mut(); @@ -261,16 +232,8 @@ fn hash_generic_param_export( } } -/// Hashes everything about a type that another file can observe. -/// -/// Only identity that is derived from a source position is normalised away: -/// a table literal's range, an instance's range and a signature's id all move -/// whenever an edit shifts offsets, and are re-homed by the remap pass rather -/// than by a re-index, so hashing them would report an export change for every -/// edit. Values, shapes and names are kept: they are what a dependent reads. +/// Hashes a type for export comparison. fn hash_lua_type_export(ids: &ExportIdentities, typ: &LuaType, hasher: &mut impl Hasher) { - // Arm order is not guaranteed for the set-like composites, so their arm - // hashes are sorted before they are folded in. fn hash_unordered( ids: &ExportIdentities, tag: &str, @@ -307,9 +270,6 @@ fn hash_lua_type_export(ids: &ExportIdentities, typ: &LuaType, hasher: &mut impl "BooleanConst".hash(hasher); b.hash(hasher); } - // The range is the literal's identity, and it moves on any offset - // shift. The file it lives in does not, and is enough to tell one - // file's literal from another's. LuaType::TableConst(range) => { "TableConst".hash(hasher); range.file_id.hash(hasher); @@ -321,8 +281,6 @@ fn hash_lua_type_export(ids: &ExportIdentities, typ: &LuaType, hasher: &mut impl ids.table_ordinal(inst.get_range()).hash(hasher); hash_lua_type_export(ids, inst.get_base(), hasher); } - // The id is a file plus a position. The signature's own shape is - // hashed by the signature section of the file fingerprint. LuaType::Signature(id) => { "Signature".hash(hasher); id.get_file_id().hash(hasher); @@ -485,17 +443,11 @@ fn hash_lua_type_export(ids: &ExportIdentities, typ: &LuaType, hasher: &mut impl None => "NoConstraint".hash(hasher), } } - // The remaining variants hold no nested type and no source position, - // so their `Debug` form describes them precisely and stably. other => format!("{:?}", other).hash(hasher), } } -/// A name another file can resolve for a documented symbol, or `None` when -/// nothing outside this file can name it. -/// -/// Every `LuaSemanticDeclId` variant is a file plus a position, and the -/// position moves on any edit above it, so the name is what gets hashed. +/// Export key for a semantic declaration. fn semantic_decl_export_key(ids: &ExportIdentities, id: &LuaSemanticDeclId) -> Option { let db = ids.db; match id { @@ -514,10 +466,6 @@ fn semantic_decl_export_key(ids: &ExportIdentities, id: &LuaSemanticDeclId) -> O } Some(format!("M:{:x}", hasher.finish())) } - // A signature has no name of its own; it is reached through the decl - // or member that holds it, and its own shape is hashed by the - // signature section. Its index among the file's signatures identifies - // it without a byte position, which would move on any edit above it. LuaSemanticDeclId::Signature(signature_id) => { let mut file_signatures: Vec<_> = db .get_signature_index() @@ -534,13 +482,7 @@ fn semantic_decl_export_key(ids: &ExportIdentities, id: &LuaSemanticDeclId) -> O } } -/// Hash of the cross-file-visible exports a single file contributes. -/// -/// Used to decide whether a re-index of this file can affect any other file. -/// Local-only state (locals, their inferred types, flow facts) is intentionally -/// excluded - those are not observable cross-file, so an edit that only touches -/// them does not require a dependency ripple, no matter how large that file's -/// fan-in would be under the old file-level expansion. +/// Hash of a file's cross-file-visible exports. pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { let mut hasher = rustc_hash::FxHasher::default(); let ids = &ExportIdentities::new(db); @@ -598,18 +540,10 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { for decl_id in decl_ids_sorted { decl_id.get_name().hash(&mut hasher); if let Some(type_decl) = db.get_type_index().get_type_decl(&decl_id) { - // An alias is read by name and resolved to its target, so - // changing the target changes what every file that names it - // infers. match type_decl.get_alias_ref() { Some(alias_ref) => hash_lua_type_export(ids, alias_ref, &mut hasher), None => "NoAlias".hash(&mut hasher), } - // Kind and flags live only on the declaration, so nothing else - // in this file moves when one changes - yet `(exact)` decides - // whether another file's write creates a member on this type, - // and `(partial)`/`(private)` gate diagnostics other files - // report. let (kind, flags) = type_decl.kind_and_flags(); format!("{kind:?}").hash(&mut hasher); flags.hash(&mut hasher); @@ -638,15 +572,7 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- Exported type caches (global/member decls, not locals) --- - // Keyed by the name a dependent resolves rather than by the owner's - // offset. Every `LuaTypeOwner` variant carries a source position, and an - // edit anywhere above one shifts it, so hashing the position reports an - // export change for every edit that is not at the very end of the file. if let Some(owners) = db.get_type_index().file_type_owners(file_id) { - // Sorted by name then source order. The position orders the entries but - // is never hashed: two declarations of the same name in one file are - // distinguished by which type each holds, and swapping them has to be - // visible, but the offsets themselves move on any edit above. let mut entries: Vec<(String, u32, u64)> = Vec::new(); for owner in owners.iter() { let key = match owner { @@ -671,8 +597,6 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } format!("M:{:x}", h.finish()) } - // The cached type of a bare expression. No other file can name - // one, so it is local memoisation rather than an export. LuaTypeOwner::SyntaxId(_) => continue, }; let owner_position = match owner { @@ -713,9 +637,6 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { None => "NoConstraint".hash(&mut hasher), } } - // A caller reads the declared parameter and return types, so a - // `@param`/`@return`/`@overload` edit is an export change even - // though it leaves the arity alone. let mut param_indices: Vec<&usize> = sig.param_docs.keys().collect(); param_indices.sort_unstable(); for idx in param_indices { @@ -739,9 +660,6 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { for overload in &sig.overloads { hash_lua_type_export(ids, &LuaType::DocFunction(overload.clone()), &mut hasher); } - // Caller-side narrowing facts derived from the body. A caller - // in another file reads them, and an edit can change one while - // leaving the declared parameters and returns alone. format!("{:?}", sig.require_guard_param()).hash(&mut hasher); sig.nil_return_guard_params().hash(&mut hasher); format!("{:?}", sig.return_correlations()).hash(&mut hasher); @@ -763,10 +681,6 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- Parameter types this file's call sites are evidence for --- - // A call argument here is the only evidence an unannotated parameter in - // another file has, and `expand_reindex_file_ids` already treats call - // sites as producing dependents. Without this the fingerprint would call - // an argument change local and never ripple it to the callee. { let contributed = db .get_call_site_param_index() @@ -775,8 +689,6 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { .iter() .map(|((signature_id, param_idx), typ)| { let mut h = rustc_hash::FxHasher::default(); - // The signature's position moves on any edit to its own file; - // the file it lives in and the parameter index do not. signature_id.get_file_id().hash(&mut h); param_idx.hash(&mut h); hash_lua_type_export(ids, typ, &mut h); @@ -792,16 +704,10 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { .get_signature_index() .inferred_guard_facts_for_files(&HashSet::from([file_id])); if !guard_facts.is_empty() { - // Sorted on the same total key the rest of the analyzer uses. A path - // alone is not total: the standard `if SERVER` pattern gives one path - // two owners that differ only by realm, and ordering them by path - // leaves the fold order to hash-map iteration. let mut guard_owners: Vec<_> = guard_facts.keys().cloned().collect(); sort_inferred_guard_owners(&mut guard_owners); for owner in guard_owners { owner.path().hash(&mut hasher); - // The realm the guard applies in is part of what a caller reads, - // and the same path can hold a different guard per realm. format!("{:?}", owner.state_mask()).hash(&mut hasher); owner.source_file_id().hash(&mut hasher); if let Some(guard) = guard_facts.get(&owner) { @@ -812,13 +718,6 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- Annotations on this file's symbols that other files act on --- - // A `@deprecated`, `@private` or `@export` on an exported symbol changes - // the diagnostics every call site in every other file reports. - // - // The free-text description and source are deliberately excluded: a hover - // in another file reads them from this index when the request arrives, so - // no dependent holds a copy that could go stale, and a doc-comment edit on - // a hub file would otherwise pay a full ripple for text nothing caches. { let default = LuaCommonProperty::new(); let default_property = format!( @@ -838,8 +737,6 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { .into_iter() .filter_map(|(owner, property)| { let key = semantic_decl_export_key(ids, owner)?; - // None of these hold a source position, so their `Debug` form - // describes them precisely and stably. let acted_on = format!( "{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}", property.visibility, @@ -848,18 +745,9 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { property.decl_features, property.version_conds, property.attribute_uses, - // Gates whether a field counts as required, and the - // missing-field diagnostic is reported by the file that - // builds the table, not the one that declares the class. property.default_value, - // Carries the GMod tag payloads (`@accessorfunc` and - // friends) that other files' call analysis reads. property.tag_content, ); - // Writing a doc comment creates a property whose acted-on - // fields are all still default. Registering it would make - // documenting a symbol an export change, which is the case - // excluding the description is meant to avoid. (acted_on != default_property).then_some((key, acted_on)) }) .collect(); @@ -868,8 +756,6 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- Metamethods this file declares --- - // Another file's inference reads these whenever it applies an operator to - // the owning type. { let mut operators: Vec = db .get_operator_index() @@ -877,8 +763,6 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { .into_iter() .map(|operator| { let mut h = rustc_hash::FxHasher::default(); - // A table owner is a literal's range, which moves on any edit - // above it, so it is identified the same way a `TableConst` is. match operator.get_owner() { LuaOperatorOwner::Table(range) => { "Table".hash(&mut h); @@ -891,8 +775,6 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } } format!("{:?}", operator.get_op()).hash(&mut h); - // The operator's own range moves on any edit above it; what a - // dependent reads is the function it resolves to. hash_lua_type_export(ids, &operator.get_operator_func(db), &mut h); h.finish() }) @@ -902,13 +784,6 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- Network flows this file declares --- - // Network diagnostics compare a message's writes against its reads across - // files, so changing either half is an export change. - // - // Field by field: `NetSendFlow`, `NetReceiveFlow` and `NetOpEntry` all - // carry the source range of the call they came from, and those move on - // every edit above them. What the peer file's diagnostic reads is the - // message name and the ordered sequence of operations. if let Some(network) = db.get_gmod_network_index().get_file_data(file_id) { fn hash_ops(ops: &[NetOpEntry], hasher: &mut impl Hasher) { for entry in ops { @@ -943,9 +818,6 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- Metatable bindings this file declares --- - // `setmetatable(t, mt)` is read by every file that resolves a member - // through `t`. Both halves are table literals, and repointing one at a - // different literal in the same file moves no member, type or signature. { let metatable_index = db.get_metatable_index(); let mut bindings: Vec<(usize, u32, usize)> = Vec::new(); @@ -970,10 +842,6 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- The realm each exported symbol is declared in --- - // Realm is first-class: wrapping an existing definition in `if SERVER` - // changes which callers may reach it and which realm-mismatch diagnostics - // other files report, while leaving its name, type and signature alone. - // The offset is used only to look the realm up, never hashed. { let gmod_infer = db.get_gmod_infer_index(); let mut realms: Vec<(String, String)> = Vec::new(); @@ -1000,10 +868,6 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { realms.hash(&mut hasher); if let Some(metadata) = gmod_infer.get_realm_file_metadata(&file_id) { - // Field by field, because `branch_realm_ranges` carries the source - // ranges of the `if CLIENT`/`if SERVER` blocks, and those move on - // every edit above them. Which realms the file narrows to is the - // part another file can observe; where the braces sit is not. format!( "{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}", metadata.inferred_realm, @@ -1025,10 +889,6 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- What this file exports as a module --- - // `local M = {} ... return M` makes the returned table the module's export - // type, which every `require`/`include` consumer reads. The local itself is - // skipped by the type-cache section, so returning a different table moves - // nothing else. if let Some(module) = db.get_module_index().get_module(file_id) { module.full_module_name.hash(&mut hasher); module.visible.hash(&mut hasher); @@ -1050,9 +910,6 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- Load edges this file declares --- - // Adding or removing an `include`/`require` changes which files load this - // one and in what order, which realm and load-order analysis both read. - // No member, type or signature moves when it happens. { let dependency_index = db.get_file_dependencies_index(); let mut sites: Vec = dependency_index @@ -1060,8 +917,6 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { .unwrap_or_default() .iter() .map(|site| { - // The call's range is left out: it moves on any edit above it, - // and the edge is identified by its target and kind. format!( "{:?}|{:?}|{:?}|{}", site.kind, site.target_file_id, site.path, site.original_expr @@ -1196,19 +1051,7 @@ fn call_resolves_to_inferred_guard_owner( semantic::get_prefix_expr_signature_id(db, cache, &call) == Some(owner.signature_id()) } -/// True when `call_expr` calls an annotated net operation — a message start, a -/// send terminator, or a payload write/read. -/// -/// Shares the analyzer's resolution path, so an alias, a local binding, or an -/// annotated wrapper is classified identically to the `net.*` builtin. Editor -/// handlers should use this instead of re-deriving the answer, and instead of -/// consulting the flow index: an op that forms no complete flow (a bare -/// `net.WriteString` with no `net.Start`) is still a net op and is never -/// recorded in the flow index. -/// -/// `cache` is supplied by the caller because classifying a document means asking -/// this for every call in it, and building an inference cache per question would -/// throw away all reuse between them. +/// True when `call_expr` is an annotated net operation. pub fn call_expr_is_net_op( db: &DbIndex, cache: &mut LuaInferCache, @@ -1251,11 +1094,7 @@ pub async fn fetch_schema_urls(urls: Vec) -> HashMap { url_contents } -/// Normalize a workspace root path so it uses the same drive-letter -/// casing that the VFS applies (uppercase on Windows). Without this, -/// `extract_module_path` would fail to match VFS paths against -/// library workspace roots supplied by the editor with a lowercase -/// drive letter. +/// Normalize workspace root for VFS path matching. fn normalize_workspace_root(root: PathBuf) -> PathBuf { file_path_to_uri(&root) .and_then(|uri| uri_to_file_path(&uri)) @@ -1382,32 +1221,16 @@ pub(crate) enum TableAnchor { Local { decl_name: String, path: String, - /// The field names the literal declares. - /// - /// The declaration's byte position cannot identify it - that is the - /// case the anchor has to survive - and neither can its index among - /// the file's same-named locals, because inserting another `local cfg` - /// above renumbers it and the old anchor would then resolve to the new - /// literal. Duplicate names are routine in Lua, so the field names are - /// the tie-break, and two that still collide are left unanchored. + /// Field names declared by the table. fields: Vec, }, - /// A literal passed to a call that also takes string literals, keyed by - /// the call's path and those strings. - /// - /// `registry.Add("first", { ... })` is the dominant shape for a literal no - /// name reaches, and the call's own string arguments identify it without a - /// sibling ordinal - inserting another registration above does not - /// renumber it. + /// Literal passed as call argument, keyed by call path. CallArgument { path: String, labels: Vec, arg_index: usize, }, - /// A literal no name and no call reaches, keyed by the field names it - /// declares. Those survive an edit to a field's *value*, which a sibling - /// ordinal would not: inserting another literal above renumbers ordinals, - /// and the old anchor would then resolve to a different literal. + /// Literal with no name or call, keyed by field names. Fields(Vec), } @@ -1478,10 +1301,7 @@ fn table_global_path_recursive(table: LuaTableExpr) -> Option { None } -/// The field names a table literal declares, sorted. -/// -/// They survive an edit to a field's *value*, which is what makes them usable -/// as identity, and they distinguish literals that no name singles out. +/// Sorted field names declared by a table literal. fn table_field_names(table: &LuaTableExpr) -> Vec { let mut fields: Vec = table .get_fields() @@ -1590,12 +1410,7 @@ fn table_local_anchor(db: &DbIndex, file_id: FileId, table: LuaTableExpr) -> Opt } } -/// The call a table literal is an argument to, described by the call's path -/// and its literal string arguments. -/// -/// `registry.Add("first", { ... })` is the dominant shape for a table literal -/// no name reaches, and this identifies it without a sibling ordinal, so -/// inserting another registration above does not renumber it. +/// Call containing a table literal argument. fn table_call_argument_anchor(table: &LuaTableExpr) -> Option { let arg_list = table.syntax().parent()?; let call = LuaCallExpr::cast(arg_list.parent()?)?; @@ -1640,12 +1455,7 @@ pub(crate) fn collect_anchored_map( }; let chunk = tree.get_chunk_node(); - // Two passes, because an anchor is only usable if it singles its literal - // out. Both the name a literal is reached by and the field names it - // declares survive an edit elsewhere in the file; the sibling ordinals in - // a tree path do not. So the most durable unique key wins, and the - // ordinals are added only to break a tie between literals that are - // otherwise indistinguishable. + // Two passes: keep only anchors that uniquely identify a literal. let candidates: Vec<(Option, LuaTableExpr)> = chunk .descendants::() .map(|table| { @@ -1673,12 +1483,7 @@ pub(crate) fn collect_anchored_map( .map(|(anchor, _)| anchor.clone()) .collect(); - // A literal with no unique durable key is left out entirely. The only key - // left for it is the sibling ordinals, and those renumber when anything is - // inserted above, so an old anchor would resolve to a *different* literal - // and the remap would re-home its members onto the wrong table. Leaving it - // out costs a stale range, which a later re-index corrects; re-homing it - // writes a wrong one that nothing does. + // Skip literals with no unique anchor. let mut map: FxHashMap> = FxHashMap::default(); for (anchor, table) in candidates { let Some(anchor) = anchor.filter(|anchor| !ambiguous.contains(anchor)) else { @@ -1698,15 +1503,8 @@ pub struct EmmyLuaAnalysis { pub(crate) inferred_guard_propagation_stats: InferredGuardPropagationStats, #[cfg(test)] cross_file_stabilization_invocations: usize, - /// Guard facts as they stood before a self-index overwrote them. - /// - /// The LSP splits one edit across two calls: it self-indexes the edited - /// files to answer requests inside them, then pays the ripple later. - /// Guard propagation has to diff against the facts from before the - /// self-index, so they are carried across the gap. + /// Guard facts before self-index. pending_guard_snapshot: Option, - /// Export fingerprints taken before a VFS mutation, for the paths that - /// write the text and re-index later. See [`Self::stash_pre_edit_state`]. pending_export_fingerprints: rustc_hash::FxHashMap, pending_table_ranges: rustc_hash::FxHashMap< FileId, @@ -1734,10 +1532,7 @@ impl EmmyLuaAnalysis { pub fn init_std_lib(&mut self) { let is_jit = matches!(self.emmyrc.runtime.version, EmmyrcLuaVersion::LuaJIT); let (std_root, files) = load_resource_std(is_jit); - // Normalize so the root's drive-letter casing matches VFS file paths - // (the URI round-trip uppercases the Windows drive letter). Without - // this, `extract_module_path` prefix matching would fail when the - // env-derived root has a lowercase drive letter. + // Normalize drive-letter casing for VFS matching. let std_root = normalize_workspace_root(std_root); self.init_std_lib_from_files(std_root, files); } @@ -1802,9 +1597,6 @@ impl EmmyLuaAnalysis { ) && old_text == new_text { // Text unchanged — if the index is already built (has module info), - // skip the costly remove+re-add cycle. This avoids unnecessary - // reindexing when VS Code opens already-loaded files for - // peek/definition (e.g. annotation/library files). if self .compilation .get_db() @@ -1825,10 +1617,6 @@ impl EmmyLuaAnalysis { } // An edit whose significant token stream is unchanged — same kinds, - // same offsets, same texts, comments included — cannot change any - // derived fact, so re-indexing it (and its whole dependency - // expansion) would only re-derive facts the index already holds. - // Store the text and stop. if let (Some(file_id), Some(new_text)) = (existing_file_id, text.as_deref()) && self .compilation @@ -1850,35 +1638,13 @@ impl EmmyLuaAnalysis { } // Change-aware incremental edit: only expand to dependents when the - // edited file's exported interface (members, types, signatures) actually - // changed. A trailing comment or a local-only edit keeps the same - // fingerprint, so the ripple collapses to empty and the edit costs only - // the file's own analysis instead of seconds for a hub file. - // - // A deletion (`text: None`) is excluded: it has no new text to index, - // and comparing fingerprints would let a file that exported nothing - // return before the removal seeds run, leaving dependents pointing at - // a file that is gone. It takes the full path below, which filters - // removed files out of `update_index` and seeds VGUI forwarding removal. if let Some(existing) = existing_file_id.filter(|_| text.is_some()) { // Both are taken before the VFS mutation. A dependent is a file - // that references this file's *old* exports, so an expansion - // computed after the re-index would not contain it. let before_fp = self.take_pre_edit_fingerprint(existing); let before_expansion = self.expand_reindex_file_ids(vec![existing]); let old_guard_snapshot = self .inferred_guard_snapshot(&before_expansion.iter().copied().collect::>()); // Inferred guards and VGUI forwarding are derived from state the - // self-index clears and the ripple then rebuilds from, so for a - // file that carries either, "did the exports change" is not a - // question the fingerprint can answer: the facts it would compare - // are gone by the time it looks. Those files take the full path. - // - // This is a correctness requirement rather than a performance - // prefilter - removing it makes - // `test_fact_preserving_guard_reindex_keeps_full_incremental_consumer_chain` - // fail, because the consumer chain is rebuilt from facts the - // self-index has already dropped. let is_special = { let db = self.compilation.get_db(); !db.get_signature_index() @@ -1912,15 +1678,9 @@ impl EmmyLuaAnalysis { .get_vfs_mut() .set_file_content(uri, text); // Self-index the edited file so its entries match its text (all a - // request inside this file needs) and so the after-fingerprint can - // be taken from the new index. profile::phase("edit/self-index", || { self.self_index_files(vec![file_id]); // The self-index derives this file's cross-file reads in - // isolation. Settling them here is what the ripple used to do - // for the whole expansion, and it is also what makes the - // after-fingerprint comparable to the before-fingerprint, - // which was taken from an already settled index. self.stabilize_cross_file_type_caches(&[file_id]); }); let after_fp = file_export_fingerprint(self.compilation.get_db(), file_id); @@ -1928,14 +1688,6 @@ impl EmmyLuaAnalysis { profile::phase_report("update_file_by_uri (no-ripple)"); return Some(file_id); } - // Export changed - pay the ripple. The edited file is re-indexed a - // second time here, as part of the expansion: its entries have to be - // derived in the same batch as its dependents' for the pass to - // converge, and it is one file out of an expansion in the thousands. - // - // The expansion and the guard snapshot are the ones taken before - // the edit, so guard propagation diffs against the facts the - // self-index has already overwritten. profile::phase("edit/ripple", || { self.reindex_expanded_files_with_old_snapshot( vec![file_id], @@ -1947,8 +1699,6 @@ impl EmmyLuaAnalysis { return Some(file_id); } - // A new file, or a deletion. Neither has a useful before-fingerprint, - // so both take the full expansion. let old_maps = existing_file_id .map(|file_id| self.take_old_anchor_maps(&[file_id])) .unwrap_or_default(); @@ -1962,9 +1712,6 @@ impl EmmyLuaAnalysis { self.reindex_expanded_files(vec![file_id], expansion) }); // A deleted file has no tree, so every one of its literals is gone and - // its Element owners are purged. That is what has to happen: the - // members other files own on them are not reachable from any file the - // re-index visited. self.apply_table_remap(old_maps, &[file_id]); profile::phase_report("update_file_by_uri"); @@ -2018,10 +1765,6 @@ impl EmmyLuaAnalysis { if trigger_reindex { // Through `self_index_files`, so the anchor stash an - // earlier text-only write left is consumed and applied. - // Re-indexing without it leaves the stash describing a tree - // two edits back, and the next edit would then remap from - // ranges the index no longer holds. self.self_index_files(vec![file_id]); self.pending_export_fingerprints.remove(&file_id); } @@ -2061,9 +1804,6 @@ impl EmmyLuaAnalysis { }; // The anchors have to be read before the VFS mutation drops the old - // tree. When this call also re-indexes, they are consumed below; - // otherwise they are stashed for whichever pass does index the file, - // because until then the index still holds the pre-edit ranges. let old_maps = match existing_file_id { Some(fid) if trigger_reindex => self.take_old_anchor_maps(&[fid]), Some(fid) => { @@ -2105,8 +1845,6 @@ impl EmmyLuaAnalysis { ); self.reindex_changed_inferred_param_consumers(&old_guard_facts, &reindex_file_ids); self.apply_table_remap(old_maps, &[file_id]); - // Settled against the current text now, so any stashed fingerprint - // describes a state that no longer exists. for reindexed in &reindex_file_ids { self.pending_export_fingerprints.remove(reindexed); } @@ -2166,8 +1904,6 @@ impl EmmyLuaAnalysis { } /// VFS-only update: parse and store the new text without touching the index. - /// The index remains stale but functional until `reindex_files` is called. - /// This is much faster than `update_file_by_uri` pub fn update_file_text_only(&mut self, uri: &Uri, text: String) -> Option { let existing_file_id = self.compilation.get_db().get_vfs().get_file_id(uri); if let Some(file_id) = existing_file_id { @@ -2194,24 +1930,13 @@ impl EmmyLuaAnalysis { Some(file_id) } - /// Reindex specific files: remove old index entries + run full analysis pipeline. - /// Call this after `update_file_text_only` once the user has paused typing. + /// See implementation. pub fn reindex_files(&mut self, file_ids: Vec) { let expansion = self.expand_reindex_file_ids(file_ids.clone()); self.reindex_expanded_files(file_ids, expansion); } - /// [`reindex_files`](Self::reindex_files) against an expansion that was - /// computed earlier. - /// - /// The expansion has to be derived from the state *before* the edit landed, - /// so a caller that wants to do anything in between — re-index the edited - /// file on its own first, say, and release the write lock so a completion - /// can be answered — has to capture it up front and hand it back here. - /// Recomputing it against a partly-updated index under-expands badly: - /// measured on a gamemode workspace, an expansion of 739 files collapsed to - /// 8 and the workspace ended up with 18 diagnostics that a cold build does - /// not produce. + /// See implementation. pub fn reindex_expanded_files(&mut self, file_ids: Vec, expansion: Vec) { self.reindex_expanded_files_inner(file_ids, expansion, None); } @@ -2226,10 +1951,6 @@ impl EmmyLuaAnalysis { } /// Re-analyses `expansion` with `file_ids` as the files that changed. - /// - /// `old_snapshot` is the guard facts to diff propagation against. Pass the - /// snapshot taken before a self-index overwrote them; `None` takes one now, - /// which is only correct when nothing has re-indexed since. fn reindex_expanded_files_inner( &mut self, file_ids: Vec, @@ -2253,8 +1974,6 @@ impl EmmyLuaAnalysis { self.add_vgui_forwarding_removal_seed(&removed_file_ids, &mut file_ids); let guard_fact_file_ids = file_ids.iter().copied().collect::>(); // A self-index may already have overwritten the guard facts this - // ripple has to diff against, in which case the snapshot from before - // it was stashed for us. let old_guard_facts = old_snapshot .or_else(|| self.pending_guard_snapshot.take()) .unwrap_or_else(|| self.inferred_guard_snapshot(&guard_fact_file_ids)); @@ -2268,8 +1987,6 @@ impl EmmyLuaAnalysis { self.compilation.update_index(update_file_ids.clone()); self.stabilize_cross_file_type_caches(&update_file_ids); } - // These files are settled against their current text now, so a - // fingerprint stashed for one describes a state that no longer exists. for file_id in &file_ids { self.pending_export_fingerprints.remove(file_id); } @@ -2289,11 +2006,6 @@ impl EmmyLuaAnalysis { } /// Records the anchors the index's stored `Element` ranges correspond to, - /// unless an earlier edit already recorded some. - /// - /// The oldest stash is the one that matches the index: a write that does - /// not re-index leaves the index describing the text from before it, so - /// that is the state the stored ranges belong to. fn stash_pre_edit_anchors(&mut self, file_id: FileId) { if self.pending_table_ranges.contains_key(&file_id) { return; @@ -2304,12 +2016,7 @@ impl EmmyLuaAnalysis { } } - /// The same, plus the export fingerprint, for the paths that write the text - /// now and re-index later. - /// - /// The fingerprint has to be taken here for the same reason the anchors do: - /// once the new text is parsed it would read the old index against the new - /// tree, and report a change for every edit that shifts a table literal. + /// See implementation. fn stash_pre_edit_state(&mut self, file_id: FileId) { self.stash_pre_edit_anchors(file_id); // Independent of the anchors: a file with no table literals stashes no @@ -2321,8 +2028,7 @@ impl EmmyLuaAnalysis { } } - /// The file's export fingerprint as it stood before the edit: the stashed - /// one when a write has already landed, otherwise one taken now. + /// See implementation. fn take_pre_edit_fingerprint(&mut self, file_id: FileId) -> u64 { self.pending_export_fingerprints .remove(&file_id) @@ -2330,11 +2036,6 @@ impl EmmyLuaAnalysis { } /// The anchor map the index's stored `Element` ranges correspond to. - /// - /// An edit stashes this before mutating the VFS, because the tree those - /// ranges came from is gone once the new text is parsed. Files re-indexed - /// without an intervening edit have no stash, so their current tree is - /// still the one the index was built from. fn take_old_anchor_maps(&mut self, file_ids: &[FileId]) -> AnchorMaps { let mut old_maps = AnchorMaps::default(); for fid in file_ids { @@ -2350,31 +2051,16 @@ impl EmmyLuaAnalysis { old_maps } - /// Re-homes index entries that name a re-indexed file's table literals by - /// range, from the range the old tree gave them to the range the new tree - /// does. Entries whose literal no longer exists are dropped. - /// - /// Only the edited file's own members are rebuilt by a re-index; every - /// other file's reference to one of its `Element` owners keeps the old - /// offset, so without this they point into the wrong table after any edit - /// that shifts offsets. + /// See implementation. fn apply_table_remap(&mut self, mut old_maps: AnchorMaps, file_ids: &[FileId]) { let mut global_remap: rustc_hash::FxHashMap< InFiled, InFiled, > = rustc_hash::FxHashMap::default(); let mut deleted: Vec> = Vec::new(); - // Driven off the files being re-indexed, not off the stashed anchors: a - // removed file whose literals were all unnameable stashes nothing, and - // its owners still have to go. for fid in file_ids.iter().copied() { let old_map = old_maps.remove(&fid).unwrap_or_default(); // A file with no tree has been removed. Only then does an anchor - // that no longer resolves mean the literal is gone: while the file - // is still there, a mismatch can equally be a heuristic the anchor - // did not survive, and purging on that basis destroys members other - // files own with nothing left to rebuild them. Leaving the entry - // stale is recoverable; deleting it is not. let file_removed = self .compilation .get_db() @@ -2383,16 +2069,10 @@ impl EmmyLuaAnalysis { .is_none(); if old_map.is_empty() && !file_removed { // Nothing stashed and the file is still there, so there is no - // range to move and none to purge. Skipping here avoids a - // `collect_anchored_map` tree walk per file, which the batch - // path would otherwise pay for every file it touches. continue; } if file_removed { // Every literal in it is gone, not just the ones an anchor - // reached: `collect_anchored_map` leaves out literals it cannot - // name uniquely, and members other files own on those are not - // reachable from any file the removal sweeps. deleted.extend( self.compilation .get_db() @@ -2418,8 +2098,6 @@ impl EmmyLuaAnalysis { db.get_member_index_mut().remap_elements(&global_remap); db.get_type_index_mut().remap_table_const(&global_remap); // Beyond the type cache and the member owner, the one store that - // can hold *another* file's literal range: a write registers a - // dynamic field on a table it does not declare. db.get_dynamic_field_index_mut() .remap_table_ranges(&global_remap); } @@ -2436,15 +2114,6 @@ impl EmmyLuaAnalysis { } /// Rebuilds only these files' own index entries. - /// - /// Nothing cross-file is settled: dependents keep whatever they inferred - /// before, and the caller still owes them a - /// [`reindex_expanded_files`](Self::reindex_expanded_files) against an - /// expansion captured beforehand. What this does buy is that the edited - /// file's declarations, members and signatures line up with its text again, - /// which is all a request positioned *inside that file* needs — the index - /// entries are keyed by position, so an edit that shifts offsets is exactly - /// what makes them stop matching the tree. pub fn self_index_files(&mut self, file_ids: Vec) { let old_maps = self.take_old_anchor_maps(&file_ids); self.compilation.remove_index(file_ids.clone()); @@ -2456,9 +2125,6 @@ impl EmmyLuaAnalysis { &mut self, file_ids: Vec, ) -> (Vec, Vec) { - // Capture fingerprints and expansion before the mutation, as in - // `update_file_by_uri`. For guard/vgui files the fingerprint shortcut - // would clobber required state, so they are treated as always changed. let has_special = file_ids.iter().any(|fid| { let db = self.compilation.get_db(); !db.get_signature_index() @@ -2478,8 +2144,6 @@ impl EmmyLuaAnalysis { } // The text is already written by the time the editor path reaches - // here, so a fingerprint taken now would read the old index against the - // new tree. Whoever wrote the text stashed one taken before it. let mut before_fps = HashMap::new(); for fid in &file_ids { before_fps.insert(*fid, self.take_pre_edit_fingerprint(*fid)); @@ -2488,8 +2152,6 @@ impl EmmyLuaAnalysis { // reference the old exports are missed. let before_expansion = self.expand_reindex_file_ids(file_ids.clone()); // The oldest snapshot in a burst is the one the ripple has to diff - // against: later batches see facts the earlier self-indexes already - // overwrote. let snapshot = self.inferred_guard_snapshot(&before_expansion.iter().copied().collect::>()); self.pending_guard_snapshot.get_or_insert(snapshot); @@ -2503,14 +2165,9 @@ impl EmmyLuaAnalysis { } } if changed.is_empty() { - // The guard snapshot is left alone: an earlier batch in this burst - // may still owe a ripple that has to diff against it. return (Vec::new(), Vec::new()); } // The before expansion already contains the dependents of the changed - // files. Narrowing it to just those would need per-file dependent - // tracking, and over-rippling here costs at most what the edit would - // have cost without the fingerprint at all. (changed, before_expansion) } @@ -2727,8 +2384,6 @@ impl EmmyLuaAnalysis { incremental_source_file_ids.contains(&owner.source_file_id()); let discovered = self.resolve_inferred_guard_reference_files(owner, true); for file_id in discovered.files { - // Cold batches resolve aliases in the main pipeline. Only edits need a - // post-publication retry for alias calls analyzed with the old guard fact. let alias_retry = allow_alias_retry && discovered.alias_calls.contains(&file_id) && file_id != owner.source_file_id(); @@ -3150,9 +2805,6 @@ impl EmmyLuaAnalysis { .filter_map(|(uri, _)| self.compilation.get_db().get_vfs().get_file_id(uri)) .collect::>(); // Taken before the writes below, as on every other edit path: the - // expansion re-derives each dependent's *type caches*, but a member - // another file owns on a literal here is not reached by that, so the - // ranges still have to be re-homed. let mut remap_source_file_ids: Vec = old_source_file_ids.iter().copied().collect(); remap_source_file_ids.sort_unstable(); let old_anchor_maps = self.take_old_anchor_maps(&remap_source_file_ids); @@ -3267,7 +2919,6 @@ impl EmmyLuaAnalysis { updated_files.insert(*file_id); } } else { - // Small batch: parse sequentially (avoids thread spawn overhead) for (uri, text) in to_parse { let file_id = self .compilation @@ -3552,9 +3203,6 @@ impl EmmyLuaAnalysis { } /// Return main-workspace files in an order that keeps parallel diagnostic - /// workers busy. Source size is a cheap proxy for diagnostic cost, so - /// processing larger files first avoids leaving one expensive file on the - /// critical path after the other workers have gone idle. pub fn get_main_workspace_file_ids_for_diagnostics(&self) -> Vec { let db = self.compilation.get_db(); let vfs = db.get_vfs(); @@ -4088,24 +3736,10 @@ mod tests { assert_eq!(reindex_file_ids, vec![main_file_id, helper_file_id]); } - /// The language server re-indexes an edited file on its own before running - /// its dependency ripple, so a completion positioned in that file can be - /// answered without waiting seconds for the ripple. This checks the split - /// path reaches the same diagnostics as doing it in one go. - /// - /// It does **not** pin the ordering constraint that makes the split safe — - /// that the expansion is captured *before* the self-index, because a - /// self-index drops the edited file's declarations and inbound dependency - /// edges and an expansion taken afterwards under-invalidates. That was - /// measured on a gamemode workspace (739 files before the self-index, 6 - /// after) and this fixture is far too small to reproduce it: it passes with - /// the two swapped. The real guard is `tools/determinism` against a real - /// workspace, and the reasoning lives on `reindex_expanded_files`. + /// See implementation. #[test] fn two_phase_reindex_matches_single_phase_diagnostics() { // A class definition plus a consumer whose inferred type references it: - // the expansion reaches the consumer through the type-cache relation, - // which is the one that collapses once the definition site is dropped. let producer_source = |member: &str| { format!( "---@class Thing diff --git a/crates/glua_doc_cli/src/cmd_args.rs b/crates/glua_doc_cli/src/cmd_args.rs index d305364ef..f2fc338ab 100644 --- a/crates/glua_doc_cli/src/cmd_args.rs +++ b/crates/glua_doc_cli/src/cmd_args.rs @@ -58,8 +58,6 @@ pub struct CmdArgs { pub site_name: Option, /// A directory whose contents are merged with the generated Markdown files. - /// For example, to override docs/index.md, create a folder called "docs" in - /// your mixin folder and create a file called "index.md" inside it. #[arg(long)] pub mixin: Option, diff --git a/crates/glua_ls/src/context/debounced_analysis.rs b/crates/glua_ls/src/context/debounced_analysis.rs index 034375b0f..d4c2c31d8 100644 --- a/crates/glua_ls/src/context/debounced_analysis.rs +++ b/crates/glua_ls/src/context/debounced_analysis.rs @@ -11,18 +11,10 @@ use super::{ClientProxy, file_diagnostic::SharedDiagnosticDataCache}; const FRESHNESS_STUCK_WARN_AFTER: Duration = Duration::from_secs(5); -/// How long the user must stay idle after a reindex before the whole workspace -/// is re-diagnosed. +/// See implementation. const IDLE_WORKSPACE_DIAGNOSTIC_DELAY: Duration = Duration::from_millis(2000); /// How long the user must stay idle before the dependency ripple runs. -/// -/// The edited file's own re-index runs on the much shorter debounce, which is -/// all a request positioned inside that file needs. This timer governs only the -/// cross-file settle, which holds the write lock for seconds on a large -/// gamemode — and while it does, no keystroke can even be applied. Starting it -/// after every brief pause is what put a ripple in flight for nearly every -/// completion. const RIPPLE_QUIET: Duration = Duration::from_millis(1000); /// Cap on how long one typing burst can hold the ripple off, so diagnostics @@ -30,41 +22,17 @@ const RIPPLE_QUIET: Duration = Duration::from_millis(1000); const MAX_RIPPLE_DEFERRAL: Duration = Duration::from_secs(5); /// How long the ripple gives requests released by the self-index to take their -/// read lock before it takes the write lock back. -/// -/// Bounded so a stream of requests cannot starve the ripple: past this, the -/// ripple proceeds and the stragglers wait it out as they did before. const READER_HANDOFF_GRACE: Duration = Duration::from_millis(250); -/// Debounced analysis: accumulates file IDs from rapid edits and runs `reindex_files` once the user pauses typing. pub struct DebouncedAnalysis { pending_files: Mutex>, reindexing_files: Mutex>, /// Documents whose own index entries do not match their text yet, either - /// because their edit is still queued or because the batch re-indexing them - /// has not reached them. - /// - /// Holds the URI the *client* used, so a request can be tested against its - /// own params without taking the analysis lock — which the re-index holds - /// for its whole duration, so resolving a file id first would wait out - /// exactly what this exists to avoid. Keyed by file id so entries are - /// cleared by identity rather than by matching that URI against the one the - /// VFS derived from a path, which need not be spelled the same way. blocked_documents: Mutex>, - /// True when document changes have arrived but reindex has not yet completed. - /// Set synchronously by `begin_in_flight_change()` (called inline in the - /// notification handler, before the didChange task is spawned) so that any - /// request handler dispatched afterwards sees the flag immediately. + /// See implementation. has_pending_changes: AtomicBool, in_flight_changes: AtomicUsize, /// Requests aimed at one document that are waiting for, or reading against, - /// that document's own index entries. - /// - /// The self-index releases them and then immediately queues the ripple's - /// write lock. A woken request still has to be polled before it can queue - /// its read, and the lock is fair-FIFO, so without this the ripple wins the - /// race every time and the request waits out the whole ripple it was just - /// released from. pending_readers: AtomicUsize, readers_idle_notify: Notify, notify: Notify, @@ -114,7 +82,6 @@ impl DebouncedAnalysis { } } - /// Add a file to the pending reindex set and reset the debounce timer. pub async fn schedule(&self, file_id: FileId, uri: Uri) { { let mut pending = self.pending_files.lock().await; @@ -129,11 +96,6 @@ impl DebouncedAnalysis { } /// Signal that document changes are in-flight but not yet scheduled. - /// - /// Called **synchronously** from the notification handler (inline, before - /// spawning the didChange task) so that request handlers dispatched - /// immediately afterward see the dirty flag and wait for reindex instead - /// of computing on stale analysis data. pub fn begin_in_flight_change(self: &Arc) -> InFlightChangeGuard { self.in_flight_changes.fetch_add(1, Ordering::AcqRel); self.has_pending_changes.store(true, Ordering::Release); @@ -173,11 +135,7 @@ impl DebouncedAnalysis { self.reindex_notify.notify_waiters(); } - /// Check whether document changes are pending reindex. - /// - /// Handlers that need consistent tree + index data (e.g. semantic tokens) - /// can use this to decide whether to serve stale results or return `None` - /// so the client keeps its previous state. + /// See implementation. pub fn is_dirty(&self) -> bool { self.has_pending_changes.load(Ordering::Acquire) } @@ -193,12 +151,6 @@ impl DebouncedAnalysis { } /// Register that a request is waiting on, or reading against, one - /// document's own index entries. - /// - /// Hold the guard until the request has finished reading. The ripple yields - /// to outstanding guards — up to [`READER_HANDOFF_GRACE`] — before it takes - /// the write lock back, so a request the self-index just released is not - /// made to wait out the ripple anyway. pub fn begin_reader_handoff(self: &Arc) -> ReaderHandoff { self.pending_readers.fetch_add(1, Ordering::AcqRel); ReaderHandoff { @@ -207,11 +159,6 @@ impl DebouncedAnalysis { } /// Let outstanding [`ReaderHandoff`]s take their read lock before the - /// caller takes the write lock. - /// - /// Returns as soon as none are outstanding, or after - /// [`READER_HANDOFF_GRACE`] so a stream of requests cannot starve the - /// ripple. async fn await_reader_handoff(&self) { let deadline = Instant::now() + READER_HANDOFF_GRACE; @@ -238,11 +185,7 @@ impl DebouncedAnalysis { } } - /// Wait until all pending document changes have been reindexed. - /// - /// Returns `true` when the analysis is fresh, `false` if the cancel token - /// fired first. Uses `enable()` so that `notify_waiters()` wakeups are - /// not lost between creating the `Notified` future and polling it. + /// See implementation. pub async fn wait_until_fresh_for( &self, cancel_token: &CancellationToken, @@ -279,18 +222,6 @@ impl DebouncedAnalysis { } /// Wait until the document at `uri` has index entries matching its text. - /// - /// A request positioned inside a file needs that file's entries to line up - /// with the tree it is resolving against — they are keyed by position, so an - /// edit that shifts offsets is what makes them stop matching, and answering - /// from the old ones is what silently returns a thinner list. It does *not* - /// need the edit's dependency ripple to have finished; that settles other - /// files' inferences, and waiting for it costs seconds on a large gamemode - /// for an answer that is already correct. - /// - /// Callers with no URI to aim at want [`wait_until_fresh_for`] instead. - /// - /// [`wait_until_fresh_for`]: Self::wait_until_fresh_for pub async fn wait_until_file_fresh_for( &self, cancel_token: &CancellationToken, @@ -326,12 +257,6 @@ impl DebouncedAnalysis { } /// Whether a request aimed at `uri` can be answered against entries that - /// match its text. - /// - /// Workspace-wide dirtiness is the wrong question for a request positioned - /// inside one document: an edit to some other file leaves this one's entries - /// matching its own text, so refusing it buys nothing and blanks the answer - /// for every open document whenever anything is typed anywhere. pub async fn file_is_answerable(&self, uri: &Uri) -> bool { // An edit whose text has not been applied yet would have the request // resolve a position against the previous tree. @@ -364,7 +289,6 @@ impl DebouncedAnalysis { ); } - /// Wait until the given file is no longer pending reindex. pub async fn wait_for_reindex(&self, file_id: FileId, cancel_token: CancellationToken) { loop { let notified = self.reindex_notify.notified(); @@ -386,16 +310,7 @@ impl DebouncedAnalysis { } } - /// Re-index the edited files' own entries, and report the dependency - /// expansion the ripple still owes them. - /// - /// The expansion is captured *before* the self-index, because deriving it - /// from a partly-updated index under-expands and leaves dependents holding - /// inferences a cold build would not produce. - /// - /// This takes the write lock and gives it back, which is the whole point: a - /// freshness flag published while the lock is still held buys a waiting - /// request nothing, since it cannot read the index until the lock is free. + /// See implementation. async fn self_index_without_queuing( &self, file_ids: Vec, @@ -408,9 +323,6 @@ impl DebouncedAnalysis { result = tokio::task::spawn_blocking(move || { let mut guard = analysis.blocking_write(); // Change-aware: only expand to dependents when the file's - // exported interface actually changed. Most keystrokes (typing - // inside a function, trailing comment, local rename) keep the - // same fingerprint and collapse the ripple to empty. let (changed, expansion) = guard.self_index_files_and_get_ripple_with_changed(file_ids); cache.invalidate(); (changed, expansion) @@ -432,15 +344,12 @@ impl DebouncedAnalysis { let analysis = self.analysis.clone(); let cache = self.shared_diagnostic_data_cache.clone(); - // Re-index under a blocking write lock on a blocking thread: the wait - // for the lock and the CPU work both stay off the Tokio workers. tokio::select! { _ = self.shutdown.cancelled() => false, result = tokio::task::spawn_blocking(move || { let mut guard = analysis.blocking_write(); guard.reindex_expanded_files(file_ids, expansion); // Invalidate under the write lock so no reader can observe the - // fresh index next to the stale shared diagnostic data. cache.invalidate(); }) => { if let Err(err) = result { @@ -453,10 +362,6 @@ impl DebouncedAnalysis { } /// Hold the owed ripple until typing has stopped for [`RIPPLE_QUIET`]. - /// - /// Returns `true` when the caller should run the ripple now, `false` when - /// another edit arrived and the loop should self-index that first — the - /// ripple it owes then joins the one already outstanding. async fn ripple_quiet_elapsed(&self, burst_started_at: Instant) -> bool { let extra = RIPPLE_QUIET.saturating_sub(self.debounce_duration); let deferral_left = MAX_RIPPLE_DEFERRAL.saturating_sub(burst_started_at.elapsed()); @@ -476,8 +381,7 @@ impl DebouncedAnalysis { self.pending_files.lock().await.is_empty() } - /// Background loop: waits for events, debounces, then runs reindex. - /// Spawn this once at server startup. + /// See implementation. pub async fn run(&self) { let mut idle_workspace_diagnostic_token: Option = None; // The ripple owed by the self-indexes run so far in this typing burst, @@ -512,7 +416,6 @@ impl DebouncedAnalysis { } } - // Timer expired — drain pending files and reindex let file_ids: Vec = { let mut pending = self.pending_files.lock().await; let mut reindexing = self.reindexing_files.lock().await; @@ -531,21 +434,12 @@ impl DebouncedAnalysis { self.debounce_duration.as_millis() ); - // Re-index the edited files themselves first and release the - // write lock, so a completion or hover positioned inside one of - // them can be answered against entries that match its text - // instead of waiting out the whole dependency ripple. The - // ripple is by far the larger half — measured on a gamemode - // workspace, 106ms against 5.1s. let Some((changed_files, expansion)) = self.self_index_without_queuing(file_ids.clone()).await else { if self.shutdown.is_cancelled() { return; } - // Release the batch, or every request aimed at these - // documents parks until some later edit happens to cover - // them. let mut reindexing = self.reindexing_files.lock().await; let mut blocked = self.blocked_documents.lock().await; for id in &file_ids { @@ -568,16 +462,10 @@ impl DebouncedAnalysis { self.reindex_notify.notify_waiters(); // The requests just released still have to be polled before - // they can queue their read. Taking the write lock back now - // would put them behind the whole ripple. self.await_reader_handoff().await; if expansion.is_empty() { // No exports changed - the self-index already makes these - // files answerable, and no dependent needs re-indexing. - // Clear them from reindexing immediately so dirty state - // can settle without waiting for a ripple that will never - // come. { let mut reindexing = self.reindexing_files.lock().await; for id in &file_ids { @@ -591,7 +479,6 @@ impl DebouncedAnalysis { } } else { // Only the files whose exports actually changed need a - // ripple; the rest are already settled by the self-index. let changed_set: HashSet = changed_files.iter().copied().collect(); { let mut reindexing = self.reindexing_files.lock().await; @@ -614,8 +501,6 @@ impl DebouncedAnalysis { } // Hold the ripple until typing has genuinely stopped. Another edit - // sends us back for its own self-index, and the ripple it owes - // joins this one. let burst_started_at_instant = burst_started_at.unwrap_or_else(Instant::now); if !self.ripple_quiet_elapsed(burst_started_at_instant).await { continue; @@ -655,8 +540,6 @@ impl DebouncedAnalysis { self.reindex_notify.notify_waiters(); if !reindex_completed { - // Only shutdown stops the loop; a panicked reindex must - // fall through so `refresh_dirty_state()` releases waiters. if self.shutdown.is_cancelled() { return; } @@ -708,15 +591,12 @@ impl DebouncedAnalysis { self.refresh_dirty_state().await; // Always notify waiters so they can re-check the condition. - // Even if we didn't reindex (pending was empty), clearing the - // dirty flag means waiters should proceed with available data. self.reindex_notify.notify_waiters(); } } async fn refresh_dirty_state(&self) { // Publish while holding both locks, or concurrent callers can - // interleave and store a stale reading. let pending = self.pending_files.lock().await; let reindexing = self.reindexing_files.lock().await; @@ -729,12 +609,6 @@ impl DebouncedAnalysis { ); // `in_flight_changes` is not covered by the locks above, so a - // concurrent `begin_in_flight_change()` can land between the load and - // the store and have its `true` overwritten. Re-reading narrows that - // window rather than closing it; what is guaranteed is only that the - // flag ends up `true` for any change whose `fetch_add` is visible by - // the time this second load runs. A change that arrives later still - // sets the flag itself, and `finish_in_flight_changes` calls back here. if self.in_flight_changes.load(Ordering::Acquire) > 0 { self.has_pending_changes.store(true, Ordering::Release); } @@ -742,8 +616,6 @@ impl DebouncedAnalysis { } /// Keeps the ripple off the write lock while one request takes its read lock. -/// -/// See [`DebouncedAnalysis::begin_reader_handoff`]. pub struct ReaderHandoff { analysis: Arc, } @@ -999,8 +871,6 @@ mod tests { } /// The point of the per-file gate: an edit to one document must not park - /// requests aimed at a different one, and must park requests aimed at - /// itself until its own entries have been rebuilt. #[gtest] fn a_pending_edit_blocks_only_its_own_document() -> Result<()> { let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build"); diff --git a/crates/glua_ls/src/handlers/test/hover_test.rs b/crates/glua_ls/src/handlers/test/hover_test.rs index a326ff964..2e27ba289 100644 --- a/crates/glua_ls/src/handlers/test/hover_test.rs +++ b/crates/glua_ls/src/handlers/test/hover_test.rs @@ -3007,8 +3007,6 @@ local EscapeStringMap: { } /// Hovering the `function` keyword in a hook.Add callback should show the anonymous callback - /// signature (e.g. `function(ply: Player, seat: Vehicle) -> boolean`) and NOT the generic - /// "The function keyword is used to define a function..." keyword docs. #[gtest] fn test_hover_hook_add_callback_function_keyword_shows_hook_signature() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); @@ -3372,9 +3370,6 @@ local EscapeStringMap: { } /// A hook declared with only `@return` and no `@param` annotations must still show - /// the return type in the anonymous callback signature (e.g. `function() -> boolean`). - /// Previously `filter_signature_type` would skip the signature when `param_docs` is empty, - /// silently degrading return-only hooks to keyword docs. #[gtest] fn test_hover_hook_add_callback_function_keyword_return_only_hook_shows_return_type() -> Result<()> { @@ -3887,9 +3882,6 @@ local EscapeStringMap: { let value = extract_hover_markdown(&ws, file_id, position); // Reads inside readPair() should appear inside a Lua code fence that - // follows a styled scope-open row for the outer for-loop — the - // helper-recursion flow_path-prefix fix carries the call site's loop - // into the helper body's reads. assert!( value.contains("net.ReadString") && value.contains("for i = 1, n, 1 do"), "expected ReadString and outer for-loop header both rendered, got: {value}" @@ -3957,14 +3949,6 @@ local EscapeStringMap: { #[gtest] fn test_hover_branched_dynamic_field_unions_vector_real_shape() -> Result<()> { // Repro of cityrp-vehicle-base/init.lua bug: - // if exitPos then - // seat.GlideExitPos = Vector(...) - // else - // seat.GlideExitPos = nil - // end - // Reading `seat.GlideExitPos` later must hover as `Vector?` (i.e. Vector|nil), - // NOT bare `nil`. Pre-fix, `retain_only_member_for_owner_key` dropped the - // Vector-branch member because the `= nil` branch ran later. let mut ws = enable_gmod_workspace(); let mut emmyrc = ws.get_emmyrc(); emmyrc.gmod.infer_dynamic_fields = true; @@ -4032,8 +4016,6 @@ local EscapeStringMap: { } /// Hover at the LHS of `seat.GlideExitPos = nil`. Assignment-target hovers - /// describe the value written at that site; the adjacent read-site - /// regression above continues to require the accumulated `Vector?` type. #[gtest] fn test_hover_branched_dynamic_field_lhs_assign_shows_assigned_nil() -> Result<()> { let mut ws = enable_gmod_workspace(); @@ -4095,11 +4077,6 @@ local EscapeStringMap: { } /// Read-site hover where the entity local comes from `self.seats[index]` - /// in a different method than the branched assignment. Mirrors - /// `cityrp-vehicle-base/init.lua:559` (`local seat = self.seats[index]` - /// then `seat.GlideExitPos[1]`). Failing red test pre-fix produced bare - /// `nil` or `never` because the branched dynamic-field assignment - /// collapsed to the last `= nil` branch. #[gtest] fn test_hover_branched_dynamic_field_read_via_self_seats_array() -> Result<()> { let mut ws = enable_gmod_workspace(); @@ -4257,13 +4234,6 @@ local EscapeStringMap: { } /// `self` inside a scripted-class method is an instance of the class the - /// authoring table stands for, so hover must name that class. - /// - /// `ENT` is virtual inside a scripted scope, so it has no decl and the - /// receiver lookup used to fall back to the enclosing method's member. That - /// is the shared semantic decl id, so goto-definition, references, - /// implementation and rename all pointed at the method too; hover is just - /// the cheapest place to observe it. #[gtest] fn test_hover_self_in_scripted_entity_method_shows_class() -> Result<()> { let mut ws = enable_gmod_workspace(); @@ -4327,9 +4297,6 @@ local EscapeStringMap: { } /// `PLAYER` and `PLUGIN` author their class through a real local, unlike the - /// virtual `ENT` / `SWEP` / `GM` tables. `self` must still be typed as that - /// file's scripted class in both styles — the local is the authoring - /// mechanism, not a different entity. #[gtest] fn test_self_in_player_class_resolves_to_class_like_the_token() -> Result<()> { let source = r#" @@ -4367,12 +4334,6 @@ local EscapeStringMap: { } /// A scripted class inherits both the GMod `ENT` annotation chain and its - /// own scripted base, so the super graph is a diamond: `ENT : ENTITY : - /// Entity` sits alongside a direct `Entity` super, and the real base comes - /// last. Walking the siblings with one shared infer guard marked `Entity` - /// visited in the `ENT` branch, so the direct `Entity` sibling failed with - /// `RecursiveInfer` and aborted the rest of the walk — the real base was - /// never searched and the inherited method lost its signature. #[gtest] fn test_inherited_scripted_method_survives_super_diamond() -> Result<()> { let mut ws = enable_gmod_workspace(); diff --git a/crates/glua_parser/src/grammar/lua/expr.rs b/crates/glua_parser/src/grammar/lua/expr.rs index 12b7eb393..75ec84d6a 100644 --- a/crates/glua_parser/src/grammar/lua/expr.rs +++ b/crates/glua_parser/src/grammar/lua/expr.rs @@ -116,8 +116,6 @@ pub fn parse_closure_expr(p: &mut LuaParser) -> ParseResult { let m = p.mark(LuaSyntaxKind::ClosureExpr); // A missing `end` is only discovered at EOF, so the error has to be pinned - // to where the function opens or it lands at the bottom of the file, far - // from the definition that is actually unclosed. let start_range = p.current_token_range(); if_token_bump(p, LuaTokenKind::TkFunction); diff --git a/crates/glua_parser/src/grammar/lua/test.rs b/crates/glua_parser/src/grammar/lua/test.rs index c3a9cacc7..dfd508e7e 100644 --- a/crates/glua_parser/src/grammar/lua/test.rs +++ b/crates/glua_parser/src/grammar/lua/test.rs @@ -1185,8 +1185,6 @@ Syntax(Chunk)@0..94 assert_ast_eq!(code, result); } /// A missing `end` is only detected at EOF, but reporting it there puts the - /// error at the bottom of the file instead of on the definition that is - /// unclosed. Every `function` form routes through `parse_closure_expr`. #[test] fn missing_end_reports_at_the_function_not_at_eof() { for src in [ diff --git a/crates/glua_parser/src/lexer/mod.rs b/crates/glua_parser/src/lexer/mod.rs index f9f75ec48..06a0612cb 100644 --- a/crates/glua_parser/src/lexer/mod.rs +++ b/crates/glua_parser/src/lexer/mod.rs @@ -18,9 +18,6 @@ fn is_name_continue(ch: char) -> bool { } /// This enum allows preserving lexer state between reader resets. This is used -/// when lexer doesn't see the whole input source, and only sees a reader -/// for each individual line. It happens when we're lexing -/// code blocks in comments. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LexerState { Normal, diff --git a/crates/glua_parser/src/syntax/mod.rs b/crates/glua_parser/src/syntax/mod.rs index 21ac2f434..c7590964a 100644 --- a/crates/glua_parser/src/syntax/mod.rs +++ b/crates/glua_parser/src/syntax/mod.rs @@ -63,17 +63,12 @@ impl From for LuaTokenKind { } /// Per-thread memo for [`LuaSyntaxId::to_node_from_root`], keyed by root -/// (MRU, a few roots kept). Holding each root alive keeps its green tree -/// alive, which is what makes identity comparison sound. mod node_memo { use super::{LuaSyntaxId, LuaSyntaxNode}; use rustc_hash::FxHashMap; const MAX_ROOTS: usize = 4; /// Each entry pins a red node, which holds an rc on its whole ancestor - /// chain, so a long-lived thread would otherwise retain most of a large - /// tree. Clearing beats evicting: the memo only pays off within one - /// traversal, so a fresh map costs a re-walk, not a lasting miss. const MAX_ENTRIES_PER_ROOT: usize = 8192; #[derive(Default)] diff --git a/crates/glua_parser/src/syntax/node/lua/expr.rs b/crates/glua_parser/src/syntax/node/lua/expr.rs index 6352fc926..370866b66 100644 --- a/crates/glua_parser/src/syntax/node/lua/expr.rs +++ b/crates/glua_parser/src/syntax/node/lua/expr.rs @@ -239,11 +239,6 @@ impl LuaNameExpr { } /// The identifier's text. - /// - /// Returns `SmolStr` rather than `String`: the token's text is already - /// `&str`, so building a `String` allocated for every name read. `SmolStr` - /// stores up to 22 bytes inline, which covers essentially every Lua - /// identifier, so the common case allocates nothing. pub fn get_name_text(&self) -> Option { self.get_name_token() .map(|it| SmolStr::new(it.get_name_text())) @@ -471,15 +466,6 @@ impl From for LuaExpr { } /// In Lua, tables are a fundamental data structure that can be used to represent arrays, objects, -/// and more. To facilitate parsing and handling of different table structures, we categorize tables -/// into three types: `TableArrayExpr`, `TableObjectExpr`, and `TableEmptyExpr`. -/// -/// - `TableArrayExpr`: Represents a table used as an array, where elements are indexed by integers. -/// - `TableObjectExpr`: Represents a table used as an object, where elements are indexed by strings or other keys. -/// - `TableEmptyExpr`: Represents an empty table with no elements. -/// -/// This categorization helps in accurately parsing and processing Lua code by distinguishing between -/// different uses of tables, thereby enabling more precise syntax analysis and manipulation. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct LuaTableExpr { syntax: LuaSyntaxNode, @@ -531,18 +517,6 @@ impl LuaTableExpr { } /// Whether this is a sequential ("array-style") table literal whose entries - /// are themselves table literals — e.g. - /// `{ { offset = .. }, { offset = .. } }`. - /// - /// Such literals carry meaningful per-row shape, so they are materialized as - /// a dynamic [`crate::LuaSyntaxKind::TableArrayExpr`]-backed table (with - /// integer-keyed members `[1]`, `[2]`, ...) rather than collapsed to a bare - /// `table`. Simple scalar arrays (`{ 1, 2, 3 }`) intentionally do NOT match, - /// so they stay summarized as `T[]`. - /// - /// This is a purely syntactic check so the declaration analyzer (which - /// registers members) and the inference pass (which assigns the type) make - /// the same decision without needing inferred element types. pub fn is_shaped_array_literal(&self) -> bool { if !self.is_array() { return false; diff --git a/crates/glua_parser/src/syntax/node/lua/path_trait.rs b/crates/glua_parser/src/syntax/node/lua/path_trait.rs index 5e01d1a9e..d08b45ae0 100644 --- a/crates/glua_parser/src/syntax/node/lua/path_trait.rs +++ b/crates/glua_parser/src/syntax/node/lua/path_trait.rs @@ -18,11 +18,6 @@ fn join_path(paths: &[SmolStr]) -> SmolStr { pub trait PathTrait: LuaAstNode { /// The dotted access path of this expression, e.g. `foo.bar.baz`. - /// - /// Returns `SmolStr` because paths are short and this is one of the hottest - /// allocation sites in analysis. A bare name — by far the common case — - /// returns without allocating at all: `paths` stays empty, so its backing - /// buffer is never allocated, and a name of 22 bytes or fewer lives inline. fn get_access_path(&self) -> Option { let mut paths: Vec = Vec::new(); let mut current_node = self.syntax().clone(); @@ -69,15 +64,6 @@ pub trait PathTrait: LuaAstNode { } /// The access path used for *member-owner identity*, where a computed key - /// collapses to `[]`. - /// - /// [`get_access_path`](Self::get_access_path) spells a computed key out, so - /// `t[a]` and `t[b]` are distinct there -- which is what flow narrowing - /// needs, since those are different values. An owner is the other question: - /// both index the same table, so a field written through one has to be - /// visible to the other. Keeping the key text here gave one runtime slot as - /// many owners as the source had ways to spell its key, and - /// `clans[v.id].models = {}` was then invisible to `clans[ply._Clan].models`. fn get_owner_access_path(&self) -> Option { let mut paths: Vec = Vec::new(); let mut current_node = self.syntax().clone(); diff --git a/crates/glua_parser/src/syntax/node/token/number_analyzer.rs b/crates/glua_parser/src/syntax/node/token/number_analyzer.rs index 73526d668..f1ba74651 100644 --- a/crates/glua_parser/src/syntax/node/token/number_analyzer.rs +++ b/crates/glua_parser/src/syntax/node/token/number_analyzer.rs @@ -10,9 +10,6 @@ pub fn float_token_value(token: &LuaSyntaxToken) -> Result { let hex = text.starts_with("0x") || text.starts_with("0X"); // This section handles the parsing of hexadecimal floating-point numbers. - // Hexadecimal floating-point literals are of the form 0x1.8p3, where: - // - "0x1.8" is the significand (integer and fractional parts in hexadecimal) - // - "p3" is the exponent (in decimal, base 2 exponent) let value = if hex { let hex_float_text = &text[2..]; let exponent_position = hex_float_text diff --git a/crates/glua_parser/src/text/reader.rs b/crates/glua_parser/src/text/reader.rs index 2e8efc082..ca31eb61c 100644 --- a/crates/glua_parser/src/text/reader.rs +++ b/crates/glua_parser/src/text/reader.rs @@ -4,38 +4,6 @@ use std::str::Chars; pub const EOF: char = '\0'; /// Reader with look-ahead and look-behind methods. -/// -/// As you read text, the part that you've read is accumulated -/// in `current_range`. The part that you haven't seen yet is available -/// in `tail_range`: -/// -/// ```text -/// valid range: a b c d e f g -/// ^^^ - current range -/// ^^^^^^^ - tail range -/// ^ - prev char -/// ^ - current char -/// ^ - next char -/// ``` -/// -/// Once you call `reset_buff`, current range is advanced to start -/// at the current char, and shrunk to zero length: -/// -/// ```text -/// valid range: a b c d e f g -/// . - current range (empty, starts at `d`) -/// ^^^^^^ - tail range -/// ^ - prev char -/// ^ - current char -/// ^ - next char -/// ``` -/// -/// The workflow in roughly this: -/// -/// - you read characters, they're put into `saved_range`; -/// - once you're at a token boundary, you emit a token with `saved_range`, -/// then call `reset_buff`, -/// - you continue onto the next token. #[derive(Debug, Clone)] pub struct Reader<'a> { text: &'a str, diff --git a/tools/benchmark/src/main.rs b/tools/benchmark/src/main.rs index f4d986fc8..a6b39ce80 100644 --- a/tools/benchmark/src/main.rs +++ b/tools/benchmark/src/main.rs @@ -22,8 +22,6 @@ macro_rules! alloc_report { static GLOBAL: MiMalloc = MiMalloc; /// Counting wrapper over mimalloc. dhat is unusable here — its per-allocation -/// backtrace capture costs ~150x on Windows — so `--features alloc-stats` -/// buys allocation counts, bytes and live-peak for a couple of atomics. #[cfg(all(feature = "alloc-stats", not(feature = "dhat-heap")))] mod alloc_stats { use std::alloc::{GlobalAlloc, Layout}; @@ -125,14 +123,9 @@ struct BenchmarkResult { } /// Process start, so incremental edits can be located on a sampling profiler's -/// timeline. Set `BENCH_EDIT_LANDMARKS=1` to print a `t+Ns` landmark per edit -/// and window the samples to just the edit. static PROCESS_START: std::sync::OnceLock = std::sync::OnceLock::new(); /// Edit each sampled file (append a comment, so the token-identity no-op gate -/// does not skip the work), timing the full production keystroke cost: reindex -/// of the file plus its dependency expansion, then the post-edit diagnostics -/// pass. Restores the original text after each edit. Returns the worst edit. fn run_incremental_edits( analysis: &mut EmmyLuaAnalysis, sample: Vec<(FileId, usize)>, @@ -141,8 +134,6 @@ fn run_incremental_edits( let mut worst = std::time::Duration::ZERO; let mut edited = 0usize; // `BENCH_EDIT_REPEAT=N` edits each file N times, which both fills a - // sampling profiler's edit window and separates first-edit cache warming - // from the steady-state cost of typing. let repeats: usize = std::env::var("BENCH_EDIT_REPEAT") .ok() .and_then(|value| value.parse().ok()) @@ -189,25 +180,15 @@ fn run_incremental_edits( ); } // `BENCH_EDIT_STAGED=1` splits the keystroke into the two halves a - // position-based request actually depends on: re-indexing the edited - // file alone, then the dependency ripple. It reports what a handler - // gated on the edited file's own freshness would wait for. let reindex = if std::env::var_os("BENCH_EDIT_STAGED").is_some() { // The expansion has to be captured before the edit lands, exactly as - // the production edit path does; recomputing it after the edited - // file has been re-indexed under-expands. let expansion = analysis.expand_reindex_file_ids(vec![file_id]); analysis.update_file_text_only(&uri, edited_text); // Just the edited file's own entries — no cross-file stabilization - // and no expansion. This is the floor a position-based request has - // to wait for if it is gated on its own file rather than on the - // whole ripple. let t = Instant::now(); analysis.self_index_files(vec![file_id]); let self_only = t.elapsed(); // `BENCH_EDIT_SELF_ONLY=1` stops after the edited file's own - // entries, so a profile of the run contains nothing but the cost a - // per-file freshness gate would pay. let ripple = if std::env::var_os("BENCH_EDIT_SELF_ONLY").is_some() { std::time::Duration::ZERO } else { @@ -242,10 +223,6 @@ fn run_incremental_edits( diagnostics.as_secs_f64() ); // Reverting through the full path costs a whole ripple per iteration — - // several times the self-index being measured, and untimed, so it - // would dominate any profile of this loop. `BENCH_EDIT_SELF_ONLY` - // exists to leave nothing but the self-index in the profile, so the - // revert has to match it. if std::env::var_os("BENCH_EDIT_SELF_ONLY").is_some() { analysis.update_file_text_only(&uri, text); analysis.self_index_files(vec![file_id]); @@ -287,13 +264,7 @@ fn contribution_entries(analysis: &EmmyLuaAnalysis, file_id: FileId) -> Vec Vec { } /// Analysis recurses over deeply nested syntax. The server does that work on -/// spawned threads, which get a far larger stack than a process main thread does -/// on Windows, so the tools have to ask for one explicitly. fn main() { std::thread::Builder::new() .stack_size(256 * 1024 * 1024) @@ -454,10 +423,6 @@ async fn run() { analysis.add_library_workspace(annotations_path.clone()); // The server resolves the gamemode base and loads it as a library, so a - // benchmark without those roots re-indexes a much smaller dependency - // expansion than a keystroke really pays for. `BENCH_LIBS` lists the extra - // library roots (e.g. the `sandbox` and `base` gamemodes) so the harness - // measures the workspace the editor actually has open. let extra_libraries = std::env::var("BENCH_LIBS") .ok() .into_iter() @@ -552,11 +517,6 @@ async fn run() { }); // Phase 4b: Incremental edit latency — the full cost a keystroke pays - // once it lands: reindex of the edited file plus its whole dependency - // expansion, then the post-edit diagnostics pass (shared-data recompute - // + the edited file), matching the production LS flow. Worst-case - // biased: files are ranked by reindex-expansion size and the top hubs - // are edited. let mut incremental_worst: Option = None; if std::env::var("BENCH_INCREMENTAL").is_ok() { let main_ids = analysis @@ -607,8 +567,6 @@ async fn run() { ranked.sort_by_key(|(_, n)| std::cmp::Reverse(*n)); { // What an edit costs depends almost entirely on how many files - // it drags in, so the shape of that distribution matters more - // than the worst case the sample below reports. let sizes: Vec = ranked.iter().map(|(_, n)| *n).collect(); let total = sizes.len(); let pct = |p: usize| sizes[(total.saturating_sub(1)) * (100 - p) / 100]; @@ -830,9 +788,6 @@ async fn run() { eprintln!("Target: ≤10s"); // A single-file edit is the interactive hot path: the user is typing, and - // every keystroke that lands pays reindex + diagnostics. Budget it - // separately from the cold index — a workspace that indexes in 10s is - // useless if each edit costs a second. if let Some(worst) = incremental_worst { let incremental_target = std::time::Duration::from_secs(1); let status = if worst <= incremental_target { diff --git a/tools/lsp_latency.js b/tools/lsp_latency.js index 71489ad6b..8fbd9d6e6 100644 --- a/tools/lsp_latency.js +++ b/tools/lsp_latency.js @@ -1,40 +1,4 @@ -// Interactive latency harness for the language server. -// -// Drives a real `glua_ls` binary over stdio with the capabilities and the -// cancellation behaviour vscode-languageclient actually uses, then reports how -// long the requests a user waits on take. Unit tests cannot catch what this -// measures: the cost of a request is dominated by whether a reindex is pending, -// which only shows up against a real workspace. -// -// Usage: -// LSP_CODEBASE=/path/to/workspace \ -// LSP_ANNOTATIONS=/path/to/annotations/output \ -// node tools/lsp_latency.js [--json] [--runs N] [--file relative/path.lua] -// [--edit code|comment] -// [--edit-find TEXT --edit-replace TEXT] -// [--completion-find TEXT] -// -// --edit-find/--edit-replace swap one snippet back and forth on every edit, so -// a specific real change to real code can be reproduced. --completion-find puts -// the cursor immediately after a given snippet instead of at the first member -// access in the file. Together they reproduce one user's exact complaint. -// -// --edit code (default) inserts a global function declaration, so each edit -// really changes what the file exports and the full dependency ripple runs. -// --edit comment inserts a comment line instead: cheaper, and useful for -// isolating offset-shift cost, but an optimisation that only helps this case -// has not made real typing any faster. -// -// LSP_SERVER overrides the binary (default: target/dist/glua_ls[.exe] if built, -// else target/release/glua_ls[.exe] — see defaultServerPath, and prefer `dist`). -// LSP_SERVER_ARGS passes extra space-separated arguments to the server, e.g. -// LSP_SERVER_ARGS='--log-level debug' to profile a slow path. -// --file defaults to the largest .lua file in the workspace, which is the -// pessimistic case and keeps runs comparable without naming a file per repo. -// -// Exits non-zero on a correctness check, not on a latency number: a cancelled -// diagnostic pull answered with a report thinner than the settled one, or a -// mid-edit completion that disagrees with the settled one. +// Interactive latency harness for glua_ls. 'use strict'; const { spawn } = require('child_process'); @@ -75,11 +39,7 @@ function parseArgs(argv) { return opts; } -/** - * Prefers the `dist` profile, which is what ships. `release` lacks its thin LTO - * and single codegen unit, so measuring it reports numbers no user experiences — - * an easy mistake to make for a whole session before noticing. - */ +/** Prefers dist profile. */ function defaultServerPath() { const exe = process.platform === 'win32' ? 'glua_ls.exe' : 'glua_ls'; const dist = path.resolve(__dirname, '..', 'target', 'dist', exe); @@ -158,11 +118,7 @@ class LspClient { _dispatch(message) { if (message.id !== undefined && message.method) { - // Server-initiated request. Record it and answer so the server is - // never left waiting on us. `workspace/configuration` has to come - // back as one entry per requested item; null is not a valid result - // and would have the server fall back to something a real client - // never makes it use. + // Answer server requests. this.serverRequests.add(message.method); const result = message.method === 'workspace/configuration' ? ((message.params && message.params.items) || []).map(() => ({})) @@ -207,11 +163,7 @@ class LspClient { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -/** - * The subset of VS Code's capabilities that changes server behaviour on the - * paths this tool measures. Keep in step with vscode-languageclient: the point - * is to exercise what the real client exercises. - */ +/** VS Code capabilities for server. */ function clientCapabilities() { return { general: { @@ -266,14 +218,7 @@ function describeReport(result) { // ------------------------------------------------------------- scenarios --- -/** - * Finds a position just after a `.` on a member access, which is the case that - * matters most: it forces the server to resolve a receiver type through the - * index rather than listing globals. - * - * `self.` is preferred because resolving it exercises the class/type indexes - * rather than a module table, which is where a stale index shows up first. - */ +/** Finds position after '.' for completion. */ function memberAccessPosition(text) { const lines = text.split('\n'); const find = (pattern) => { @@ -354,9 +299,7 @@ async function main() { }); await sleep(1500); - // `--completion-find` puts the cursor immediately after a given snippet, so - // a specific completion can be reproduced instead of whatever member access - // happens to come first in the file. + // Completion position via --completion-find. const completionPosition = (currentText) => { if (opts.completionFind === null) return memberAccessPosition(currentText); const index = currentText.indexOf(opts.completionFind); @@ -383,8 +326,7 @@ async function main() { const labelsOf = (items) => (Array.isArray(items) ? items : []).map((item) => item.label); - // Recomputed per call: every edit inserts a line, so a position captured - // once would drift and silently start measuring an empty completion. + // Completion position recomputed per call. const completionAt = async () => client.request('textDocument/completion', { textDocument: { uri }, position: completionPosition(text), @@ -396,9 +338,7 @@ async function main() { version += 1; editSerial += 1; - // `--edit-find`/`--edit-replace` reproduce one specific edit, swapping - // back and forth so every keystroke is a real change to real code. Use - // it to measure the edit a user actually complained about. + // Specific edit via --edit-find. if (opts.editFind !== null) { const [from, to] = editSerial % 2 === 1 ? [opts.editFind, opts.editReplace] @@ -414,11 +354,7 @@ async function main() { return; } - // `--edit comment` keeps the edit syntactically inert, which makes runs - // comparable but is also the case an early-cutoff optimisation can make - // fast without helping anyone. `--edit code` (the default) declares a - // global function instead, so the edit really does change what the file - // exports and the whole dependency ripple has to run. + // Insert edit per --edit mode. const inserted = opts.edit === 'comment' ? '-- perf\n' : `function _PerfProbe${editSerial}(a) return a end\n`; @@ -429,9 +365,7 @@ async function main() { }); }; - // A settled measurement is only meaningful once nothing is pending. An - // uncancelled diagnostic pull returns exactly when the analysis is fresh, - // so it is the cheapest way to wait for quiescence without guessing. + // Wait for analysis to settle. const waitUntilQuiet = async () => { await client.request('textDocument/diagnostic', { textDocument: { uri }, previousResultId, @@ -441,7 +375,7 @@ async function main() { for (let run = 0; run < opts.runs; run++) { await waitUntilQuiet(); - // Settled: no pending edit, so this is the pure compute cost. + // Measure without pending edits. const settled = await completionAt(); settledCompletion.push(settled.ms); const items = settled.message.result @@ -457,10 +391,7 @@ async function main() { if (described.resultId) previousResultId = described.resultId; report.checks.diagnosticCount = described.count ?? report.checks.diagnosticCount; - // While typing: the request the user actually waits on. Its result is - // compared against the settled one, because the whole risk of answering - // before a reindex finishes is answering *differently* — a thinner or - // wrong list is the failure mode, not a slow one. + // Measure while typing. editDocument(); const typing = await completionAt(); typingCompletion.push(typing.ms); @@ -474,10 +405,7 @@ async function main() { completionDrift.push({ missing: missing.length, extra: extra.length, sampleMissing: missing.slice(0, 5) }); - // Completion is not the only thing gated on freshness. Hover is issued - // from the same keystroke and measured separately, so a fix that makes - // completion answer early but leaves every other position-based feature - // parked shows up here instead of looking like a win. + // Measure hover separately. editDocument(); const [hovered, completed] = await Promise.all([ client.request('textDocument/hover', { @@ -488,9 +416,7 @@ async function main() { typingHover.push(hovered.ms); typingCompletionConcurrent.push(completed.ms); - // Syntax highlighting. The client retries on ContentModified rather than - // waiting, so the number the user feels is the time until a real token - // set arrives, not the latency of any single request. + // Measure semantic tokens. editDocument(); const highlightStarted = Date.now(); let highlightRetried = 0; @@ -513,12 +439,7 @@ async function main() { }); editToFresh.push(fresh.ms); - // A pull cancelled mid-flight must never come back thinner than the - // settled answer — a full report short of what the file really has is - // what drops diagnostics in VS Code, and an empty one clears the file. - // Stated against the settled count rather than against zero, so a file - // that legitimately has no diagnostics does not read as a failure: on a - // clean file the correct report is empty too. + // Verify cancelled pull does not drop diagnostics. editDocument(); const doomed = client.request('textDocument/diagnostic', { textDocument: { uri }, previousResultId, @@ -544,8 +465,7 @@ async function main() { report.measurements.editToFreshAnswer = summarise(editToFresh); report.checks.thinReportsOnCancel = cancelledPulls.filter((p) => p.thinFullReport).length; - // A mid-edit completion that differs from the settled one is a correctness - // regression, however fast it came back. + // Check mid-edit completion matches baseline. report.checks.completionDriftWhileTyping = { worstMissing: Math.max(0, ...completionDrift.map((d) => d.missing)), worstExtra: Math.max(0, ...completionDrift.map((d) => d.extra)), From f9ca6da4f92c652e4c27701c058e20fb4133a6f4 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sat, 29 Aug 2026 06:29:52 +0100 Subject: [PATCH 065/159] fix: completions instability --- .../src/db_index/member/mod.rs | 5 + .../src/semantic/infer/infer_index/mod.rs | 6 +- .../src/semantic/infer/infer_name.rs | 124 +++++++++++++++++- .../src/semantic/infer/mod.rs | 1 + .../src/semantic/member/find_members.rs | 61 ++++++++- .../semantic_info/infer_expr_semantic_decl.rs | 44 ++++++- .../completion/providers/member_provider.rs | 119 ++++++++++++++++- .../src/handlers/test/completion_test.rs | 80 +++++++++++ 8 files changed, 427 insertions(+), 13 deletions(-) diff --git a/crates/glua_code_analysis/src/db_index/member/mod.rs b/crates/glua_code_analysis/src/db_index/member/mod.rs index 00cb7c6cb..e7aafbab2 100644 --- a/crates/glua_code_analysis/src/db_index/member/mod.rs +++ b/crates/glua_code_analysis/src/db_index/member/mod.rs @@ -1488,6 +1488,11 @@ impl LuaMemberIndex { self.non_overwriting_assignment_members.contains(&member_id) } + pub fn is_conditional_branch_assignment_member(&self, member_id: LuaMemberId) -> bool { + self.conditional_branch_assignment_members + .contains(&member_id) + } + pub fn mark_conditional_branch_assignment_member( &mut self, member_id: LuaMemberId, 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 08fb5ee0d..0a4ef4a24 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 @@ -2597,7 +2597,11 @@ fn infer_global_path_member( resolved } -fn global_expr_access_path(db: &DbIndex, file_id: FileId, expr: &LuaExpr) -> Option { +pub(crate) fn global_expr_access_path( + db: &DbIndex, + file_id: FileId, + expr: &LuaExpr, +) -> Option { if !expr_root_is_global(db, file_id, expr) { return None; } 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 b203a3ceb..62afc3b1b 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_name.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_name.rs @@ -710,9 +710,18 @@ fn infer_define_baseclass_type(db: &DbIndex, file_id: FileId, name: &str) -> Opt } fn infer_self(db: &DbIndex, cache: &mut LuaInferCache, name_expr: LuaNameExpr) -> InferResult { - 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), + let var_ref_id = get_name_expr_var_ref_id(db, cache, &name_expr); + let self_ref_id = match var_ref_id { + Some(VarRefId::SelfRef(self_ref_id)) => Some(self_ref_id), + // Receiver-id resolution can fail even when the receiver's type is + // still derivable: `find_self_receiver_id` resolves the colon-method + // prefix through semantic-decl lookup, which has no route for a + // shapeless (bare `table`) intermediate link — e.g. after the settled + // widening of a guarded `x.y = x.y or {}` bootstrap. The seed below + // re-derives the receiver type through prefix inference, which does + // resolve such chains, so fall back to it instead of erroring: an + // error here silently empties `self.` member completions and hover. + _ => None, }; // Compute a region-aware base for the implicit `self` (the colon-method @@ -727,6 +736,12 @@ fn infer_self(db: &DbIndex, cache: &mut LuaInferCache, name_expr: LuaNameExpr) - // the canonical `get_var_ref_type` resolution. let base_seed = infer_implicit_method_self_type(db, cache, &name_expr); + let Some(self_ref_id) = self_ref_id else { + // No receiver id, but the seed may still know the receiver's type. + // Narrowing needs the id, so the seed is the final answer here. + return base_seed.ok_or(InferFailReason::None); + }; + infer_expr_narrow_type_with_self_base( db, cache, @@ -2978,11 +2993,11 @@ fn infer_method_prefix_type( mod test { use super::{ direct_table_field_from_member_id, find_param_type_from_contextual_member, - get_name_expr_var_ref_id, infer_name_expr, + find_self_ref_id, get_name_expr_var_ref_id, infer_name_expr, }; use crate::{ - Emmyrc, LuaInferCache, LuaMemberId, LuaSignatureId, LuaType, LuaTypeCache, VarRefId, - VirtualWorkspace, + Emmyrc, FileId, LuaInferCache, LuaMemberId, LuaSignatureId, LuaType, LuaTypeCache, + VarRefId, VirtualWorkspace, }; use glua_parser::{ LuaAstNode, LuaAstToken, LuaClosureExpr, LuaIndexKey, LuaLocalName, LuaNameExpr, @@ -3106,6 +3121,103 @@ mod test { Ok(()) } + /// The bootstrap fixture both `self` tests use: `cityrp.configuration` + /// comes from a shapeless bootstrap link, and `self` is used inside a + /// function on its `ranks` member. + fn def_bootstrap_fixture(ws: &mut VirtualWorkspace) -> FileId { + ws.def_file( + "gamemode/shared.lua", + r#" +cityrp = cityrp or {} + +---@return table +function cityrp.bootstrap() end + +cityrp.configuration = cityrp.bootstrap() +"#, + ); + ws.def_file( + "gamemode/core/sh_configuration.lua", + r#" +cityrp.configuration.ranks = { + owner = { level = 5 }, + remap = { admin = "mod" }, +} + +function cityrp.configuration.ranks:Get(rank) + return self.remap +end +"#, + ) + } + + #[gtest] + fn test_infer_self_falls_back_to_seed_when_receiver_id_resolution_is_shapeless() -> Result<()> { + let mut ws = VirtualWorkspace::new(); + let config_id = def_bootstrap_fixture(&mut ws); + + // The intermediate link `cityrp.configuration` is shapeless (bare + // `table`), so semantic-decl receiver resolution cannot see through it + // and no receiver id is derived. Prefix inference through the member + // index still resolves the chain, so `self` must fall back to the seed + // instead of degrading to `Unknown` (which empties `self.` completion). + let semantic_model = ws + .analysis + .compilation + .get_semantic_model(config_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 db = ws.analysis.compilation.get_db(); + let mut cache = LuaInferCache::new(config_id, Default::default()); + let self_type = + infer_name_expr(db, &mut cache, self_expr).expect("self inference should succeed"); + + let LuaType::TableConst(in_file) = self_type else { + panic!("expected self to resolve to the ranks table literal, got {self_type:?}"); + }; + expect_that!(in_file.file_id, eq(config_id)); + + Ok(()) + } + + #[gtest] + fn test_self_receiver_id_resolves_through_shapeless_bootstrap_link() -> Result<()> { + let mut ws = VirtualWorkspace::new(); + let config_id = def_bootstrap_fixture(&mut ws); + + // The bootstrap link `cityrp.configuration` resolves to an empty + // literal, so the prefix-type member lookup misses. The global-path + // fallback must still resolve the chain's declaration, so `self`'s + // receiver id resolves to the `ranks` member instead of failing. + let semantic_model = ws + .analysis + .compilation + .get_semantic_model(config_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 db = ws.analysis.compilation.get_db(); + let mut cache = LuaInferCache::new(config_id, Default::default()); + let self_ref_id = + find_self_ref_id(db, &mut cache, &self_expr).expect("receiver id should resolve"); + expect_that!( + matches!( + self_ref_id.receiver, + crate::db_index::LuaDeclOrMemberId::Member(_) + ), + eq(true) + ); + + Ok(()) + } + #[test] fn clear_for_unresolve_drops_global_var_ref_selected_from_mutable_types() { let mut ws = VirtualWorkspace::new(); diff --git a/crates/glua_code_analysis/src/semantic/infer/mod.rs b/crates/glua_code_analysis/src/semantic/infer/mod.rs index 73ce9eb7f..7d412cbd7 100644 --- a/crates/glua_code_analysis/src/semantic/infer/mod.rs +++ b/crates/glua_code_analysis/src/semantic/infer/mod.rs @@ -23,6 +23,7 @@ pub(crate) use infer_call::signature_call_selects_declared_overload; pub use infer_doc_type::{DocTypeInferContext, infer_doc_type}; pub use infer_fail_reason::InferFailReason; pub(crate) use infer_index::check_iter_var_range; +pub(crate) use infer_index::global_expr_access_path; 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; diff --git a/crates/glua_code_analysis/src/semantic/member/find_members.rs b/crates/glua_code_analysis/src/semantic/member/find_members.rs index 58a40ee3f..7041f8fd0 100644 --- a/crates/glua_code_analysis/src/semantic/member/find_members.rs +++ b/crates/glua_code_analysis/src/semantic/member/find_members.rs @@ -1129,7 +1129,7 @@ fn append_dynamic_fields_for_type( field_names.sort_unstable(); for field_name in field_names { - let member_key = LuaMemberKey::Name(field_name); + let member_key = LuaMemberKey::Name(field_name.clone()); if !should_include_member(&member_key, filter) { continue; } @@ -1138,6 +1138,19 @@ fn append_dynamic_fields_for_type( continue; } + if let Some(caller_position) = ctx.caller_position() + && let Some(caller_file_id) = ctx.file_id() + && !dynamic_field_visible_at_offset( + db, + &owner, + &field_name, + caller_file_id, + caller_position, + ) + { + continue; + } + let resolved = ctx.file_id().and_then(|file_id| { resolve_dynamic_field_member_for_file(db, file_id, &prefix_type, &member_key) }); @@ -1208,7 +1221,7 @@ fn append_dynamic_fields_for_table( field_names.sort_unstable(); for field_name in field_names { - let member_key = LuaMemberKey::Name(field_name); + let member_key = LuaMemberKey::Name(field_name.clone()); if !should_include_member(&member_key, filter) { continue; } @@ -1217,6 +1230,19 @@ fn append_dynamic_fields_for_table( continue; } + if let Some(caller_position) = ctx.caller_position() + && let Some(caller_file_id) = ctx.file_id() + && !dynamic_field_visible_at_offset( + db, + &owner, + &field_name, + caller_file_id, + caller_position, + ) + { + continue; + } + let resolved = ctx.file_id().and_then(|file_id| { resolve_dynamic_field_member_for_file(db, file_id, &prefix_type, &member_key) }); @@ -1239,6 +1265,31 @@ fn append_dynamic_fields_for_table( false } +/// A dynamic field is only as visible as its assignments: a definition in the +/// caller file that starts after the caller position has not executed yet, so +/// the field must not be offered there — a simplified form of the rule +/// `member_visible_at_offset` applies to regular members (it has no +/// same-function execution-region exception). Definitions in other files stay +/// visible; their load-order and realm rules are the member item's to decide. +fn dynamic_field_visible_at_offset( + db: &DbIndex, + owner: &crate::DynamicFieldOwner, + field_name: &str, + caller_file_id: FileId, + caller_position: TextSize, +) -> bool { + let definitions = db + .get_dynamic_field_index() + .get_field_definitions(owner, field_name); + if definitions.is_empty() { + return true; + } + + definitions.iter().any(|definition| { + definition.file_id != caller_file_id || definition.value.start() <= caller_position + }) +} + fn append_keyed_dynamic_field( db: &DbIndex, ctx: &FindMembersContext, @@ -1270,6 +1321,12 @@ fn append_keyed_dynamic_field( if !is_visible { return Some(false); } + if let Some(caller_position) = ctx.caller_position() + && let Some(caller_file_id) = ctx.file_id() + && !dynamic_field_visible_at_offset(db, owner, field_name, caller_file_id, caller_position) + { + return Some(false); + } let resolved = ctx.file_id().and_then(|file_id| { resolve_dynamic_field_member_for_file(db, file_id, prefix_type, member_key) diff --git a/crates/glua_code_analysis/src/semantic/semantic_info/infer_expr_semantic_decl.rs b/crates/glua_code_analysis/src/semantic/semantic_info/infer_expr_semantic_decl.rs index 3796b9fa4..d078da993 100644 --- a/crates/glua_code_analysis/src/semantic/semantic_info/infer_expr_semantic_decl.rs +++ b/crates/glua_code_analysis/src/semantic/semantic_info/infer_expr_semantic_decl.rs @@ -13,6 +13,7 @@ use crate::{ }, }; +use super::super::infer::global_expr_access_path; use super::{ SemanticDeclLevel, infer_expr, infer_token_semantic_decl, semantic_guard::SemanticDeclGuard, }; @@ -252,7 +253,7 @@ fn infer_index_expr_semantic_decl( let Some(prefix_expr) = index_expr.get_prefix_expr() else { return Ok(None); }; - let prefix_type = match infer_expr(db, cache, prefix_expr) { + let prefix_type = match infer_expr(db, cache, prefix_expr.clone()) { Ok(typ) => typ, Err(reason) => return terminal(reason), }; @@ -266,13 +267,52 @@ fn infer_index_expr_semantic_decl( let Some(next_guard) = semantic_guard.next_level() else { return Ok(None); }; - infer_member_semantic_decl_by_member_key( + let resolved = infer_member_semantic_decl_by_member_key( db, cache, &prefix_type, &member_key, Some(index_expr.get_position()), next_guard, + )?; + if resolved.is_some() { + return Ok(resolved); + } + + // A global-rooted chain whose link carries no members of its own — e.g. a + // guarded `x.y = x.y or {}` bootstrap whose slot resolves to the empty + // bootstrap literal — keeps its accumulated members in the member index + // under the global path owner. The type-level route falls back to that + // owner (`infer_global_path_member`); the semantic-decl route must too, or + // receiver and declaration resolution through such chains silently miss. + if is_shapeless_prefix_type(&prefix_type) + && let Some(owner_path) = global_expr_access_path(db, cache.get_file_id(), &prefix_expr) + { + let owner = LuaMemberOwner::GlobalPath(GlobalId::new(&owner_path)); + if let Some(member_item) = db.get_member_index().get_member_item(&owner, &member_key) + && let Some(decl) = member_item.resolve_semantic_decl_with_realm_at_offset( + db, + &cache.get_file_id(), + index_expr.get_position(), + ) + { + return Ok(Some(decl)); + } + } + Ok(None) +} + +/// Prefix types that carry no member shape of their own, so a miss against +/// them says nothing about whether the member exists. +fn is_shapeless_prefix_type(prefix_type: &LuaType) -> bool { + matches!( + prefix_type, + LuaType::Table + | LuaType::TableConst(_) + | LuaType::Unknown + | LuaType::Any + | LuaType::Nil + | LuaType::Global ) } diff --git a/crates/glua_ls/src/handlers/completion/providers/member_provider.rs b/crates/glua_ls/src/handlers/completion/providers/member_provider.rs index 3fda5d6aa..8c30ed0ef 100644 --- a/crates/glua_ls/src/handlers/completion/providers/member_provider.rs +++ b/crates/glua_ls/src/handlers/completion/providers/member_provider.rs @@ -1,6 +1,7 @@ use glua_code_analysis::{ - DbIndex, FileId, GmodRealm, GmodStateMask, LuaMemberInfo, LuaMemberKey, LuaSemanticDeclId, - LuaType, LuaTypeDeclId, SemanticModel, enum_variable_is_param, get_tpl_ref_extend_type, + DbIndex, FileId, GmodRealm, GmodStateMask, LuaMemberId, LuaMemberInfo, LuaMemberKey, + LuaMemberOwner, LuaSemanticDeclId, LuaType, LuaTypeDeclId, MemberAssignmentContributionStore, + SemanticModel, enum_variable_is_param, get_tpl_ref_extend_type, }; use glua_parser::{ LuaAstNode, LuaAstToken, LuaComment, LuaCommentOwner, LuaDocTag, LuaDocTagRealm, LuaExpr, @@ -73,6 +74,7 @@ pub fn add_completion(builder: &mut CompletionBuilder) -> Option<()> { None }; extend_gmod_hook_fallback_members(builder, gmod_fallback_owner, &mut member_info_map); + dedupe_member_infos(builder, &mut member_info_map); add_completions_for_members_with_gmod_owner( builder, @@ -99,6 +101,11 @@ fn extend_global_path_members( return; }; + // The namespace route is a fallback for keys the prefix type cannot + // answer — e.g. a guarded bootstrap slot that resolves to an empty + // literal while the accumulated members live under the global path + // owner. Its members join the prefix type's own, and superseded + // same-file writers are collapsed afterwards. let mut existing = collect_member_identities(members); for (key, infos) in global_path_members { @@ -180,6 +187,114 @@ fn extend_gmod_hook_fallback_members( } } +/// Writers of one key, resolved the way the settled slot resolves them. +/// +/// Writers within one file are sequential — the file's latest write of the +/// key, whatever its class, is what a load of the file leaves in the slot, so +/// earlier same-file writers collapse to it. Across files, a plain load-time +/// write supersedes conditional-branch writers (`if ... then t.k = x end` +/// fires only at runtime events, after the slot's load-time value exists), +/// while guarded bootstrap siblings (`x = x or {}`) and other cross-file +/// writers coexist — realm and load order at the caller decide which of those +/// a caller sees (the item resolution's job). +fn dedupe_member_infos( + builder: &CompletionBuilder, + members: &mut HashMap>, +) { + let db = builder.semantic_model.get_db(); + let contributions = db.get_member_index().member_assignment_contributions(); + for infos in members.values_mut() { + resolve_slot_writers(db, contributions, infos); + let mut seen = HashSet::new(); + infos.retain(|info| seen.insert(MemberInfoIdentity::from(info))); + } +} + +fn resolve_slot_writers( + db: &DbIndex, + contributions: &MemberAssignmentContributionStore, + infos: &mut Vec, +) { + let member_index = db.get_member_index(); + // Class declaration fields (`---@field`) are contracts, not sequential + // writes — they never take part in the per-file supersession below. + let is_declaration = |member_id: &LuaMemberId| { + matches!( + member_index.get_member_owner(member_id), + Some(LuaMemberOwner::Type(_)) + ) + }; + let is_plain_writer = |member_id: &LuaMemberId| { + !member_index.is_conditional_branch_assignment_member(*member_id) + && contributions + .contribution_of(member_id) + .is_some_and(|contribution| { + !contribution.guarded_bootstrap && !contribution.preserve_table_literals + }) + }; + + // Writers of a key within one file are sequential: the file's latest write + // — whatever its class — is what a load of the file leaves in the slot. + if infos.len() <= 1 { + return; + } + let mut latest_per_file: HashMap = HashMap::new(); + for info in infos.iter() { + let Some(LuaSemanticDeclId::Member(member_id)) = &info.property_owner_id else { + continue; + }; + if is_declaration(member_id) { + continue; + } + let position = member_id.get_position(); + latest_per_file + .entry(member_id.file_id) + .and_modify(|latest| { + if position > *latest { + *latest = position; + } + }) + .or_insert(position); + } + if latest_per_file.is_empty() { + return; + } + + let mut plain_files: HashSet = HashSet::new(); + let mut has_plain_writer = false; + for info in infos.iter() { + let Some(LuaSemanticDeclId::Member(member_id)) = &info.property_owner_id else { + continue; + }; + if !is_declaration(member_id) && is_plain_writer(member_id) { + has_plain_writer = true; + plain_files.insert(member_id.file_id); + } + } + + infos.retain(|info| match &info.property_owner_id { + Some(LuaSemanticDeclId::Member(member_id)) => { + if is_declaration(member_id) { + return true; + } + if latest_per_file.get(&member_id.file_id) != Some(&member_id.get_position()) { + return false; + } + // A plain load-time write supersedes conditional-branch writers + // from files with no plain writer of the key: those only fire at + // runtime events, after the slot's load-time value exists. + if has_plain_writer + && !plain_files.contains(&member_id.file_id) + && member_index.is_conditional_branch_assignment_member(*member_id) + { + return false; + } + true + } + _ => true, + }); +} + type MemberIdentityMap = HashMap>; #[derive(Clone, Debug, Eq, Hash, PartialEq)] diff --git a/crates/glua_ls/src/handlers/test/completion_test.rs b/crates/glua_ls/src/handlers/test/completion_test.rs index cf2b9eec3..e2c748d0b 100644 --- a/crates/glua_ls/src/handlers/test/completion_test.rs +++ b/crates/glua_ls/src/handlers/test/completion_test.rs @@ -1474,6 +1474,86 @@ mod tests { Ok(()) } + /// A global slot written by a plain top-level assignment in one file and + /// a conditional-branch writer in another resolves like the settled slot: + /// the plain write supersedes the conditional writer, so the key is + /// offered once. + #[gtest] + fn test_completion_plain_write_supersedes_cross_file_conditional_writer() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let mut emmyrc = ws.get_emmyrc(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + ws.def_file( + "a.lua", + r#" + cfg = cfg or {} + cfg.mode = "plain" + "#, + ); + ws.def_file( + "b.lua", + r#" + cfg = cfg or {} + if cfg.mode then + cfg.mode = "conditional" + end + "#, + ); + check!(ws.check_completion( + r#" + cfg. + "#, + vec![VirtualCompletionItem { + label: "mode".to_string(), + kind: CompletionItemKind::FIELD, + ..Default::default() + }], + )); + Ok(()) + } + + /// Guarded bootstrap writers of a slot coexist: the completion must not + /// collapse them the way it collapses superseded writers. + #[gtest] + fn test_completion_keeps_guarded_bootstrap_sibling_writers() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let mut emmyrc = ws.get_emmyrc(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + ws.def_file( + "a.lua", + r#" + cfg = cfg or { alpha = {} } + "#, + ); + ws.def_file( + "b.lua", + r#" + cfg = cfg or {} + cfg.alpha = cfg.alpha or {} + "#, + ); + check!(ws.check_completion( + r#" + cfg. + "#, + vec![ + VirtualCompletionItem { + label: "alpha".to_string(), + kind: CompletionItemKind::STRUCT, + ..Default::default() + }, + VirtualCompletionItem { + label: "alpha".to_string(), + kind: CompletionItemKind::INTERFACE, + ..Default::default() + }, + ], + )); + Ok(()) + } + #[gtest] fn test_issue_572() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); From f9fc9b9ee13e2c9ecfda9888473d27985f6bac24 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:23:30 +0100 Subject: [PATCH 066/159] fix: dynamic-field visibility is function-body aware GLua load order only constrains top-level statements. A definition inside a function body runs when that function is called, which load order does not pin down, and a caller inside a function body runs after the whole file has loaded - so both stay visible. Only a same-file top-level definition that starts after a top-level caller position is hidden. --- .../src/semantic/member/find_members.rs | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/glua_code_analysis/src/semantic/member/find_members.rs b/crates/glua_code_analysis/src/semantic/member/find_members.rs index 7041f8fd0..26e85ef49 100644 --- a/crates/glua_code_analysis/src/semantic/member/find_members.rs +++ b/crates/glua_code_analysis/src/semantic/member/find_members.rs @@ -1265,12 +1265,14 @@ fn append_dynamic_fields_for_table( false } -/// A dynamic field is only as visible as its assignments: a definition in the -/// caller file that starts after the caller position has not executed yet, so -/// the field must not be offered there — a simplified form of the rule -/// `member_visible_at_offset` applies to regular members (it has no -/// same-function execution-region exception). Definitions in other files stay -/// visible; their load-order and realm rules are the member item's to decide. +/// A dynamic field is only as visible as its assignments. GLua load order only +/// constrains *top-level* statements: a same-file, top-level definition that +/// starts after the caller position has not executed yet, so the field must not +/// be offered there. A definition inside a function body runs when that +/// function is called, which load order does not pin down, and a caller inside +/// a function body runs after the whole file has loaded — both stay visible. +/// Definitions in other files are always visible; their load-order and realm +/// rules are the member item's to decide. fn dynamic_field_visible_at_offset( db: &DbIndex, owner: &crate::DynamicFieldOwner, @@ -1285,8 +1287,24 @@ fn dynamic_field_visible_at_offset( return true; } + let member_index = db.get_member_index(); + // A caller inside a function body runs after the whole file has loaded, so + // every same-file definition is visible to it. + let caller_in_function = member_index + .enclosing_function_scope_range(caller_file_id, caller_position) + .is_some(); definitions.iter().any(|definition| { - definition.file_id != caller_file_id || definition.value.start() <= caller_position + if definition.file_id != caller_file_id || definition.value.start() <= caller_position { + return true; + } + if caller_in_function { + return true; + } + // A definition inside a function body runs when that function is + // called, which load order does not pin down, so it stays visible. + member_index + .enclosing_function_scope_range(definition.file_id, definition.value.start()) + .is_some() }) } From 834e7b3c519900d933c97d30c6b53f459e1cf37b Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:23:31 +0100 Subject: [PATCH 067/159] perf: parallelize the settled iter-var re-derivation The pass ran 1476 candidates sequentially on CityRP, 443ms per cold build. Run the inference read-only on the parallel file workers, carrying each candidate across the thread boundary as syntax ids and positions, then apply the recorded type writes in stable file and source order on the caller thread. Pass cost drops to 184ms with identical diagnostics and index state. --- .../src/compilation/analyzer/mod.rs | 134 ++++++++++++++++-- .../src/compilation/analyzer/unresolve/mod.rs | 5 +- .../compilation/analyzer/unresolve/resolve.rs | 97 +++++++++---- 3 files changed, 201 insertions(+), 35 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs index c4d8b161a..47a4258fb 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs @@ -501,6 +501,58 @@ fn rederive_settled_inferred_returns(db: &mut DbIndex, context: &mut AnalyzeCont changed } +/// The parallel re-derivation's per-file output: candidate type writes for the +/// settled iterator variables, plus the cache side effects inference produced. +struct IterVarRefreshResult { + file_id: FileId, + pending_type_decls: Vec, + guard_dependencies: HashSet, + updates: Vec, +} + +impl IterVarRefreshResult { + fn new(file_id: FileId) -> Self { + Self { + file_id, + pending_type_decls: Vec::new(), + guard_dependencies: HashSet::new(), + updates: Vec::new(), + } + } +} + +/// A settled iterator-variable candidate carried across the parallel boundary: +/// rowan-backed AST handles are not `Send`, so the closure re-reads the +/// expressions from the file's syntax tree by syntax id. +struct SettledIterVarCandidate { + iter_expr_ids: Vec, + var_positions: Vec, +} + +impl SettledIterVarCandidate { + fn new(iter_var: &UnResolveIterVar) -> Self { + Self { + iter_expr_ids: iter_var + .iter_exprs + .iter() + .map(LuaAstNode::get_syntax_id) + .collect(), + var_positions: iter_var + .iter_vars + .iter() + .map(glua_parser::LuaAstToken::get_position) + .collect(), + } + } + + fn iter_exprs(&self, root: &LuaSyntaxNode) -> Option> { + self.iter_expr_ids + .iter() + .map(|id| LuaExpr::cast(id.to_node_from_root(root)?)) + .collect() + } +} + /// Re-derives `for ... in pairs(t)` variable types that were read off `t`'s /// member map or its declared field type. /// @@ -523,16 +575,82 @@ fn rederive_settled_iter_vars(db: &mut DbIndex, context: &mut AnalyzeContext) -> ) }); - let files = candidates - .iter() - .map(|iter_var| iter_var.file_id) - .collect::>(); - context.infer_manager.clear_files_iter_var_results(&files); + let mut candidates_by_file = HashMap::>::new(); + for iter_var in candidates { + let candidate = SettledIterVarCandidate::new(&iter_var); + candidates_by_file + .entry(iter_var.file_id) + .or_default() + .push(candidate); + } + + let file_ids = candidates_by_file.keys().copied().collect::>(); + context + .infer_manager + .clear_files_iter_var_results(&file_ids.iter().copied().collect()); + + let analysis_phase = context.infer_manager.current_phase(); + let dynamic_fields_visible = context.infer_manager.dynamic_fields_visible(); + + // Inference reads the settled indexes and records candidate type writes + // without mutating the database. Apply the writes in stable file and source + // order on the caller thread, so the result does not depend on the schedule. + let results = parallel::map_files_collect(db, &file_ids, |db, file_id| { + let mut infer_cache = crate::LuaInferCache::new( + file_id, + crate::CacheOptions { + analysis_phase, + dynamic_fields_visible, + building_dynamic_field_index: false, + }, + ); + let mut result = IterVarRefreshResult::new(file_id); + let root = db + .get_vfs() + .get_syntax_tree(&file_id) + .map(|tree| tree.get_red_root()); + if let Some(root) = &root { + for candidate in &candidates_by_file[&file_id] { + let Some(iter_exprs) = candidate.iter_exprs(root) else { + continue; + }; + if let Ok(updates) = unresolve::resolve_settled_iter_var_readonly( + db, + &mut infer_cache, + file_id, + &iter_exprs, + &candidate.var_positions, + ) { + result.updates.extend(updates); + } + } + } + result.pending_type_decls = infer_cache.take_pending_str_tpl_type_decls(); + result.guard_dependencies = infer_cache.take_inferred_guard_dependencies(); + result + }); let writes_before = db.get_type_index().type_writes(); - for mut iter_var in candidates { - let cache = context.infer_manager.get_infer_cache(iter_var.file_id); - let _ = unresolve::resolve_settled_iter_var(db, cache, &mut iter_var); + for result in results { + context.infer_manager.merge_inference_side_effects( + result.file_id, + result.pending_type_decls, + result.guard_dependencies, + ); + for update in result.updates { + common::write_type_cache(db, update.owner.clone(), update.cache.clone(), update.mode); + // The same answer serves the iter-var reads: mirror it into the + // per-file iter-var cache the way the sequential re-derivation's + // inference did. + if let LuaTypeOwner::Decl(decl_id) = update.owner { + let typ = update.cache.as_type().clone(); + context + .infer_manager + .get_infer_cache(decl_id.file_id) + .for_range_iter_var_type_cache + .insert(decl_id, crate::CacheEntry::Cache(typ)); + } + } } db.get_type_index().type_writes() != writes_before } diff --git a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs index ecedb8b58..51929cb9c 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs @@ -32,7 +32,10 @@ use resolve_closure::{ }; pub(crate) use resolve::get_wrapped_callable_target_expr; -pub(crate) use resolve::{resolve_settled_iter_var, try_resolve_member, try_resolve_return_point}; +pub(crate) use resolve::{ + IterVarTypeUpdate, resolve_settled_iter_var_readonly, try_resolve_member, + try_resolve_return_point, +}; pub use resolve_closure::extract_hook_name; pub use resolve_closure::{ resolve_gmod_hook_add_callback_doc_function, resolve_gmod_hook_callback_doc_function, 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 af8eb61e8..f37ff2ef4 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs @@ -720,12 +720,18 @@ pub fn try_resolve_iter_var( /// [`try_resolve_iter_var`] for the settled re-derivation, where the answer was /// taken against the complete member map and so replaces whatever partial one a /// wave left behind rather than only widening it. -pub fn resolve_settled_iter_var( - db: &mut DbIndex, +/// The inference of the settled iterator-variable re-derivation without the +/// writes, for the parallel pass: updates are applied later in stable file +/// order. Each update pairs a variable's resolved type with the write-mode +/// decision it earned. +pub fn resolve_settled_iter_var_readonly( + db: &DbIndex, cache: &mut LuaInferCache, - unresolve_iter_var: &mut UnResolveIterVar, -) -> ResolveResult { - try_resolve_iter_var_inner(db, cache, unresolve_iter_var, true) + file_id: FileId, + iter_exprs: &[LuaExpr], + var_positions: &[TextSize], +) -> Result, InferFailReason> { + compute_iter_var_updates(db, cache, file_id, iter_exprs, var_positions, true) } fn try_resolve_iter_var_inner( @@ -734,25 +740,60 @@ fn try_resolve_iter_var_inner( unresolve_iter_var: &mut UnResolveIterVar, settled: bool, ) -> ResolveResult { - let iter_var_types = - match infer_for_range_iter_expr_func(db, cache, &unresolve_iter_var.iter_exprs) { - Ok(types) => types, - // Placeholder items have nothing to add on a failed retry: the template - // ref is already cached. Keep the failure in this reason's own group - // rather than injecting the item into another group's fixpoint. - Err(reason) => { - return Err( - if iter_var_holds_tpl_placeholder(db, unresolve_iter_var, 0) { - InferFailReason::UnResolveIterTemplate - } else { - reason - }, - ); - } - }; - for (idx, var_name) in unresolve_iter_var.iter_vars.iter().enumerate() { - let position = var_name.get_position(); - let decl_id = LuaDeclId::new(unresolve_iter_var.file_id, position); + let var_positions = unresolve_iter_var + .iter_vars + .iter() + .map(LuaAstToken::get_position) + .collect::>(); + let updates = match compute_iter_var_updates( + db, + cache, + unresolve_iter_var.file_id, + &unresolve_iter_var.iter_exprs, + &var_positions, + settled, + ) { + Ok(updates) => updates, + // Placeholder items have nothing to add on a failed retry: the template + // ref is already cached. Keep the failure in this reason's own group + // rather than injecting the item into another group's fixpoint. + Err(reason) => { + return Err( + if iter_var_holds_tpl_placeholder(db, unresolve_iter_var, 0) { + InferFailReason::UnResolveIterTemplate + } else { + reason + }, + ); + } + }; + for update in updates { + write_type_cache(db, update.owner, update.cache, update.mode); + } + Ok(()) +} + +/// A resolved iterator-variable type plus the write-mode decision it earned, so +/// the settled re-derivation can run the inference read-only on parallel workers +/// and apply the updates in stable file order. +pub struct IterVarTypeUpdate { + pub(crate) owner: LuaTypeOwner, + pub(crate) cache: LuaTypeCache, + pub(crate) mode: TypeCacheWriteMode, +} + +fn compute_iter_var_updates( + db: &DbIndex, + cache: &mut LuaInferCache, + file_id: FileId, + iter_exprs: &[LuaExpr], + var_positions: &[TextSize], + settled: bool, +) -> Result, InferFailReason> { + let iter_var_types = infer_for_range_iter_expr_func(db, cache, iter_exprs)?; + let mut updates = Vec::with_capacity(var_positions.len()); + for (idx, &position) in var_positions.iter().enumerate() { + let decl_id = LuaDeclId::new(file_id, position); let ret_type = iter_var_types .get_type(idx) .cloned() @@ -766,9 +807,13 @@ fn try_resolve_iter_var_inner( } else { iter_var_write_mode(cached, &ret_type) }; - write_type_cache(db, owner, LuaTypeCache::InferType(ret_type), mode); + updates.push(IterVarTypeUpdate { + owner, + cache: LuaTypeCache::InferType(ret_type), + mode, + }); } - Ok(()) + Ok(updates) } /// The write mode for a settled iterator-variable type. From d094e44f5def566aaa444554b9d1a0b8a7194bec Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:49:13 +0100 Subject: [PATCH 068/159] fix: a branch write is not a load-order fact The slot-writer rule rested on two claims the index contradicts. Writers within one file are not sequential: position order ignores function bodies, so a write inside one could swallow the definition the file loads with. And a conditional-branch write does not "fire only at runtime events" - is_member_assignment_in_conditional_branch marks any write under an if/while/for, top-level `if SERVER then` included, which runs during the load like anything else. Instrumenting the rule showed the machinery built on the first claim was also dead: member resolution collapses same-owner writers of a key before completion sees them, so the per-file supersession never fired once across the suite. What is left is the one separation available here - whether a write is conditional at all - since load order and the caller's realm are both decided upstream. A branch-only write now drops where an unconditional write of the key exists, and nothing else is weighed. Guarded bootstrap siblings run on every load, so they are unconditional and coexist as before; a branch write beside one now drops, which the new test pins. --- .../completion/providers/member_provider.rs | 145 +++++++----------- .../src/handlers/test/completion_test.rs | 73 +++++++++ 2 files changed, 128 insertions(+), 90 deletions(-) diff --git a/crates/glua_ls/src/handlers/completion/providers/member_provider.rs b/crates/glua_ls/src/handlers/completion/providers/member_provider.rs index 8c30ed0ef..fe0ccd8b6 100644 --- a/crates/glua_ls/src/handlers/completion/providers/member_provider.rs +++ b/crates/glua_ls/src/handlers/completion/providers/member_provider.rs @@ -1,7 +1,7 @@ use glua_code_analysis::{ - DbIndex, FileId, GmodRealm, GmodStateMask, LuaMemberId, LuaMemberInfo, LuaMemberKey, - LuaMemberOwner, LuaSemanticDeclId, LuaType, LuaTypeDeclId, MemberAssignmentContributionStore, - SemanticModel, enum_variable_is_param, get_tpl_ref_extend_type, + DbIndex, FileId, GmodRealm, GmodStateMask, LuaMemberInfo, LuaMemberKey, LuaMemberOwner, + LuaSemanticDeclId, LuaType, LuaTypeDeclId, SemanticModel, enum_variable_is_param, + get_tpl_ref_extend_type, }; use glua_parser::{ LuaAstNode, LuaAstToken, LuaComment, LuaCommentOwner, LuaDocTag, LuaDocTagRealm, LuaExpr, @@ -187,112 +187,77 @@ fn extend_gmod_hook_fallback_members( } } -/// Writers of one key, resolved the way the settled slot resolves them. -/// -/// Writers within one file are sequential — the file's latest write of the -/// key, whatever its class, is what a load of the file leaves in the slot, so -/// earlier same-file writers collapse to it. Across files, a plain load-time -/// write supersedes conditional-branch writers (`if ... then t.k = x end` -/// fires only at runtime events, after the slot's load-time value exists), -/// while guarded bootstrap siblings (`x = x or {}`) and other cross-file -/// writers coexist — realm and load order at the caller decide which of those -/// a caller sees (the item resolution's job). +/// Collapses each key's candidates to one entry per distinct definition the +/// caller can be looking at: branch-only writes drop out where an unconditional +/// one exists, and what remains is deduplicated by identity. fn dedupe_member_infos( builder: &CompletionBuilder, members: &mut HashMap>, ) { let db = builder.semantic_model.get_db(); - let contributions = db.get_member_index().member_assignment_contributions(); for infos in members.values_mut() { - resolve_slot_writers(db, contributions, infos); + resolve_slot_writes(db, infos); let mut seen = HashSet::new(); infos.retain(|info| seen.insert(MemberInfoIdentity::from(info))); } } -fn resolve_slot_writers( - db: &DbIndex, - contributions: &MemberAssignmentContributionStore, - infos: &mut Vec, -) { - let member_index = db.get_member_index(); - // Class declaration fields (`---@field`) are contracts, not sequential - // writes — they never take part in the per-file supersession below. - let is_declaration = |member_id: &LuaMemberId| { - matches!( - member_index.get_member_owner(member_id), - Some(LuaMemberOwner::Type(_)) - ) - }; - let is_plain_writer = |member_id: &LuaMemberId| { - !member_index.is_conditional_branch_assignment_member(*member_id) - && contributions - .contribution_of(member_id) - .is_some_and(|contribution| { - !contribution.guarded_bootstrap && !contribution.preserve_table_literals - }) - }; +/// What a candidate for one key is, as far as the rule below is concerned. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SlotWrite { + /// Not a write: a class declaration field (`---@field`) is a contract, and + /// a dynamic field carries no member at all. Never collapsed. + NotAWrite, + /// Runs whenever the statement around it is reached. A write in a function + /// body counts: reaching the body is the caller's business, not a condition + /// on the write. + Unconditional, + /// Runs only when its branch is taken, so the slot may never hold it. + Branch, +} - // Writers of a key within one file are sequential: the file's latest write - // — whatever its class — is what a load of the file leaves in the slot. +/// Drops the writes of a key that only happen in a branch, where one outside +/// any branch exists. +/// +/// This is the only separation available here. Load order across files is +/// decided by member resolution, which also picks the write the caller's realm +/// selects, both before these infos are built; what is left is whether a write +/// is conditional at all. Writes that are all unconditional — guarded bootstrap +/// siblings (`x = x or {}`) among them — say nothing about each other and all +/// survive. +fn resolve_slot_writes(db: &DbIndex, infos: &mut Vec) { if infos.len() <= 1 { return; } - let mut latest_per_file: HashMap = HashMap::new(); - for info in infos.iter() { - let Some(LuaSemanticDeclId::Member(member_id)) = &info.property_owner_id else { - continue; - }; - if is_declaration(member_id) { - continue; - } - let position = member_id.get_position(); - latest_per_file - .entry(member_id.file_id) - .and_modify(|latest| { - if position > *latest { - *latest = position; - } - }) - .or_insert(position); - } - if latest_per_file.is_empty() { + + let writes = infos + .iter() + .map(|info| classify_slot_write(db, info)) + .collect::>(); + if !writes.contains(&SlotWrite::Unconditional) { return; } - let mut plain_files: HashSet = HashSet::new(); - let mut has_plain_writer = false; - for info in infos.iter() { - let Some(LuaSemanticDeclId::Member(member_id)) = &info.property_owner_id else { - continue; - }; - if !is_declaration(member_id) && is_plain_writer(member_id) { - has_plain_writer = true; - plain_files.insert(member_id.file_id); - } - } + let mut writes = writes.into_iter(); + infos.retain(|_| writes.next() != Some(SlotWrite::Branch)); +} - infos.retain(|info| match &info.property_owner_id { - Some(LuaSemanticDeclId::Member(member_id)) => { - if is_declaration(member_id) { - return true; - } - if latest_per_file.get(&member_id.file_id) != Some(&member_id.get_position()) { - return false; - } - // A plain load-time write supersedes conditional-branch writers - // from files with no plain writer of the key: those only fire at - // runtime events, after the slot's load-time value exists. - if has_plain_writer - && !plain_files.contains(&member_id.file_id) - && member_index.is_conditional_branch_assignment_member(*member_id) - { - return false; - } - true - } - _ => true, - }); +fn classify_slot_write(db: &DbIndex, info: &LuaMemberInfo) -> SlotWrite { + let Some(LuaSemanticDeclId::Member(member_id)) = &info.property_owner_id else { + return SlotWrite::NotAWrite; + }; + let member_index = db.get_member_index(); + if matches!( + member_index.get_member_owner(member_id), + Some(LuaMemberOwner::Type(_)) + ) { + return SlotWrite::NotAWrite; + } + if member_index.is_conditional_branch_assignment_member(*member_id) { + SlotWrite::Branch + } else { + SlotWrite::Unconditional + } } type MemberIdentityMap = HashMap>; diff --git a/crates/glua_ls/src/handlers/test/completion_test.rs b/crates/glua_ls/src/handlers/test/completion_test.rs index e2c748d0b..7f5152d4b 100644 --- a/crates/glua_ls/src/handlers/test/completion_test.rs +++ b/crates/glua_ls/src/handlers/test/completion_test.rs @@ -1513,6 +1513,79 @@ mod tests { Ok(()) } + /// A function that runs more than once — every GMod hook — has already + /// executed the assignments below the cursor on its later calls, so the + /// fields they define stay listed inside it. Only a file's top-level + /// statements are a strict sequence. The name is offered even though a read + /// at that position is not yet given its type. + #[gtest] + fn test_completion_offers_dynamic_field_defined_later_in_the_same_function() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let mut emmyrc = ws.get_emmyrc(); + emmyrc.gmod.enabled = true; + emmyrc.gmod.infer_dynamic_fields = true; + ws.update_emmyrc(emmyrc); + + check!(ws.check_completion( + r#" + ---@class DynLater.Entity + + ---@type DynLater.Entity + local ent + + function ENT:Think() + ent. + local now = 1 + ent.cooldown = now + end + "#, + vec![VirtualCompletionItem { + label: "cooldown".to_string(), + kind: CompletionItemKind::VARIABLE, + label_detail: None, + }], + )); + Ok(()) + } + + /// A branch write is dropped beside an unconditional one whatever form the + /// unconditional one takes — a guarded bootstrap included, since it runs on + /// every load of its file. + #[gtest] + fn test_completion_branch_write_drops_beside_guarded_bootstrap_sibling() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let mut emmyrc = ws.get_emmyrc(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + ws.def_file( + "a.lua", + r#" + cfg = cfg or {} + cfg.alpha = cfg.alpha or {} + "#, + ); + ws.def_file( + "b.lua", + r#" + cfg = cfg or {} + if cfg.alpha then + cfg.alpha = 2 + end + "#, + ); + check!(ws.check_completion( + r#" + cfg. + "#, + vec![VirtualCompletionItem { + label: "alpha".to_string(), + kind: CompletionItemKind::FIELD, + label_detail: None, + }], + )); + Ok(()) + } + /// Guarded bootstrap writers of a slot coexist: the completion must not /// collapse them the way it collapses superseded writers. #[gtest] From 496498a7be84ea6d126265ce68f1b93236a48005 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:49:25 +0100 Subject: [PATCH 069/159] perf: resolve dynamic-field visibility state once per listing A member listing asks about every dynamic field of an owner from one position, and the visibility check re-derived the caller's enclosing function scope for each of them - a binary search plus a backward scan over every function range in the file, repeated per field. Resolve it once per listing instead. The check also copied the field's definition vector on every call. get_field_definitions now borrows, so the listing, goto-definition and the resolution path all walk the stored slice; nothing needed the copy. --- .../src/db_index/dynamic_field/mod.rs | 22 +++--- .../src/semantic/member/find_members.rs | 75 +++++++++++-------- .../src/semantic/member/mod.rs | 8 +- crates/glua_ls/src/handlers/definition/mod.rs | 4 +- 4 files changed, 61 insertions(+), 48 deletions(-) diff --git a/crates/glua_code_analysis/src/db_index/dynamic_field/mod.rs b/crates/glua_code_analysis/src/db_index/dynamic_field/mod.rs index e64832549..35583287b 100644 --- a/crates/glua_code_analysis/src/db_index/dynamic_field/mod.rs +++ b/crates/glua_code_analysis/src/db_index/dynamic_field/mod.rs @@ -138,7 +138,7 @@ impl DynamicFieldIndex { .entry(field_name.clone()) .or_default(); let definition = InFiled::new(file_id, range); - // Kept in canonical order: `get_field_definitions` feeds a union of + // Kept in canonical order: `field_definitions` feeds a union of // overloads, so insertion order would make the elected arm depend on the // batch walk order rather than on the workspace. let insert_at = field_definitions.partition_point(|existing| { @@ -375,16 +375,16 @@ impl DynamicFieldIndex { .unwrap_or_default() } - pub fn get_field_definitions( + /// Every recorded definition of one field, in canonical order. + pub fn field_definitions( &self, owner: &DynamicFieldOwner, field_name: &str, - ) -> Vec> { + ) -> &[InFiled] { self.field_definitions .get(owner) .and_then(|fields| fields.get(field_name)) - .cloned() - .unwrap_or_default() + .map_or(&[], Vec::as_slice) } pub fn get_wildcard_definitions(&self, owner: &DynamicFieldOwner) -> Vec> { @@ -679,9 +679,9 @@ mod tests { index.remove(file_to_remove); - assert_eq!(index.get_field_definitions(&owner, &field).len(), 1); + assert_eq!(index.field_definitions(&owner, &field).len(), 1); assert_eq!( - index.get_field_definitions(&owner, &field)[0].file_id, + index.field_definitions(&owner, &field)[0].file_id, remaining_file ); assert_eq!(index.get_wildcard_definitions(&owner).len(), 1); @@ -735,7 +735,7 @@ mod tests { assert!(!index.has_field(&owner, &field)); assert!(index.get_fields(&owner).is_none()); - assert!(index.get_field_definitions(&owner, &field).is_empty()); + assert!(index.field_definitions(&owner, &field).is_empty()); } #[test] @@ -758,7 +758,7 @@ mod tests { } assert_eq!( - forward.get_field_definitions(&owner, &field), + forward.field_definitions(&owner, &field), vec![ InFiled::new(FileId::new(1), range(3, 4)), InFiled::new(FileId::new(1), range(9, 10)), @@ -766,8 +766,8 @@ mod tests { ] ); assert_eq!( - forward.get_field_definitions(&owner, &field), - reverse.get_field_definitions(&owner, &field) + forward.field_definitions(&owner, &field), + reverse.field_definitions(&owner, &field) ); } diff --git a/crates/glua_code_analysis/src/semantic/member/find_members.rs b/crates/glua_code_analysis/src/semantic/member/find_members.rs index 26e85ef49..b7b5b46a8 100644 --- a/crates/glua_code_analysis/src/semantic/member/find_members.rs +++ b/crates/glua_code_analysis/src/semantic/member/find_members.rs @@ -241,6 +241,31 @@ impl FindMembersContext { fn caller_position(&self) -> Option { self.caller_position } + + /// The position dynamic-field visibility is judged from. `None` when the + /// request carries no position, where every definition is visible. + fn dynamic_field_access_site(&self, db: &DbIndex) -> Option { + let file_id = self.file_id?; + let position = self.caller_position?; + Some(DynamicFieldAccessSite { + file_id, + position, + // A listing asks about many fields from one position, and this is a + // binary search plus a backward scan over every function range in + // the file, so it is resolved per listing rather than per field. + in_function: db + .get_member_index() + .enclosing_function_scope_range(file_id, position) + .is_some(), + }) + } +} + +/// The position a dynamic-field listing is taken from. +struct DynamicFieldAccessSite { + file_id: FileId, + position: TextSize, + in_function: bool, } fn find_members_guard( @@ -1127,6 +1152,7 @@ fn append_dynamic_fields_for_type( }; field_names.sort_unstable(); + let access_site = ctx.dynamic_field_access_site(db); for field_name in field_names { let member_key = LuaMemberKey::Name(field_name.clone()); @@ -1138,15 +1164,8 @@ fn append_dynamic_fields_for_type( continue; } - if let Some(caller_position) = ctx.caller_position() - && let Some(caller_file_id) = ctx.file_id() - && !dynamic_field_visible_at_offset( - db, - &owner, - &field_name, - caller_file_id, - caller_position, - ) + if let Some(site) = &access_site + && !dynamic_field_visible_at_offset(db, &owner, &field_name, site) { continue; } @@ -1219,6 +1238,7 @@ fn append_dynamic_fields_for_table( }; field_names.sort_unstable(); + let access_site = ctx.dynamic_field_access_site(db); for field_name in field_names { let member_key = LuaMemberKey::Name(field_name.clone()); @@ -1230,15 +1250,8 @@ fn append_dynamic_fields_for_table( continue; } - if let Some(caller_position) = ctx.caller_position() - && let Some(caller_file_id) = ctx.file_id() - && !dynamic_field_visible_at_offset( - db, - &owner, - &field_name, - caller_file_id, - caller_position, - ) + if let Some(site) = &access_site + && !dynamic_field_visible_at_offset(db, &owner, &field_name, site) { continue; } @@ -1273,31 +1286,32 @@ fn append_dynamic_fields_for_table( /// a function body runs after the whole file has loaded — both stay visible. /// Definitions in other files are always visible; their load-order and realm /// rules are the member item's to decide. +/// +/// This is deliberately broader than [`super::resolve_dynamic_field_member`]'s +/// execution-region rule, which answers what a read *yields* at a position: a +/// field a hook assigns further down its own body is a name the table has, even +/// on the call that has not reached the assignment yet. fn dynamic_field_visible_at_offset( db: &DbIndex, owner: &crate::DynamicFieldOwner, field_name: &str, - caller_file_id: FileId, - caller_position: TextSize, + site: &DynamicFieldAccessSite, ) -> bool { let definitions = db .get_dynamic_field_index() - .get_field_definitions(owner, field_name); + .field_definitions(owner, field_name); if definitions.is_empty() { return true; } let member_index = db.get_member_index(); - // A caller inside a function body runs after the whole file has loaded, so - // every same-file definition is visible to it. - let caller_in_function = member_index - .enclosing_function_scope_range(caller_file_id, caller_position) - .is_some(); definitions.iter().any(|definition| { - if definition.file_id != caller_file_id || definition.value.start() <= caller_position { + if definition.file_id != site.file_id || definition.value.start() <= site.position { return true; } - if caller_in_function { + // A caller inside a function body runs after the whole file has loaded, + // so every same-file definition is visible to it. + if site.in_function { return true; } // A definition inside a function body runs when that function is @@ -1339,9 +1353,8 @@ fn append_keyed_dynamic_field( if !is_visible { return Some(false); } - if let Some(caller_position) = ctx.caller_position() - && let Some(caller_file_id) = ctx.file_id() - && !dynamic_field_visible_at_offset(db, owner, field_name, caller_file_id, caller_position) + if let Some(site) = ctx.dynamic_field_access_site(db) + && !dynamic_field_visible_at_offset(db, owner, field_name, &site) { return Some(false); } diff --git a/crates/glua_code_analysis/src/semantic/member/mod.rs b/crates/glua_code_analysis/src/semantic/member/mod.rs index 72964b82b..f75988d0e 100644 --- a/crates/glua_code_analysis/src/semantic/member/mod.rs +++ b/crates/glua_code_analysis/src/semantic/member/mod.rs @@ -526,20 +526,20 @@ fn dynamic_field_definitions_for_owner( .enclosing_function_scope_range(caller_file_id, position) }); db.get_dynamic_field_index() - .get_field_definitions(owner, field_name) - .into_iter() + .field_definitions(owner, field_name) + .iter() .filter(|definition| dynamic_fields_global || definition.file_id == caller_file_id) .filter(|definition| is_dynamic_field_realm_compatible(db, caller_mask, definition)) .filter_map(|definition| { dynamic_field_definition_visibility_at( db, caller_file_id, - &definition, + definition, access_position, access_function, ) .map(|visibility| VisibleDynamicFieldDefinition { - location: definition, + location: definition.clone(), visibility, }) }) diff --git a/crates/glua_ls/src/handlers/definition/mod.rs b/crates/glua_ls/src/handlers/definition/mod.rs index f4c41b064..a20dede5a 100644 --- a/crates/glua_ls/src/handlers/definition/mod.rs +++ b/crates/glua_ls/src/handlers/definition/mod.rs @@ -301,7 +301,7 @@ fn collect_dynamic_field_locations( let definitions = semantic_model .get_db() .get_dynamic_field_index() - .get_field_definitions(&owner, field_name); + .field_definitions(&owner, field_name); for definition in definitions { if respect_file_scope && !dynamic_fields_global @@ -321,7 +321,7 @@ fn collect_dynamic_field_locations( let definitions = semantic_model .get_db() .get_dynamic_field_index() - .get_field_definitions(&owner, field_name); + .field_definitions(&owner, field_name); for definition in definitions { if respect_file_scope && !dynamic_fields_global From 9bc75c97fb6d217977ba3654e4f8f7afb47b05ca Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:49:25 +0100 Subject: [PATCH 070/159] fix: apply settled iter-var writes in source order The re-derivation collected its file ids from a HashMap, so it applied the recorded writes in whatever order the hash gave that process while claiming to apply them in stable file and source order. The results come back index-aligned with the file list, so sorting it is what makes the claim true. --- .../glua_code_analysis/src/compilation/analyzer/mod.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs index 47a4258fb..fcb45e7ba 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs @@ -584,7 +584,8 @@ fn rederive_settled_iter_vars(db: &mut DbIndex, context: &mut AnalyzeContext) -> .push(candidate); } - let file_ids = candidates_by_file.keys().copied().collect::>(); + let mut file_ids = candidates_by_file.keys().copied().collect::>(); + file_ids.sort_unstable(); context .infer_manager .clear_files_iter_var_results(&file_ids.iter().copied().collect()); @@ -593,8 +594,10 @@ fn rederive_settled_iter_vars(db: &mut DbIndex, context: &mut AnalyzeContext) -> let dynamic_fields_visible = context.infer_manager.dynamic_fields_visible(); // Inference reads the settled indexes and records candidate type writes - // without mutating the database. Apply the writes in stable file and source - // order on the caller thread, so the result does not depend on the schedule. + // without mutating the database. The results come back in `file_ids` order, + // which is why it is sorted above: the writes are applied on the caller + // thread in file and source order, never in the order the workers finished + // or a hash map happened to yield. let results = parallel::map_files_collect(db, &file_ids, |db, file_id| { let mut infer_cache = crate::LuaInferCache::new( file_id, From 6e2e01c3eb6a496051308fcc71dc913cffa80005 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:49:26 +0100 Subject: [PATCH 071/159] refactor: drop the settled flag from the iter-var retry The parallel re-derivation takes the settled path itself, so the flag threaded through try_resolve_iter_var_inner could only ever be false. Fold the wrapper away, and rewrite the two comments the split left behind: the readonly entry point carried a stale paragraph describing writes it does not perform, and is_shapeless_prefix_type claimed a table literal carries no member shape. --- .../compilation/analyzer/unresolve/resolve.rs | 27 +++++++------------ .../semantic_info/infer_expr_semantic_decl.rs | 5 ++-- 2 files changed, 12 insertions(+), 20 deletions(-) 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 f37ff2ef4..d36930ab9 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs @@ -709,21 +709,13 @@ fn return_docs_to_type(return_docs: &[LuaDocReturnInfo]) -> LuaType { } } -pub fn try_resolve_iter_var( - db: &mut DbIndex, - cache: &mut LuaInferCache, - unresolve_iter_var: &mut UnResolveIterVar, -) -> ResolveResult { - try_resolve_iter_var_inner(db, cache, unresolve_iter_var, false) -} - -/// [`try_resolve_iter_var`] for the settled re-derivation, where the answer was -/// taken against the complete member map and so replaces whatever partial one a -/// wave left behind rather than only widening it. -/// The inference of the settled iterator-variable re-derivation without the -/// writes, for the parallel pass: updates are applied later in stable file -/// order. Each update pairs a variable's resolved type with the write-mode -/// decision it earned. +/// [`try_resolve_iter_var`] for the settled re-derivation, minus the writes. +/// +/// The answer is taken against the complete member map, so it replaces whatever +/// partial one a wave left behind rather than only widening it. The pass runs on +/// parallel workers against an immutable index and applies the updates it +/// returns in file order afterwards, so each one pairs a variable's resolved +/// type with the write-mode decision it earned. pub fn resolve_settled_iter_var_readonly( db: &DbIndex, cache: &mut LuaInferCache, @@ -734,11 +726,10 @@ pub fn resolve_settled_iter_var_readonly( compute_iter_var_updates(db, cache, file_id, iter_exprs, var_positions, true) } -fn try_resolve_iter_var_inner( +pub fn try_resolve_iter_var( db: &mut DbIndex, cache: &mut LuaInferCache, unresolve_iter_var: &mut UnResolveIterVar, - settled: bool, ) -> ResolveResult { let var_positions = unresolve_iter_var .iter_vars @@ -751,7 +742,7 @@ fn try_resolve_iter_var_inner( unresolve_iter_var.file_id, &unresolve_iter_var.iter_exprs, &var_positions, - settled, + false, ) { Ok(updates) => updates, // Placeholder items have nothing to add on a failed retry: the template diff --git a/crates/glua_code_analysis/src/semantic/semantic_info/infer_expr_semantic_decl.rs b/crates/glua_code_analysis/src/semantic/semantic_info/infer_expr_semantic_decl.rs index d078da993..39777a3ae 100644 --- a/crates/glua_code_analysis/src/semantic/semantic_info/infer_expr_semantic_decl.rs +++ b/crates/glua_code_analysis/src/semantic/semantic_info/infer_expr_semantic_decl.rs @@ -302,8 +302,9 @@ fn infer_index_expr_semantic_decl( Ok(None) } -/// Prefix types that carry no member shape of their own, so a miss against -/// them says nothing about whether the member exists. +/// Prefix types whose miss is not evidence of absence: they either carry no +/// member shape at all, or (a table literal) carry one that a bootstrap link +/// left empty while the accumulated members live under the global path owner. fn is_shapeless_prefix_type(prefix_type: &LuaType) -> bool { matches!( prefix_type, From 629dc0570e3a7505d6e46bc8bbe38228c3ba8bad Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:06:48 +0100 Subject: [PATCH 072/159] test: isolate the progress tests from concurrent analysis --- crates/glua_code_analysis/src/progress.rs | 50 ------------------ crates/glua_code_analysis/tests/progress.rs | 54 ++++++++++++++++++++ crates/glua_ls/src/util/analysis_progress.rs | 13 +++-- 3 files changed, 63 insertions(+), 54 deletions(-) create mode 100644 crates/glua_code_analysis/tests/progress.rs diff --git a/crates/glua_code_analysis/src/progress.rs b/crates/glua_code_analysis/src/progress.rs index 7fa642b03..092cc6190 100644 --- a/crates/glua_code_analysis/src/progress.rs +++ b/crates/glua_code_analysis/src/progress.rs @@ -118,53 +118,3 @@ pub fn phase_label(pipeline_type_name: &str) -> &str { other => other, } } - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Mutex; - use std::sync::atomic::{AtomicUsize, Ordering}; - - /// The sink is process-global, so these must not run concurrently. - static TEST_LOCK: Mutex<()> = Mutex::new(()); - - #[test] - fn phase_label_maps_known_pipelines_and_passes_through_others() { - assert_eq!(phase_label("LuaAnalysisPipeline"), "Inferring types"); - assert_eq!(phase_label("SomeNewPipeline"), "SomeNewPipeline"); - } - - #[test] - fn report_is_a_noop_without_a_sink() { - let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - clear_sink(); - assert!(!is_active()); - enter_phase("anything", 2, "files"); - advance_current_phase(1, 2, "files"); - } - - #[test] - fn advance_reports_under_the_phase_last_entered() { - let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let calls = Arc::new(AtomicUsize::new(0)); - let seen_phase = Arc::new(Mutex::new(String::new())); - - let counter = calls.clone(); - let phase_slot = seen_phase.clone(); - set_sink(Arc::new(move |progress: PhaseProgress<'_>| { - counter.fetch_add(1, Ordering::Relaxed); - if let Ok(mut slot) = phase_slot.lock() { - *slot = progress.phase.to_string(); - } - })); - - enter_phase("Inferring types", 10, "files"); - advance_current_phase(5, 10, "files"); - assert_eq!(calls.load(Ordering::Relaxed), 2); - assert_eq!(seen_phase.lock().unwrap().as_str(), "Inferring types"); - - clear_sink(); - advance_current_phase(6, 10, "files"); - assert_eq!(calls.load(Ordering::Relaxed), 2); - } -} diff --git a/crates/glua_code_analysis/tests/progress.rs b/crates/glua_code_analysis/tests/progress.rs new file mode 100644 index 000000000..22b19e46b --- /dev/null +++ b/crates/glua_code_analysis/tests/progress.rs @@ -0,0 +1,54 @@ +//! The progress sink and current phase are process-global, so these tests run +//! in their own binary: in the unit-test binary every test that analyses a +//! workspace reports into them, which changes both the call count and the phase +//! name under test. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use glua_code_analysis::progress::{ + PhaseProgress, advance_current_phase, clear_sink, enter_phase, is_active, phase_label, set_sink, +}; + +/// The sink is process-global, so these must not run concurrently. +static TEST_LOCK: Mutex<()> = Mutex::new(()); + +#[test] +fn phase_label_maps_known_pipelines_and_passes_through_others() { + assert_eq!(phase_label("LuaAnalysisPipeline"), "Inferring types"); + assert_eq!(phase_label("SomeNewPipeline"), "SomeNewPipeline"); +} + +#[test] +fn report_is_a_noop_without_a_sink() { + let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + clear_sink(); + assert!(!is_active()); + enter_phase("anything", 2, "files"); + advance_current_phase(1, 2, "files"); +} + +#[test] +fn advance_reports_under_the_phase_last_entered() { + let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let calls = Arc::new(AtomicUsize::new(0)); + let seen_phase = Arc::new(Mutex::new(String::new())); + + let counter = calls.clone(); + let phase_slot = seen_phase.clone(); + set_sink(Arc::new(move |progress: PhaseProgress<'_>| { + counter.fetch_add(1, Ordering::Relaxed); + if let Ok(mut slot) = phase_slot.lock() { + *slot = progress.phase.to_string(); + } + })); + + enter_phase("Inferring types", 10, "files"); + advance_current_phase(5, 10, "files"); + assert_eq!(calls.load(Ordering::Relaxed), 2); + assert_eq!(seen_phase.lock().unwrap().as_str(), "Inferring types"); + + clear_sink(); + advance_current_phase(6, 10, "files"); + assert_eq!(calls.load(Ordering::Relaxed), 2); +} diff --git a/crates/glua_ls/src/util/analysis_progress.rs b/crates/glua_ls/src/util/analysis_progress.rs index 30f5b83d9..f2b799fa7 100644 --- a/crates/glua_ls/src/util/analysis_progress.rs +++ b/crates/glua_ls/src/util/analysis_progress.rs @@ -231,17 +231,22 @@ mod tests { progress::clear_sink(); assert!(!progress::is_active()); + // Tests that analyse a workspace report into the same global sink, so + // count only the phase this test enters. + const PHASE: &str = "clearing_the_sink_stops_reports"; let counter = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let seen = counter.clone(); - progress::set_sink(Arc::new(move |_: progress::PhaseProgress<'_>| { - seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + progress::set_sink(Arc::new(move |progress: progress::PhaseProgress<'_>| { + if progress.phase == PHASE { + seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } })); assert!(progress::is_active()); - progress::enter_phase("phase", 0, "files"); + progress::enter_phase(PHASE, 0, "files"); assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), 1); progress::clear_sink(); - progress::enter_phase("phase", 0, "files"); + progress::enter_phase(PHASE, 0, "files"); assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), 1); } From 5f34eaac01f55bc9f03aa6008387ee965ebc7427 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:16:31 +0100 Subject: [PATCH 073/159] test: drop the duplicated global-sink test from glua_ls --- crates/glua_ls/src/util/analysis_progress.rs | 26 -------------------- 1 file changed, 26 deletions(-) diff --git a/crates/glua_ls/src/util/analysis_progress.rs b/crates/glua_ls/src/util/analysis_progress.rs index f2b799fa7..ddb3d0d5b 100644 --- a/crates/glua_ls/src/util/analysis_progress.rs +++ b/crates/glua_ls/src/util/analysis_progress.rs @@ -224,32 +224,6 @@ mod tests { /// other. static SINK: Mutex<()> = Mutex::new(()); - #[test] - fn clearing_the_sink_stops_reports() { - let _guard = SINK.lock().unwrap_or_else(|error| error.into_inner()); - // The reporter owns the global sink, so dropping it must clear it. - progress::clear_sink(); - assert!(!progress::is_active()); - - // Tests that analyse a workspace report into the same global sink, so - // count only the phase this test enters. - const PHASE: &str = "clearing_the_sink_stops_reports"; - let counter = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let seen = counter.clone(); - progress::set_sink(Arc::new(move |progress: progress::PhaseProgress<'_>| { - if progress.phase == PHASE { - seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - } - })); - assert!(progress::is_active()); - progress::enter_phase(PHASE, 0, "files"); - assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), 1); - - progress::clear_sink(); - progress::enter_phase(PHASE, 0, "files"); - assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), 1); - } - #[test] fn an_overlapping_reporter_keeps_the_sink_until_the_newest_one_goes() { let _guard = SINK.lock().unwrap_or_else(|error| error.into_inner()); From ca46070bdbfad64da3af082085be715649608c38 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:28:59 +0100 Subject: [PATCH 074/159] docs: restore the comments the cleanup pass truncated --- .../src/compilation/analyzer/gmod/mod.rs | 585 +++++++++++++++++- .../analyzer/lua/for_range_stat.rs | 21 +- .../lua/member_write_policy/scalar.rs | 7 + .../src/compilation/analyzer/lua/mod.rs | 3 + .../src/compilation/analyzer/lua/stats.rs | 259 +++++++- crates/glua_code_analysis/src/lib.rs | 410 +++++++++++- crates/glua_doc_cli/src/cmd_args.rs | 2 + .../glua_ls/src/context/debounced_analysis.rs | 142 ++++- .../glua_ls/src/handlers/test/hover_test.rs | 39 ++ crates/glua_parser/src/grammar/lua/expr.rs | 2 + crates/glua_parser/src/grammar/lua/test.rs | 2 + crates/glua_parser/src/lexer/mod.rs | 3 + crates/glua_parser/src/syntax/mod.rs | 5 + .../glua_parser/src/syntax/node/lua/expr.rs | 26 + .../src/syntax/node/lua/path_trait.rs | 14 + .../src/syntax/node/token/number_analyzer.rs | 3 + crates/glua_parser/src/text/reader.rs | 32 + tools/benchmark/src/main.rs | 47 +- tools/lsp_latency.js | 112 +++- 19 files changed, 1655 insertions(+), 59 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 19c18f31d..b2df99c31 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs @@ -140,6 +140,11 @@ fn scan_gmod_keywords( } /// Pre-analysis phase: runs BEFORE lua_analyze. +/// Collects purely syntactic metadata (hooks, network, realm, scripted class +/// type declarations) so that lua_analyze has correct realm keys and scripted +/// class types available from the start. Without them lua_analyze would see +/// `GmodRealm::Unknown` and the unresolve phase would have to recompute every +/// flow once the realm became known. pub struct GmodPreAnalysisPipeline; impl AnalysisPipeline for GmodPreAnalysisPipeline { @@ -166,8 +171,19 @@ impl AnalysisPipeline for GmodPreAnalysisPipeline { let mut t_scoped = std::time::Duration::ZERO; let mut profile = do_profile.then(GmodPreProfile::default); - // Cache key covers signature index size. + // The registry is derived by scanning the entire signature index, so the + // cache key has to cover how much of that index existed when it was + // built — not just VFS content. Keying on content alone let a registry + // built during an earlier workspace group (with fewer files indexed) be + // served to a later group that could see more. let helper_revision = helper_registry_revision(db); + // `collect_gmod_call_sites` already built this pair for every group + // before any group entered resolution, so reuse it whenever the + // signature index has not grown since — deriving it again means + // another fold over the whole signature index. + // + // On a miss the rebuild is served from the per-file scan cache, so it + // only re-derives the files that changed. let reusable_roles = context .gmod_global_call_roles .as_ref() @@ -204,6 +220,11 @@ impl AnalysisPipeline for GmodPreAnalysisPipeline { } // Per-file metadata collection is read-only against `&DbIndex` (it only + // reads the reference/decl indexes built by earlier passes plus each + // file's own AST), so it runs in parallel across files. The collected + // results are merged into the db sequentially afterward in file order to + // preserve identical behavior. The scoped-class (`is_in_scope`) work + // mutates the db and stays in the sequential merge loop. let s_collect = do_profile.then(std::time::Instant::now); let collect_file_ids: Vec = tree_list.iter().map(|tree| tree.file_id).collect(); let collected = crate::profile::phase("gmodpre/collect_file_metadata", || { @@ -440,6 +461,22 @@ impl GmodPreProfile { } } +/// Post-analysis phase: runs AFTER lua_analyze. +/// Synthesizes members that depend on metadata collected during lua_analyze +/// (gmod_class_metadata_index: AccessorFunc, NetworkVar, VGUI register calls). +/// Collects GMod `net` message flows. +/// +/// This runs at the very end of the batch, after declaration, doc, lua and +/// unresolve analysis, because flow collection *reads* what those produce: +/// resolving `net.Start`/`net.Send` reached through a wrapper needs the +/// wrapper's signature, its receiver's type, and the members those depend on. +/// +/// Collecting any earlier would let the collector see a poorer index on a cold +/// build than on a re-index, which makes `gmod-net-*` diagnostics change across +/// the workspace after the first edit. Nothing in the analysis pipeline reads +/// the network index (only diagnostics do), so collecting it last costs nothing +/// and is the only point at which the input state is the same for a cold build +/// and a partial re-index. pub struct GmodNetworkAnalysisPipeline; impl AnalysisPipeline for GmodNetworkAnalysisPipeline { @@ -454,6 +491,10 @@ impl AnalysisPipeline for GmodNetworkAnalysisPipeline { } let _p = Profile::cond_new("gmod net-analyze", tree_list.len() > 1); + // The gmod pre-pass already built these for this batch; reuse them + // unless the signature index has grown since (the revision covers that). + // On a miss the rebuild is served from the per-file scan cache, so it + // only re-derives the files that changed. let helper_revision = helper_registry_revision(db); let reusable_roles = context .gmod_global_call_roles @@ -521,6 +562,8 @@ fn collect_file_network_flows( let mut local_fns = LocalFnCache::default(); let mut net = NetCallResolver::default(); // One memo for both walks: the receive walk and the three send walks start + // from the same call expressions and reach the same helpers, so a shared + // memo resolves each of them once. let mut resolve_memo = ResolveMemo::default(); let (_, _, _, receive_flows) = crate::profile::phase("gmodnet/receive_walk", || { @@ -573,6 +616,8 @@ impl AnalysisPipeline for GmodPostAnalysisPipeline { }); // Resolve scripted_ents.GetMember delegations BEFORE synthesizing + // members so that NetworkVar calls copied from target entities are + // picked up by synthesize_scripted_class_members. let t_deleg = do_profile.then(std::time::Instant::now); crate::profile::phase("gmodpost/getmember_delegations", || { resolve_getmember_network_var_delegations(db, &scripted_scope_files, context) @@ -586,6 +631,8 @@ impl AnalysisPipeline for GmodPostAnalysisPipeline { let t_class = do_profile.then(std::time::Instant::now); // Same per-file cached scan the net pass uses. Folding the signature + // index directly here took its `HashMap` iteration order, so a call path + // defined by two files resolved differently between processes. let (_, annotated_global_call_roles) = crate::profile::phase("gmodpost/call_roles_and_registry", || { build_call_roles_and_registry(db) @@ -694,6 +741,10 @@ fn collect_annotated_scripted_class_calls( } /// A db write produced by the per-file annotated call-site scan. +/// +/// The scan itself reads only the file's own AST plus immutable db state; the +/// writes are buffered so the scan can run off the caller's thread and be +/// applied afterwards in the original file-then-call order. enum PendingCallSite { VguiParent(GmodVguiParentCallMetadata), ScriptedClass(GmodScriptedClassCallKind, GmodScriptedClassCallMetadata), @@ -781,6 +832,12 @@ fn collect_annotated_call_sites_with( } } +/// Scripted-class registration and `load`-style call sites for one +/// workspace group, collected before *any* group resolves. +/// +/// The role map is stashed on the context because `GmodPreAnalysisPipeline` +/// needs the same one; rebuilding it there would repeat a full signature-index +/// fold per group. pub(crate) fn collect_gmod_call_sites(db: &mut DbIndex, context: &mut AnalyzeContext) { if !db.get_emmyrc().gmod.enabled { return; @@ -832,11 +889,19 @@ fn collect_annotated_load_dependency_site( } /// Workspace-global registry of helper function definitions, stored as +/// `(FileId, LuaSyntaxId)` rather than live red-tree nodes so the registry is +/// `Send + Sync` and can be shared across the parallel per-file collection +/// workers. Each entry is resolved back to a `(LuaBlock, LuaChunk)` on demand by +/// rebuilding the owning file's red tree from the (Send) green tree in the VFS. #[derive(Default)] pub(crate) struct HelperRegistry { /// Function bodies keyed by the language server's canonical global symbol + /// identity. This is a call-graph lookup, not a net-op recognizer: the body + /// is still inspected through annotated signatures only. globals: HashMap, /// Unique method names provide a conservative fallback for dynamic Lua + /// receivers whose class cannot be inferred. Ambiguous method names are + /// deliberately removed during construction. methods: HashMap, signatures: HashMap, } @@ -844,6 +909,10 @@ pub(crate) struct HelperRegistry { type IndexedHelperDefinition = (LuaSignatureId, FileId, LuaSyntaxId, Option); /// Cache key for the net-helper registry. +/// +/// The registry is a pure function of the syntax trees reachable through the +/// signature index, so both the VFS content revision and the size of that index +/// have to take part in the key. Both reads are `O(1)`. fn helper_registry_revision(db: &DbIndex) -> u64 { let content_revision = db.get_vfs().content_revision(); let signature_count = db.get_signature_index().indexed_signature_count() as u64; @@ -878,6 +947,10 @@ fn build_call_roles_and_registry( map }); // Merge order decides which file wins a call path both define, so it has to + // be a property of the source, not of the session. `FileId`s are handed out + // in workspace-collection order and shift when a file is removed and + // re-added, so order by normalized path — the same policy + // `HelperRegistryBuilder::build` already uses for its definitions. let scan_files = crate::profile::phase("ccs/sort_scan_files", || { let vfs = db.get_vfs(); let mut scan_files = signatures_by_file.keys().copied().collect::>(); @@ -895,6 +968,11 @@ fn build_call_roles_and_registry( scan_files }); + // A file's scan reads only its own signatures plus immutable db state, so + // the uncached ones are derived concurrently. On a cold index that is every + // file in the workspace, and the per-file syntax-tree walk behind + // `has_calls` dominates. Results are stored and merged below in exactly the + // previous fixed file order, so the fold is unchanged. let uncached = scan_files .iter() .copied() @@ -970,6 +1048,8 @@ fn build_call_roles_and_registry( struct HelperRegistryBuilder { definitions: Vec, /// Whether the scanned file contains any call expression at all. Computed + /// once per file so annotation libraries full of empty stubs are rejected + /// before resolving every signature back to a red-tree closure. file_has_calls: bool, } @@ -987,6 +1067,9 @@ impl HelperRegistryBuilder { return; }; // Annotation libraries contain thousands of empty function stubs. + // They can carry net metadata, but they cannot be wrapper bodies. + // Checking for a first statement is constant-time and avoids walking + // every empty stub's red subtree during the signature scan. if block.get_stats().next().is_none() { return; } @@ -1069,6 +1152,14 @@ fn expr_written_name(expr: &LuaExpr) -> Option { } /// The names the shipped net operations are declared under (`Start`, +/// `Receive`, and any annotated wrapper of them). +/// +/// Read straight out of the annotated call-role map the pre-analysis pass +/// already built, which is keyed by access path and already tags these calls +/// `NetStart`/`NetReceive`. The pre-pass records op *call sites* by path and so +/// cannot see `local recv = net.Receive`; carrying the op names lets the same +/// reference lookup and per-file binding closure that finds helper calls follow +/// that alias to its call sites. fn net_operation_names(annotated_roles: &AnnotatedGmodGlobalCallRoleMap) -> HashSet { annotated_roles .roles_by_path @@ -1145,10 +1236,26 @@ fn var_expr_written_name(var_expr: &LuaVarExpr) -> Option { } /// The names a call has to be written with for it to expand into a helper that +/// can reach a `net.Start`. +/// +/// The helpers that can answer yes are a small fixed set, and resolution +/// matches declarations by written name, so a call written with a name no such +/// helper carries cannot expand into one. +/// +/// Seeded from the net operations' own references, then grown outward: each +/// site is walked *up* to the function containing it, and that function's own +/// call sites come from the reference index, whose enclosing functions are the +/// next level. The set settles when a round adds no new site. +/// +/// Nothing is scanned. The cost is proportional to how much net code the +/// workspace actually has, not to its size. fn net_producing_function_names(db: &DbIndex, op_names: &HashSet) -> HashSet { let mut names: HashSet = HashSet::new(); let mut visited_decls: HashSet = HashSet::new(); // Seeded from the net operations' own references rather than from the + // pre-pass's recorded call sites: that record is only written for files + // that also need hook metadata, so it is not a complete list of net ops. + // The reference index records every reference unconditionally. let mut frontier: Vec> = op_names .iter() .flat_map(|name| name_reference_sites(db, name)) @@ -1183,6 +1290,8 @@ fn net_producing_function_names(db: &DbIndex, op_names: &HashSet) -> Ha continue; }; // A local enters neither name-keyed reference table, so a chain + // through local wrappers only continues if the next level comes + // from the declaration's own references. if let Some(decl_id) = closure_local_decl_id(file_id, &closure) && visited_decls.insert(decl_id) { @@ -1244,6 +1353,9 @@ fn decl_reference_sites(db: &DbIndex, decl_id: LuaDeclId) -> Vec Option { if let Some(local_func_stat) = closure.get_parent::() { return Some(LuaDeclId::new( @@ -1262,11 +1374,17 @@ fn closure_local_decl_id(file_id: FileId, closure: &LuaClosureExpr) -> Option>, /// The helper names themselves, needed per file to pick up locals: a + /// `local function send()` never enters the global reference table, so its + /// call sites are only reachable through its declaration's own references. names: HashSet, } @@ -1293,6 +1411,9 @@ fn net_helper_call_sites(db: &DbIndex, names: HashSet) -> NetHelperCall } } // Source order, so a file's flows are collected in the same order the walk + // produced them. The key is the whole identity rather than just the start + // offset: sites arrive in hash order, and sorting on a partial key leaves + // equal ids non-adjacent, which silently defeats the dedup. for sites in by_file.values_mut() { sites.sort_by_key(|syntax_id| { let range = syntax_id.get_range(); @@ -1310,6 +1431,8 @@ struct FileFunctionMap { /// `local f = function() end`, `f = function() end`. bare: HashMap, /// All top-level function-defining blocks in source order, including + /// duplicates and unnamed closures. Lets callers that need to scan every + /// function body in the file skip running 4 separate `descendants` walks. all_blocks: Vec, } @@ -1414,6 +1537,8 @@ impl FileFunctionMap { } /// Lazy cache of per-file function maps, keyed by file identity and chunk +/// range. Used so cross-file helper recursion doesn't rebuild the same map +/// repeatedly or alias equal-sized chunks from different files. #[derive(Default)] struct LocalFnCache { cache: HashMap<(FileId, TextRange), FileFunctionMap>, @@ -1429,9 +1554,13 @@ impl LocalFnCache { } /// All per-file gmod pre-analysis metadata collected off-thread for one file. +/// Produced by [`collect_file_gmod_metadata`] (read-only against `&DbIndex`) and +/// merged into the db sequentially by the pipeline in file order. struct GmodFileMetadataResult { keywords: GmodKeywords, /// `Some` when hook metadata was collected (file had hook-relevant + /// keywords): (hook sites, system metadata, gm-method realm annotations). + /// `None` means the hook walk was skipped for this file. hook_metadata: Option<( Vec, GmodSystemFileMetadata, @@ -1448,6 +1577,10 @@ struct GmodFileMetadataResult { } /// Collect all per-file gmod pre-analysis metadata for `file_id`. Read-only +/// against `&DbIndex`: reads the file's own AST (rebuilt locally from the Send +/// green tree) plus pre-existing immutable index state, so this is safe to run +/// concurrently across files. The returned [`GmodFileMetadataResult`] is merged +/// into the db sequentially by the caller. fn collect_file_gmod_metadata( db: &DbIndex, file_id: FileId, @@ -1485,6 +1618,8 @@ fn collect_file_gmod_metadata( let mut local_fns = LocalFnCache::default(); // One resolver per file: it memoizes signature resolution per call site and + // holds an infer cache per file touched, including helper bodies expanded + // from other files. let mut net = NetCallResolver::default(); // Hook metadata collection never expands wrapper chains for send flows, so @@ -1493,6 +1628,8 @@ fn collect_file_gmod_metadata( let collect_non_net_metadata = keywords.needs_hook_metadata(); // Network flows are collected later, by `GmodNetworkAnalysisPipeline`; see + // that pipeline for why. Receive-flow collection therefore no longer rides + // along here, so a file that needs no hook metadata skips the walk whole. let hook_metadata = collect_non_net_metadata.then(|| { let (hook_sites, system_metadata, gm_method_realms, _receive_flows) = collect_hook_and_receive_metadata( @@ -1577,6 +1714,9 @@ fn collect_hook_and_receive_metadata( file_id, }; // Built once for the whole walk rather than per call expression, so the + // memo carries across the walk instead of being reallocated empty each + // time. `resolve_memo` is a pure function of `(file_id, call range)` for a + // fixed registry and index — see the field's own doc. let mut net_ctx = NetCollectCtx { db, helper_registry, @@ -1588,6 +1728,8 @@ fn collect_hook_and_receive_metadata( }; // Collecting receive flows alone needs no walk: the `net.Receive` sites are + // already recorded by annotation, and the calls that can expand into a + // wrapper that reaches one come from the reference index. if !collect_non_net_metadata { if collect_receive_flows { for call_expr in net_candidate_call_exprs(db, &net_site, helper_call_sites) { @@ -2043,6 +2185,9 @@ fn collect_wrapped_net_send_flows_in_function_block( } // Wrapped helper flows can start a net message in one function and send at call-site. + // Keep a conservative stub so counterpart diagnostics can still resolve by message name. + // The realm is a placeholder: `is_wrapped` flows are used for counterpart + // presence only and are skipped by every realm-sensitive check. flows.push(NetSendFlow { message_name, start_range: call_expr.get_range(), @@ -2062,6 +2207,14 @@ fn collect_wrapped_net_send_flows_in_function_block( } /// Collect complete send flows performed by ordinary, unannotated helpers. +/// +/// The helper itself is found through the metadata-derived helper registry, and +/// every operation inside it is still classified by [`NetCallResolver`] from +/// the shipped signature annotations. The only extra work here is propagating +/// literal string arguments from the call site into the helper's parameters so +/// `net.Start(messageName)` can become concrete at +/// `MyLib.SendString("Message", value)`. Static message names take this same +/// path, which lets a no-argument helper produce a complete call-site flow. fn collect_unannotated_net_wrapper_send_flows( ctx: &mut NetCollectCtx<'_>, site: &NetWalkSite, @@ -2090,6 +2243,10 @@ fn collect_unannotated_net_wrapper_send_flows( } /// The calls in a file that can take part in net-flow collection. +/// +/// A lookup rather than a walk: the call sites that can expand into a +/// net-producing helper come from the reference index, which already records +/// every reference to those helpers' names. fn net_candidate_call_exprs( db: &DbIndex, site: &NetWalkSite, @@ -2097,10 +2254,16 @@ fn net_candidate_call_exprs( ) -> Vec { let root_syntax = site.root.syntax().clone(); // Locals never enter the global reference table, so a helper declared + // `local function send()` is reached through its own declaration's + // references instead. let mut local_sites: Vec = Vec::new(); if let Some(decl_tree) = db.get_decl_index().get_decl_tree(&site.file_id) { let names = &helper_call_sites.names; // `local sendString = MyLib.SendString` calls the helper under a name + // the reference index files under the local binding rather than under + // the helper, so this file's own bindings of a helper name count as + // call sites too. Chains settle by iterating, bounded by the number of + // bindings in the file. let mut aliases: HashSet = HashSet::new(); let bindings = decl_tree .get_decls() @@ -2170,6 +2333,8 @@ fn net_candidate_call_exprs( .collect::>(); // Source order. The key is the whole range so that equal calls reached + // through both the name and the local-declaration lookup end up adjacent + // and the dedup can see them. calls.sort_by_key(|call_expr| { let range = call_expr.get_range(); (range.start(), range.end()) @@ -2203,6 +2368,9 @@ fn collect_send_flows_from_helper_call( }; // A send flow always starts at a `net.Start` somewhere in the expansion, so + // a helper that cannot reach one contributes nothing however it is called. + // The answer depends only on the helper, so it is cached per helper rather + // than recomputed for each calling file. let helper_id = (helper_file_id, helper_key.clone()); let (reaches_start, _) = helper_reaches_net_role( ctx, @@ -2361,6 +2529,8 @@ fn collect_net_receive_flow( call_expr: &LuaCallExpr, ) -> Option { // Same ordering as the send collector: this runs for every call expression + // in the file, so the cheap literal-string check gates the far more + // expensive signature resolution. if !call_has_literal_string_arg(call_expr) { return None; } @@ -2397,6 +2567,8 @@ fn build_net_receive_flow( let mut reads = Vec::new(); // No annotated `callback` role means we cannot know which argument holds the + // receiver, so treat the reads as unknown rather than as none — asserting an + // empty read list here would invent count mismatches against every send. let mut reads_opaque = callback_idx.is_none(); if let Some(callback_expr) = callback_idx.and_then(|idx| { call_expr @@ -2409,6 +2581,10 @@ fn build_net_receive_flow( } None => { // Inline closure that can't yield a block is malformed — but a + // bare name reference we couldn't resolve in the file is the + // common case (callback defined elsewhere). Mark opaque so the + // mismatch checker skips this flow without losing the + // counterpart record. if !matches!(callback_expr, LuaExpr::ClosureExpr(_)) { reads_opaque = true; } @@ -2467,6 +2643,10 @@ fn collect_receive_flows_from_helper_call( }; // Mirror of the send walk's prune: a receive flow always originates at a + // `net.Receive` somewhere in the expansion, so a helper that cannot reach + // one contributes nothing however it is called. Without this, every call + // with a literal string argument re-walked the full body of whatever it + // resolved to, once per calling site. let helper_id = (helper_file_id, helper_key.clone()); let (reaches_receive, _) = helper_reaches_net_role( ctx, @@ -2496,6 +2676,8 @@ fn collect_receive_flows_from_helper_call( callback_idx, }) => { // Literal registrations are already indexed in the helper's + // defining file. Only materialize a call-site flow when the + // wrapper call makes a dynamic message parameter concrete. if extract_static_string_arg_value(&nested_call, message_idx).is_some() { continue; } @@ -2528,6 +2710,11 @@ fn collect_receive_flows_from_helper_call( } /// Resolve the callback block for a `net.Receive` second argument. Handles +/// inline closures (`function() ... end`) and same-file local/global function +/// references (`net.Receive("Msg", doRetrieve)` paired with +/// `local function doRetrieve() ... end` or `local doRetrieve = function() ... end`). +/// Cross-file references are out of scope — those resolve at semantic-model +/// time and are not part of the per-file collection pass. fn resolve_callback_block( file_id: FileId, root: &LuaChunk, @@ -2551,6 +2738,14 @@ fn resolve_callback_block( } /// Resolve a call expression to a function definition, returning a +/// stable string key (used for cycle detection), the function body block, +/// and the chunk that owns the body (which becomes the new `root` for +/// further nested helper resolution within that body). +/// +/// Resolve a `(FileId, LuaSyntaxId)` helper-registry entry back to its +/// `(LuaBlock, LuaChunk)` by rebuilding the owning file's red tree on demand. +/// Returns an owned `LuaChunk` (cheap clone of a red node) which becomes the new +/// `root` for further nested helper resolution within that body. fn resolve_registry_entry( db: &DbIndex, file_id: &FileId, @@ -2573,6 +2768,9 @@ fn resolve_call_to_function_block( db: &DbIndex, ) -> Option<(String, LuaBlock, LuaChunk, FileId)> { // Direct global member calls have a stable symbol identity even when + // duplicate declarations make the inferred signature winner dependent on + // index order. Resolve that identity through the deterministic registry. + // Aliases and locals take the signature-identity path below. if !call_expr_has_shadowing_local_root(db, root_file_id, call_expr) && let Some(call_path) = call_expr.get_access_path() { @@ -2603,6 +2801,8 @@ fn resolve_call_to_function_block( } // Pre-analysis can run before every local/global callable type cache is + // available. Preserve lexical same-file wrapper expansion only when the + // written bare name identifies exactly one function body in that file. if let Some(LuaExpr::NameExpr(name_expr)) = call_expr.get_prefix_expr() && let Some(name) = name_expr.get_name_text() && let Some(block) = local_fns @@ -2620,6 +2820,8 @@ fn resolve_call_to_function_block( } // Dynamic receivers sometimes have no inferable class in Lua source. Keep + // existing wrapper support only when the method name maps to exactly one + // indexed function body workspace-wide; ambiguity is a hard stop. if call_expr.is_colon_call() && let Some(LuaExpr::IndexExpr(index_expr)) = call_expr.get_prefix_expr() && let Some(LuaIndexKey::Name(method_token)) = index_expr.get_index_key() @@ -2641,6 +2843,14 @@ fn resolve_call_to_function_block( } /// Shared state for a file's net collection walk. Bundled so the recursive +/// helpers stay readable: they already carried 11 positional arguments before +/// `file_id` and the call resolver had to be threaded for annotation lookup. +/// +/// The three `&mut` fields are pure memo state: `local_fns`, `net` and +/// `resolve_memo` are all keyed by syntax position and each entry is a function +/// of the file's own text and the index, so a walk can only ever fill them in a +/// different order — never with a different answer, and never with one that +/// makes a later lookup depend on the walk that preceded it. struct NetCollectCtx<'a> { db: &'a DbIndex, helper_registry: &'a HelperRegistry, @@ -2658,6 +2868,8 @@ struct NetCollectCtx<'a> { type ResolvedHelperFn = (String, LuaBlock, LuaChunk, FileId); /// See [`NetCollectCtx::resolve_memo`]. Owned per file by the collector that +/// drives both the receive walk and the send walks, so one file's helper +/// resolutions are computed once instead of once per walk. type ResolveMemo = FxHashMap<(FileId, TextRange), Option>; type HelperId = (FileId, String); @@ -2803,6 +3015,8 @@ fn resolve_call_to_function_block_cached( } /// Position of the walk within a file, which changes when helper expansion +/// crosses into a function body defined elsewhere. `file_id` must travel with +/// `root` so signature resolution runs against the owning file. #[derive(Clone)] struct NetWalkSite { root: LuaChunk, @@ -2851,6 +3065,10 @@ fn collect_net_write_ops_from_stat( } /// Walk `subtree` for net payload call expressions, treating non-net +/// calls that resolve to a same-file function as helper expansions: we recurse +/// into the helper body so writes/reads it performs participate in the +/// outer flow. Cycles are guarded via `visited`, and dynamic-context propagates +/// from the call site into the helper body. #[allow(clippy::too_many_arguments)] fn collect_net_ops_recursive( ctx: &mut NetCollectCtx<'_>, @@ -2864,6 +3082,8 @@ fn collect_net_ops_recursive( flow_prefix: &[NetFlowFrame], ) { // Keep the public helper name used by read/write collection call sites, + // while the implementation below documents the call-argument evaluation + // ordering needed for nested reads such as `net.ReadData(net.ReadUInt(16))`. collect_net_ops_eval_order( ctx, site, @@ -2989,6 +3209,8 @@ fn collect_net_ops_from_call_expr( let helper_force_dynamic = force_dynamic || is_call_expr_in_dynamic_control_flow(enclosing_block, call_expr); // Carry the call-site's flow context into the helper so reads/writes + // performed inside the helper appear under the correct outer + // `for`/`if`/`while` frames in hover. let local_path = extract_flow_path(enclosing_block, call_expr); let mut nested_prefix = Vec::with_capacity(flow_prefix.len() + local_path.len()); nested_prefix.extend_from_slice(flow_prefix); @@ -3058,9 +3280,24 @@ fn is_call_expr_in_dynamic_control_flow(block: &LuaBlock, call_expr: &LuaCallExp } /// Walks ancestors from `call_expr` up to (but not including) `block`, +/// collecting one `NetFlowFrame` per enclosing if/while/for/repeat. Frames +/// are returned outer-to-inner so the renderer can nest them naturally. +/// +/// `if`/`elseif`/`else` are folded into a single frame per if-chain branch: +/// when the op lives inside an `elseif cond then ... end` clause, that frame +/// records `elseif cond then` (instead of the outer `if cond then`) so the +/// developer sees the actual branch the op is gated by. Same for `else`. The +/// frame's id is the clause's source range so two ops in different branches +/// of the same if are distinct frames (different patterns can result). +/// +/// The header text is a single-line trimmed summary of the statement opener +/// (e.g. `if cond then`, `for i = 1, #items do`). Multi-line headers and +/// excessively long ones are stored as `None` to keep hover popups compact. fn extract_flow_path(block: &LuaBlock, call_expr: &LuaCallExpr) -> Vec { let mut frames: Vec = Vec::new(); // When set, the next ancestor (which we know is the parent LuaIfStat of + // an elseif/else clause we just captured) should be skipped so we don't + // double-count the if-chain. let mut skip_parent_if = false; for node in call_expr .syntax() @@ -3126,6 +3363,13 @@ enum BranchKind { } /// The node's text up to its first newline. +/// +/// `node.text().to_string()` walks and concatenates every token underneath, so +/// asking an `if` statement for its header used to materialise the statement's +/// whole body — thousands of lines for a large branch — to read one line of it. +/// Network flow analysis does that for every branch around every `net` call in +/// every re-analysed file, which made it one of the more expensive things a +/// keystroke paid for. fn first_line_text(node: &LuaSyntaxNode) -> String { let mut line = String::new(); for token in node @@ -3177,6 +3421,9 @@ fn extract_branch_header(node: &LuaSyntaxNode, kind: BranchKind) -> Option 0 then`, `for i = 1, n do`. +/// Returns `None` for multi-line or oversized headers; the renderer falls +/// back to a generic label in that case. fn extract_flow_header(stat_node: &LuaSyntaxNode, kind: NetFlowKind) -> Option { const MAX_HEADER_LEN: usize = 80; // Only the opener is ever read, and it bails on a multi-line one, so there @@ -3196,9 +3443,13 @@ fn extract_flow_header(stat_node: &LuaSyntaxNode, kind: NetFlowKind) -> Option Option { } /// What a call does in the net subsystem, resolved purely from the callee's +/// signature metadata. Because resolution goes through the type layer, aliases +/// (`local netStart = net.Start`), cross-file globals, and annotated replacement +/// APIs are all recognized identically to the builtins. Ordinary wrappers are +/// expanded through their bodies and need no annotations of their own. #[derive(Debug, Clone)] enum NetCallRole { /// Begins a message: `call_arg("gmod.net_message", "start")`. Carries the + /// index of the parameter holding the message name, so a wrapper that takes + /// it somewhere other than first is read correctly. Start { message_idx: usize }, /// Registers a receiver: `call_arg("gmod.net_message", "receive")`, with the /// message-name index and the `callback` role's index when annotated. @@ -3256,11 +3513,22 @@ enum NetCallRole { } /// Resolves [`NetCallRole`] for call expressions, memoizing per call site. +/// +/// Signature resolution runs type inference, which is far more expensive than +/// the syntax match it replaces, so results are cached by syntax id — the send +/// and wrapped-send passes both scan the same statements, and helper expansion +/// can revisit a body. One [`LuaInferCache`] is kept per file so expansion into +/// a helper defined in another file still resolves against that file. #[derive(Default)] struct NetCallResolver { caches: HashMap, memo: HashMap<(FileId, LuaSyntaxId), Option>, /// `role` memoises the *role*, but the signature behind it is asked for + /// twice per call site: once here and once by + /// [`resolve_call_to_function_block`]'s signature path. Resolving a call's + /// signature means resolving its prefix to a semantic decl, which is the + /// single most expensive operation in this pipeline, so the answer is + /// memoised on the same key the role is. signature_memo: HashMap<(FileId, LuaSyntaxId), Option>, } @@ -3378,6 +3646,10 @@ impl NetCallResolver { } // Some same-file member declarations do not yet have a semantic owner + // edge at this pre-analysis phase, while their inferred callable type + // is already available. Read that type instead of falling back to the + // source spelling of the member. Ambiguous callable unions are left + // unresolved rather than choosing an arbitrary body. let prefix = call_expr.get_prefix_expr()?; let typ = crate::semantic::infer_expr(db, cache, prefix).ok()?; unique_signature_id_from_type(&typ) @@ -3407,6 +3679,8 @@ fn unique_signature_id_from_type(typ: &LuaType) -> Option { } /// Source text of the called function, used for display in diagnostics, hover +/// and code lens. Prefers what the developer actually wrote so an aliased or +/// wrapped call reports its own name. fn call_display_name(call_expr: &LuaCallExpr) -> SmolStr { call_expr .get_prefix_expr() @@ -3425,6 +3699,11 @@ fn call_display_name(call_expr: &LuaCallExpr) -> SmolStr { } /// Captures a short snippet of the value-arg source text for a write op so +/// hover can display *what* is being written (e.g. `net.WriteString("hi")` +/// instead of just `net.WriteString`). Returns `None` for read ops, when the +/// arg is missing, when it spans multiple lines, or when it's too long to +/// render inline — robustness over completeness; we'd rather show the bare +/// op name than blow up the hover popup with a 200-char expression. fn extract_write_value_text(call_expr: &LuaCallExpr, op: &NetOpDescriptor) -> Option { if !op.is_write() { return None; @@ -3448,6 +3727,10 @@ fn extract_write_value_text(call_expr: &LuaCallExpr, op: &NetOpDescriptor) -> Op } /// Extracts the static bit-width literal from a payload op that declares a +/// `gmod.net_payload`/`bits` parameter. Returns `None` for ops with no such +/// parameter, or when the argument is not an integer literal (variable, +/// expression, runtime computation) — anything else is unknowable at index time +/// and would produce false-positive mismatches if compared. fn extract_bit_width_arg(call_expr: &LuaCallExpr, bits_arg_idx: usize) -> Option { let arg_expr = call_expr.get_args_list()?.get_args().nth(bits_arg_idx)?; let LuaExpr::LiteralExpr(literal_expr) = arg_expr else { @@ -3474,6 +3757,14 @@ fn extract_static_string_arg_value(call_expr: &LuaCallExpr, arg_idx: usize) -> O } /// Cheap syntactic gate for the flow collectors, which run over every +/// statement-level call in a candidate file. A tracked flow always names its +/// message with a literal string, so a call carrying none can never start or +/// receive one, and is rejected here before the far more expensive signature +/// resolution runs. +/// +/// Deliberately index-agnostic: the message parameter's position comes from the +/// annotation and is not fixed at zero. Ordering only — every call that would +/// have produced a flow still reaches the resolver. fn call_has_literal_string_arg(call_expr: &LuaCallExpr) -> bool { let Some(args_list) = call_expr.get_args_list() else { return false; @@ -3487,6 +3778,10 @@ fn call_has_literal_string_arg(call_expr: &LuaCallExpr) -> bool { } /// Captures the recipient argument of a send terminator as a single-line snippet +/// for display in code lens. The argument position comes from the +/// `gmod.net_payload`/`target` call-arg role, so terminators with no recipient +/// (`net.Broadcast`, `net.SendToServer`) yield `None` without a name check. +/// Returns `None` when the source is multi-line or too long to render inline. fn extract_send_target_text(call_expr: &LuaCallExpr, send_kind: NetSendKind) -> Option { const MAX_INLINE_LEN: usize = 40; @@ -3511,6 +3806,8 @@ pub(crate) struct GmodScopedClassMatch { pub aliases: Vec, pub super_types: Vec, /// The scope's `classNamePrefix` (if any). Used to derive the stripped + /// short name for parent-alias synthesis (e.g. `gamemode_sandbox` → + /// `sandbox` → `Sandbox`). pub class_name_prefix: Option, } @@ -3710,6 +4007,9 @@ fn resolve_vgui_parent_relations( batch_file_ids: &[FileId], ) { // This group's files have had their calls re-collected by the passes + // before this one, so their removal marks come off: whatever relations + // they still contribute are resolved below. A marked file with no syntax + // tree left was deleted outright, and its relations are legitimately gone. let mut settled_pending = db .get_gmod_class_metadata_index() .pending_vgui_parent_relation_file_ids() @@ -3790,6 +4090,9 @@ fn resolve_vgui_parent_relations( continue; } // Every call already resolved means this file was not rebuilt, so + // walking its syntax tree would reproduce what is cached. Skipping it is + // the whole point: only a handful of the workspace's vgui files are + // touched by any one edit. if let Some(cached) = calls .iter() .map(|call| { @@ -4687,6 +4990,9 @@ fn scoped_class_uses_global_namespace(global_name: &str) -> bool { } /// Scopes whose authoring table is conventionally declared as a `local` +/// (e.g. `local PLUGIN = {}`, `local PLAYER = {}`) rather than a bare global. +/// For these, an explicit local declaration with the scope's global name is +/// treated as the scoped class table even without the synthetic seed. pub(crate) fn scoped_class_authored_as_local(global_name: &str) -> bool { matches!(global_name, "PLUGIN" | "PLAYER") } @@ -4697,6 +5003,11 @@ fn scoped_class_super_types( configured: &[String], ) -> Vec { // PLAYER is special: the runtime authoring table is named `PLAYER`, but that + // identifier is already a GMod enum alias (`PLAYER_IDLE`, ... in enums.lua), + // so the authoring-class annotation cannot use it. The shared player-class + // fields live on the `PlayerClass` annotation class instead. The player-class + // table is NOT itself a Player entity (methods use `self.Player:...`), so it + // inherits only `PlayerClass`. if global_name == "PLAYER" { return vec![LuaType::Ref(LuaTypeDeclId::global("PlayerClass"))]; } @@ -4755,12 +5066,26 @@ pub(crate) fn ensure_scoped_class_type_decl_for_file( } /// Resolve scripted_ents.GetMember("class", "method") delegation patterns. +/// +/// Detects patterns like: +/// ```lua +/// function ENT:SetupDataTables() +/// local f = scripted_ents.GetMember("target_class", "SetupDataTables") +/// f(self) +/// end +/// ``` +/// +/// When such a delegation is found, NetworkVar calls from the target entity's +/// metadata are copied into the current entity's metadata so that +/// `synthesize_scripted_class_members` will produce Get/Set members for them. fn resolve_getmember_network_var_delegations( db: &mut DbIndex, scripted_scope_files: &HashSet, context: &AnalyzeContext, ) { // Collect files to process: only scripted scope files whose source + // contains "scripted_ents.GetMember". Collect into owned structures + // so we can drop the immutable VFS borrow before mutable db access. let candidate_files: Vec<(FileId, LuaChunk, LuaTypeDeclId)> = { let vfs = db.get_vfs(); context @@ -4824,6 +5149,8 @@ fn build_class_file_map(db: &DbIndex) -> HashMap> { } /// Walk a scripted class file's AST looking for `scripted_ents.GetMember` delegation +/// patterns. When found, copy NetworkVar calls from the target class into this file's +/// metadata. fn find_and_resolve_getmember_delegations( db: &mut DbIndex, current_file_id: FileId, @@ -4894,6 +5221,9 @@ fn find_and_resolve_getmember_delegations( }; // Also check as a statement: f(self) as a statement + // Actually the descendant walk will hit both LuaCallExpr and + // LuaCallExprStat, and the LuaCallExpr inside a LuaCallExprStat + // will match either way. // Look up the target class if let Some(target_file_ids) = class_file_map.get(target_class) { @@ -5180,10 +5510,14 @@ fn synthesize_vgui_registrations( let mut vgui_registration_regions: Vec = Vec::new(); let mut synthesis_cache = VguiSynthesisCache::default(); // Tracks local table regions that have already been registered via + // `vgui.RegisterTable` so that subsequent `vgui.CreateFromTable` calls + // referencing the same region do not trigger a second class synthesis. let mut registered_table_regions: HashSet<(LuaDeclId, TextSize)> = HashSet::new(); for file_id in file_ids.iter().copied() { // Borrow first and skip files with no VGUI-relevant calls before paying + // for the (multi-Vec) metadata clone. The vast majority of files have + // class metadata but no VGUI register/derma calls. let has_vgui_work = match db .get_gmod_class_metadata_index() .get_file_metadata(&file_id) @@ -5277,6 +5611,21 @@ fn synthesize_vgui_registrations( resolve_local_registration_region(db, file_id, table_var, register_position) { // Skip synthesis when this table is already registered via a + // prior `vgui.RegisterTable` call. `vgui.CreateFromTable` uses + // the same `register_table` call_arg kind, which means it also + // lands in `vgui_register_table_calls`. Without this guard, the + // `CreateFromTable` call synthesizes a SECOND class at its own + // position, overwriting the first registration's binding and + // producing false-positive `undefined-field` / + // `unchecked-nil-access` on the original panel's `self.Field` + // accesses. + // + // Only actual `vgui.RegisterTable` calls populate the dedup + // set. A `CreateFromTable` call that appears before the real + // `RegisterTable` must not insert a key, otherwise the later + // `RegisterTable` can lose its base/type synthesis. The key is + // region-specific so reused locals can register later table + // regions without being blocked by earlier registrations. let registration_key = (decl_id, region_start); if registered_table_regions.contains(®istration_key) { continue; @@ -5371,6 +5720,8 @@ fn synthesize_vgui_registrations( flush_vgui_table_const_replacements(db, &mut synthesis_cache); // Synthesize AccessorFunc members for VGUI-registered classes. Group by + // file so each accessor target is resolved once instead of once per + // registration in that file. let mut registrations_by_file: HashMap> = HashMap::new(); for registration in &vgui_registration_regions { registrations_by_file @@ -5619,6 +5970,11 @@ fn synthesize_scripted_ent_registration( } // Inject extra super-types based on `ENT.Type`. The `Type` field selects + // the engine-side entity framework (e.g. `"nextbot"` provides `NextBot` + // methods like `StartActivity`, `loco`, `MoveToPos` via C++ metatable + // injection). Without this, `self:StartActivity()` on a `base_nextbot` + // entity produces false-positive `undefined-field` diagnostics because + // the synthesized `base_nextbot` class doesn't inherit from `NextBot`. if let Some((type_name, source_range)) = resolve_registered_scripted_ent_type(&table_expr) && let Some(super_name) = super_type_for_entity_type(&type_name) { @@ -5679,6 +6035,8 @@ fn resolve_registered_scripted_ent_type(table_expr: &LuaTableExpr) -> Option<(St } /// Maps `ENT.Type` values to the annotation class that provides the +/// engine-side framework methods. C++ metatable injection makes these +/// methods available at runtime; we model them via super-types. fn super_type_for_entity_type(type_name: &str) -> Option<&'static str> { match type_name { "nextbot" => Some("NextBot"), @@ -5805,6 +6163,8 @@ fn synthesize_scoped_base_assignments_with( let expected_base_path = format!("{}.Base", scope_match.global_name); // ENT.Type selects the engine-side entity framework (e.g. "nextbot" + // provides NextBot methods). Only entities use this field — SWEP, TOOL, + // PLAYER, etc. have their own Type field with different semantics. let expected_type_path = if scope_match.global_name == "ENT" { Some(format!("{}.Type", scope_match.global_name)) } else { @@ -5844,6 +6204,8 @@ fn synthesize_scoped_base_assignments_with( && access_path.eq_ignore_ascii_case(type_path) { // ENT.Type = "nextbot" → inject NextBot as a super-type so + // engine-side framework methods (StartActivity, loco, etc.) + // are visible on the synthesized class. let Some(type_name) = extract_scoped_base_name(value_expr) else { continue; }; @@ -5905,6 +6267,12 @@ fn extract_scoped_base_name(expr: &LuaExpr) -> Option { } /// A wrapper function that internally calls NetworkVar or NetworkVarElement. +/// For example: +/// ```lua +/// function ENT:SetupNW(type, name) +/// self:NetworkVar(type, 0, name) +/// end +/// ``` #[derive(Debug, Clone)] struct NetworkVarWrapper { /// The method name of the wrapper (e.g. "SetupNW") @@ -6121,6 +6489,10 @@ fn find_networkvar_in_closure( resolve_wrapper_arg_mapping(&inner_args, 0, param_names); // Determine the name argument — find the last string-like argument + // For 3-arg NetworkVar: name is at index 2 + // For 2-arg NetworkVar: name is at index 1 + // For 4-arg NetworkVarElement: name is at index 3 + // Try from the end to find the name position let name_indices: &[usize] = if is_element { &[3, 2, 1] } else { &[2, 1] }; let mut fixed_name = None; @@ -6158,6 +6530,8 @@ fn find_networkvar_in_closure( } /// Given a call argument expression and the wrapper's parameter names, +/// determine if the argument is a fixed string literal or a reference to +/// one of the wrapper's parameters. fn resolve_wrapper_arg_mapping( inner_args: &[LuaExpr], arg_index: usize, @@ -6190,6 +6564,8 @@ fn resolve_wrapper_arg_mapping( } /// Given a call to a known wrapper method and the wrapper's parameter mapping, +/// resolve the concrete type and name from the call arguments and synthesize +/// Get/Set members. fn synthesize_from_wrapper_call( db: &mut DbIndex, file_id: FileId, @@ -6420,6 +6796,22 @@ fn resolve_effective_inheritance_base( } /// Synthesize a parent-name alias member on a derived scripted class. +/// +/// In Garry's Mod, derived gamemodes can access their inherited base via a +/// field named after the parent's short (prefix-stripped) folder name. For +/// example, a DarkRP gamemode inheriting from Sandbox uses `self.Sandbox` to +/// reach the base gamemode table. The runtime exposes this field, but the +/// analyzer would otherwise have no type for it, which breaks hover, goto, +/// and completion on `self..`. +/// +/// Rules (mirroring the oracle-approved design): +/// - Only applies when the scope declares a non-empty `classNamePrefix`. +/// - The parent class name must start with that prefix, and the remainder +/// must be non-empty (otherwise we skip silently to avoid bogus aliases +/// on malformed or cross-scope base names). +/// - If the derived class already has a member with the alias name (for +/// example, because the user wrote `GM.Sandbox = BaseClass` themselves), +/// the explicit field wins and we do not synthesize a duplicate. fn synthesize_define_baseclass_parent_alias( db: &mut DbIndex, file_id: FileId, @@ -6484,6 +6876,8 @@ fn synthesize_define_baseclass_parent_alias( } /// Uppercase the first ASCII letter of `s`, leaving the rest untouched. +/// Non-ASCII leading bytes are preserved as-is (GMod class names are ASCII +/// in practice, so this keeps the implementation simple and allocation-light). fn capitalize_ascii_first(s: &str) -> String { let mut chars = s.chars(); match chars.next() { @@ -6506,6 +6900,10 @@ fn synthesize_accessor_func( call: &GmodScriptedClassCallMetadata, ) { // AccessorFunc(target, "m_VarKey", "Name", forceType) + // args[0] = target (ENT etc) - non-literal name ref + // args[1] = backing field name (string) + // args[2] = accessor name (string) + // args[3] = force type (FORCE_STRING, number, bool, etc) let accessor_name = match call.literal_args.get(2) { Some(Some(GmodClassCallLiteral::String(name))) => name.clone(), @@ -6600,6 +6998,10 @@ fn synthesize_network_var( call: &GmodScriptedClassCallMetadata, ) { // ENT:NetworkVar("Type", slot, "Name") — 3-arg form + // ENT:NetworkVar("Type", "Name") — 2-arg form (slot omitted) + // args[0] = type name (string) + // args[1] = slot (integer) OR name (string, if 2-arg form) + // args[2] = name (string, if 3-arg form) let type_arg_idx = call.network_var_type_arg_idx().unwrap_or(0); let type_name = match call.literal_args.get(type_arg_idx) { @@ -6687,6 +7089,13 @@ fn synthesize_network_var_element( call: &GmodScriptedClassCallMetadata, ) { // ENT:NetworkVarElement("Type", slot, element, "Name") — 4-arg form + // ENT:NetworkVarElement("Type", slot, "Name") — 3-arg form + // ENT:NetworkVarElement("Type", "Name") — 2-arg form + // The value type is always `number` for element access. + // args[0] = type name (string) — used only for validation, not for type + // args[1] = slot or name + // args[2] = element or name + // args[3] = name (if 4-arg form) let type_arg_idx = call.network_var_type_arg_idx().unwrap_or(0); if call @@ -6786,6 +7195,9 @@ fn synthesize_vgui_register( resolved_registration: Option, ) { // vgui.Register("PanelName", TABLE, "BasePanel") + // args[0] = panel name (string) + // args[1] = table variable (name ref) + // args[2] = base panel name (string) let table_source = call.vgui_panel_table_arg_source(1); let base_source = call.vgui_panel_base_arg_source(Some(2)); @@ -6824,6 +7236,10 @@ fn synthesize_derma_define_control( resolved_registration: Option, ) { // derma.DefineControl("ControlName", "description", TABLE, "BasePanel") + // args[0] = control name (string) + // args[1] = description (string, ignored) + // args[2] = table variable (name ref) + // args[3] = base panel name (string) let table_source = call.vgui_panel_table_arg_source(2); let base_source = call.vgui_panel_base_arg_source(Some(3)); @@ -6864,6 +7280,8 @@ fn synthesize_vgui_register_table( resolved_registration: Option, ) { // vgui.RegisterTable(TABLE, "BasePanel") + // args[0] = table variable (name ref) + // args[1] = base panel name (string) let table_source = call.vgui_panel_table_arg_source(0); let base_source = call.vgui_panel_base_arg_source(Some(1)); @@ -6902,6 +7320,8 @@ fn synthesize_vgui_register_file_target( call: &GmodScriptedClassCallMetadata, ) -> Option<(FileId, LuaDeclId, LuaTypeDeclId, String, TextSize, TextSize)> { // vgui.RegisterFile("path/to/panel.lua") includes a file with a temporary + // global PANEL table. The file itself is not a named VGUI class, but its + // methods should still see PANEL.Base inheritance while it is being loaded. let panel_source = call.vgui_panel_define_arg_source(); let GmodClassCallLiteral::String(path) = call.value_for_arg_source(&panel_source)? else { return None; @@ -6928,6 +7348,8 @@ fn synthesize_vgui_register_file_target( let class_type = LuaType::Def(class_decl_id.clone()); // `vgui.RegisterFile` returns the temporary PANEL table it loaded. Bind + // that call expression to the synthesized class so a subsequent + // `vgui.CreateFromTable(result)` preserves the file's PANEL members. write_type_cache( db, LuaTypeOwner::SyntaxId(InFiled::new(source_file_id, call.syntax_id)), @@ -7181,8 +7603,25 @@ fn register_global_panel( } // 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, @@ -7190,11 +7629,27 @@ fn find_registered_table_expr( register_position: TextSize, ) -> 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) .unwrap_or(decl_id.position); find_registered_table_expr_at_write_position(db, file_id, write_position).or_else(|| { + // When the latest write is a reassignment whose RHS is not a table + // literal (e.g. `PANEL = vgui.RegisterTable(PANEL, "DPanel")`), + // the table constructor still lives at the original `local PANEL = + // {...}` declaration. + // + // Only fall back when the registration call is the reassignment RHS + // itself — i.e. `register_position` is within the write statement's + // range. This avoids mis-modeling unrelated reassignments such as + // `PANEL = MakePanel()` followed by a separate `vgui.RegisterTable` + // call, where the stale initializer should NOT be used. if write_position == decl_id.position { return None; } @@ -7209,6 +7664,10 @@ fn find_registered_table_expr( } /// Checks whether `register_position` falls within the RHS expression +/// corresponding to the LHS at `write_position`. This identifies the +/// self-assignment registration pattern `PANEL = vgui.RegisterTable(PANEL, ...)` +/// while rejecting multi-assignments where the registration call is on a +/// different LHS (e.g. `PANEL, OTHER = MakePanel(), vgui.RegisterTable(...)`). fn write_position_contains_register( db: &DbIndex, file_id: FileId, @@ -7230,6 +7689,9 @@ fn write_position_contains_register( return false; }; // Find the specific RHS expression for the LHS at write_position. + // In a simple assignment `PANEL = expr`, there is one RHS at index 0. + // In a multi-assignment `A, B = expr1, expr2`, each LHS maps to its + // corresponding RHS by position index. let (lhs_list, rhs_list) = assign_stat.get_var_and_expr_list(); let Some(lhs_idx) = lhs_list .iter() @@ -7245,6 +7707,10 @@ fn write_position_contains_register( } /// Checks whether the call at the given metadata is `vgui.RegisterTable` +/// (not `vgui.CreateFromTable`). Both use the `register_table` call_arg +/// kind and land in `vgui_register_table_calls`, but only `RegisterTable` +/// actually registers a panel class. `CreateFromTable` instantiates from +/// an already-registered table and should not populate the dedup set. pub(crate) fn is_vgui_register_table_call( db: &DbIndex, file_id: FileId, @@ -7446,6 +7912,16 @@ fn synthesize_panel_class_with_id( } // 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 register_position = call.syntax_id.get_range().start(); let Some(resolved_registration) = resolved_registration.or_else(|| { @@ -7495,6 +7971,10 @@ fn synthesize_panel_class_with_id( if !cached_decl_has_reassignment(cache, db, file_id, decl_id) { // For single-panel files the `PANEL` local has one stable identity. + // Bind the decl slot too so method-self collection during the Lua + // pass sees the synthesized class before it caches member values. + // Reassigned locals remain table-literal-only to avoid collapsing + // distinct registration regions onto one class. write_type_cache( db, decl_id.into(), @@ -7504,11 +7984,27 @@ fn synthesize_panel_class_with_id( } // 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()); // 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(cache, db, file_id, decl_id, &table_range); @@ -7528,6 +8024,10 @@ fn synthesize_panel_class_with_id( .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, @@ -7545,6 +8045,12 @@ fn synthesize_panel_class_with_id( } // A derma file conventionally uses the *global* `PANEL` scratch + // table (`PANEL = {}` … `function PANEL:Paint()` … + // `vgui.Register("X", PANEL, "DButton")`). At runtime that + // table is consumed by the register call and the next file + // overwrites the global, so each file's `PANEL` is a separate + // class — exactly like `ENT`/`SWEP`, which are modelled as + // scoped class globals. for global_owner in global_panel_member_owners(db, var_name) { let members = db .get_member_index() @@ -7579,6 +8085,8 @@ fn synthesize_panel_class_with_id( } // Backfill persistent type caches that still hold this exact + // table-const identity (scoped to the current range only — never + // carried forward across registrations). cache .table_const_replacements .insert(table_range, class_type.clone()); @@ -7672,6 +8180,26 @@ fn bind_inline_vgui_panel_table( } /// 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. +/// Owners a *global* panel-table variable's members can be sitting on. +/// +/// Decl analysis parks `PANEL.Field` / `function PANEL:Method()` under +/// `GlobalPath("PANEL")`; the global-member migration then re-homes them onto +/// whatever the `PANEL` declaration resolved to, which for GMod workspaces is +/// the annotation `@class PANEL`. Both are checked so the transfer works +/// whichever stage the member reached. fn global_panel_member_owners(db: &DbIndex, var_name: &str) -> Vec { let mut owners = vec![LuaMemberOwner::GlobalPath(GlobalId::new(var_name))]; let type_decl_id = LuaTypeDeclId::global(var_name); @@ -7692,6 +8220,11 @@ fn collect_panel_member_source_ranges( 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: VGUI + // synthesis rewrites table-const caches after collecting region members, so + // cache state is intentionally not the source of truth here. if let Some(initializer_range) = cached_decl_initializer_table_range(cache, db, file_id, decl_id) && !ranges.iter().any(|existing| existing == &initializer_range) @@ -7721,6 +8254,8 @@ fn cached_decl_initializer_table_range( } /// 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, @@ -7750,6 +8285,9 @@ fn find_decl_initializer_table_range( } /// 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() @@ -7764,6 +8302,12 @@ fn decl_has_reassignment(db: &DbIndex, file_id: FileId, decl_id: LuaDeclId) -> b } /// 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, @@ -7968,11 +8512,16 @@ fn detect_scoped_class_from_path(db: &DbIndex, file_id: FileId) -> Option Option<(String, String)> { get_scripted_class_info_with_prefix(db, file_id).map(|(c, g, _)| (c, g)) } /// Like [`get_scripted_class_info_for_file`] but also returns the scope's +/// `class_name_prefix`, so callers can correctly strip it to recover the +/// folder short-name (used for parent-alias synthesis on inherited classes). pub(crate) fn get_scripted_class_info_with_prefix( db: &DbIndex, file_id: FileId, @@ -8024,6 +8573,9 @@ pub(crate) struct AnnotatedGmodGlobalCallRoleMap { candidate_call_path_kinds: Vec, environment_role_source_files: HashSet, /// Canonical function metadata per `(wire_format, direction)`, published to + /// the network index for features that must emit a net call. Values keep + /// their precedence rank so the winner does not depend on the signature + /// index's iteration order. canonical_net_ops: HashMap<(SmolStr, NetOpDirection), (CanonicalNetOpRank, crate::db_index::CanonicalNetOp)>, } @@ -8765,6 +9317,8 @@ fn match_bool(matches: bool) -> StaticArgTypeMatch { } /// Total precedence of canonical net metadata, lowest wins. Workspace class is +/// the meaningful preference; path and position make equal-name duplicates +/// deterministic even when their metadata conflicts. #[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] struct CanonicalNetOpRank { workspace: u8, @@ -8799,6 +9353,9 @@ fn canonical_net_op_rank(db: &DbIndex, signature_id: LuaSignatureId) -> Canonica } impl AnnotatedGmodGlobalCallRoleMap { + /// One file's contribution to the role map, plus the helper definitions its + /// signatures produced. Cached on the db and re-derived only when the file + /// is re-analysed — see `DbIndex::get_cached_file_helper_scan`. fn build_for_file( db: &DbIndex, signature_ids: &[LuaSignatureId], @@ -8832,6 +9389,9 @@ impl AnnotatedGmodGlobalCallRoleMap { } /// Folds another file's fragment in. Files are merged in a fixed order and + /// the first file to define a call path wins, so a path that two files both + /// define resolves the same way every run — the previous whole-index fold + /// took the signature index's `HashMap` order, which varies per process. fn merge_from(&mut self, other: &Self) { for (path, roles) in &other.roles_by_path { self.roles_by_path @@ -8875,6 +9435,9 @@ impl AnnotatedGmodGlobalCallRoleMap { if let Some(descriptor) = descriptor { // Wrappers are expected to share a wire format with the builtin they + // wrap, so collisions here are normal rather than exceptional. The + // signature index is a `HashMap`, so its iteration order varies per + // process; ranking the candidates keeps the published name stable. let candidate = ( canonical_net_op_rank(db, signature_id), crate::db_index::CanonicalNetOp { @@ -9363,6 +9926,8 @@ fn roles_from_inferred_receiver_method( call_path: &str, ) -> Option { // A local access path such as self.tabContainer:AddPanel cannot match the + // annotated DHorizontalScroller.AddPanel path, but its member signature can. + // Most calls have no VGUI parent role, so avoid semantic inference for them. if !matches!(call_expr.get_prefix_expr(), Some(LuaExpr::IndexExpr(_))) || !matches!( call_path.rsplit('.').next(), @@ -10788,6 +11353,8 @@ fn collect_member_realm_ranges(root: &LuaChunk) -> Vec { } /// Extract realm narrowing from a single if-statement, handling if/elseif/else clauses. +/// Also handles early-return guards like `if not CLIENT then return end` which narrows +/// the realm of code after the if-statement to the complementary realm. fn collect_if_realm_ranges(if_stat: &LuaIfStat, ranges: &mut Vec) { let condition_realm = if_stat .get_condition_expr() @@ -10800,6 +11367,8 @@ fn collect_if_realm_ranges(if_stat: &LuaIfStat, ranges: &mut Vec ranges.push(GmodRealmRange { range, realm }); } else { // Empty block (e.g., comment-only if-body): still record the realm + // so that realm-awareness checks (like AddCSLuaFile CLIENT detection) work. + // Use a zero-width range at the start of the if-statement as a marker. let pos = if_stat.syntax().text_range().start(); ranges.push(GmodRealmRange { range: TextRange::new(pos, pos), @@ -11908,6 +12477,10 @@ fn collect_dynamic_loaders( } // Every file is content-scanned for a `file_find` candidate before almost + // all of them bail out, and the whole walk is read-only against `&DbIndex`, + // so it runs across files in parallel. Results stay index-aligned and are + // flattened in file order, so the pattern list is identical to the previous + // sequential build. let per_file = super::parallel::map_files_collect(db, file_ids, |db, source_file_id| { let mut patterns = Vec::new(); let Some(tree) = db.get_vfs().get_syntax_tree(&source_file_id) else { @@ -13085,6 +13658,10 @@ fn dynamic_file_find_targets( relative_paths_by_parent: &HashMap>, ) -> Vec<(FileId, String)> { // `targets` is consumed in order by `apply_dynamic_loaders`, which feeds the + // load-graph fixpoint, so the order has to be a property of the source. The + // directory branch walks a `HashSet` of suffixes and a `HashMap` keyed by + // parent path — doubly hash-random per process. Same policy as + // `build_call_roles_and_registry`: order by normalized path, then file id. let mut targets = dynamic_file_find_targets_unordered(glob, result_kind, usage, relative_paths_by_parent); targets.sort_by_cached_key(|(file_id, target_path)| { @@ -14094,6 +14671,10 @@ fn infer_realm_from_filename(db: &DbIndex, file_id: FileId) -> Option } // 2. Check parent directory names for realm hints SECOND + // Prefer the path segment after the last `/lua/` anchor to avoid false realm hints + // from unrelated parent directory names (e.g. a user home directory named "server"). + // If there is no `/lua/` anchor, still allow inference for known GMod workspace layouts + // such as addon-root (`lua/...`) and gamemode-root (`gamemode/...`, `entities/...`). let path_str = file_path.to_string_lossy().to_ascii_lowercase(); let path_str = path_str.replace('\\', "/"); let components = file_path @@ -14141,6 +14722,8 @@ fn infer_realm_from_filename(db: &DbIndex, file_id: FileId) -> Option } // 3. Check GMod special directory patterns (engine-defined realm behavior per GMod loading order) + // These MUST come before the init.lua/shared.lua filename checks because e.g. + // effects/init.lua should be Shared (effects load on both realms), not Server. if search_str.contains("/effects/") { return Some(GmodRealm::Shared); } diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/for_range_stat.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/for_range_stat.rs index e17803d10..7f0e804b5 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/for_range_stat.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/for_range_stat.rs @@ -47,7 +47,11 @@ pub fn analyze_for_range_stat( } if iter_var_types.contain_tpl() || enumerates_member_map { - // Defer when iter type depends on incomplete enumeration. + // Either nothing bound the generic, so the vars hold raw + // template refs, or the types came from enumerating a + // table's member map. Either way the answer only covers the + // members indexed when this ran, and which those are + // depends on the order files were analysed in. let unresolved = UnResolveIterVar { file_id: analyzer.file_id, iter_exprs: iter_exprs.clone(), @@ -92,7 +96,9 @@ pub fn analyze_for_range_stat( Some(()) } -/// Whether loop iter types come from table member enumeration. +/// Whether this loop's variable types come from enumerating a table's +/// member map, the union [`try_infer_pairs_iter_types_from_table_members`] +/// builds. pub fn iterates_table_member_map( db: &DbIndex, file_id: FileId, @@ -248,7 +254,10 @@ fn try_infer_pairs_iter_types_from_table_members( let table_type = infer_expr(db, cache, table_arg)?; if matches!(table_type, LuaType::Global) { - // Global table has no stable enumerable answer. + // The global table has no enumerable answer: its member types are the very + // thing analysis is computing, and a loop over it can declare further + // globals whose types are then part of the same union. Any snapshot is a + // record of how far inference had progressed, not a fact about the program. return Ok(Some(VariadicType::Multi(vec![ LuaType::String, LuaType::Any, @@ -281,7 +290,11 @@ fn try_infer_pairs_iter_types_from_table_members( .collect::>(); member_entries.sort_by_key(|(key, _)| member_key_stable_key(key)); - // Dynamic keys alias by type; literal keys are incomplete samples. + // A dynamic key aliases every access whose key infers to the same type + // rather than naming a member, so once one is present the literal keys + // beside it are a sample of the indices some file happened to write, not + // the table's key domain. Which samples are in the map depends on how far + // inference had progressed, so the keys are reported by kind. let keys_are_sampled = member_entries .iter() .any(|(key, _)| matches!(key, LuaMemberKey::ExprType(_))); diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/scalar.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/scalar.rs index fe9cf608c..e3fc2c5a4 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/scalar.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/scalar.rs @@ -166,6 +166,13 @@ fn should_merge_table_literals( .all(|state| state.all_table_assignment_merge_types) } +/// The merge of every writer's table type. +/// +/// Answering bare `table` here -- which is what widening each writer to +/// `table_literal_widen_type` and unioning amounts to -- throws away the only +/// thing the writers carry, and every field of the slot then reads as nil-able. +/// The writers name one runtime table, so merging them is both more precise and +/// independent of which writer the batch happened to reach first. fn merged_table_assignment_type( db: &DbIndex, incoming_type: &LuaType, diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs index 1e2bc0e65..6def389cd 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs @@ -146,6 +146,8 @@ impl AnalysisPipeline for LuaAnalysisPipeline { } // Detailed per-file logging is intentionally reserved for explicit profiling. + // Info logging can be enabled in normal server sessions, and logging every + // >1ms file turns large workspace analysis into a log-I/O hotspot. let should_log_file = if stderr_profile_enabled { file_elapsed.as_millis() > 1 } else { @@ -211,6 +213,7 @@ impl AnalysisPipeline for LuaAnalysisPipeline { } /// Measures how much of `lua analyze` could overlap if each dependency level ran +/// concurrently: the critical path is the sum of each level's slowest file. /// Profiling only (`GLUALS_PROFILE=1`). #[derive(Default)] struct LevelShape { diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs index 3e3ba5ab2..aac7c10b3 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -63,6 +63,8 @@ pub fn analyze_local_stat(analyzer: &mut LuaAnalyzer, local_stat: LuaLocalStat) return Some(()); } // Skip Nil binding for mutable locals (those with subsequent write-assignments). + // This prevents false "cannot assign X to never" diagnostics when a local is used + // as an upvalue inside a closure and assigned before the closure is first called. if is_local_mutable(analyzer, decl_id) { continue; } @@ -86,6 +88,9 @@ pub fn analyze_local_stat(analyzer: &mut LuaAnalyzer, local_stat: LuaLocalStat) break; }; let decl_id = LuaDeclId::new(analyzer.file_id, position); + // A copy of a loop variable holds whatever the variable held when the + // copy landed, and the settled re-derivation moves those, so it needs + // re-reading for the same reason a call or index read does. if is_call_or_index_expr(&expr) || reads_settling_iter_var(analyzer.db, analyzer.file_id, &expr) { @@ -93,6 +98,10 @@ pub fn analyze_local_stat(analyzer: &mut LuaAnalyzer, local_stat: LuaLocalStat) .context .request_uninformative_local_decl_reinfer(decl_id); } + // A read through a multi-declaration global answers from whichever backing + // tables the walk had reached; a decl deferred to the unresolve wave never + // reaches the settled-global-read recording below, so record it here + // before it can branch off. Re-derived once every backing table has landed. if initializer_reads_through_multi_decl_global(analyzer, &expr) { analyzer .context @@ -203,6 +212,10 @@ pub fn analyze_local_stat(analyzer: &mut LuaAnalyzer, local_stat: LuaLocalStat) continue; } + // A global's type is the merge of every file that writes it, and + // a batch that retains some of those writers while its own are + // still empty answers this read from a smaller set than a cold + // build sees. Re-derived once they have all landed. if reads_global_name(analyzer, &expr) { analyzer .context @@ -556,6 +569,10 @@ fn set_index_expr_owner(analyzer: &mut LuaAnalyzer, var_expr: LuaVarExpr) -> Opt let Some((member_owner, set_owner_only)) = resolve_index_expr_member_owner_for_file(&prefix_type, Some(analyzer.file_id)) else { + // The prefix inferred, but to nothing that names an owner. That + // is not a property of the source: the prefix may simply not + // have settled yet, and nothing revisits this attach. Record it + // so the settled pass can retry it once the batch is done. if prefix_carries_no_owner_information(&prefix_type) { analyzer .context @@ -577,7 +594,11 @@ fn set_index_expr_owner(analyzer: &mut LuaAnalyzer, var_expr: LuaVarExpr) -> Opt )); } Err(reason) => { - // Defer member with unresolvable prefix via unresolve. + // Every other branch above reaches + // `apply_index_expr_member_owner`, which *creates* the + // `LuaMember` and then attaches it. This branch cannot: the + // prefix is not inferable yet, so there is no owner to attach + // to. let unresolve_member = UnResolveMember { file_id: analyzer.file_id, member_id: LuaMemberId::new(var_expr.get_syntax_id(), analyzer.file_id), @@ -594,9 +615,17 @@ fn set_index_expr_owner(analyzer: &mut LuaAnalyzer, var_expr: LuaVarExpr) -> Opt Some(()) } -/// Whether prefix type carries no owner information. +/// Whether a prefix type says nothing about which table a member write lands on. +/// +/// Distinguishes "this prefix has no owner" (a number, a string — a real answer) +/// from "this prefix has not settled yet", which is the only case worth retrying +/// after the batch is done. fn prefix_carries_no_owner_information(prefix_type: &LuaType) -> bool { match prefix_type { + // `table` belongs here for the same reason `any` does: it names no + // element, so nothing can attach through it. It is also what a slot + // collapses to while a writer's literal is still being widened against + // siblings the walk has not reached, which is a property of the batch. LuaType::Unknown | LuaType::Any | LuaType::Table => true, LuaType::Union(union) => union.types().all(|arm| { matches!( @@ -808,7 +837,10 @@ fn apply_index_expr_member_owner_with_guarded( } let member_index = analyzer.db.get_member_index_mut(); member_index.add_member(member_owner, member); - // Set scope for non-FileDefine members. + // `add_member` already records the enclosing function scope for + // `FileDefine` index-expr members (via + // `assignment_file_define_scope_for_member`). For other features + // (e.g. `MetaDefine`) it stores `None`, so set the real scope here. if !matches!(decl_feature, LuaMemberFeature::FileDefine) { let function_scope = member_index .enclosing_function_scope_range(analyzer.file_id, member_id.get_position()); @@ -894,6 +926,10 @@ pub fn analyze_assign_stat(analyzer: &mut LuaAnalyzer, assign_stat: LuaAssignSta let type_owner = get_var_owner(analyzer, var.clone()); + // A local reassigned from a multi-declaration global field read has the + // same batch-order exposure as a local *initialized* from one: the walk + // answers it from whichever backing tables it had reached. Record it so + // the settled pass re-derives it against the complete set. if let LuaTypeOwner::Decl(decl_id) = &type_owner && initializer_reads_through_multi_decl_global(analyzer, expr) { @@ -1010,6 +1046,8 @@ pub fn analyze_assign_stat(analyzer: &mut LuaAnalyzer, assign_stat: LuaAssignSta } } // Reading an undefined global yields `nil` at runtime, so the + // assignment target's value is `nil` (not unknown). This mirrors + // the local-stat path above so hover/inference stays consistent. Err(InferFailReason::None) => { if should_defer_none_infer_expr(expr) { add_unresolve_for_assignment( @@ -1357,6 +1395,11 @@ fn should_skip_nil_table_shape_assignment( return false; }; + // A prefix that has not settled yet cannot answer this, and the write is a + // delete either way: `t[k] = nil` removes an entry, it never adds a member + // typed `nil`. A receiver typed by a `fun(self: T)` callback slot is still + // `unknown` while its file is walked, so a member attached here would land + // on the slot every closure filling it shares. if matches!(prefix_type, LuaType::Unknown | LuaType::Never) { return true; } @@ -1528,9 +1571,21 @@ fn reads_global_name(analyzer: &LuaAnalyzer, expr: &LuaExpr) -> bool { .is_none() } +/// Whether the initializer reads through a global whose root name has more than +/// one declaration — the `X = X or {}` per-realm bootstrap whose backing tables +/// the walk merges incrementally. Recurses index/call prefixes and operator +/// operands so a member path (`cityrp.presidential.Taxes`) or an arithmetic read +/// (`... / 100`) is caught, not only a bare `local x = cityrp`. +/// Record the current file if any `panel:GetParent()` read in it fell back to +/// the broad `Panel` type because the vgui parent chain was not complete. The +/// chains finish in the gmod-post pass; the fallback set accumulates over the +/// file walk, so a later statement is enough to flag the file for re-derivation. fn note_vgui_parent_fallback_file(analyzer: &mut LuaAnalyzer) { let file_id = analyzer.file_id; let cache = analyzer.context.infer_manager.get_infer_cache(file_id); + // Chain-derived successes are as batch-sensitive as fallbacks: the chain a + // read went through can be one the final chain state contradicts, so both + // kinds flag the file for the settled re-derivation. let has_chain_read = !cache.vgui_parent_fallback_calls.is_empty() || !cache.vgui_parent_chain_calls.is_empty(); if has_chain_read { @@ -1549,6 +1604,11 @@ fn initializer_reads_through_multi_decl_global(analyzer: &LuaAnalyzer, expr: &Lu .is_some_and(|decl_ids| decl_ids.len() > 1) } +/// The root global name a *field read* is rooted at (`cityrp` for +/// `cityrp.presidential.Taxes`), or `None` if it is not a field read rooted at a +/// global. A call is deliberately not followed: `cityrp.player.get(x)` returns +/// whatever the callee returns, not a field off the merged backing tables, so +/// re-deriving it against the complete set is neither needed nor sound. fn global_read_root_name(analyzer: &LuaAnalyzer, expr: &LuaExpr) -> Option { match expr { LuaExpr::NameExpr(name_expr) => reads_global_name(analyzer, expr) @@ -1679,6 +1739,15 @@ fn should_defer_pending_local_alias( } /// Whether `expr` reads out of `decl_id` itself: the `x = x.field` shape, +/// and the same read buried in an operand or call argument (`width = +/// bit.bor(bit.lshift(width:byte(1), 24), ...)`). Depth does not change the +/// self-contradiction — the value still cannot be the decl's lifetime type, +/// because it was computed from a read that type would reject. +/// Whether `expr` is the default-value idiom for `decl_id` — `p = p or DEFAULT`. +/// +/// The result always includes the declaration's own type, so unlike a plain +/// reassignment it refines the declaration rather than replacing it, and is the +/// one body write a parameter may take its type from. pub(crate) fn expr_fills_own_default( db: &DbIndex, file_id: crate::FileId, @@ -1780,6 +1849,10 @@ fn add_unresolve_for_assignment( match type_owner { LuaTypeOwner::Decl(decl_id) => { // A read out of the decl being assigned (`limit = + // limit.maximum`) must not queue a deferred write. The decl + // slot is empty until one of the file's deferred writes + // resolves, and `bind_type` has no acceptance rule for an empty + // slot, so whichever lands first owns the decl's lifetime type. if expr_reads_out_of_decl(analyzer.db, analyzer.file_id, decl_id, &expr) { return; } @@ -1813,6 +1886,13 @@ fn add_unresolve_for_assignment( prefix, ret_idx: 0, }; + // The deferred write resolves against whatever the index held + // when its retry ran, and an attempt that reaches `unknown` + // succeeds: the item retires and the placeholder becomes the + // member's final type. Whether the retry was early or late is a + // property of batch order, not of the source, so record the + // member for the settled re-infer in + // `refresh_member_initializer_caches`. analyzer .context .request_member_initializer_reinfer(member_id); @@ -1837,6 +1917,12 @@ fn assign_merge_type_owner_and_expr_type( expr_type = multi.get_type(idx).unwrap_or(&LuaType::Nil).clone(); } + // A self-referential guarded bootstrap (`x.y = x.y or {}`) assigns its + // own `{}` whatever the self-read resolves to, which is what + // `special_or_rule` folds it to. The inferred expression type can still + // carry that self-read when it was memoised before the fold ran, and + // what the read resolved to is whichever sibling file the batch + // analysed first. if let LuaTypeOwner::Member(member_id) = &type_owner && let Some(bootstrap_type) = guarded_table_bootstrap_member_type(analyzer.db, *member_id, true) @@ -1844,7 +1930,11 @@ fn assign_merge_type_owner_and_expr_type( expr_type = bootstrap_type; } - // Skip widening when all writers are guarded bootstraps. + // Where every writer of this member is a `x.y = x.y or {}` guard they all + // name one table, so there are no competing writes to merge — each writer + // resolves to the earliest one's literal and the sibling widening is + // skipped. Widening them against each other unions two literals into a bare + // `table`, which drops the members another file attached to the namespace. let canonical_guarded_bootstrap = match &type_owner { LuaTypeOwner::Member(member_id) => { canonical_guarded_table_bootstrap_type(analyzer.db, *member_id) @@ -1852,12 +1942,21 @@ fn assign_merge_type_owner_and_expr_type( _ => None, }; - // Preserve table literals for guarded bootstrap members. + // A repeated `x.y = x.y or {}` guard names one table, however many files + // open with it: each writer means "reuse it if it is there". Widening those + // literals against each other answers `table`, which drops whatever another + // file attached to it — so the guard has to preserve them here too, the same + // way the contribution record below already reads it. let preserve_table_literals = preserve_table_literals || matches!(&type_owner, LuaTypeOwner::Member(member_id) if is_guarded_table_assignment_member(analyzer.db, *member_id)); - // Preserve literals for plain writers sharing guarded slot. + // A plain writer that shares the slot has to preserve them too: widening + // `self.x = {}` against a `self.x = self.x or {}` in another file answers + // `table`, and the guard's literal is then gone for every reader — + // including the writes that attach members through it. This says nothing + // about *this* write being a guarded one, so it feeds the widening decision + // alone and not the classification below. let preserve_sibling_table_literals = preserve_table_literals || matches!(&type_owner, LuaTypeOwner::Member(member_id) if slot_has_guarded_table_bootstrap(analyzer.db, *member_id)); @@ -1889,6 +1988,10 @@ fn assign_merge_type_owner_and_expr_type( } Some(None) => {} None => { + // Whether every sibling writer already carried a type is a + // property of how far the batch has run, not of the source. + // Where one did not, the merge below is provisional and the + // settled pass re-derives it against the complete writer set. let mut skipped_uncached_sibling = false; let widened = get_widened_member_assignment_type( analyzer.db, @@ -1897,6 +2000,10 @@ fn assign_merge_type_owner_and_expr_type( preserve_sibling_table_literals, &mut skipped_uncached_sibling, ); + // Recorded on the skip, not on the answer: a walk that read no + // sibling type declines to widen at all, and that write needs + // the settled re-derivation just as much as one that widened + // from a partial set. if skipped_uncached_sibling && let LuaTypeOwner::Member(member_id) = &type_owner { analyzer.context.record_settled_member_widening_candidate( *member_id, @@ -1909,6 +2016,11 @@ fn assign_merge_type_owner_and_expr_type( } } } + // A table literal written into a slot another file bootstraps with + // `x.y = x.y or {}` keeps its own literal — but whether that sibling had + // been indexed when the walk asked is a property of the walk order. Where + // the literal was widened away, queue it so the settled pass can ask + // again against the whole writer set. if let LuaTypeOwner::Member(member_id) = &type_owner && matches!(source_type, Some(LuaType::TableConst(_))) && matches!(expr_type, LuaType::Table) @@ -1949,6 +2061,11 @@ fn assign_merge_type_owner_and_expr_type( let guarded_table_assignment = preserve_table_literals || is_guarded_table_assignment_member(analyzer.db, *member_id); if guarded_table_assignment { + // Whichever canonical writer this found — including none at all — it + // read the sibling set off a half-built owner index, and which + // writers are visible there is a property of how far the batch has + // got. Re-derived once they have all landed and been migrated to + // their final owner. See `resettle_guarded_table_bootstraps`. analyzer .context .record_settled_guarded_bootstrap_candidate(*member_id); @@ -2134,6 +2251,14 @@ fn get_cached_widened_member_assignment_type( visible_count, ) { WideningCacheLookup::FirstSighting => { + // Being the only writer the owner can currently see is a statement + // about how far the batch has run: until the global this member + // hangs off resolves, its siblings sit on the global path instead + // and are invisible here. Re-derived once they have all been + // migrated to their owner. + // + // Only a named slot: a key the source writes as an expression names + // one entry of a collection, and those never migrate as a group. if matches!(cache_key.key, LuaMemberKey::Name(_)) { analyzer.context.record_settled_member_widening_candidate( *member_id, @@ -2172,7 +2297,8 @@ fn get_cached_widened_member_assignment_type( } } -/// Record member assignment contribution. +/// Stores this write's own evidence so the settled re-derivation can merge the +/// complete writer set. See [`MemberAssignmentContribution`]. fn record_member_assignment_contribution( analyzer: &mut LuaAnalyzer, member_id: LuaMemberId, @@ -2215,7 +2341,15 @@ fn record_member_assignment_contribution_in( .record_member_assignment_contribution(member_id, contribution); } -/// Record contribution for resolved member assignment. +/// Records the evidence of an assignment whose value only resolved after the +/// walk had moved on. +/// +/// The walk records a contribution as it binds each write, so a write whose +/// right-hand side deferred contributes nothing and the settled merge never +/// sees it. Whether a write deferred is a fact about how far the batch had +/// run - a re-index keeps out-of-batch types standing and resolves inline what +/// a cold build had to defer - so the writer set the merge reads would +/// otherwise differ between the two. pub(in crate::compilation::analyzer) fn record_resolved_member_assignment_contribution( db: &mut DbIndex, member_id: LuaMemberId, @@ -2243,7 +2377,16 @@ pub(in crate::compilation::analyzer) fn record_resolved_member_assignment_contri ); } -/// Apply visibility marks for resolved member assignment. +/// Applies the visibility marks a write earns from its own syntax, for a write +/// the walk did not get to classify. +/// +/// The walk marks each assignment as it binds it — guarded bootstrap, or +/// conditional branch — and those marks decide which writers stay visible for +/// the slot. A write whose right-hand side could not be inferred in time is +/// finished by the unresolve pass instead, which binds the type and stops, so +/// the marks are never applied. Both tests read syntax alone, so the answer is +/// the same either way; only whether anything asks was in question, and that is +/// a fact about how far the batch had run. pub(in crate::compilation::analyzer) fn mark_resolved_member_assignment( db: &mut DbIndex, member_id: LuaMemberId, @@ -2326,6 +2469,22 @@ pub(in crate::compilation::analyzer) fn preserve_guarded_table_assignment_member } /// Returns true when the assignment that introduced this member sits inside a +/// branching construct (if / while / repeat / for). In those cases we must not +/// collapse to a single "latest write" member, because the assignments in +/// sibling branches (or earlier loop iterations) are not dominated by this one +/// and their types must remain available so reads can union them. +/// +/// Without this guard, a pattern like +/// +/// ```lua +/// if cond then +/// obj.field = Vector(...) +/// else +/// obj.field = nil +/// end +/// ``` +/// +/// would silently drop the `Vector` branch and hover `obj.field` as just `nil`. fn is_member_assignment_in_conditional_branch(db: &DbIndex, member_id: LuaMemberId) -> bool { let Some(tree) = db.get_vfs().get_syntax_tree(&member_id.file_id) else { return false; @@ -2404,6 +2563,14 @@ fn assigns_bare_table_literal(db: &DbIndex, member_id: LuaMemberId) -> bool { } /// Every writer of this member's slot, when at least one of them bootstraps it +/// with `x.y = x.y or {}`. +/// +/// The guard means "reuse it if it is there", and a plain `x.y = {}` resets that +/// same table, so every writer names one runtime table however many literals the +/// source spells. Returning them together is what lets the slot hold a single +/// identity: treating a plain writer as a rival definition forks it, the merge +/// of the forks answers bare `table`, and `table` names no element — so every +/// member attached through the slot is lost. fn guarded_table_assignment_member_ids_for_owner_key( db: &DbIndex, member_id: LuaMemberId, @@ -2420,6 +2587,8 @@ fn guarded_table_assignment_member_ids_for_owner_key( bootstrapped = true; } else if !assigns_bare_table_literal(db, related_member_id) { // This writer contributes something that is not a fresh table -- a + // class, a call result -- so the slot really can hold more than one + // thing and there is no single identity to resolve to. return None; } @@ -2468,6 +2637,14 @@ pub(in crate::compilation::analyzer) fn get_widened_member_assignment_type( if related_member_id == *member_id { continue; } + // Only writers that come before this one are evidence for it. The walk + // otherwise settles that with "the sibling already has a type cache", + // which reports how far the batch has run rather than anything about + // the source: a re-index clears the batch's caches and leaves the rest + // standing, so the same sibling counts on one run and not on another. + // Reading the order off the source makes the set identical on both, + // and it is the rule the settled re-derivation already applies - a + // later write must not widen the type it is itself checked against. if !preserve_table_literals && member_id_sort_key(related_member_id) >= member_id_sort_key(*member_id) { @@ -2521,6 +2698,12 @@ pub(in crate::compilation::analyzer) fn get_widened_member_assignment_type( previous_states.iter(), ) } + // Only reachable once a preceding writer has been seen but every one of + // them was skipped for having no type yet: siblings exist, and not one + // of them is evidence. Widening the literal here guesses at writers this + // pass has not read, and how many it has read is how far the batch has + // run, not anything about the source. Leave the write as it stands and + // let the settled re-derivation decide against the complete set. MemberAssignmentWideningDecision::NoPreviousAssignments => return None, }; @@ -2552,6 +2735,17 @@ pub(super) fn flush_pending_dynamic_key_collection_widenings(analyzer: &mut LuaA } /// Whether a runtime write may give the class it names a field that class never +/// declares. +/// +/// A write through a reference only *names* the class; the value it runs on is +/// one instance of it. When a subclass already declares the same field, the +/// write is evidence about that subclass, not about the class it was typed +/// through -- and giving the base the field hands it to every subclass, which +/// hides the declarations and stops any receiver narrowing to the subclass that +/// really owns it. +/// +/// The subtype walk is not cheap, so it only runs for a write that would open a +/// key the owner does not already hold. fn write_may_declare_on_owner(db: &crate::DbIndex, member_id: LuaMemberId) -> bool { let member_index = db.get_member_index(); let Some(LuaMemberOwner::Type(owner_id)) = member_index.get_member_owner(&member_id).cloned() @@ -2571,6 +2765,9 @@ fn write_may_declare_on_owner(db: &crate::DbIndex, member_id: LuaMemberId) -> bo return true; } // Asked from the declaring side rather than by enumerating subtypes: + // collecting the subtypes of a base rescans the type index once per level + // of the hierarchy, while the types that declare this key at all are few and + // each answers with one walk up its own supers. let type_index = db.get_type_index(); !type_index.get_all_types().into_iter().any(|type_decl| { let candidate_id = type_decl.get_id(); @@ -2643,6 +2840,12 @@ fn guarded_bootstrap_range_for_node( } /// Range of the table a field of a guarded table literal names. +/// +/// `x = x or { y = {} }` creates `x.y` on exactly the condition `x.y = x.y or +/// {}` does — the namespace not existing yet — so the two shapes bootstrap the +/// same slot and have to resolve to one literal between them. Treating only the +/// second as a guarded writer leaves the first looking like a plain write, which +/// makes the whole slot ineligible for canonicalisation. fn guarded_table_literal_field_range( table_field: &LuaTableField, empty_only: bool, @@ -2669,6 +2872,8 @@ fn guarded_table_literal_field_range( } /// Range of the table arm of a self-referential guarded bootstrap (`x.y = +/// x.y or {}`), which is what the assignment's type is when the guard falls +/// through. fn guarded_table_assignment_bootstrap_range( index_expr: &LuaIndexExpr, empty_only: bool, @@ -2777,6 +2982,20 @@ fn guarded_table_bootstrap_range( ) } +/// The one table a repeated `x.y = x.y or {}` guard names. +/// +/// Every such writer means "reuse it if it is there", so at runtime they are all +/// the same table and only the first to run creates it. Giving each writer its +/// own literal instead makes a file that re-guards the namespace read its own +/// empty table and lose whatever another file attached, so they resolve to the +/// earliest writer's literal — the one that would have won at runtime. +/// Re-derives the literal each `x.y = x.y or {}` writer names, now that every +/// writer of the slot has landed. +/// +/// The canonical pick is the lowest-sorting writer, so a writer analysed before +/// its siblings existed either found no canonical at all (fewer than two were +/// indexed) or picked one that a later, lower-sorting writer displaces. Which of +/// those happened is a property of the batch, not of the source. pub(in crate::compilation::analyzer) fn resettle_guarded_table_bootstraps( db: &mut DbIndex, candidates: Vec, @@ -2817,6 +3036,9 @@ pub(in crate::compilation::analyzer) fn resettle_guarded_table_bootstraps( continue; }; // Every writer of the slot, not only the ones queued as candidates: the + // slot holds one table, so a writer left on its own literal forks the + // identity again, and whether it was queued depends on how far the walk + // had got when it ran. let members = guarded_table_assignment_member_ids_for_owner_key(db, first).unwrap_or(members); for member_id in members { @@ -2972,6 +3194,10 @@ fn register_expr_key_member(analyzer: &mut LuaAnalyzer, field: &LuaTableField) { } /// Whether this value-field (positional `{ expr }`) belongs to a shaped +/// sequential table literal whose integer members were registered in the +/// declaration pass (see `analyze_table_expr`). Such members need their value +/// types inferred and bound here, exactly like keyed/assign fields, otherwise +/// the registered `[n]` member has no type cache and dynamic indexing degrades. fn is_shaped_array_value_field(field: &LuaTableField) -> bool { field.is_value_field() && field @@ -3077,6 +3303,10 @@ fn special_assign_pattern( } // Register inferred string default for `x = x or "literal"`. + // This is a SIBLING branch to the table-guard path: only fires + // when the RHS is NOT a TableExpr and IS a string literal, + // and the type_owner is a plain Decl. Completely disjoint from + // the table-guard path. if !guarded_table_expr { if let LuaTypeOwner::Decl(decl_id) = &type_owner { if let Some(string_value) = extract_string_literal_from_expr(&right) { @@ -3285,6 +3515,12 @@ fn get_delayed_definition_decl_id( } /// Returns `true` when `expr` is a bare `NameExpr` that resolves to neither a +/// local declaration nor a registered global. Such reads evaluate to `nil` at +/// runtime, but `infer_expr` reports them as `Unknown` (see +/// `semantic/infer/mod.rs` where `InferFailReason::None` is collapsed to +/// `Ok(LuaType::Unknown)`). Callers use this to substitute `Nil` when binding +/// the LHS of a local/assign/table-field declaration so hover and downstream +/// inference reflect the runtime value. fn is_undefined_global_name_expr(analyzer: &LuaAnalyzer, expr: &LuaExpr) -> bool { let LuaExpr::NameExpr(name_expr) = expr else { return false; @@ -3306,6 +3542,9 @@ fn is_undefined_global_name_expr(analyzer: &LuaAnalyzer, expr: &LuaExpr) -> bool return false; } // Workspace-scoped lookup matches the diagnostic's own visibility check + // (see `diagnostic/checker/undefined_global.rs`). With multi-workspace + // isolation enabled, a global declared in another root must not "rescue" + // an undefined read in the current root. let module_index = analyzer.db.get_module_index(); let global_index = analyzer.db.get_global_index(); let has_global = if let Some(ws_id) = module_index.get_workspace_id(analyzer.file_id) { @@ -3333,6 +3572,8 @@ mod tests { } /// A sibling assignment that has not been analysed carries no type cache, so + /// the cross-file merge can only keep it by deriving its type from syntax. + /// Only the self-referential bootstrap has a syntax-determined type. #[test] fn guarded_table_bootstrap_range_names_only_the_self_referential_arm() { let source = "lib.store = lib.store or {}\nlib.other = fetch() or {}\n"; diff --git a/crates/glua_code_analysis/src/lib.rs b/crates/glua_code_analysis/src/lib.rs index 4f02adb71..cf04d2b6b 100644 --- a/crates/glua_code_analysis/src/lib.rs +++ b/crates/glua_code_analysis/src/lib.rs @@ -102,6 +102,13 @@ fn hash_member_owner_stable( tid.get_name().hash(hasher); } LuaMemberOwner::Element(range) => { + // Named by its anchor where it has one, and by its file plus + // ordinal otherwise - see `ExportIdentities::table_identity`. + // + // Deliberately *not* the identity `TableConst` uses. A member's + // owner is the one place where the resolver's choice between two + // files' literals for a single logical table would otherwise read + // as an export change on every edit. "Element".hash(hasher); ids.table_identity(range).hash(hasher); } @@ -135,7 +142,17 @@ fn hash_lua_member_key_export( } } -/// Offset-free identities for types. +/// Offset-free identities for the things a type can point at. +/// +/// A signature id and a table literal's range are both a file plus a position, +/// and the position moves whenever an edit shifts the file. Hashing the +/// position reports an export change for every edit; hashing only the file +/// makes *repointing* an export at a different function or literal in the same +/// file invisible. The index among the file's signatures, or among its table +/// literals, is stable under a shift and still tells the two apart. +/// +/// Built per file on first use, because a fingerprint usually reaches only a +/// handful of files. struct ExportIdentities<'a> { db: &'a DbIndex, signature_ordinals: std::cell::RefCell>>, @@ -171,7 +188,19 @@ impl<'a> ExportIdentities<'a> { positions.binary_search(&id.get_position()).ok() } - /// Returns stable identity for a table literal. + /// What a table literal is called, for the purpose of deciding whether an + /// export changed. + /// + /// The anchor, when the literal has one: a name like `cityrp.configuration` + /// identifies the *logical* table, and several files can declare a literal + /// for it. Which of those the resolver picks as a member's owner is not + /// stable across a partial re-index, so keying on the literal's file and + /// position makes an unrelated edit look like an export change. The anchor + /// is the same whichever literal wins. + /// + /// Falls back to file plus ordinal for a literal no name reaches - still + /// enough to tell two literals in one file apart, which is what a member + /// moving between them needs. fn table_identity(&self, range: &InFiled) -> String { let file_id = range.file_id; let mut cache = self.table_anchors.borrow_mut(); @@ -232,8 +261,16 @@ fn hash_generic_param_export( } } -/// Hashes a type for export comparison. +/// Hashes everything about a type that another file can observe. +/// +/// Only identity that is derived from a source position is normalised away: +/// a table literal's range, an instance's range and a signature's id all move +/// whenever an edit shifts offsets, and are re-homed by the remap pass rather +/// than by a re-index, so hashing them would report an export change for every +/// edit. Values, shapes and names are kept: they are what a dependent reads. fn hash_lua_type_export(ids: &ExportIdentities, typ: &LuaType, hasher: &mut impl Hasher) { + // Arm order is not guaranteed for the set-like composites, so their arm + // hashes are sorted before they are folded in. fn hash_unordered( ids: &ExportIdentities, tag: &str, @@ -270,6 +307,9 @@ fn hash_lua_type_export(ids: &ExportIdentities, typ: &LuaType, hasher: &mut impl "BooleanConst".hash(hasher); b.hash(hasher); } + // The range is the literal's identity, and it moves on any offset + // shift. The file it lives in does not, and is enough to tell one + // file's literal from another's. LuaType::TableConst(range) => { "TableConst".hash(hasher); range.file_id.hash(hasher); @@ -281,6 +321,8 @@ fn hash_lua_type_export(ids: &ExportIdentities, typ: &LuaType, hasher: &mut impl ids.table_ordinal(inst.get_range()).hash(hasher); hash_lua_type_export(ids, inst.get_base(), hasher); } + // The id is a file plus a position. The signature's own shape is + // hashed by the signature section of the file fingerprint. LuaType::Signature(id) => { "Signature".hash(hasher); id.get_file_id().hash(hasher); @@ -443,11 +485,17 @@ fn hash_lua_type_export(ids: &ExportIdentities, typ: &LuaType, hasher: &mut impl None => "NoConstraint".hash(hasher), } } + // The remaining variants hold no nested type and no source position, + // so their `Debug` form describes them precisely and stably. other => format!("{:?}", other).hash(hasher), } } -/// Export key for a semantic declaration. +/// A name another file can resolve for a documented symbol, or `None` when +/// nothing outside this file can name it. +/// +/// Every `LuaSemanticDeclId` variant is a file plus a position, and the +/// position moves on any edit above it, so the name is what gets hashed. fn semantic_decl_export_key(ids: &ExportIdentities, id: &LuaSemanticDeclId) -> Option { let db = ids.db; match id { @@ -466,6 +514,10 @@ fn semantic_decl_export_key(ids: &ExportIdentities, id: &LuaSemanticDeclId) -> O } Some(format!("M:{:x}", hasher.finish())) } + // A signature has no name of its own; it is reached through the decl + // or member that holds it, and its own shape is hashed by the + // signature section. Its index among the file's signatures identifies + // it without a byte position, which would move on any edit above it. LuaSemanticDeclId::Signature(signature_id) => { let mut file_signatures: Vec<_> = db .get_signature_index() @@ -482,7 +534,13 @@ fn semantic_decl_export_key(ids: &ExportIdentities, id: &LuaSemanticDeclId) -> O } } -/// Hash of a file's cross-file-visible exports. +/// Hash of the cross-file-visible exports a single file contributes. +/// +/// Used to decide whether a re-index of this file can affect any other file. +/// Local-only state (locals, their inferred types, flow facts) is intentionally +/// excluded - those are not observable cross-file, so an edit that only touches +/// them does not require a dependency ripple, no matter how large that file's +/// fan-in would be under the old file-level expansion. pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { let mut hasher = rustc_hash::FxHasher::default(); let ids = &ExportIdentities::new(db); @@ -540,10 +598,18 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { for decl_id in decl_ids_sorted { decl_id.get_name().hash(&mut hasher); if let Some(type_decl) = db.get_type_index().get_type_decl(&decl_id) { + // An alias is read by name and resolved to its target, so + // changing the target changes what every file that names it + // infers. match type_decl.get_alias_ref() { Some(alias_ref) => hash_lua_type_export(ids, alias_ref, &mut hasher), None => "NoAlias".hash(&mut hasher), } + // Kind and flags live only on the declaration, so nothing else + // in this file moves when one changes - yet `(exact)` decides + // whether another file's write creates a member on this type, + // and `(partial)`/`(private)` gate diagnostics other files + // report. let (kind, flags) = type_decl.kind_and_flags(); format!("{kind:?}").hash(&mut hasher); flags.hash(&mut hasher); @@ -572,7 +638,15 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- Exported type caches (global/member decls, not locals) --- + // Keyed by the name a dependent resolves rather than by the owner's + // offset. Every `LuaTypeOwner` variant carries a source position, and an + // edit anywhere above one shifts it, so hashing the position reports an + // export change for every edit that is not at the very end of the file. if let Some(owners) = db.get_type_index().file_type_owners(file_id) { + // Sorted by name then source order. The position orders the entries but + // is never hashed: two declarations of the same name in one file are + // distinguished by which type each holds, and swapping them has to be + // visible, but the offsets themselves move on any edit above. let mut entries: Vec<(String, u32, u64)> = Vec::new(); for owner in owners.iter() { let key = match owner { @@ -597,6 +671,8 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } format!("M:{:x}", h.finish()) } + // The cached type of a bare expression. No other file can name + // one, so it is local memoisation rather than an export. LuaTypeOwner::SyntaxId(_) => continue, }; let owner_position = match owner { @@ -637,6 +713,9 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { None => "NoConstraint".hash(&mut hasher), } } + // A caller reads the declared parameter and return types, so a + // `@param`/`@return`/`@overload` edit is an export change even + // though it leaves the arity alone. let mut param_indices: Vec<&usize> = sig.param_docs.keys().collect(); param_indices.sort_unstable(); for idx in param_indices { @@ -660,6 +739,9 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { for overload in &sig.overloads { hash_lua_type_export(ids, &LuaType::DocFunction(overload.clone()), &mut hasher); } + // Caller-side narrowing facts derived from the body. A caller + // in another file reads them, and an edit can change one while + // leaving the declared parameters and returns alone. format!("{:?}", sig.require_guard_param()).hash(&mut hasher); sig.nil_return_guard_params().hash(&mut hasher); format!("{:?}", sig.return_correlations()).hash(&mut hasher); @@ -681,6 +763,10 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- Parameter types this file's call sites are evidence for --- + // A call argument here is the only evidence an unannotated parameter in + // another file has, and `expand_reindex_file_ids` already treats call + // sites as producing dependents. Without this the fingerprint would call + // an argument change local and never ripple it to the callee. { let contributed = db .get_call_site_param_index() @@ -689,6 +775,8 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { .iter() .map(|((signature_id, param_idx), typ)| { let mut h = rustc_hash::FxHasher::default(); + // The signature's position moves on any edit to its own file; + // the file it lives in and the parameter index do not. signature_id.get_file_id().hash(&mut h); param_idx.hash(&mut h); hash_lua_type_export(ids, typ, &mut h); @@ -704,10 +792,16 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { .get_signature_index() .inferred_guard_facts_for_files(&HashSet::from([file_id])); if !guard_facts.is_empty() { + // Sorted on the same total key the rest of the analyzer uses. A path + // alone is not total: the standard `if SERVER` pattern gives one path + // two owners that differ only by realm, and ordering them by path + // leaves the fold order to hash-map iteration. let mut guard_owners: Vec<_> = guard_facts.keys().cloned().collect(); sort_inferred_guard_owners(&mut guard_owners); for owner in guard_owners { owner.path().hash(&mut hasher); + // The realm the guard applies in is part of what a caller reads, + // and the same path can hold a different guard per realm. format!("{:?}", owner.state_mask()).hash(&mut hasher); owner.source_file_id().hash(&mut hasher); if let Some(guard) = guard_facts.get(&owner) { @@ -718,6 +812,13 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- Annotations on this file's symbols that other files act on --- + // A `@deprecated`, `@private` or `@export` on an exported symbol changes + // the diagnostics every call site in every other file reports. + // + // The free-text description and source are deliberately excluded: a hover + // in another file reads them from this index when the request arrives, so + // no dependent holds a copy that could go stale, and a doc-comment edit on + // a hub file would otherwise pay a full ripple for text nothing caches. { let default = LuaCommonProperty::new(); let default_property = format!( @@ -737,6 +838,8 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { .into_iter() .filter_map(|(owner, property)| { let key = semantic_decl_export_key(ids, owner)?; + // None of these hold a source position, so their `Debug` form + // describes them precisely and stably. let acted_on = format!( "{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}", property.visibility, @@ -745,9 +848,18 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { property.decl_features, property.version_conds, property.attribute_uses, + // Gates whether a field counts as required, and the + // missing-field diagnostic is reported by the file that + // builds the table, not the one that declares the class. property.default_value, + // Carries the GMod tag payloads (`@accessorfunc` and + // friends) that other files' call analysis reads. property.tag_content, ); + // Writing a doc comment creates a property whose acted-on + // fields are all still default. Registering it would make + // documenting a symbol an export change, which is the case + // excluding the description is meant to avoid. (acted_on != default_property).then_some((key, acted_on)) }) .collect(); @@ -756,6 +868,8 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- Metamethods this file declares --- + // Another file's inference reads these whenever it applies an operator to + // the owning type. { let mut operators: Vec = db .get_operator_index() @@ -763,6 +877,8 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { .into_iter() .map(|operator| { let mut h = rustc_hash::FxHasher::default(); + // A table owner is a literal's range, which moves on any edit + // above it, so it is identified the same way a `TableConst` is. match operator.get_owner() { LuaOperatorOwner::Table(range) => { "Table".hash(&mut h); @@ -775,6 +891,8 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } } format!("{:?}", operator.get_op()).hash(&mut h); + // The operator's own range moves on any edit above it; what a + // dependent reads is the function it resolves to. hash_lua_type_export(ids, &operator.get_operator_func(db), &mut h); h.finish() }) @@ -784,6 +902,13 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- Network flows this file declares --- + // Network diagnostics compare a message's writes against its reads across + // files, so changing either half is an export change. + // + // Field by field: `NetSendFlow`, `NetReceiveFlow` and `NetOpEntry` all + // carry the source range of the call they came from, and those move on + // every edit above them. What the peer file's diagnostic reads is the + // message name and the ordered sequence of operations. if let Some(network) = db.get_gmod_network_index().get_file_data(file_id) { fn hash_ops(ops: &[NetOpEntry], hasher: &mut impl Hasher) { for entry in ops { @@ -818,6 +943,9 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- Metatable bindings this file declares --- + // `setmetatable(t, mt)` is read by every file that resolves a member + // through `t`. Both halves are table literals, and repointing one at a + // different literal in the same file moves no member, type or signature. { let metatable_index = db.get_metatable_index(); let mut bindings: Vec<(usize, u32, usize)> = Vec::new(); @@ -842,6 +970,10 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- The realm each exported symbol is declared in --- + // Realm is first-class: wrapping an existing definition in `if SERVER` + // changes which callers may reach it and which realm-mismatch diagnostics + // other files report, while leaving its name, type and signature alone. + // The offset is used only to look the realm up, never hashed. { let gmod_infer = db.get_gmod_infer_index(); let mut realms: Vec<(String, String)> = Vec::new(); @@ -868,6 +1000,10 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { realms.hash(&mut hasher); if let Some(metadata) = gmod_infer.get_realm_file_metadata(&file_id) { + // Field by field, because `branch_realm_ranges` carries the source + // ranges of the `if CLIENT`/`if SERVER` blocks, and those move on + // every edit above them. Which realms the file narrows to is the + // part another file can observe; where the braces sit is not. format!( "{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}", metadata.inferred_realm, @@ -889,6 +1025,10 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- What this file exports as a module --- + // `local M = {} ... return M` makes the returned table the module's export + // type, which every `require`/`include` consumer reads. The local itself is + // skipped by the type-cache section, so returning a different table moves + // nothing else. if let Some(module) = db.get_module_index().get_module(file_id) { module.full_module_name.hash(&mut hasher); module.visible.hash(&mut hasher); @@ -910,6 +1050,9 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { } // --- Load edges this file declares --- + // Adding or removing an `include`/`require` changes which files load this + // one and in what order, which realm and load-order analysis both read. + // No member, type or signature moves when it happens. { let dependency_index = db.get_file_dependencies_index(); let mut sites: Vec = dependency_index @@ -917,6 +1060,8 @@ pub(crate) fn file_export_fingerprint(db: &DbIndex, file_id: FileId) -> u64 { .unwrap_or_default() .iter() .map(|site| { + // The call's range is left out: it moves on any edit above it, + // and the edge is identified by its target and kind. format!( "{:?}|{:?}|{:?}|{}", site.kind, site.target_file_id, site.path, site.original_expr @@ -1051,7 +1196,19 @@ fn call_resolves_to_inferred_guard_owner( semantic::get_prefix_expr_signature_id(db, cache, &call) == Some(owner.signature_id()) } -/// True when `call_expr` is an annotated net operation. +/// True when `call_expr` calls an annotated net operation — a message start, a +/// send terminator, or a payload write/read. +/// +/// Shares the analyzer's resolution path, so an alias, a local binding, or an +/// annotated wrapper is classified identically to the `net.*` builtin. Editor +/// handlers should use this instead of re-deriving the answer, and instead of +/// consulting the flow index: an op that forms no complete flow (a bare +/// `net.WriteString` with no `net.Start`) is still a net op and is never +/// recorded in the flow index. +/// +/// `cache` is supplied by the caller because classifying a document means asking +/// this for every call in it, and building an inference cache per question would +/// throw away all reuse between them. pub fn call_expr_is_net_op( db: &DbIndex, cache: &mut LuaInferCache, @@ -1094,7 +1251,11 @@ pub async fn fetch_schema_urls(urls: Vec) -> HashMap { url_contents } -/// Normalize workspace root for VFS path matching. +/// Normalize a workspace root path so it uses the same drive-letter +/// casing that the VFS applies (uppercase on Windows). Without this, +/// `extract_module_path` would fail to match VFS paths against +/// library workspace roots supplied by the editor with a lowercase +/// drive letter. fn normalize_workspace_root(root: PathBuf) -> PathBuf { file_path_to_uri(&root) .and_then(|uri| uri_to_file_path(&uri)) @@ -1221,16 +1382,32 @@ pub(crate) enum TableAnchor { Local { decl_name: String, path: String, - /// Field names declared by the table. + /// The field names the literal declares. + /// + /// The declaration's byte position cannot identify it - that is the + /// case the anchor has to survive - and neither can its index among + /// the file's same-named locals, because inserting another `local cfg` + /// above renumbers it and the old anchor would then resolve to the new + /// literal. Duplicate names are routine in Lua, so the field names are + /// the tie-break, and two that still collide are left unanchored. fields: Vec, }, - /// Literal passed as call argument, keyed by call path. + /// A literal passed to a call that also takes string literals, keyed by + /// the call's path and those strings. + /// + /// `registry.Add("first", { ... })` is the dominant shape for a literal no + /// name reaches, and the call's own string arguments identify it without a + /// sibling ordinal - inserting another registration above does not + /// renumber it. CallArgument { path: String, labels: Vec, arg_index: usize, }, - /// Literal with no name or call, keyed by field names. + /// A literal no name and no call reaches, keyed by the field names it + /// declares. Those survive an edit to a field's *value*, which a sibling + /// ordinal would not: inserting another literal above renumbers ordinals, + /// and the old anchor would then resolve to a different literal. Fields(Vec), } @@ -1301,7 +1478,10 @@ fn table_global_path_recursive(table: LuaTableExpr) -> Option { None } -/// Sorted field names declared by a table literal. +/// The field names a table literal declares, sorted. +/// +/// They survive an edit to a field's *value*, which is what makes them usable +/// as identity, and they distinguish literals that no name singles out. fn table_field_names(table: &LuaTableExpr) -> Vec { let mut fields: Vec = table .get_fields() @@ -1410,7 +1590,12 @@ fn table_local_anchor(db: &DbIndex, file_id: FileId, table: LuaTableExpr) -> Opt } } -/// Call containing a table literal argument. +/// The call a table literal is an argument to, described by the call's path +/// and its literal string arguments. +/// +/// `registry.Add("first", { ... })` is the dominant shape for a table literal +/// no name reaches, and this identifies it without a sibling ordinal, so +/// inserting another registration above does not renumber it. fn table_call_argument_anchor(table: &LuaTableExpr) -> Option { let arg_list = table.syntax().parent()?; let call = LuaCallExpr::cast(arg_list.parent()?)?; @@ -1455,7 +1640,12 @@ pub(crate) fn collect_anchored_map( }; let chunk = tree.get_chunk_node(); - // Two passes: keep only anchors that uniquely identify a literal. + // Two passes, because an anchor is only usable if it singles its literal + // out. Both the name a literal is reached by and the field names it + // declares survive an edit elsewhere in the file; the sibling ordinals in + // a tree path do not. So the most durable unique key wins, and the + // ordinals are added only to break a tie between literals that are + // otherwise indistinguishable. let candidates: Vec<(Option, LuaTableExpr)> = chunk .descendants::() .map(|table| { @@ -1483,7 +1673,12 @@ pub(crate) fn collect_anchored_map( .map(|(anchor, _)| anchor.clone()) .collect(); - // Skip literals with no unique anchor. + // A literal with no unique durable key is left out entirely. The only key + // left for it is the sibling ordinals, and those renumber when anything is + // inserted above, so an old anchor would resolve to a *different* literal + // and the remap would re-home its members onto the wrong table. Leaving it + // out costs a stale range, which a later re-index corrects; re-homing it + // writes a wrong one that nothing does. let mut map: FxHashMap> = FxHashMap::default(); for (anchor, table) in candidates { let Some(anchor) = anchor.filter(|anchor| !ambiguous.contains(anchor)) else { @@ -1503,8 +1698,15 @@ pub struct EmmyLuaAnalysis { pub(crate) inferred_guard_propagation_stats: InferredGuardPropagationStats, #[cfg(test)] cross_file_stabilization_invocations: usize, - /// Guard facts before self-index. + /// Guard facts as they stood before a self-index overwrote them. + /// + /// The LSP splits one edit across two calls: it self-indexes the edited + /// files to answer requests inside them, then pays the ripple later. + /// Guard propagation has to diff against the facts from before the + /// self-index, so they are carried across the gap. pending_guard_snapshot: Option, + /// Export fingerprints taken before a VFS mutation, for the paths that + /// write the text and re-index later. See [`Self::stash_pre_edit_state`]. pending_export_fingerprints: rustc_hash::FxHashMap, pending_table_ranges: rustc_hash::FxHashMap< FileId, @@ -1532,7 +1734,10 @@ impl EmmyLuaAnalysis { pub fn init_std_lib(&mut self) { let is_jit = matches!(self.emmyrc.runtime.version, EmmyrcLuaVersion::LuaJIT); let (std_root, files) = load_resource_std(is_jit); - // Normalize drive-letter casing for VFS matching. + // Normalize so the root's drive-letter casing matches VFS file paths + // (the URI round-trip uppercases the Windows drive letter). Without + // this, `extract_module_path` prefix matching would fail when the + // env-derived root has a lowercase drive letter. let std_root = normalize_workspace_root(std_root); self.init_std_lib_from_files(std_root, files); } @@ -1597,6 +1802,9 @@ impl EmmyLuaAnalysis { ) && old_text == new_text { // Text unchanged — if the index is already built (has module info), + // skip the costly remove+re-add cycle. This avoids unnecessary + // reindexing when VS Code opens already-loaded files for + // peek/definition (e.g. annotation/library files). if self .compilation .get_db() @@ -1617,6 +1825,10 @@ impl EmmyLuaAnalysis { } // An edit whose significant token stream is unchanged — same kinds, + // same offsets, same texts, comments included — cannot change any + // derived fact, so re-indexing it (and its whole dependency + // expansion) would only re-derive facts the index already holds. + // Store the text and stop. if let (Some(file_id), Some(new_text)) = (existing_file_id, text.as_deref()) && self .compilation @@ -1638,13 +1850,35 @@ impl EmmyLuaAnalysis { } // Change-aware incremental edit: only expand to dependents when the + // edited file's exported interface (members, types, signatures) actually + // changed. A trailing comment or a local-only edit keeps the same + // fingerprint, so the ripple collapses to empty and the edit costs only + // the file's own analysis instead of seconds for a hub file. + // + // A deletion (`text: None`) is excluded: it has no new text to index, + // and comparing fingerprints would let a file that exported nothing + // return before the removal seeds run, leaving dependents pointing at + // a file that is gone. It takes the full path below, which filters + // removed files out of `update_index` and seeds VGUI forwarding removal. if let Some(existing) = existing_file_id.filter(|_| text.is_some()) { // Both are taken before the VFS mutation. A dependent is a file + // that references this file's *old* exports, so an expansion + // computed after the re-index would not contain it. let before_fp = self.take_pre_edit_fingerprint(existing); let before_expansion = self.expand_reindex_file_ids(vec![existing]); let old_guard_snapshot = self .inferred_guard_snapshot(&before_expansion.iter().copied().collect::>()); // Inferred guards and VGUI forwarding are derived from state the + // self-index clears and the ripple then rebuilds from, so for a + // file that carries either, "did the exports change" is not a + // question the fingerprint can answer: the facts it would compare + // are gone by the time it looks. Those files take the full path. + // + // This is a correctness requirement rather than a performance + // prefilter - removing it makes + // `test_fact_preserving_guard_reindex_keeps_full_incremental_consumer_chain` + // fail, because the consumer chain is rebuilt from facts the + // self-index has already dropped. let is_special = { let db = self.compilation.get_db(); !db.get_signature_index() @@ -1678,9 +1912,15 @@ impl EmmyLuaAnalysis { .get_vfs_mut() .set_file_content(uri, text); // Self-index the edited file so its entries match its text (all a + // request inside this file needs) and so the after-fingerprint can + // be taken from the new index. profile::phase("edit/self-index", || { self.self_index_files(vec![file_id]); // The self-index derives this file's cross-file reads in + // isolation. Settling them here is what the ripple used to do + // for the whole expansion, and it is also what makes the + // after-fingerprint comparable to the before-fingerprint, + // which was taken from an already settled index. self.stabilize_cross_file_type_caches(&[file_id]); }); let after_fp = file_export_fingerprint(self.compilation.get_db(), file_id); @@ -1688,6 +1928,14 @@ impl EmmyLuaAnalysis { profile::phase_report("update_file_by_uri (no-ripple)"); return Some(file_id); } + // Export changed - pay the ripple. The edited file is re-indexed a + // second time here, as part of the expansion: its entries have to be + // derived in the same batch as its dependents' for the pass to + // converge, and it is one file out of an expansion in the thousands. + // + // The expansion and the guard snapshot are the ones taken before + // the edit, so guard propagation diffs against the facts the + // self-index has already overwritten. profile::phase("edit/ripple", || { self.reindex_expanded_files_with_old_snapshot( vec![file_id], @@ -1699,6 +1947,8 @@ impl EmmyLuaAnalysis { return Some(file_id); } + // A new file, or a deletion. Neither has a useful before-fingerprint, + // so both take the full expansion. let old_maps = existing_file_id .map(|file_id| self.take_old_anchor_maps(&[file_id])) .unwrap_or_default(); @@ -1712,6 +1962,9 @@ impl EmmyLuaAnalysis { self.reindex_expanded_files(vec![file_id], expansion) }); // A deleted file has no tree, so every one of its literals is gone and + // its Element owners are purged. That is what has to happen: the + // members other files own on them are not reachable from any file the + // re-index visited. self.apply_table_remap(old_maps, &[file_id]); profile::phase_report("update_file_by_uri"); @@ -1765,6 +2018,10 @@ impl EmmyLuaAnalysis { if trigger_reindex { // Through `self_index_files`, so the anchor stash an + // earlier text-only write left is consumed and applied. + // Re-indexing without it leaves the stash describing a tree + // two edits back, and the next edit would then remap from + // ranges the index no longer holds. self.self_index_files(vec![file_id]); self.pending_export_fingerprints.remove(&file_id); } @@ -1804,6 +2061,9 @@ impl EmmyLuaAnalysis { }; // The anchors have to be read before the VFS mutation drops the old + // tree. When this call also re-indexes, they are consumed below; + // otherwise they are stashed for whichever pass does index the file, + // because until then the index still holds the pre-edit ranges. let old_maps = match existing_file_id { Some(fid) if trigger_reindex => self.take_old_anchor_maps(&[fid]), Some(fid) => { @@ -1845,6 +2105,8 @@ impl EmmyLuaAnalysis { ); self.reindex_changed_inferred_param_consumers(&old_guard_facts, &reindex_file_ids); self.apply_table_remap(old_maps, &[file_id]); + // Settled against the current text now, so any stashed fingerprint + // describes a state that no longer exists. for reindexed in &reindex_file_ids { self.pending_export_fingerprints.remove(reindexed); } @@ -1904,6 +2166,8 @@ impl EmmyLuaAnalysis { } /// VFS-only update: parse and store the new text without touching the index. + /// The index remains stale but functional until `reindex_files` is called. + /// This is much faster than `update_file_by_uri` pub fn update_file_text_only(&mut self, uri: &Uri, text: String) -> Option { let existing_file_id = self.compilation.get_db().get_vfs().get_file_id(uri); if let Some(file_id) = existing_file_id { @@ -1930,13 +2194,24 @@ impl EmmyLuaAnalysis { Some(file_id) } - /// See implementation. + /// Reindex specific files: remove old index entries + run full analysis pipeline. + /// Call this after `update_file_text_only` once the user has paused typing. pub fn reindex_files(&mut self, file_ids: Vec) { let expansion = self.expand_reindex_file_ids(file_ids.clone()); self.reindex_expanded_files(file_ids, expansion); } - /// See implementation. + /// [`reindex_files`](Self::reindex_files) against an expansion that was + /// computed earlier. + /// + /// The expansion has to be derived from the state *before* the edit landed, + /// so a caller that wants to do anything in between — re-index the edited + /// file on its own first, say, and release the write lock so a completion + /// can be answered — has to capture it up front and hand it back here. + /// Recomputing it against a partly-updated index under-expands badly: + /// measured on a gamemode workspace, an expansion of 739 files collapsed to + /// 8 and the workspace ended up with 18 diagnostics that a cold build does + /// not produce. pub fn reindex_expanded_files(&mut self, file_ids: Vec, expansion: Vec) { self.reindex_expanded_files_inner(file_ids, expansion, None); } @@ -1951,6 +2226,10 @@ impl EmmyLuaAnalysis { } /// Re-analyses `expansion` with `file_ids` as the files that changed. + /// + /// `old_snapshot` is the guard facts to diff propagation against. Pass the + /// snapshot taken before a self-index overwrote them; `None` takes one now, + /// which is only correct when nothing has re-indexed since. fn reindex_expanded_files_inner( &mut self, file_ids: Vec, @@ -1974,6 +2253,8 @@ impl EmmyLuaAnalysis { self.add_vgui_forwarding_removal_seed(&removed_file_ids, &mut file_ids); let guard_fact_file_ids = file_ids.iter().copied().collect::>(); // A self-index may already have overwritten the guard facts this + // ripple has to diff against, in which case the snapshot from before + // it was stashed for us. let old_guard_facts = old_snapshot .or_else(|| self.pending_guard_snapshot.take()) .unwrap_or_else(|| self.inferred_guard_snapshot(&guard_fact_file_ids)); @@ -1987,6 +2268,8 @@ impl EmmyLuaAnalysis { self.compilation.update_index(update_file_ids.clone()); self.stabilize_cross_file_type_caches(&update_file_ids); } + // These files are settled against their current text now, so a + // fingerprint stashed for one describes a state that no longer exists. for file_id in &file_ids { self.pending_export_fingerprints.remove(file_id); } @@ -2006,6 +2289,11 @@ impl EmmyLuaAnalysis { } /// Records the anchors the index's stored `Element` ranges correspond to, + /// unless an earlier edit already recorded some. + /// + /// The oldest stash is the one that matches the index: a write that does + /// not re-index leaves the index describing the text from before it, so + /// that is the state the stored ranges belong to. fn stash_pre_edit_anchors(&mut self, file_id: FileId) { if self.pending_table_ranges.contains_key(&file_id) { return; @@ -2016,7 +2304,12 @@ impl EmmyLuaAnalysis { } } - /// See implementation. + /// The same, plus the export fingerprint, for the paths that write the text + /// now and re-index later. + /// + /// The fingerprint has to be taken here for the same reason the anchors do: + /// once the new text is parsed it would read the old index against the new + /// tree, and report a change for every edit that shifts a table literal. fn stash_pre_edit_state(&mut self, file_id: FileId) { self.stash_pre_edit_anchors(file_id); // Independent of the anchors: a file with no table literals stashes no @@ -2028,7 +2321,8 @@ impl EmmyLuaAnalysis { } } - /// See implementation. + /// The file's export fingerprint as it stood before the edit: the stashed + /// one when a write has already landed, otherwise one taken now. fn take_pre_edit_fingerprint(&mut self, file_id: FileId) -> u64 { self.pending_export_fingerprints .remove(&file_id) @@ -2036,6 +2330,11 @@ impl EmmyLuaAnalysis { } /// The anchor map the index's stored `Element` ranges correspond to. + /// + /// An edit stashes this before mutating the VFS, because the tree those + /// ranges came from is gone once the new text is parsed. Files re-indexed + /// without an intervening edit have no stash, so their current tree is + /// still the one the index was built from. fn take_old_anchor_maps(&mut self, file_ids: &[FileId]) -> AnchorMaps { let mut old_maps = AnchorMaps::default(); for fid in file_ids { @@ -2051,16 +2350,31 @@ impl EmmyLuaAnalysis { old_maps } - /// See implementation. + /// Re-homes index entries that name a re-indexed file's table literals by + /// range, from the range the old tree gave them to the range the new tree + /// does. Entries whose literal no longer exists are dropped. + /// + /// Only the edited file's own members are rebuilt by a re-index; every + /// other file's reference to one of its `Element` owners keeps the old + /// offset, so without this they point into the wrong table after any edit + /// that shifts offsets. fn apply_table_remap(&mut self, mut old_maps: AnchorMaps, file_ids: &[FileId]) { let mut global_remap: rustc_hash::FxHashMap< InFiled, InFiled, > = rustc_hash::FxHashMap::default(); let mut deleted: Vec> = Vec::new(); + // Driven off the files being re-indexed, not off the stashed anchors: a + // removed file whose literals were all unnameable stashes nothing, and + // its owners still have to go. for fid in file_ids.iter().copied() { let old_map = old_maps.remove(&fid).unwrap_or_default(); // A file with no tree has been removed. Only then does an anchor + // that no longer resolves mean the literal is gone: while the file + // is still there, a mismatch can equally be a heuristic the anchor + // did not survive, and purging on that basis destroys members other + // files own with nothing left to rebuild them. Leaving the entry + // stale is recoverable; deleting it is not. let file_removed = self .compilation .get_db() @@ -2069,10 +2383,16 @@ impl EmmyLuaAnalysis { .is_none(); if old_map.is_empty() && !file_removed { // Nothing stashed and the file is still there, so there is no + // range to move and none to purge. Skipping here avoids a + // `collect_anchored_map` tree walk per file, which the batch + // path would otherwise pay for every file it touches. continue; } if file_removed { // Every literal in it is gone, not just the ones an anchor + // reached: `collect_anchored_map` leaves out literals it cannot + // name uniquely, and members other files own on those are not + // reachable from any file the removal sweeps. deleted.extend( self.compilation .get_db() @@ -2098,6 +2418,8 @@ impl EmmyLuaAnalysis { db.get_member_index_mut().remap_elements(&global_remap); db.get_type_index_mut().remap_table_const(&global_remap); // Beyond the type cache and the member owner, the one store that + // can hold *another* file's literal range: a write registers a + // dynamic field on a table it does not declare. db.get_dynamic_field_index_mut() .remap_table_ranges(&global_remap); } @@ -2114,6 +2436,15 @@ impl EmmyLuaAnalysis { } /// Rebuilds only these files' own index entries. + /// + /// Nothing cross-file is settled: dependents keep whatever they inferred + /// before, and the caller still owes them a + /// [`reindex_expanded_files`](Self::reindex_expanded_files) against an + /// expansion captured beforehand. What this does buy is that the edited + /// file's declarations, members and signatures line up with its text again, + /// which is all a request positioned *inside that file* needs — the index + /// entries are keyed by position, so an edit that shifts offsets is exactly + /// what makes them stop matching the tree. pub fn self_index_files(&mut self, file_ids: Vec) { let old_maps = self.take_old_anchor_maps(&file_ids); self.compilation.remove_index(file_ids.clone()); @@ -2125,6 +2456,9 @@ impl EmmyLuaAnalysis { &mut self, file_ids: Vec, ) -> (Vec, Vec) { + // Capture fingerprints and expansion before the mutation, as in + // `update_file_by_uri`. For guard/vgui files the fingerprint shortcut + // would clobber required state, so they are treated as always changed. let has_special = file_ids.iter().any(|fid| { let db = self.compilation.get_db(); !db.get_signature_index() @@ -2144,6 +2478,8 @@ impl EmmyLuaAnalysis { } // The text is already written by the time the editor path reaches + // here, so a fingerprint taken now would read the old index against the + // new tree. Whoever wrote the text stashed one taken before it. let mut before_fps = HashMap::new(); for fid in &file_ids { before_fps.insert(*fid, self.take_pre_edit_fingerprint(*fid)); @@ -2152,6 +2488,8 @@ impl EmmyLuaAnalysis { // reference the old exports are missed. let before_expansion = self.expand_reindex_file_ids(file_ids.clone()); // The oldest snapshot in a burst is the one the ripple has to diff + // against: later batches see facts the earlier self-indexes already + // overwrote. let snapshot = self.inferred_guard_snapshot(&before_expansion.iter().copied().collect::>()); self.pending_guard_snapshot.get_or_insert(snapshot); @@ -2165,9 +2503,14 @@ impl EmmyLuaAnalysis { } } if changed.is_empty() { + // The guard snapshot is left alone: an earlier batch in this burst + // may still owe a ripple that has to diff against it. return (Vec::new(), Vec::new()); } // The before expansion already contains the dependents of the changed + // files. Narrowing it to just those would need per-file dependent + // tracking, and over-rippling here costs at most what the edit would + // have cost without the fingerprint at all. (changed, before_expansion) } @@ -2384,6 +2727,8 @@ impl EmmyLuaAnalysis { incremental_source_file_ids.contains(&owner.source_file_id()); let discovered = self.resolve_inferred_guard_reference_files(owner, true); for file_id in discovered.files { + // Cold batches resolve aliases in the main pipeline. Only edits need a + // post-publication retry for alias calls analyzed with the old guard fact. let alias_retry = allow_alias_retry && discovered.alias_calls.contains(&file_id) && file_id != owner.source_file_id(); @@ -2805,6 +3150,9 @@ impl EmmyLuaAnalysis { .filter_map(|(uri, _)| self.compilation.get_db().get_vfs().get_file_id(uri)) .collect::>(); // Taken before the writes below, as on every other edit path: the + // expansion re-derives each dependent's *type caches*, but a member + // another file owns on a literal here is not reached by that, so the + // ranges still have to be re-homed. let mut remap_source_file_ids: Vec = old_source_file_ids.iter().copied().collect(); remap_source_file_ids.sort_unstable(); let old_anchor_maps = self.take_old_anchor_maps(&remap_source_file_ids); @@ -2919,6 +3267,7 @@ impl EmmyLuaAnalysis { updated_files.insert(*file_id); } } else { + // Small batch: parse sequentially (avoids thread spawn overhead) for (uri, text) in to_parse { let file_id = self .compilation @@ -3203,6 +3552,9 @@ impl EmmyLuaAnalysis { } /// Return main-workspace files in an order that keeps parallel diagnostic + /// workers busy. Source size is a cheap proxy for diagnostic cost, so + /// processing larger files first avoids leaving one expensive file on the + /// critical path after the other workers have gone idle. pub fn get_main_workspace_file_ids_for_diagnostics(&self) -> Vec { let db = self.compilation.get_db(); let vfs = db.get_vfs(); @@ -3736,10 +4088,24 @@ mod tests { assert_eq!(reindex_file_ids, vec![main_file_id, helper_file_id]); } - /// See implementation. + /// The language server re-indexes an edited file on its own before running + /// its dependency ripple, so a completion positioned in that file can be + /// answered without waiting seconds for the ripple. This checks the split + /// path reaches the same diagnostics as doing it in one go. + /// + /// It does **not** pin the ordering constraint that makes the split safe — + /// that the expansion is captured *before* the self-index, because a + /// self-index drops the edited file's declarations and inbound dependency + /// edges and an expansion taken afterwards under-invalidates. That was + /// measured on a gamemode workspace (739 files before the self-index, 6 + /// after) and this fixture is far too small to reproduce it: it passes with + /// the two swapped. The real guard is `tools/determinism` against a real + /// workspace, and the reasoning lives on `reindex_expanded_files`. #[test] fn two_phase_reindex_matches_single_phase_diagnostics() { // A class definition plus a consumer whose inferred type references it: + // the expansion reaches the consumer through the type-cache relation, + // which is the one that collapses once the definition site is dropped. let producer_source = |member: &str| { format!( "---@class Thing diff --git a/crates/glua_doc_cli/src/cmd_args.rs b/crates/glua_doc_cli/src/cmd_args.rs index f2fc338ab..d305364ef 100644 --- a/crates/glua_doc_cli/src/cmd_args.rs +++ b/crates/glua_doc_cli/src/cmd_args.rs @@ -58,6 +58,8 @@ pub struct CmdArgs { pub site_name: Option, /// A directory whose contents are merged with the generated Markdown files. + /// For example, to override docs/index.md, create a folder called "docs" in + /// your mixin folder and create a file called "index.md" inside it. #[arg(long)] pub mixin: Option, diff --git a/crates/glua_ls/src/context/debounced_analysis.rs b/crates/glua_ls/src/context/debounced_analysis.rs index d4c2c31d8..034375b0f 100644 --- a/crates/glua_ls/src/context/debounced_analysis.rs +++ b/crates/glua_ls/src/context/debounced_analysis.rs @@ -11,10 +11,18 @@ use super::{ClientProxy, file_diagnostic::SharedDiagnosticDataCache}; const FRESHNESS_STUCK_WARN_AFTER: Duration = Duration::from_secs(5); -/// See implementation. +/// How long the user must stay idle after a reindex before the whole workspace +/// is re-diagnosed. const IDLE_WORKSPACE_DIAGNOSTIC_DELAY: Duration = Duration::from_millis(2000); /// How long the user must stay idle before the dependency ripple runs. +/// +/// The edited file's own re-index runs on the much shorter debounce, which is +/// all a request positioned inside that file needs. This timer governs only the +/// cross-file settle, which holds the write lock for seconds on a large +/// gamemode — and while it does, no keystroke can even be applied. Starting it +/// after every brief pause is what put a ripple in flight for nearly every +/// completion. const RIPPLE_QUIET: Duration = Duration::from_millis(1000); /// Cap on how long one typing burst can hold the ripple off, so diagnostics @@ -22,17 +30,41 @@ const RIPPLE_QUIET: Duration = Duration::from_millis(1000); const MAX_RIPPLE_DEFERRAL: Duration = Duration::from_secs(5); /// How long the ripple gives requests released by the self-index to take their +/// read lock before it takes the write lock back. +/// +/// Bounded so a stream of requests cannot starve the ripple: past this, the +/// ripple proceeds and the stragglers wait it out as they did before. const READER_HANDOFF_GRACE: Duration = Duration::from_millis(250); +/// Debounced analysis: accumulates file IDs from rapid edits and runs `reindex_files` once the user pauses typing. pub struct DebouncedAnalysis { pending_files: Mutex>, reindexing_files: Mutex>, /// Documents whose own index entries do not match their text yet, either + /// because their edit is still queued or because the batch re-indexing them + /// has not reached them. + /// + /// Holds the URI the *client* used, so a request can be tested against its + /// own params without taking the analysis lock — which the re-index holds + /// for its whole duration, so resolving a file id first would wait out + /// exactly what this exists to avoid. Keyed by file id so entries are + /// cleared by identity rather than by matching that URI against the one the + /// VFS derived from a path, which need not be spelled the same way. blocked_documents: Mutex>, - /// See implementation. + /// True when document changes have arrived but reindex has not yet completed. + /// Set synchronously by `begin_in_flight_change()` (called inline in the + /// notification handler, before the didChange task is spawned) so that any + /// request handler dispatched afterwards sees the flag immediately. has_pending_changes: AtomicBool, in_flight_changes: AtomicUsize, /// Requests aimed at one document that are waiting for, or reading against, + /// that document's own index entries. + /// + /// The self-index releases them and then immediately queues the ripple's + /// write lock. A woken request still has to be polled before it can queue + /// its read, and the lock is fair-FIFO, so without this the ripple wins the + /// race every time and the request waits out the whole ripple it was just + /// released from. pending_readers: AtomicUsize, readers_idle_notify: Notify, notify: Notify, @@ -82,6 +114,7 @@ impl DebouncedAnalysis { } } + /// Add a file to the pending reindex set and reset the debounce timer. pub async fn schedule(&self, file_id: FileId, uri: Uri) { { let mut pending = self.pending_files.lock().await; @@ -96,6 +129,11 @@ impl DebouncedAnalysis { } /// Signal that document changes are in-flight but not yet scheduled. + /// + /// Called **synchronously** from the notification handler (inline, before + /// spawning the didChange task) so that request handlers dispatched + /// immediately afterward see the dirty flag and wait for reindex instead + /// of computing on stale analysis data. pub fn begin_in_flight_change(self: &Arc) -> InFlightChangeGuard { self.in_flight_changes.fetch_add(1, Ordering::AcqRel); self.has_pending_changes.store(true, Ordering::Release); @@ -135,7 +173,11 @@ impl DebouncedAnalysis { self.reindex_notify.notify_waiters(); } - /// See implementation. + /// Check whether document changes are pending reindex. + /// + /// Handlers that need consistent tree + index data (e.g. semantic tokens) + /// can use this to decide whether to serve stale results or return `None` + /// so the client keeps its previous state. pub fn is_dirty(&self) -> bool { self.has_pending_changes.load(Ordering::Acquire) } @@ -151,6 +193,12 @@ impl DebouncedAnalysis { } /// Register that a request is waiting on, or reading against, one + /// document's own index entries. + /// + /// Hold the guard until the request has finished reading. The ripple yields + /// to outstanding guards — up to [`READER_HANDOFF_GRACE`] — before it takes + /// the write lock back, so a request the self-index just released is not + /// made to wait out the ripple anyway. pub fn begin_reader_handoff(self: &Arc) -> ReaderHandoff { self.pending_readers.fetch_add(1, Ordering::AcqRel); ReaderHandoff { @@ -159,6 +207,11 @@ impl DebouncedAnalysis { } /// Let outstanding [`ReaderHandoff`]s take their read lock before the + /// caller takes the write lock. + /// + /// Returns as soon as none are outstanding, or after + /// [`READER_HANDOFF_GRACE`] so a stream of requests cannot starve the + /// ripple. async fn await_reader_handoff(&self) { let deadline = Instant::now() + READER_HANDOFF_GRACE; @@ -185,7 +238,11 @@ impl DebouncedAnalysis { } } - /// See implementation. + /// Wait until all pending document changes have been reindexed. + /// + /// Returns `true` when the analysis is fresh, `false` if the cancel token + /// fired first. Uses `enable()` so that `notify_waiters()` wakeups are + /// not lost between creating the `Notified` future and polling it. pub async fn wait_until_fresh_for( &self, cancel_token: &CancellationToken, @@ -222,6 +279,18 @@ impl DebouncedAnalysis { } /// Wait until the document at `uri` has index entries matching its text. + /// + /// A request positioned inside a file needs that file's entries to line up + /// with the tree it is resolving against — they are keyed by position, so an + /// edit that shifts offsets is what makes them stop matching, and answering + /// from the old ones is what silently returns a thinner list. It does *not* + /// need the edit's dependency ripple to have finished; that settles other + /// files' inferences, and waiting for it costs seconds on a large gamemode + /// for an answer that is already correct. + /// + /// Callers with no URI to aim at want [`wait_until_fresh_for`] instead. + /// + /// [`wait_until_fresh_for`]: Self::wait_until_fresh_for pub async fn wait_until_file_fresh_for( &self, cancel_token: &CancellationToken, @@ -257,6 +326,12 @@ impl DebouncedAnalysis { } /// Whether a request aimed at `uri` can be answered against entries that + /// match its text. + /// + /// Workspace-wide dirtiness is the wrong question for a request positioned + /// inside one document: an edit to some other file leaves this one's entries + /// matching its own text, so refusing it buys nothing and blanks the answer + /// for every open document whenever anything is typed anywhere. pub async fn file_is_answerable(&self, uri: &Uri) -> bool { // An edit whose text has not been applied yet would have the request // resolve a position against the previous tree. @@ -289,6 +364,7 @@ impl DebouncedAnalysis { ); } + /// Wait until the given file is no longer pending reindex. pub async fn wait_for_reindex(&self, file_id: FileId, cancel_token: CancellationToken) { loop { let notified = self.reindex_notify.notified(); @@ -310,7 +386,16 @@ impl DebouncedAnalysis { } } - /// See implementation. + /// Re-index the edited files' own entries, and report the dependency + /// expansion the ripple still owes them. + /// + /// The expansion is captured *before* the self-index, because deriving it + /// from a partly-updated index under-expands and leaves dependents holding + /// inferences a cold build would not produce. + /// + /// This takes the write lock and gives it back, which is the whole point: a + /// freshness flag published while the lock is still held buys a waiting + /// request nothing, since it cannot read the index until the lock is free. async fn self_index_without_queuing( &self, file_ids: Vec, @@ -323,6 +408,9 @@ impl DebouncedAnalysis { result = tokio::task::spawn_blocking(move || { let mut guard = analysis.blocking_write(); // Change-aware: only expand to dependents when the file's + // exported interface actually changed. Most keystrokes (typing + // inside a function, trailing comment, local rename) keep the + // same fingerprint and collapse the ripple to empty. let (changed, expansion) = guard.self_index_files_and_get_ripple_with_changed(file_ids); cache.invalidate(); (changed, expansion) @@ -344,12 +432,15 @@ impl DebouncedAnalysis { let analysis = self.analysis.clone(); let cache = self.shared_diagnostic_data_cache.clone(); + // Re-index under a blocking write lock on a blocking thread: the wait + // for the lock and the CPU work both stay off the Tokio workers. tokio::select! { _ = self.shutdown.cancelled() => false, result = tokio::task::spawn_blocking(move || { let mut guard = analysis.blocking_write(); guard.reindex_expanded_files(file_ids, expansion); // Invalidate under the write lock so no reader can observe the + // fresh index next to the stale shared diagnostic data. cache.invalidate(); }) => { if let Err(err) = result { @@ -362,6 +453,10 @@ impl DebouncedAnalysis { } /// Hold the owed ripple until typing has stopped for [`RIPPLE_QUIET`]. + /// + /// Returns `true` when the caller should run the ripple now, `false` when + /// another edit arrived and the loop should self-index that first — the + /// ripple it owes then joins the one already outstanding. async fn ripple_quiet_elapsed(&self, burst_started_at: Instant) -> bool { let extra = RIPPLE_QUIET.saturating_sub(self.debounce_duration); let deferral_left = MAX_RIPPLE_DEFERRAL.saturating_sub(burst_started_at.elapsed()); @@ -381,7 +476,8 @@ impl DebouncedAnalysis { self.pending_files.lock().await.is_empty() } - /// See implementation. + /// Background loop: waits for events, debounces, then runs reindex. + /// Spawn this once at server startup. pub async fn run(&self) { let mut idle_workspace_diagnostic_token: Option = None; // The ripple owed by the self-indexes run so far in this typing burst, @@ -416,6 +512,7 @@ impl DebouncedAnalysis { } } + // Timer expired — drain pending files and reindex let file_ids: Vec = { let mut pending = self.pending_files.lock().await; let mut reindexing = self.reindexing_files.lock().await; @@ -434,12 +531,21 @@ impl DebouncedAnalysis { self.debounce_duration.as_millis() ); + // Re-index the edited files themselves first and release the + // write lock, so a completion or hover positioned inside one of + // them can be answered against entries that match its text + // instead of waiting out the whole dependency ripple. The + // ripple is by far the larger half — measured on a gamemode + // workspace, 106ms against 5.1s. let Some((changed_files, expansion)) = self.self_index_without_queuing(file_ids.clone()).await else { if self.shutdown.is_cancelled() { return; } + // Release the batch, or every request aimed at these + // documents parks until some later edit happens to cover + // them. let mut reindexing = self.reindexing_files.lock().await; let mut blocked = self.blocked_documents.lock().await; for id in &file_ids { @@ -462,10 +568,16 @@ impl DebouncedAnalysis { self.reindex_notify.notify_waiters(); // The requests just released still have to be polled before + // they can queue their read. Taking the write lock back now + // would put them behind the whole ripple. self.await_reader_handoff().await; if expansion.is_empty() { // No exports changed - the self-index already makes these + // files answerable, and no dependent needs re-indexing. + // Clear them from reindexing immediately so dirty state + // can settle without waiting for a ripple that will never + // come. { let mut reindexing = self.reindexing_files.lock().await; for id in &file_ids { @@ -479,6 +591,7 @@ impl DebouncedAnalysis { } } else { // Only the files whose exports actually changed need a + // ripple; the rest are already settled by the self-index. let changed_set: HashSet = changed_files.iter().copied().collect(); { let mut reindexing = self.reindexing_files.lock().await; @@ -501,6 +614,8 @@ impl DebouncedAnalysis { } // Hold the ripple until typing has genuinely stopped. Another edit + // sends us back for its own self-index, and the ripple it owes + // joins this one. let burst_started_at_instant = burst_started_at.unwrap_or_else(Instant::now); if !self.ripple_quiet_elapsed(burst_started_at_instant).await { continue; @@ -540,6 +655,8 @@ impl DebouncedAnalysis { self.reindex_notify.notify_waiters(); if !reindex_completed { + // Only shutdown stops the loop; a panicked reindex must + // fall through so `refresh_dirty_state()` releases waiters. if self.shutdown.is_cancelled() { return; } @@ -591,12 +708,15 @@ impl DebouncedAnalysis { self.refresh_dirty_state().await; // Always notify waiters so they can re-check the condition. + // Even if we didn't reindex (pending was empty), clearing the + // dirty flag means waiters should proceed with available data. self.reindex_notify.notify_waiters(); } } async fn refresh_dirty_state(&self) { // Publish while holding both locks, or concurrent callers can + // interleave and store a stale reading. let pending = self.pending_files.lock().await; let reindexing = self.reindexing_files.lock().await; @@ -609,6 +729,12 @@ impl DebouncedAnalysis { ); // `in_flight_changes` is not covered by the locks above, so a + // concurrent `begin_in_flight_change()` can land between the load and + // the store and have its `true` overwritten. Re-reading narrows that + // window rather than closing it; what is guaranteed is only that the + // flag ends up `true` for any change whose `fetch_add` is visible by + // the time this second load runs. A change that arrives later still + // sets the flag itself, and `finish_in_flight_changes` calls back here. if self.in_flight_changes.load(Ordering::Acquire) > 0 { self.has_pending_changes.store(true, Ordering::Release); } @@ -616,6 +742,8 @@ impl DebouncedAnalysis { } /// Keeps the ripple off the write lock while one request takes its read lock. +/// +/// See [`DebouncedAnalysis::begin_reader_handoff`]. pub struct ReaderHandoff { analysis: Arc, } @@ -871,6 +999,8 @@ mod tests { } /// The point of the per-file gate: an edit to one document must not park + /// requests aimed at a different one, and must park requests aimed at + /// itself until its own entries have been rebuilt. #[gtest] fn a_pending_edit_blocks_only_its_own_document() -> Result<()> { let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build"); diff --git a/crates/glua_ls/src/handlers/test/hover_test.rs b/crates/glua_ls/src/handlers/test/hover_test.rs index 2e27ba289..a326ff964 100644 --- a/crates/glua_ls/src/handlers/test/hover_test.rs +++ b/crates/glua_ls/src/handlers/test/hover_test.rs @@ -3007,6 +3007,8 @@ local EscapeStringMap: { } /// Hovering the `function` keyword in a hook.Add callback should show the anonymous callback + /// signature (e.g. `function(ply: Player, seat: Vehicle) -> boolean`) and NOT the generic + /// "The function keyword is used to define a function..." keyword docs. #[gtest] fn test_hover_hook_add_callback_function_keyword_shows_hook_signature() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); @@ -3370,6 +3372,9 @@ local EscapeStringMap: { } /// A hook declared with only `@return` and no `@param` annotations must still show + /// the return type in the anonymous callback signature (e.g. `function() -> boolean`). + /// Previously `filter_signature_type` would skip the signature when `param_docs` is empty, + /// silently degrading return-only hooks to keyword docs. #[gtest] fn test_hover_hook_add_callback_function_keyword_return_only_hook_shows_return_type() -> Result<()> { @@ -3882,6 +3887,9 @@ local EscapeStringMap: { let value = extract_hover_markdown(&ws, file_id, position); // Reads inside readPair() should appear inside a Lua code fence that + // follows a styled scope-open row for the outer for-loop — the + // helper-recursion flow_path-prefix fix carries the call site's loop + // into the helper body's reads. assert!( value.contains("net.ReadString") && value.contains("for i = 1, n, 1 do"), "expected ReadString and outer for-loop header both rendered, got: {value}" @@ -3949,6 +3957,14 @@ local EscapeStringMap: { #[gtest] fn test_hover_branched_dynamic_field_unions_vector_real_shape() -> Result<()> { // Repro of cityrp-vehicle-base/init.lua bug: + // if exitPos then + // seat.GlideExitPos = Vector(...) + // else + // seat.GlideExitPos = nil + // end + // Reading `seat.GlideExitPos` later must hover as `Vector?` (i.e. Vector|nil), + // NOT bare `nil`. Pre-fix, `retain_only_member_for_owner_key` dropped the + // Vector-branch member because the `= nil` branch ran later. let mut ws = enable_gmod_workspace(); let mut emmyrc = ws.get_emmyrc(); emmyrc.gmod.infer_dynamic_fields = true; @@ -4016,6 +4032,8 @@ local EscapeStringMap: { } /// Hover at the LHS of `seat.GlideExitPos = nil`. Assignment-target hovers + /// describe the value written at that site; the adjacent read-site + /// regression above continues to require the accumulated `Vector?` type. #[gtest] fn test_hover_branched_dynamic_field_lhs_assign_shows_assigned_nil() -> Result<()> { let mut ws = enable_gmod_workspace(); @@ -4077,6 +4095,11 @@ local EscapeStringMap: { } /// Read-site hover where the entity local comes from `self.seats[index]` + /// in a different method than the branched assignment. Mirrors + /// `cityrp-vehicle-base/init.lua:559` (`local seat = self.seats[index]` + /// then `seat.GlideExitPos[1]`). Failing red test pre-fix produced bare + /// `nil` or `never` because the branched dynamic-field assignment + /// collapsed to the last `= nil` branch. #[gtest] fn test_hover_branched_dynamic_field_read_via_self_seats_array() -> Result<()> { let mut ws = enable_gmod_workspace(); @@ -4234,6 +4257,13 @@ local EscapeStringMap: { } /// `self` inside a scripted-class method is an instance of the class the + /// authoring table stands for, so hover must name that class. + /// + /// `ENT` is virtual inside a scripted scope, so it has no decl and the + /// receiver lookup used to fall back to the enclosing method's member. That + /// is the shared semantic decl id, so goto-definition, references, + /// implementation and rename all pointed at the method too; hover is just + /// the cheapest place to observe it. #[gtest] fn test_hover_self_in_scripted_entity_method_shows_class() -> Result<()> { let mut ws = enable_gmod_workspace(); @@ -4297,6 +4327,9 @@ local EscapeStringMap: { } /// `PLAYER` and `PLUGIN` author their class through a real local, unlike the + /// virtual `ENT` / `SWEP` / `GM` tables. `self` must still be typed as that + /// file's scripted class in both styles — the local is the authoring + /// mechanism, not a different entity. #[gtest] fn test_self_in_player_class_resolves_to_class_like_the_token() -> Result<()> { let source = r#" @@ -4334,6 +4367,12 @@ local EscapeStringMap: { } /// A scripted class inherits both the GMod `ENT` annotation chain and its + /// own scripted base, so the super graph is a diamond: `ENT : ENTITY : + /// Entity` sits alongside a direct `Entity` super, and the real base comes + /// last. Walking the siblings with one shared infer guard marked `Entity` + /// visited in the `ENT` branch, so the direct `Entity` sibling failed with + /// `RecursiveInfer` and aborted the rest of the walk — the real base was + /// never searched and the inherited method lost its signature. #[gtest] fn test_inherited_scripted_method_survives_super_diamond() -> Result<()> { let mut ws = enable_gmod_workspace(); diff --git a/crates/glua_parser/src/grammar/lua/expr.rs b/crates/glua_parser/src/grammar/lua/expr.rs index 75ec84d6a..12b7eb393 100644 --- a/crates/glua_parser/src/grammar/lua/expr.rs +++ b/crates/glua_parser/src/grammar/lua/expr.rs @@ -116,6 +116,8 @@ pub fn parse_closure_expr(p: &mut LuaParser) -> ParseResult { let m = p.mark(LuaSyntaxKind::ClosureExpr); // A missing `end` is only discovered at EOF, so the error has to be pinned + // to where the function opens or it lands at the bottom of the file, far + // from the definition that is actually unclosed. let start_range = p.current_token_range(); if_token_bump(p, LuaTokenKind::TkFunction); diff --git a/crates/glua_parser/src/grammar/lua/test.rs b/crates/glua_parser/src/grammar/lua/test.rs index dfd508e7e..c3a9cacc7 100644 --- a/crates/glua_parser/src/grammar/lua/test.rs +++ b/crates/glua_parser/src/grammar/lua/test.rs @@ -1185,6 +1185,8 @@ Syntax(Chunk)@0..94 assert_ast_eq!(code, result); } /// A missing `end` is only detected at EOF, but reporting it there puts the + /// error at the bottom of the file instead of on the definition that is + /// unclosed. Every `function` form routes through `parse_closure_expr`. #[test] fn missing_end_reports_at_the_function_not_at_eof() { for src in [ diff --git a/crates/glua_parser/src/lexer/mod.rs b/crates/glua_parser/src/lexer/mod.rs index 06a0612cb..f9f75ec48 100644 --- a/crates/glua_parser/src/lexer/mod.rs +++ b/crates/glua_parser/src/lexer/mod.rs @@ -18,6 +18,9 @@ fn is_name_continue(ch: char) -> bool { } /// This enum allows preserving lexer state between reader resets. This is used +/// when lexer doesn't see the whole input source, and only sees a reader +/// for each individual line. It happens when we're lexing +/// code blocks in comments. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LexerState { Normal, diff --git a/crates/glua_parser/src/syntax/mod.rs b/crates/glua_parser/src/syntax/mod.rs index c7590964a..21ac2f434 100644 --- a/crates/glua_parser/src/syntax/mod.rs +++ b/crates/glua_parser/src/syntax/mod.rs @@ -63,12 +63,17 @@ impl From for LuaTokenKind { } /// Per-thread memo for [`LuaSyntaxId::to_node_from_root`], keyed by root +/// (MRU, a few roots kept). Holding each root alive keeps its green tree +/// alive, which is what makes identity comparison sound. mod node_memo { use super::{LuaSyntaxId, LuaSyntaxNode}; use rustc_hash::FxHashMap; const MAX_ROOTS: usize = 4; /// Each entry pins a red node, which holds an rc on its whole ancestor + /// chain, so a long-lived thread would otherwise retain most of a large + /// tree. Clearing beats evicting: the memo only pays off within one + /// traversal, so a fresh map costs a re-walk, not a lasting miss. const MAX_ENTRIES_PER_ROOT: usize = 8192; #[derive(Default)] diff --git a/crates/glua_parser/src/syntax/node/lua/expr.rs b/crates/glua_parser/src/syntax/node/lua/expr.rs index 370866b66..6352fc926 100644 --- a/crates/glua_parser/src/syntax/node/lua/expr.rs +++ b/crates/glua_parser/src/syntax/node/lua/expr.rs @@ -239,6 +239,11 @@ impl LuaNameExpr { } /// The identifier's text. + /// + /// Returns `SmolStr` rather than `String`: the token's text is already + /// `&str`, so building a `String` allocated for every name read. `SmolStr` + /// stores up to 22 bytes inline, which covers essentially every Lua + /// identifier, so the common case allocates nothing. pub fn get_name_text(&self) -> Option { self.get_name_token() .map(|it| SmolStr::new(it.get_name_text())) @@ -466,6 +471,15 @@ impl From for LuaExpr { } /// In Lua, tables are a fundamental data structure that can be used to represent arrays, objects, +/// and more. To facilitate parsing and handling of different table structures, we categorize tables +/// into three types: `TableArrayExpr`, `TableObjectExpr`, and `TableEmptyExpr`. +/// +/// - `TableArrayExpr`: Represents a table used as an array, where elements are indexed by integers. +/// - `TableObjectExpr`: Represents a table used as an object, where elements are indexed by strings or other keys. +/// - `TableEmptyExpr`: Represents an empty table with no elements. +/// +/// This categorization helps in accurately parsing and processing Lua code by distinguishing between +/// different uses of tables, thereby enabling more precise syntax analysis and manipulation. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct LuaTableExpr { syntax: LuaSyntaxNode, @@ -517,6 +531,18 @@ impl LuaTableExpr { } /// Whether this is a sequential ("array-style") table literal whose entries + /// are themselves table literals — e.g. + /// `{ { offset = .. }, { offset = .. } }`. + /// + /// Such literals carry meaningful per-row shape, so they are materialized as + /// a dynamic [`crate::LuaSyntaxKind::TableArrayExpr`]-backed table (with + /// integer-keyed members `[1]`, `[2]`, ...) rather than collapsed to a bare + /// `table`. Simple scalar arrays (`{ 1, 2, 3 }`) intentionally do NOT match, + /// so they stay summarized as `T[]`. + /// + /// This is a purely syntactic check so the declaration analyzer (which + /// registers members) and the inference pass (which assigns the type) make + /// the same decision without needing inferred element types. pub fn is_shaped_array_literal(&self) -> bool { if !self.is_array() { return false; diff --git a/crates/glua_parser/src/syntax/node/lua/path_trait.rs b/crates/glua_parser/src/syntax/node/lua/path_trait.rs index d08b45ae0..5e01d1a9e 100644 --- a/crates/glua_parser/src/syntax/node/lua/path_trait.rs +++ b/crates/glua_parser/src/syntax/node/lua/path_trait.rs @@ -18,6 +18,11 @@ fn join_path(paths: &[SmolStr]) -> SmolStr { pub trait PathTrait: LuaAstNode { /// The dotted access path of this expression, e.g. `foo.bar.baz`. + /// + /// Returns `SmolStr` because paths are short and this is one of the hottest + /// allocation sites in analysis. A bare name — by far the common case — + /// returns without allocating at all: `paths` stays empty, so its backing + /// buffer is never allocated, and a name of 22 bytes or fewer lives inline. fn get_access_path(&self) -> Option { let mut paths: Vec = Vec::new(); let mut current_node = self.syntax().clone(); @@ -64,6 +69,15 @@ pub trait PathTrait: LuaAstNode { } /// The access path used for *member-owner identity*, where a computed key + /// collapses to `[]`. + /// + /// [`get_access_path`](Self::get_access_path) spells a computed key out, so + /// `t[a]` and `t[b]` are distinct there -- which is what flow narrowing + /// needs, since those are different values. An owner is the other question: + /// both index the same table, so a field written through one has to be + /// visible to the other. Keeping the key text here gave one runtime slot as + /// many owners as the source had ways to spell its key, and + /// `clans[v.id].models = {}` was then invisible to `clans[ply._Clan].models`. fn get_owner_access_path(&self) -> Option { let mut paths: Vec = Vec::new(); let mut current_node = self.syntax().clone(); diff --git a/crates/glua_parser/src/syntax/node/token/number_analyzer.rs b/crates/glua_parser/src/syntax/node/token/number_analyzer.rs index f1ba74651..73526d668 100644 --- a/crates/glua_parser/src/syntax/node/token/number_analyzer.rs +++ b/crates/glua_parser/src/syntax/node/token/number_analyzer.rs @@ -10,6 +10,9 @@ pub fn float_token_value(token: &LuaSyntaxToken) -> Result { let hex = text.starts_with("0x") || text.starts_with("0X"); // This section handles the parsing of hexadecimal floating-point numbers. + // Hexadecimal floating-point literals are of the form 0x1.8p3, where: + // - "0x1.8" is the significand (integer and fractional parts in hexadecimal) + // - "p3" is the exponent (in decimal, base 2 exponent) let value = if hex { let hex_float_text = &text[2..]; let exponent_position = hex_float_text diff --git a/crates/glua_parser/src/text/reader.rs b/crates/glua_parser/src/text/reader.rs index ca31eb61c..2e8efc082 100644 --- a/crates/glua_parser/src/text/reader.rs +++ b/crates/glua_parser/src/text/reader.rs @@ -4,6 +4,38 @@ use std::str::Chars; pub const EOF: char = '\0'; /// Reader with look-ahead and look-behind methods. +/// +/// As you read text, the part that you've read is accumulated +/// in `current_range`. The part that you haven't seen yet is available +/// in `tail_range`: +/// +/// ```text +/// valid range: a b c d e f g +/// ^^^ - current range +/// ^^^^^^^ - tail range +/// ^ - prev char +/// ^ - current char +/// ^ - next char +/// ``` +/// +/// Once you call `reset_buff`, current range is advanced to start +/// at the current char, and shrunk to zero length: +/// +/// ```text +/// valid range: a b c d e f g +/// . - current range (empty, starts at `d`) +/// ^^^^^^ - tail range +/// ^ - prev char +/// ^ - current char +/// ^ - next char +/// ``` +/// +/// The workflow in roughly this: +/// +/// - you read characters, they're put into `saved_range`; +/// - once you're at a token boundary, you emit a token with `saved_range`, +/// then call `reset_buff`, +/// - you continue onto the next token. #[derive(Debug, Clone)] pub struct Reader<'a> { text: &'a str, diff --git a/tools/benchmark/src/main.rs b/tools/benchmark/src/main.rs index a6b39ce80..f4d986fc8 100644 --- a/tools/benchmark/src/main.rs +++ b/tools/benchmark/src/main.rs @@ -22,6 +22,8 @@ macro_rules! alloc_report { static GLOBAL: MiMalloc = MiMalloc; /// Counting wrapper over mimalloc. dhat is unusable here — its per-allocation +/// backtrace capture costs ~150x on Windows — so `--features alloc-stats` +/// buys allocation counts, bytes and live-peak for a couple of atomics. #[cfg(all(feature = "alloc-stats", not(feature = "dhat-heap")))] mod alloc_stats { use std::alloc::{GlobalAlloc, Layout}; @@ -123,9 +125,14 @@ struct BenchmarkResult { } /// Process start, so incremental edits can be located on a sampling profiler's +/// timeline. Set `BENCH_EDIT_LANDMARKS=1` to print a `t+Ns` landmark per edit +/// and window the samples to just the edit. static PROCESS_START: std::sync::OnceLock = std::sync::OnceLock::new(); /// Edit each sampled file (append a comment, so the token-identity no-op gate +/// does not skip the work), timing the full production keystroke cost: reindex +/// of the file plus its dependency expansion, then the post-edit diagnostics +/// pass. Restores the original text after each edit. Returns the worst edit. fn run_incremental_edits( analysis: &mut EmmyLuaAnalysis, sample: Vec<(FileId, usize)>, @@ -134,6 +141,8 @@ fn run_incremental_edits( let mut worst = std::time::Duration::ZERO; let mut edited = 0usize; // `BENCH_EDIT_REPEAT=N` edits each file N times, which both fills a + // sampling profiler's edit window and separates first-edit cache warming + // from the steady-state cost of typing. let repeats: usize = std::env::var("BENCH_EDIT_REPEAT") .ok() .and_then(|value| value.parse().ok()) @@ -180,15 +189,25 @@ fn run_incremental_edits( ); } // `BENCH_EDIT_STAGED=1` splits the keystroke into the two halves a + // position-based request actually depends on: re-indexing the edited + // file alone, then the dependency ripple. It reports what a handler + // gated on the edited file's own freshness would wait for. let reindex = if std::env::var_os("BENCH_EDIT_STAGED").is_some() { // The expansion has to be captured before the edit lands, exactly as + // the production edit path does; recomputing it after the edited + // file has been re-indexed under-expands. let expansion = analysis.expand_reindex_file_ids(vec![file_id]); analysis.update_file_text_only(&uri, edited_text); // Just the edited file's own entries — no cross-file stabilization + // and no expansion. This is the floor a position-based request has + // to wait for if it is gated on its own file rather than on the + // whole ripple. let t = Instant::now(); analysis.self_index_files(vec![file_id]); let self_only = t.elapsed(); // `BENCH_EDIT_SELF_ONLY=1` stops after the edited file's own + // entries, so a profile of the run contains nothing but the cost a + // per-file freshness gate would pay. let ripple = if std::env::var_os("BENCH_EDIT_SELF_ONLY").is_some() { std::time::Duration::ZERO } else { @@ -223,6 +242,10 @@ fn run_incremental_edits( diagnostics.as_secs_f64() ); // Reverting through the full path costs a whole ripple per iteration — + // several times the self-index being measured, and untimed, so it + // would dominate any profile of this loop. `BENCH_EDIT_SELF_ONLY` + // exists to leave nothing but the self-index in the profile, so the + // revert has to match it. if std::env::var_os("BENCH_EDIT_SELF_ONLY").is_some() { analysis.update_file_text_only(&uri, text); analysis.self_index_files(vec![file_id]); @@ -264,7 +287,13 @@ fn contribution_entries(analysis: &EmmyLuaAnalysis, file_id: FileId) -> Vec Vec { } /// Analysis recurses over deeply nested syntax. The server does that work on +/// spawned threads, which get a far larger stack than a process main thread does +/// on Windows, so the tools have to ask for one explicitly. fn main() { std::thread::Builder::new() .stack_size(256 * 1024 * 1024) @@ -423,6 +454,10 @@ async fn run() { analysis.add_library_workspace(annotations_path.clone()); // The server resolves the gamemode base and loads it as a library, so a + // benchmark without those roots re-indexes a much smaller dependency + // expansion than a keystroke really pays for. `BENCH_LIBS` lists the extra + // library roots (e.g. the `sandbox` and `base` gamemodes) so the harness + // measures the workspace the editor actually has open. let extra_libraries = std::env::var("BENCH_LIBS") .ok() .into_iter() @@ -517,6 +552,11 @@ async fn run() { }); // Phase 4b: Incremental edit latency — the full cost a keystroke pays + // once it lands: reindex of the edited file plus its whole dependency + // expansion, then the post-edit diagnostics pass (shared-data recompute + // + the edited file), matching the production LS flow. Worst-case + // biased: files are ranked by reindex-expansion size and the top hubs + // are edited. let mut incremental_worst: Option = None; if std::env::var("BENCH_INCREMENTAL").is_ok() { let main_ids = analysis @@ -567,6 +607,8 @@ async fn run() { ranked.sort_by_key(|(_, n)| std::cmp::Reverse(*n)); { // What an edit costs depends almost entirely on how many files + // it drags in, so the shape of that distribution matters more + // than the worst case the sample below reports. let sizes: Vec = ranked.iter().map(|(_, n)| *n).collect(); let total = sizes.len(); let pct = |p: usize| sizes[(total.saturating_sub(1)) * (100 - p) / 100]; @@ -788,6 +830,9 @@ async fn run() { eprintln!("Target: ≤10s"); // A single-file edit is the interactive hot path: the user is typing, and + // every keystroke that lands pays reindex + diagnostics. Budget it + // separately from the cold index — a workspace that indexes in 10s is + // useless if each edit costs a second. if let Some(worst) = incremental_worst { let incremental_target = std::time::Duration::from_secs(1); let status = if worst <= incremental_target { diff --git a/tools/lsp_latency.js b/tools/lsp_latency.js index 8fbd9d6e6..71489ad6b 100644 --- a/tools/lsp_latency.js +++ b/tools/lsp_latency.js @@ -1,4 +1,40 @@ -// Interactive latency harness for glua_ls. +// Interactive latency harness for the language server. +// +// Drives a real `glua_ls` binary over stdio with the capabilities and the +// cancellation behaviour vscode-languageclient actually uses, then reports how +// long the requests a user waits on take. Unit tests cannot catch what this +// measures: the cost of a request is dominated by whether a reindex is pending, +// which only shows up against a real workspace. +// +// Usage: +// LSP_CODEBASE=/path/to/workspace \ +// LSP_ANNOTATIONS=/path/to/annotations/output \ +// node tools/lsp_latency.js [--json] [--runs N] [--file relative/path.lua] +// [--edit code|comment] +// [--edit-find TEXT --edit-replace TEXT] +// [--completion-find TEXT] +// +// --edit-find/--edit-replace swap one snippet back and forth on every edit, so +// a specific real change to real code can be reproduced. --completion-find puts +// the cursor immediately after a given snippet instead of at the first member +// access in the file. Together they reproduce one user's exact complaint. +// +// --edit code (default) inserts a global function declaration, so each edit +// really changes what the file exports and the full dependency ripple runs. +// --edit comment inserts a comment line instead: cheaper, and useful for +// isolating offset-shift cost, but an optimisation that only helps this case +// has not made real typing any faster. +// +// LSP_SERVER overrides the binary (default: target/dist/glua_ls[.exe] if built, +// else target/release/glua_ls[.exe] — see defaultServerPath, and prefer `dist`). +// LSP_SERVER_ARGS passes extra space-separated arguments to the server, e.g. +// LSP_SERVER_ARGS='--log-level debug' to profile a slow path. +// --file defaults to the largest .lua file in the workspace, which is the +// pessimistic case and keeps runs comparable without naming a file per repo. +// +// Exits non-zero on a correctness check, not on a latency number: a cancelled +// diagnostic pull answered with a report thinner than the settled one, or a +// mid-edit completion that disagrees with the settled one. 'use strict'; const { spawn } = require('child_process'); @@ -39,7 +75,11 @@ function parseArgs(argv) { return opts; } -/** Prefers dist profile. */ +/** + * Prefers the `dist` profile, which is what ships. `release` lacks its thin LTO + * and single codegen unit, so measuring it reports numbers no user experiences — + * an easy mistake to make for a whole session before noticing. + */ function defaultServerPath() { const exe = process.platform === 'win32' ? 'glua_ls.exe' : 'glua_ls'; const dist = path.resolve(__dirname, '..', 'target', 'dist', exe); @@ -118,7 +158,11 @@ class LspClient { _dispatch(message) { if (message.id !== undefined && message.method) { - // Answer server requests. + // Server-initiated request. Record it and answer so the server is + // never left waiting on us. `workspace/configuration` has to come + // back as one entry per requested item; null is not a valid result + // and would have the server fall back to something a real client + // never makes it use. this.serverRequests.add(message.method); const result = message.method === 'workspace/configuration' ? ((message.params && message.params.items) || []).map(() => ({})) @@ -163,7 +207,11 @@ class LspClient { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -/** VS Code capabilities for server. */ +/** + * The subset of VS Code's capabilities that changes server behaviour on the + * paths this tool measures. Keep in step with vscode-languageclient: the point + * is to exercise what the real client exercises. + */ function clientCapabilities() { return { general: { @@ -218,7 +266,14 @@ function describeReport(result) { // ------------------------------------------------------------- scenarios --- -/** Finds position after '.' for completion. */ +/** + * Finds a position just after a `.` on a member access, which is the case that + * matters most: it forces the server to resolve a receiver type through the + * index rather than listing globals. + * + * `self.` is preferred because resolving it exercises the class/type indexes + * rather than a module table, which is where a stale index shows up first. + */ function memberAccessPosition(text) { const lines = text.split('\n'); const find = (pattern) => { @@ -299,7 +354,9 @@ async function main() { }); await sleep(1500); - // Completion position via --completion-find. + // `--completion-find` puts the cursor immediately after a given snippet, so + // a specific completion can be reproduced instead of whatever member access + // happens to come first in the file. const completionPosition = (currentText) => { if (opts.completionFind === null) return memberAccessPosition(currentText); const index = currentText.indexOf(opts.completionFind); @@ -326,7 +383,8 @@ async function main() { const labelsOf = (items) => (Array.isArray(items) ? items : []).map((item) => item.label); - // Completion position recomputed per call. + // Recomputed per call: every edit inserts a line, so a position captured + // once would drift and silently start measuring an empty completion. const completionAt = async () => client.request('textDocument/completion', { textDocument: { uri }, position: completionPosition(text), @@ -338,7 +396,9 @@ async function main() { version += 1; editSerial += 1; - // Specific edit via --edit-find. + // `--edit-find`/`--edit-replace` reproduce one specific edit, swapping + // back and forth so every keystroke is a real change to real code. Use + // it to measure the edit a user actually complained about. if (opts.editFind !== null) { const [from, to] = editSerial % 2 === 1 ? [opts.editFind, opts.editReplace] @@ -354,7 +414,11 @@ async function main() { return; } - // Insert edit per --edit mode. + // `--edit comment` keeps the edit syntactically inert, which makes runs + // comparable but is also the case an early-cutoff optimisation can make + // fast without helping anyone. `--edit code` (the default) declares a + // global function instead, so the edit really does change what the file + // exports and the whole dependency ripple has to run. const inserted = opts.edit === 'comment' ? '-- perf\n' : `function _PerfProbe${editSerial}(a) return a end\n`; @@ -365,7 +429,9 @@ async function main() { }); }; - // Wait for analysis to settle. + // A settled measurement is only meaningful once nothing is pending. An + // uncancelled diagnostic pull returns exactly when the analysis is fresh, + // so it is the cheapest way to wait for quiescence without guessing. const waitUntilQuiet = async () => { await client.request('textDocument/diagnostic', { textDocument: { uri }, previousResultId, @@ -375,7 +441,7 @@ async function main() { for (let run = 0; run < opts.runs; run++) { await waitUntilQuiet(); - // Measure without pending edits. + // Settled: no pending edit, so this is the pure compute cost. const settled = await completionAt(); settledCompletion.push(settled.ms); const items = settled.message.result @@ -391,7 +457,10 @@ async function main() { if (described.resultId) previousResultId = described.resultId; report.checks.diagnosticCount = described.count ?? report.checks.diagnosticCount; - // Measure while typing. + // While typing: the request the user actually waits on. Its result is + // compared against the settled one, because the whole risk of answering + // before a reindex finishes is answering *differently* — a thinner or + // wrong list is the failure mode, not a slow one. editDocument(); const typing = await completionAt(); typingCompletion.push(typing.ms); @@ -405,7 +474,10 @@ async function main() { completionDrift.push({ missing: missing.length, extra: extra.length, sampleMissing: missing.slice(0, 5) }); - // Measure hover separately. + // Completion is not the only thing gated on freshness. Hover is issued + // from the same keystroke and measured separately, so a fix that makes + // completion answer early but leaves every other position-based feature + // parked shows up here instead of looking like a win. editDocument(); const [hovered, completed] = await Promise.all([ client.request('textDocument/hover', { @@ -416,7 +488,9 @@ async function main() { typingHover.push(hovered.ms); typingCompletionConcurrent.push(completed.ms); - // Measure semantic tokens. + // Syntax highlighting. The client retries on ContentModified rather than + // waiting, so the number the user feels is the time until a real token + // set arrives, not the latency of any single request. editDocument(); const highlightStarted = Date.now(); let highlightRetried = 0; @@ -439,7 +513,12 @@ async function main() { }); editToFresh.push(fresh.ms); - // Verify cancelled pull does not drop diagnostics. + // A pull cancelled mid-flight must never come back thinner than the + // settled answer — a full report short of what the file really has is + // what drops diagnostics in VS Code, and an empty one clears the file. + // Stated against the settled count rather than against zero, so a file + // that legitimately has no diagnostics does not read as a failure: on a + // clean file the correct report is empty too. editDocument(); const doomed = client.request('textDocument/diagnostic', { textDocument: { uri }, previousResultId, @@ -465,7 +544,8 @@ async function main() { report.measurements.editToFreshAnswer = summarise(editToFresh); report.checks.thinReportsOnCancel = cancelledPulls.filter((p) => p.thinFullReport).length; - // Check mid-edit completion matches baseline. + // A mid-edit completion that differs from the settled one is a correctness + // regression, however fast it came back. report.checks.completionDriftWhileTyping = { worstMissing: Math.max(0, ...completionDrift.map((d) => d.missing)), worstExtra: Math.max(0, ...completionDrift.map((d) => d.extra)), From 1b4d61f7de93a54ed47344be22cd876db44f8c80 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:29:03 +0100 Subject: [PATCH 075/159] fix: a guard snapshot outlived the burst that took it --- crates/glua_code_analysis/src/lib.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/glua_code_analysis/src/lib.rs b/crates/glua_code_analysis/src/lib.rs index cf04d2b6b..93caa8059 100644 --- a/crates/glua_code_analysis/src/lib.rs +++ b/crates/glua_code_analysis/src/lib.rs @@ -2492,6 +2492,7 @@ impl EmmyLuaAnalysis { // overwrote. let snapshot = self.inferred_guard_snapshot(&before_expansion.iter().copied().collect::>()); + let opened_the_burst = self.pending_guard_snapshot.is_none(); self.pending_guard_snapshot.get_or_insert(snapshot); self.self_index_files(file_ids.clone()); self.stabilize_cross_file_type_caches(&file_ids); @@ -2503,8 +2504,14 @@ impl EmmyLuaAnalysis { } } if changed.is_empty() { - // The guard snapshot is left alone: an earlier batch in this burst - // may still owe a ripple that has to diff against it. + // An earlier batch in this burst may still owe a ripple that has to + // diff against its snapshot, so that one is left alone. The one + // this call took is not: no ripple is owed for it, and nothing else + // clears it, so it would be handed to the next unrelated edit's + // ripple as if it were that edit's own before-state. + if opened_the_burst { + self.pending_guard_snapshot = None; + } return (Vec::new(), Vec::new()); } // The before expansion already contains the dependents of the changed From 180b2ff3905b4fb3b662428f499768cf60f8bdd1 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:29:09 +0100 Subject: [PATCH 076/159] fix: a dynamic field stayed on the range its literal left --- .../src/db_index/dynamic_field/mod.rs | 129 ++++++++++++++++-- 1 file changed, 121 insertions(+), 8 deletions(-) diff --git a/crates/glua_code_analysis/src/db_index/dynamic_field/mod.rs b/crates/glua_code_analysis/src/db_index/dynamic_field/mod.rs index 35583287b..95f63b35f 100644 --- a/crates/glua_code_analysis/src/db_index/dynamic_field/mod.rs +++ b/crates/glua_code_analysis/src/db_index/dynamic_field/mod.rs @@ -82,6 +82,35 @@ fn definition_sort_key(definition: &InFiled) -> (u32, u32, u32) { ) } +/// Merges one owner's named definitions into another's, per field name, so a +/// name both hold keeps the definitions of each. +fn merge_field_definitions( + into: &mut HashMap>>, + from: HashMap>>, +) { + for (field_name, definitions) in from { + // The canonical order and the no-duplicates rule `add_field_inner` + // maintains on insert have to survive the merge. + let slot = into.entry(field_name).or_default(); + slot.extend(definitions); + slot.sort_unstable_by_key(definition_sort_key); + slot.dedup(); + } +} + +/// Appends the wildcard definitions the target does not already hold. +/// +/// Order is left alone: `add_wildcard_definition` files these in walk order +/// rather than a canonical one, so sorting here would make a re-indexed +/// workspace disagree with a cold build. +fn merge_wildcard_definitions(into: &mut Vec>, from: Vec>) { + for definition in from { + if !into.contains(&definition) { + into.push(definition); + } + } +} + impl DynamicFieldIndex { pub fn new() -> Self { Self::default() @@ -210,8 +239,13 @@ impl DynamicFieldIndex { } } + // The moved group is merged into whatever the target key already + // holds rather than replacing it. Only literals with a stable anchor + // are in `map`, so an unanchored literal's cross-file entry can already + // sit on the range an anchored one moves onto; `extend` on the nested + // maps would drop that entry instead of merging it. macro_rules! remap_owner_keyed { - ($field:expr) => {{ + ($field:expr, |$slot:ident, $group:ident| $merge:block) => {{ let moved: Vec<(DynamicFieldOwner, DynamicFieldOwner)> = $field .keys() .filter_map(|owner| Some((owner.clone(), remap_owner(owner, map)?))) @@ -222,16 +256,31 @@ impl DynamicFieldIndex { .into_iter() .filter_map(|(old, new)| Some((new, $field.remove(&old)?))) .collect(); - for (new, value) in detached { - $field.entry(new).or_default().extend(value); + for (new, group) in detached { + let $slot = $field.entry(new).or_default(); + let $group = group; + $merge } }}; } - remap_owner_keyed!(self.field_definitions); - remap_owner_keyed!(self.direct_field_definitions); - remap_owner_keyed!(self.finite_named_members); - remap_owner_keyed!(self.wildcard_definitions); + remap_owner_keyed!(self.owner_fields, |slot, group| { + for (field_name, files) in group { + slot.entry(field_name).or_default().extend(files); + } + }); + remap_owner_keyed!(self.field_definitions, |slot, group| { + merge_field_definitions(slot, group) + }); + remap_owner_keyed!(self.direct_field_definitions, |slot, group| { + merge_field_definitions(slot, group) + }); + remap_owner_keyed!(self.finite_named_members, |slot, group| { + slot.extend(group) + }); + remap_owner_keyed!(self.wildcard_definitions, |slot, group| { + merge_wildcard_definitions(slot, group) + }); for entries in self.file_contributions.values_mut() { for (owner, _, _) in entries.iter_mut() { @@ -258,8 +307,9 @@ impl DynamicFieldIndex { DynamicFieldOwner::Type(_) => None, } } - self.field_definitions + self.owner_fields .keys() + .chain(self.field_definitions.keys()) .chain(self.direct_field_definitions.keys()) .chain(self.finite_named_members.keys()) .chain(self.wildcard_definitions.keys()) @@ -650,6 +700,69 @@ mod tests { TextRange::new(TextSize::from(start), TextSize::from(end)) } + fn shift( + file_id: FileId, + from: TextRange, + to: TextRange, + ) -> rustc_hash::FxHashMap, InFiled> { + let mut map = rustc_hash::FxHashMap::default(); + map.insert(InFiled::new(file_id, from), InFiled::new(file_id, to)); + map + } + + /// Every owner-keyed store has to move together. `owner_fields` backs + /// `has_field`, so leaving it behind strands the field on a range no type + /// resolves to any more. + #[test] + fn remapping_a_literal_moves_the_field_lookup_with_it() { + let edited = FileId::new(1); + let contributor = FileId::new(2); + let old = DynamicFieldOwner::Table(InFiled::new(edited, range(0, 10))); + let new = DynamicFieldOwner::Table(InFiled::new(edited, range(20, 30))); + + let mut index = DynamicFieldIndex::new(); + index.add_field(old.clone(), SmolStr::new("f"), contributor, range(1, 2)); + index.remap_table_ranges(&shift(edited, range(0, 10), range(20, 30))); + + assert!(index.has_field(&new, "f")); + assert!(!index.has_field(&old, "f")); + assert_eq!(index.field_definitions(&new, "f").len(), 1); + } + + /// Only anchored literals are remapped, so an unanchored one's cross-file + /// entry can already sit on the range an anchored one moves onto. Merging + /// has to keep both, per field name. + #[test] + fn remapping_onto_an_occupied_range_keeps_both_owners_definitions() { + let edited = FileId::new(1); + let contributor = FileId::new(2); + let moved_from = DynamicFieldOwner::Table(InFiled::new(edited, range(0, 10))); + let occupied = DynamicFieldOwner::Table(InFiled::new(edited, range(20, 30))); + + let mut index = DynamicFieldIndex::new(); + index.add_field( + moved_from.clone(), + SmolStr::new("shared"), + contributor, + range(1, 2), + ); + index.add_field( + occupied.clone(), + SmolStr::new("shared"), + contributor, + range(3, 4), + ); + index.remap_table_ranges(&shift(edited, range(0, 10), range(20, 30))); + + assert_eq!( + index.field_definitions(&occupied, "shared"), + [ + InFiled::new(contributor, range(1, 2)), + InFiled::new(contributor, range(3, 4)), + ] + ); + } + #[test] fn remove_prunes_orphaned_field_definitions_without_contribution_entries() { let file_to_remove = FileId::new(1); From 141c998d7f157ea58580c0d5554d08c85248d2df Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 30 Aug 2026 03:13:34 +0100 Subject: [PATCH 077/159] fix: calling a value typed any answered undetermined --- .../src/compilation/test/out_of_order.rs | 56 +++++++++++++++++++ .../src/semantic/infer/infer_call/mod.rs | 19 +++++++ 2 files changed, 75 insertions(+) diff --git a/crates/glua_code_analysis/src/compilation/test/out_of_order.rs b/crates/glua_code_analysis/src/compilation/test/out_of_order.rs index 0f082efe0..cf3ac2994 100644 --- a/crates/glua_code_analysis/src/compilation/test/out_of_order.rs +++ b/crates/glua_code_analysis/src/compilation/test/out_of_order.rs @@ -210,4 +210,60 @@ mod test { ); } } + + /// A member with one definition per realm settles to `any`, so a caller + /// that reaches it after that resolves the call against an `any` callee. + /// Answering "cannot infer" there makes the call's type depend on when the + /// caller was walked: the file walk can still see a signature and write a + /// real type, while a later unresolve retry sees the settled `any` and + /// comes back undetermined, and which of the two claims the slot is a + /// property of how the workspace was batched rather than of the source. + /// Calling `any` yields `any`, so both paths agree. + #[test] + fn test_call_on_an_any_callee_yields_any() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.def( + r#" + ---@type any + AnyCallee = nil + + ---@class AnyHolder + ---@field opaque any + + ---@type AnyHolder + AnyHolderValue = nil + + AnyPlainCall = AnyCallee() + AnyCallWithArgs = AnyCallee(1, "two") + AnyMemberCall = AnyHolderValue.opaque() + "#, + ); + + assert!(ws.expr_ty("AnyCallee").is_any()); + assert!(ws.expr_ty("AnyHolderValue.opaque").is_any()); + + for expr in ["AnyPlainCall", "AnyCallWithArgs", "AnyMemberCall"] { + let ty = ws.expr_ty(expr); + assert!( + ty.is_any(), + "calling an `any` callee should yield `any`, got {ty:?} for {expr}" + ); + } + } + + /// The synthesized callable has to accept any arity, or every argument to + /// an `any` callee is reported redundant. + #[test] + fn test_call_on_an_any_callee_reports_no_redundant_parameter() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + assert!(ws.check_code_for( + crate::DiagnosticCode::RedundantParameter, + r#" + ---@type any + local opaque + + local _ = opaque(1, 2, 3) + "#, + )); + } } diff --git a/crates/glua_code_analysis/src/semantic/infer/infer_call/mod.rs b/crates/glua_code_analysis/src/semantic/infer/infer_call/mod.rs index 95e5fd229..5db2b21d4 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_call/mod.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_call/mod.rs @@ -8,6 +8,7 @@ use super::{ super::{InferGuard, LuaInferCache, instantiate_type_generic, resolve_signature}, InferFailReason, InferResult, }; +use crate::AsyncState; use crate::compilation::analyzer::unresolve::get_wrapped_callable_target_expr; use crate::{ CacheEntry, DbIndex, InFiled, LuaArrayType, LuaFunctionType, LuaGenericType, LuaInstanceType, @@ -132,6 +133,24 @@ pub fn infer_call_expr_func( infer_union(db, cache, union, call_expr.clone(), args_count) } } + // Calling `any` yields `any`, the same answer every other reader of an + // `any` gets. Failing instead makes the call's type depend on whether + // some earlier write happened to reach the slot first: a member with + // two realm-branched definitions settles to `any`, so the walk can + // infer the call against a signature while a later unresolve retry + // infers it against the settled `any` and comes back undetermined. + // Which of the two lands is a property of how the workspace was + // batched, not of the source. + // The `...` param is what makes it accept any arity: the arity checker + // looks for that name, so omitting it reports every argument as + // redundant. + LuaType::Any => Ok(Arc::new(LuaFunctionType::new( + AsyncState::None, + false, + true, + vec![("...".to_string(), Some(LuaType::Any))], + LuaType::Any, + ))), _ => Err(InferFailReason::None), }; let result = match result { From 357d23d8c90b6dde4bc4fed5936775a7d4dc6638 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 30 Aug 2026 04:03:41 +0100 Subject: [PATCH 078/159] docs: clarify determinism harness --- tools/determinism/src/main.rs | 314 +++++++++++++--------------------- 1 file changed, 118 insertions(+), 196 deletions(-) diff --git a/tools/determinism/src/main.rs b/tools/determinism/src/main.rs index 882c0a89b..3cb91ae69 100644 --- a/tools/determinism/src/main.rs +++ b/tools/determinism/src/main.rs @@ -5,45 +5,35 @@ //! `init_analysis` does, then re-analyses it in various ways and diffs both the //! diagnostic sets and the derived indexes they are read from. //! -//! The stages are ordered by how much they re-analyse, which is what makes a -//! divergence diagnosable: if `allreindex` matches cold but `mainreindex` does -//! not, re-analysis itself is sound and the gap is in which files a partial -//! re-index covers; if `allreindex` diverges too, per-file removal is leaving -//! state behind. `mainexpand` is the production path — it is the one that has to -//! be identical. +//! The stages are ordered by how much they re-analyse, which localises a +//! divergence: `allreindex` matching cold while `mainreindex` does not puts the +//! gap in which files a partial re-index covers; both diverging puts it in +//! per-file removal. //! //! # Release gates vs bisect stages //! -//! Only some stages are pass/fail. The gates are `repeat`, `noopedit`, -//! `realedit`, `mainexpand`, `allreindex`, `reindex`, `order` and `fresh`: each -//! of these runs a path the language server actually takes (or a ground-truth -//! rebuild), so any divergence one of them reports is a defect that ships. +//! Gates: `repeat`, `fresh`, `order`, `reindex`, `allreindex`, `mainexpand`, +//! `noopedit`, `realedit`, `editrevert`, `indexrepeat`, `burst`. Each runs a +//! path the language server takes, or a ground-truth rebuild. Every one must +//! report `+0` diagnostics and `+0` index. //! -//! `noopedit` and `realedit` gate different halves of editing. `noopedit` -//! verifies that the semantic no-op gate skips the re-index and that skipping -//! preserves state — it cannot verify re-analysis, because its edit pair is -//! exactly what that gate rejects as meaningless. `realedit` is the gate for -//! re-analysis itself: its edit changes what the file means, so the incremental -//! result has to land where a cold build of the edited source lands. It does -//! not reach zero today — see AGENTS.md for the known divergence and its cause -//! — so measure it before and after a change and treat any *growth* as yours. +//! `noopedit` and `realedit` gate different halves of editing. `noopedit` gates +//! the semantic no-op skip; its edit pair is what that gate rejects, so it +//! cannot reach re-analysis. `realedit`'s edit changes what the file means, so +//! it gates re-analysis against a cold build of the edited source. //! -//! `mainreindex`, `exact`, `editmid` and `split:N` are **bisect stages** — diagnostic -//! instruments, not gates, and they are expected to diverge. Both `mainreindex` -//! and `exact` go through `reindex_files_without_expansion`, which deliberately -//! skips three convergence passes that production's `reindex_files` performs +//! Bisect stages, expected to diverge: `mainreindex`, `exact`, `editmid`, +//! `split:N`. `mainreindex` and `exact` run `reindex_files_without_expansion`, +//! which skips three convergence passes production performs //! (`refresh_file_source_dependencies`, -//! `reindex_changed_inferred_guard_references` and -//! `reindex_changed_inferred_param_consumers`). They are "production minus its -//! fixpoint": a divergence there localises which file's re-analysis perturbs a -//! fact, and is only a defect if `mainexpand` diverges too. `split:N` likewise -//! only answers whether a fact depends on batch composition. +//! `reindex_changed_inferred_guard_references`, +//! `reindex_changed_inferred_param_consumers`). A divergence there localises +//! which file's re-analysis perturbs a fact. `split:N` answers whether a fact +//! depends on batch composition. //! -//! `restabilize` is its own thing: it is a demonstration that re-running does -//! not converge, not a stage anything is expected to pass. -//! -//! `expandwhy` and `faithful` are measurements, not comparisons: they report -//! numbers rather than diff two snapshots, so they pass or fail nothing. +//! `restabilize` demonstrates that re-running without removing first does not +//! converge. `expandwhy` and `faithful` report numbers rather than diff +//! snapshots, so they gate nothing. //! //! Example: //! DET_CODEBASE=/path/to/addon DET_ANNOTATIONS=/path/to/annotations/output \ @@ -67,22 +57,11 @@ //! indexrepeat //! re-index each DET_TARGETS entry with its text //! untouched and require the INDEX to come back -//! identical. The diagnostic gates cannot see -//! this: re-analysing a file can attach different -//! members, or settle a decl's type differently -//! from the cold build, and still produce the same -//! diagnostics — `repeat` and `noopedit` both -//! report IDENTICAL while the index underneath has -//! drifted. That drift is why no incremental work -//! can be skipped: every "did this actually -//! change?" test answers yes. Does **not** pass -//! today (CityRP: 82 type caches, 3 signatures and -//! 11 class members change), and it is a real -//! defect rather than a harness artefact, so treat -//! any *growth* in those counts as yours. Listed -//! last in the default set because it re-indexes -//! in place and leaves that warm state behind, so -//! an in-place stage after it inherits it. +//! identical. Catches drift the diagnostic gates +//! cannot see. Honours DET_INDEXREPEAT_ROUNDS +//! (default 1). Listed last in the default set: it +//! re-indexes in place, so an in-place stage after +//! it inherits that warm state. //! editmid offset-shifting no-op edit pair (newline at the //! front of the file, then removed): the semantic //! no-op gate cannot fire, so both edits run the @@ -196,16 +175,12 @@ use std::alloc::{GlobalAlloc, Layout}; /// mimalloc, plus a count of every allocation it hands out. /// -/// A sampling profiler blames the allocator, never the code that asked for the -/// memory, so it cannot answer "is this phase allocation-bound?". Counting can: -/// `GLUALS_PROFILE=1` prints allocations alongside each phase's cost, and -/// dividing by the phase's unit of work gives allocations-per-step directly. +/// A sampling profiler blames the allocator rather than the caller. +/// `GLUALS_PROFILE=1` prints allocation counts alongside each phase's cost. struct CountingMiMalloc; -/// Sample one in every `DET_ALLOC_SAMPLE` allocations and record where it came -/// from. This is a poor-man's allocation profiler: it attributes allocations to -/// source locations, which a CPU sampling profiler cannot do (it blames the -/// allocator) — and it works even where external profilers fail to read the PDB. +/// Sample one in every `DET_ALLOC_SAMPLE` allocations and record its source +/// location. mod alloc_sample { use std::collections::HashMap; use std::sync::Mutex; @@ -227,11 +202,9 @@ mod alloc_sample { /// Raw instruction pointers for the current stack, innermost first. Returns /// how many of `buffer` were filled. /// - /// `backtrace::trace` goes through dbghelp's StackWalkEx on Windows, which - /// takes a process-wide lock and costs milliseconds per capture: a full run - /// never finished. `RtlCaptureStackBackTrace` unwinds via the x64 unwind - /// tables instead and costs microseconds. Elsewhere the crate's own unwinder - /// is already cheap enough. + /// Windows uses `RtlCaptureStackBackTrace`, which unwinds via the x64 + /// unwind tables. `backtrace::trace` goes through dbghelp's StackWalkEx + /// there, taking a process-wide lock and costing milliseconds per capture. #[cfg(windows)] fn capture(buffer: &mut [*mut std::ffi::c_void]) -> usize { let captured = unsafe { @@ -265,9 +238,8 @@ mod alloc_sample { /// Frame address -> number of sampled allocations whose stack contained it. /// Addresses are resolved to names once, at report time. static FRAMES: Mutex>> = Mutex::new(None); - /// Ordered stacks (innermost first) -> count. Kept alongside FRAMES because - /// "which of our functions asked for this memory" needs frame order, which - /// the flat per-frame tally throws away. + /// Ordered stacks (innermost first) -> count. Kept alongside FRAMES, whose + /// flat per-frame tally throws frame order away. type StackCounts = HashMap, u64>; static STACKS: Mutex> = Mutex::new(None); @@ -336,10 +308,9 @@ mod alloc_sample { }); } - /// Attribute each sampled allocation to the innermost frame belonging to our - /// own crates. The inclusive tally says an allocation happened *somewhere* - /// under a function; this says which of our functions actually asked for the - /// memory, which is the line you can go and change. + /// Attribute each sampled allocation to the innermost frame in our own + /// crates. The inclusive tally says an allocation happened somewhere under a + /// function; this says which function asked for it. fn nearest_caller_report(top: usize) { let stacks = STACKS.lock().unwrap_or_else(|p| p.into_inner()); let Some(stacks) = stacks.as_ref() else { @@ -542,9 +513,8 @@ fn build_analysis(codebase: &Path, annotations: &Path) -> EmmyLuaAnalysis { build_analysis_with(codebase, annotations, Order::Natural, 1, &[]) } -/// Comparison key for a workspace file path. The override list is written by -/// hand from `DET_TARGETS`, so it has to match what the loader collected -/// regardless of separator or case. +/// Comparison key for a workspace file path. `DET_TARGETS` is written by hand, +/// so it has to match the loader's paths regardless of separator or case. fn path_key(path: &Path) -> String { path.to_string_lossy().replace('\\', "/").to_lowercase() } @@ -689,12 +659,11 @@ fn collect(analysis: &EmmyLuaAnalysis, label: &str) -> BTreeSet { struct IndexSnapshot { type_caches: BTreeMap, /// How each cached type was reached, under `DET_PROVENANCE`. Diffed apart - /// from the type so a value that stayed put while the reasoning behind it - /// moved is still visible. + /// from the type, so a value that stayed put while its reasoning moved is + /// still visible. type_facts: BTreeMap, /// The assignment-contribution store, under `DET_PROVENANCE`: which writers - /// each owner/key group merges. A member's type is a function of this, so a - /// group that gains or loses a writer explains a drifting type directly. + /// each owner/key group merges. A member's type is a function of this. contribution_groups: BTreeMap, /// Where each member is currently homed, under `DET_PROVENANCE`. Class /// member lists and contribution groups are both keyed off this. @@ -702,9 +671,8 @@ struct IndexSnapshot { members: BTreeSet, net_flows: Vec, inferred_params: BTreeMap, - /// Class hierarchy. A `---@class A : B` link that survives a cold build but - /// not a partial re-index silently breaks inherited-member lookup, which - /// then falls back to a by-name search and can land on a sibling class. + /// Class hierarchy. A dropped `---@class A : B` link breaks inherited-member + /// lookup, which falls back to a by-name search and can land on a sibling. super_types: BTreeMap, class_members: BTreeMap, signatures: BTreeMap, @@ -715,10 +683,9 @@ fn collect_index(analysis: &EmmyLuaAnalysis, label: &str) -> IndexSnapshot { let type_index = db.get_type_index(); let mut type_caches = BTreeMap::new(); let mut type_facts: BTreeMap = BTreeMap::new(); - // `DET_PROVENANCE=1` records how each cached type was reached, not just what - // it is. Drift entries then carry the pass that produced them on both sides, - // so the whole set can be grouped by cause in one run rather than traced one - // at a time. + // `DET_PROVENANCE=1` records how each cached type was reached, so drift + // entries carry the producing pass on both sides and can be grouped by + // cause. let with_provenance = std::env::var_os("DET_PROVENANCE").is_some(); for (owner, cache) in type_index.iter_type_caches() { let value = format!("{:?}", cache.as_type()); @@ -737,10 +704,9 @@ fn collect_index(analysis: &EmmyLuaAnalysis, label: &str) -> IndexSnapshot { None => ("-".into(), "-".into(), String::new()), }; let doc = if cache.is_doc() { "doc" } else { "infer" }; - // How many writers this member's value is merged from, and whether - // any of them contributed an unsettled type. If drift tracks these - // rather than the individual site, the cause is the merge, not the - // sites. + // How many writers this member's value merges, and whether any + // contributed an unsettled type. Drift tracking these rather than + // the individual site points at the merge. let writers = match owner { glua_code_analysis::LuaTypeOwner::Member(member_id) => { let member_index = db.get_member_index(); @@ -799,9 +765,8 @@ fn collect_index(analysis: &EmmyLuaAnalysis, label: &str) -> IndexSnapshot { let mut super_types = BTreeMap::new(); // Ordered member list per class. Two members can share a key (a doc `@field` - // and a file define, or two partial-class contributions); which one wins is - // decided by this order, so drift here silently changes overload selection - // without changing the member set at all. + // and a file define, or two partial-class contributions) and this order picks + // the winner, so drift changes overload selection without changing the set. let mut class_members = BTreeMap::new(); for type_decl in type_index.get_all_types() { let decl_id = type_decl.get_id(); @@ -830,9 +795,8 @@ fn collect_index(analysis: &EmmyLuaAnalysis, label: &str) -> IndexSnapshot { } } - // Same visible-item view for *non-type* owners (table constants, element - // owners, global paths). Those decide member lookup on anonymous tables, - // and drift there is invisible to `class_members`. + // The same visible-item view for non-type owners (table constants, element + // owners, global paths), which decide member lookup on anonymous tables. for (key, owner) in &all_owners { if let Some(members) = db.get_member_index().get_members(owner) { let ordered = members @@ -843,9 +807,8 @@ fn collect_index(analysis: &EmmyLuaAnalysis, label: &str) -> IndexSnapshot { } } - // Resolved signature shapes. `inferred_params` only covers call-site-derived - // facts; the signature's own param and return types are a separate index and - // drift there changes overload selection without moving a single member. + // Resolved signature shapes. `inferred_params` covers only call-site-derived + // facts; a signature's own param and return types are a separate index. let mut signatures = BTreeMap::new(); for (signature_id, signature) in db.get_signature_index().iter() { signatures.insert( @@ -879,8 +842,7 @@ fn collect_index(analysis: &EmmyLuaAnalysis, label: &str) -> IndexSnapshot { net_flows.len() ); // Where every member ended up. Contribution groups and class member lists - // are both keyed off this, so a member homed differently explains drift in - // either without having to trace them apart. + // are both keyed off this. let mut member_owners = BTreeMap::new(); if with_provenance { for file_id in db.get_vfs().get_all_file_ids() { @@ -898,9 +860,8 @@ fn collect_index(analysis: &EmmyLuaAnalysis, label: &str) -> IndexSnapshot { } } - // Every assignment-contribution group, so a writer that landed under the - // wrong owner is visible as a group that moved rather than as a member - // whose type merely changed. + // Every assignment-contribution group, so a writer homed under the wrong + // owner shows as a moved group rather than a changed type. let mut contribution_groups = BTreeMap::new(); if with_provenance { let all_files = db.get_vfs().get_all_file_ids().into_iter().collect(); @@ -1177,16 +1138,13 @@ fn diff(base_label: &str, base: &BTreeSet, label: &str, other: &BTreeS /// Re-index the targets without touching their text and require the index to /// come back byte-identical. /// -/// The diagnostic gates cannot see this: re-analysing a file can attach members -/// or settle a decl's type differently from the cold build and still produce the -/// same diagnostics, so `repeat` and `noopedit` both report IDENTICAL while the -/// index underneath has drifted. That drift is what makes incremental work -/// impossible to skip — every "did this actually change?" test reports yes — so -/// it needs a gate of its own. +/// Catches drift the diagnostic gates cannot see: re-analysing a file can attach +/// members or settle a decl's type differently and still produce the same +/// diagnostics. /// -/// Like `editrevert` it builds its own analysis, for the same reason: sharing -/// one with the other index gate lets whichever runs second measure against an -/// already-converged index and report a clean 0. +/// Builds its own analysis, as `editrevert` does: sharing one with the other +/// index gate lets whichever runs second measure against an already-converged +/// index. fn run_index_repeat(codebase: &Path, annotations: &Path, targets: &[String]) { let analysis = &mut build_analysis(codebase, annotations); for target in targets { @@ -1214,12 +1172,8 @@ fn run_index_repeat(codebase: &Path, annotations: &Path, targets: &[String]) { } } - // Re-indexing the same unchanged file again asks whether the index is - // converging on a fixed point or just oscillating. Round 1 measures the - // cold build against a re-index; every later round measures a re-index - // against the one before it, so a shrinking count means the cold build - // had simply not settled, while a steady one means each pass invents a - // fresh answer. + // Round 1 measures the cold build against a re-index; every later round + // measures a re-index against the one before it. let rounds = std::env::var("DET_INDEXREPEAT_ROUNDS") .ok() .and_then(|raw| raw.parse::().ok()) @@ -1242,10 +1196,9 @@ fn run_index_repeat(codebase: &Path, annotations: &Path, targets: &[String]) { /// Applies a semantically-neutral edit pair and lets the analysis settle. /// -/// `at_front` decides which kind: appending a trailing newline is a semantic -/// no-op the update gate can skip outright, while inserting one at the front -/// shifts every offset in the file, so the gate cannot fire and the full -/// re-index expansion runs. +/// `at_front` inserts the newline at the start of the file, shifting every +/// offset so the update gate cannot skip the re-index. Otherwise it appends, +/// which the gate can skip outright. fn noop_edit(analysis: &mut EmmyLuaAnalysis, target: &Path, at_front: bool) -> bool { let tag = if at_front { "editmid" } else { "noopedit" }; let Some(uri) = glua_code_analysis::file_path_to_uri(&target.to_path_buf()) else { @@ -1311,8 +1264,7 @@ fn noop_edit(analysis: &mut EmmyLuaAnalysis, target: &Path, at_front: bool) -> b /// members. /// /// A member with several writers holds the merge of those writes, so -/// re-inferring one initializer answers a different question and is expected to -/// disagree. +/// re-inferring one initializer is expected to disagree. fn refresh_faithfulness(analysis: &EmmyLuaAnalysis) { let db = analysis.compilation.get_db(); let member_index = db.get_member_index(); @@ -1424,9 +1376,9 @@ fn member_initializer_expr( /// Attributes the re-index expansion to the source that produced each file. /// -/// `expand_reindex_file_ids` unions the dependent sets to a fixpoint and returns -/// only the total. This mirrors that loop and reports, per round, how many files -/// each source contributed that no earlier source already had. +/// Mirrors `expand_reindex_file_ids`, which unions dependent sets to a fixpoint +/// and returns only the total, and reports per round how many files each source +/// contributed that no earlier source had. fn expand_why(analysis: &EmmyLuaAnalysis, codebase: &Path, targets: &[String]) { for target in targets { let path = codebase.join(target.replace('/', std::path::MAIN_SEPARATOR_STR)); @@ -1520,23 +1472,17 @@ fn expand_why(analysis: &EmmyLuaAnalysis, codebase: &Path, targets: &[String]) { /// Edit a file through the single-file update path, then put it back. /// -/// The source ends up exactly as it started, so the index has to as well. Any -/// difference is drift the edit path introduced rather than a fact about the -/// code: state the edit added and the revert did not take away, or state the -/// edit dropped and the revert did not restore. +/// The source ends as it started, so the index has to as well. Any difference is +/// drift the edit path introduced: state the edit added and the revert did not +/// take away, or state the edit dropped and the revert did not restore. /// -/// This is the update-path counterpart of `indexrepeat`. That stage re-indexes -/// with the text untouched, so it never exercises the invalidation an edit -/// triggers; this one does, and unlike `realedit` it needs no ground-truth -/// build, because the pre-edit index *is* the truth. `noopedit` does not cover -/// it either: its edit pair is semantically neutral, so the update path skips -/// the re-index outright and nothing is invalidated. +/// The update-path counterpart of `indexrepeat`, which re-indexes with the text +/// untouched and so never exercises an edit's invalidation. Needs no +/// ground-truth build, because the pre-edit index is the truth. /// -/// It builds its own analysis rather than sharing the caller's. Both index -/// gates re-index in place and leave a converged index behind, so whichever ran -/// second would measure drift against the other's converged state instead of -/// against a cold build and report a clean 0 — the drift does not go away, it -/// stops being visible. +/// Builds its own analysis: both index gates re-index in place and leave a +/// converged index behind, so whichever ran second would measure against the +/// other's converged state. fn edit_revert(codebase: &Path, annotations: &Path, targets: &[String]) { let Ok(find) = std::env::var("DET_EDIT_FIND") else { eprintln!("[editrevert] SKIPPED: DET_EDIT_FIND is not set, so no drift gate ran"); @@ -1591,12 +1537,9 @@ fn edit_revert(codebase: &Path, annotations: &Path, targets: &[String]) { /// Applies a **real** edit and compares the incremental result against a cold /// build of the same edited source. /// -/// Every other edit stage perturbs a file without changing what it means, so -/// they answer "does re-analysis preserve state". This one answers the question -/// the others cannot: when an edit genuinely changes a published fact, does the -/// incremental path land where a full build of the edited workspace lands, and -/// how much of the index does that edit actually move? The second number bounds -/// what any cheaper re-index mechanism has to reproduce. +/// The only edit stage whose edit changes what the file means, so it also +/// measures how much of the index an edit moves. That bounds what a cheaper +/// re-index mechanism has to reproduce. fn real_edit( analysis: &mut EmmyLuaAnalysis, codebase: &Path, @@ -1675,19 +1618,15 @@ fn real_edit( /// Gate the deferred ripple: several self-indexes, then one re-index over the /// union of their separately-captured expansions. /// -/// The LSP debounce runs the edited file's own re-index on a short timer and -/// owes the dependency ripple afterwards. Deferring that ripple behind a longer -/// idle timer means a typing burst produces several self-indexes before one -/// ripple, so the ripple has to run against a *union* of expansions each -/// captured at a different point. +/// The LSP debounce re-indexes the edited file on a short timer and owes the +/// dependency ripple afterwards. Deferring that ripple behind a longer idle +/// timer means a typing burst produces several self-indexes before one ripple, +/// so the ripple runs against a union of expansions captured at different +/// points. /// -/// That union is the whole risk. An expansion recomputed after a self-index is -/// known to under-expand badly — 739 files collapsed to 8 — which is why the -/// production path captures before self-indexing. Union survives that, because -/// a collapsed later capture only ever adds files and the burst's first capture -/// is taken before any self-index, exactly as today. What it cannot rule out by -/// argument is a dependent present in *no* capture, and that is what this -/// stage measures: the burst's result against a cold build of the final text. +/// An expansion recomputed after a self-index under-expands, which is why the +/// production path captures before self-indexing. The union can still miss a +/// dependent present in no capture, which is what this stage measures. fn burst_edit( analysis: &mut EmmyLuaAnalysis, codebase: &Path, @@ -1745,10 +1684,8 @@ fn burst_edit( return; } - // Three keystroke groups, chosen to cover what a burst can do that a single - // edit cannot: change meaning, shift offsets only, and introduce a class - // definition partway through — the case where reusing the first capture - // would miss the new dependents outright. + // Three keystroke groups: change meaning, shift offsets only, and introduce + // a class definition partway through. let step_text = |target: &Target, step: usize| -> String { let mut text = target.original.replace(find.as_str(), replace.as_str()); if step >= 1 { @@ -1795,9 +1732,7 @@ fn burst_edit( let warm = collect(analysis, "warm"); // The control: the same three edits through today's path, one ripple each. - // Deferral is only a regression if it drifts further than this does — the - // incremental path already drifts from cold on its own, so comparing the - // burst against cold alone would charge it for drift it did not cause. + // Localises a failure to the deferral or to the path underneath it. for target in &resolved { analysis.update_file_by_uri(&target.uri, Some(target.original.clone())); } @@ -1823,14 +1758,13 @@ fn burst_edit( let truth_index = collect_index(&ground_truth, "cold_burst"); let truth = collect(&ground_truth, "cold_burst"); - // What the burst genuinely moves, so a reader can tell a real miss from a - // change the edits were always going to make. + // Context: what the edits themselves move. diff_index("cold", &cold_index, "cold_burst", &truth_index); diff("cold", cold, "cold_burst", &truth); - // What today's path already gets wrong about it. + // Context: the same comparison for the undeferred path. diff_index("cold_burst", &truth_index, "control", &control_index); diff("cold_burst", &truth, "control", &control); - // The gate: deferring the ripple must not get more wrong than the control. + // The gate: the burst must land where a cold build of the final text lands. diff_index("cold_burst", &truth_index, "warm", &warm_index); diff("cold_burst", &truth, "warm", &warm); @@ -1865,9 +1799,8 @@ fn reindex_exact(analysis: &mut EmmyLuaAnalysis, codebase: &Path, relatives: &[S true } -/// Analysis recurses over deeply nested syntax. The server does that work on -/// spawned threads, which get a far larger stack than a process main thread does -/// on Windows, so the tools have to ask for one explicitly. +/// Analysis recurses over deeply nested syntax. The server runs it on spawned +/// threads, which get a larger stack than a Windows process main thread. fn main() { std::thread::Builder::new() .stack_size(256 * 1024 * 1024) @@ -1975,10 +1908,9 @@ fn run() { } } - // Re-run the pipeline over every main-workspace file with the full DB - // already populated. Tests whether the cold build under-resolved because - // library/std workspace groups were analysed before main-workspace files - // existed in the index. + // Re-run the pipeline over every main-workspace file with the DB already + // populated. Tests whether the cold build under-resolved because library and + // std groups were analysed before main-workspace files existed. if stages.iter().any(|s| s == "mainreindex") { let cold_index = collect_index(&analysis, "cold"); let main_ids = analysis.get_main_workspace_file_ids_for_diagnostics(); @@ -2004,11 +1936,9 @@ fn run() { diff("after_mainreindex", &after, "after_mainreindex_2", &second); } - // Remove and re-add *every* file, without clearing the index first. Cold and - // this stage then analyse exactly the same file set from exactly the same - // (empty, for those files) starting point, so any difference isolates state - // that `remove_index` fails to drop — as opposed to `mainreindex`, which - // legitimately leaves the library workspace resolved. + // Remove and re-add every file without clearing the index first. Cold and + // this stage analyse the same file set from the same starting point, so a + // difference isolates state `remove_index` fails to drop. if stages.iter().any(|s| s == "allreindex") { let cold_index = collect_index(&analysis, "cold"); let all_ids = analysis.compilation.get_db().get_vfs().get_all_file_ids(); @@ -2025,11 +1955,9 @@ fn run() { diff("cold", &cold, "after_allreindex", &after); } - // Re-run analysis over every file *without* removing anything first, so each - // file re-infers against the settled index while every existing fact is - // retained. This is the only stage that gives a full build the same - // advantage a partial re-index gets from the state it inherits, so it says - // whether the cold answer is simply under-converged. + // Re-run analysis over every file without removing anything first, so each + // file re-infers against the settled index with every existing fact + // retained. Says whether the cold answer is under-converged. if stages.iter().any(|s| s == "restabilize") { let all_ids = analysis.compilation.get_db().get_vfs().get_all_file_ids(); for round in 0..3 { @@ -2046,13 +1974,9 @@ fn run() { } } - // Remove and re-add each file ON ITS OWN, so every file is re-derived - // against the complete settled workspace rather than against the prefix the - // cold walk had built when it reached that file. This is the "fully - // informed" fixed point: if the index converges here, the cold answer is - // simply one computed from an incomplete view, and a subset re-index — which - // also sees a settled workspace — is agreeing with the informed answer - // rather than drifting. + // Remove and re-add each file on its own, so every file is re-derived + // against the complete settled workspace rather than the prefix the cold + // walk had built when it reached that file. if stages.iter().any(|s| s == "perfile") { let all_ids = analysis.compilation.get_db().get_vfs().get_all_file_ids(); let rounds = std::env::var("DET_PERFILE_ROUNDS") @@ -2084,10 +2008,8 @@ fn run() { } // The production incremental path: `reindex_files` runs the same re-analysis - // as `mainreindex` but first widens the set through `expand_reindex_file_ids`. - // Comparing the two says whether the expansion is what closes the gap, i.e. - // whether `mainreindex`'s divergence is a real defect or an artefact of the - // harness bypassing the safety net. + // as `mainreindex` but first widens the set through + // `expand_reindex_file_ids`. if stages.iter().any(|s| s == "mainexpand") { let cold_index = collect_index(&analysis, "cold"); let main_ids = analysis.get_main_workspace_file_ids_for_diagnostics(); From 6fd87b838a2c120858501b4181c0bda2bfc496f2 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 30 Aug 2026 07:21:31 +0100 Subject: [PATCH 079/159] fix: stabilise call-site dependency indexes --- .../src/db_index/call_site_param.rs | 499 +++++++++++++++++- crates/glua_code_analysis/src/db_index/mod.rs | 2 +- .../src/db_index/type/mod.rs | 164 ++++-- .../src/db_index/type/test.rs | 111 +++- crates/glua_code_analysis/src/lib.rs | 11 + 5 files changed, 736 insertions(+), 51 deletions(-) diff --git a/crates/glua_code_analysis/src/db_index/call_site_param.rs b/crates/glua_code_analysis/src/db_index/call_site_param.rs index 34ebb72dd..a83798c41 100644 --- a/crates/glua_code_analysis/src/db_index/call_site_param.rs +++ b/crates/glua_code_analysis/src/db_index/call_site_param.rs @@ -5,11 +5,30 @@ use rowan::TextSize; use super::traits::LuaIndex; use crate::{ - FileId, LuaDeclId, LuaDefinitionId, LuaInferenceConfidence, LuaInferenceDiagnosticEvent, - LuaInferenceProvenanceKind, LuaInferenceStep, LuaMemberId, LuaSignatureId, LuaType, - LuaTypeFact, + FileId, InFiled, LuaDeclId, LuaDefinitionId, LuaInferenceConfidence, + LuaInferenceDiagnosticEvent, LuaInferenceProvenanceKind, LuaInferenceStep, LuaMemberId, + LuaSignatureId, LuaType, LuaTypeFact, }; +/// A single thing outside a file that the file's call-site inference read. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CallSiteSourceId { + /// The syntax node a contribution's provenance step came from. + Node(InFiled), + /// The signature whose return a consumer in this file reads. + Signature(LuaSignatureId), +} + +impl CallSiteSourceId { + /// The file the source lives in. + pub fn file_id(&self) -> FileId { + match self { + Self::Node(node) => node.file_id, + Self::Signature(signature_id) => signature_id.get_file_id(), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CallSiteReturnConsumer { pub signature_id: LuaSignatureId, @@ -90,6 +109,12 @@ fn sorted_file_ids(map: &HashMap) -> Vec { pub struct CallSiteParamIndex { /// file → source function access paths and their mutated parameter indexes declared by that file. file_source_signatures: HashMap)>>, + /// The source signatures a re-indexed file had before its removal, kept so + /// the update can tell which of its signatures merely moved. A signature is + /// identified by position, so an edit that shifts offsets leaves every + /// contribution made by a file outside the re-index expansion pointing at a + /// position no signature occupies any more. + previous_source_signatures: HashMap>, /// access path → current source function signature candidates. source_signatures_by_path: HashMap>, /// Flat map for fast check: signature_id -> list of mutated parameter indices. @@ -103,6 +128,17 @@ pub struct CallSiteParamIndex { deferred_contributions: Vec<(FileId, CallSiteParamContribution)>, /// signature → param index → union of all observed types from current file contributions. inferred_params: HashMap>, + /// file declaring a signature → files whose calls have supplied evidence for + /// it. + /// + /// Accumulated rather than rebuilt, like `file_source_dependencies`: an + /// edit that stops a call from resolving drops the contribution, and an + /// edge rebuilt from the current contributions would go with it - leaving + /// nothing to re-analyse the caller with when the edit is taken back out. + contributor_files_by_signature: HashMap>, + /// File declaring a signature -> the signatures of that file that have + /// contributors, so the file-level view is a lookup rather than a scan. + contributor_signatures_by_file: HashMap>, pending_previous_params: HashMap<(LuaSignatureId, usize), LuaTypeFact>, file_return_consumers: HashMap>, return_consumers: HashMap>, @@ -114,8 +150,9 @@ pub struct CallSiteParamIndex { /// /// These survive dependent reindexing while a producer is absent so reopening the producer /// can invalidate its consumers. Direct consumer edits refresh their entry exactly. - file_source_dependencies: HashMap>, - source_dependents: HashMap>, + file_source_dependencies: HashMap>, + source_dependents: HashMap>, + source_file_dependents: HashMap>, source_paths: HashMap, source_path_dependents: HashMap>, } @@ -129,10 +166,142 @@ impl CallSiteParamIndex { &mut self, updates: Vec<(FileId, Vec<(String, LuaSignatureId, Vec)>)>, ) { + let moved = self.moved_signatures(&updates); for (file_id, signatures) in updates { + // Drained per file rather than wholesale: `analyze` runs this pass + // once per workspace group, so a batch spanning a library and the + // main workspace reaches here more than once and the later group's + // parks have to survive the earlier one. + self.previous_source_signatures.remove(&file_id); self.file_source_signatures.insert(file_id, signatures); } self.rebuild_source_signatures(); + if !moved.is_empty() { + self.remap_signatures(&moved); + } + } + + /// Old → new signature id for every source function this batch re-indexed + /// that kept its access path but changed position. + /// + /// Paths repeat when a file defines the same function twice, so a path is + /// matched positionally within its group, and a group whose size changed is + /// skipped outright: a definition added or removed makes the pairing a + /// guess, and a wrong pairing merges one function's call sites into + /// another's. A group that lost one definition and gained another in the + /// same edit keeps its size and is still paired positionally, which is as + /// close as the access path can get without a second key to match on. + fn moved_signatures( + &self, + updates: &[(FileId, Vec<(String, LuaSignatureId, Vec)>)], + ) -> HashMap { + let mut moved = HashMap::new(); + for (file_id, signatures) in updates { + let Some(previous) = self.previous_source_signatures.get(file_id) else { + continue; + }; + let mut by_path: HashMap<&str, Vec> = HashMap::new(); + for (path, signature_id) in previous { + by_path + .entry(path.as_str()) + .or_default() + .push(*signature_id); + } + let mut current: HashMap<&str, Vec> = HashMap::new(); + for (path, signature_id, _) in signatures { + current + .entry(path.as_str()) + .or_default() + .push(*signature_id); + } + for (path, old_ids) in by_path { + let Some(new_ids) = current.get(path) else { + continue; + }; + if new_ids.len() != old_ids.len() { + continue; + } + for (old_id, new_id) in old_ids.into_iter().zip(new_ids) { + if old_id != *new_id { + moved.insert(old_id, *new_id); + } + } + } + } + moved + } + + /// Re-keys every stored reference to a signature that moved. + /// + /// Only the files the re-index visited rebuild their own contributions; a + /// contributor outside the expansion keeps the id it recorded, so without + /// this its evidence is stranded on a position the callee no longer has. + fn remap_signatures(&mut self, moved: &HashMap) { + for contributions in self.file_contributions.values_mut() { + for contribution in contributions { + if let Some(new_id) = moved.get(&contribution.signature_id) { + contribution.signature_id = *new_id; + } + } + } + for (_, contribution) in &mut self.deferred_contributions { + if let Some(new_id) = moved.get(&contribution.signature_id) { + contribution.signature_id = *new_id; + } + } + for consumers in self.file_return_consumers.values_mut() { + for consumer in consumers { + if let Some(new_id) = moved.get(&consumer.signature_id) { + consumer.signature_id = *new_id; + } + } + } + let contributors = std::mem::take(&mut self.contributor_files_by_signature); + self.contributor_signatures_by_file.clear(); + for (signature_id, files) in contributors { + let signature_id = moved.get(&signature_id).copied().unwrap_or(signature_id); + self.contributor_signatures_by_file + .entry(signature_id.get_file_id()) + .or_default() + .insert(signature_id); + self.contributor_files_by_signature + .entry(signature_id) + .or_default() + .extend(files); + } + for sources in self.file_source_dependencies.values_mut() { + *sources = sources + .iter() + .map(|source| match source { + CallSiteSourceId::Signature(signature_id) => match moved.get(signature_id) { + Some(new_id) => CallSiteSourceId::Signature(*new_id), + None => source.clone(), + }, + CallSiteSourceId::Node(_) => source.clone(), + }) + .collect(); + } + let parked = std::mem::take(&mut self.pending_previous_params); + self.pending_previous_params = HashMap::with_capacity(parked.len()); + for ((signature_id, param_idx), fact) in parked { + match moved.get(&signature_id) { + // A remapped entry describes the signature now at that + // position, so it wins over one parked there before the edit + // moved its owner away. Without the split the winner would be + // whichever the map happened to yield last. + Some(new_id) => { + self.pending_previous_params + .insert((*new_id, param_idx), fact); + } + None => { + self.pending_previous_params + .entry((signature_id, param_idx)) + .or_insert(fact); + } + } + } + self.rebuild_derived_state(); + self.rebuild_return_consumers(); } pub fn get_source_signature_for_file_at( @@ -405,6 +574,42 @@ impl CallSiteParamIndex { consumers } + /// The files whose calls supply the call-site param evidence for signatures + /// declared in `signature_files`. + /// + /// A re-index rebuilds only the files it visits, so a contributor left out + /// keeps evidence derived from - and keyed by - the callee's previous text. + pub fn collect_contributor_files(&self, signature_files: &HashSet) -> Vec { + let mut files = signature_files + .iter() + .filter_map(|file_id| self.contributor_signatures_by_file.get(file_id)) + .flatten() + .filter_map(|signature_id| self.contributor_files_by_signature.get(signature_id)) + .flatten() + .copied() + .collect::>(); + files.sort_unstable(); + files.dedup(); + files + } + + /// The files whose calls supply the call-site param evidence for + /// `signatures`. + pub fn collect_signature_contributor_files( + &self, + signatures: &[LuaSignatureId], + ) -> Vec { + let mut files = signatures + .iter() + .filter_map(|signature_id| self.contributor_files_by_signature.get(signature_id)) + .flatten() + .copied() + .collect::>(); + files.sort_unstable(); + files.dedup(); + files + } + pub fn collect_contribution_signature_files( &self, source_files: &HashSet, @@ -443,7 +648,7 @@ impl CallSiteParamIndex { pub fn collect_source_dependents(&self, source_files: &HashSet) -> Vec { let mut dependents = source_files .iter() - .filter_map(|file_id| self.source_dependents.get(file_id)) + .filter_map(|file_id| self.source_file_dependents.get(file_id)) .flatten() .copied() .collect::>(); @@ -460,6 +665,19 @@ impl CallSiteParamIndex { dependents } + /// The files whose call-site inference read one of `sources`. + pub fn collect_source_node_dependents(&self, sources: &[CallSiteSourceId]) -> Vec { + let mut dependents = sources + .iter() + .filter_map(|source| self.source_dependents.get(source)) + .flatten() + .copied() + .collect::>(); + dependents.sort_unstable(); + dependents.dedup(); + dependents + } + pub fn collect_source_path_dependents<'a>( &self, source_paths: impl IntoIterator, @@ -480,6 +698,38 @@ impl CallSiteParamIndex { self.rebuild_source_dependents(); } + /// Drops the state kept for files that are gone rather than re-indexed. + /// + /// Both stores here deliberately outlive a removal - one so an edit's + /// signature moves can still be matched, the other so a caller stays a + /// dependent while its contribution is absent. Neither is worth keeping + /// once the file itself is gone. + pub fn forget_removed_files(&mut self, file_ids: &HashSet) { + for file_id in file_ids { + self.previous_source_signatures.remove(file_id); + if let Some(signatures) = self.contributor_signatures_by_file.remove(file_id) { + for signature_id in signatures { + self.contributor_files_by_signature.remove(&signature_id); + } + } + } + let contributor_signatures_by_file = &mut self.contributor_signatures_by_file; + self.contributor_files_by_signature + .retain(|signature_id, contributors| { + contributors.retain(|file_id| !file_ids.contains(file_id)); + if contributors.is_empty() { + if let Some(signatures) = + contributor_signatures_by_file.get_mut(&signature_id.get_file_id()) + { + signatures.remove(signature_id); + } + return false; + } + true + }); + contributor_signatures_by_file.retain(|_, signatures| !signatures.is_empty()); + } + pub fn refresh_file_source_dependencies(&mut self, file_id: FileId) { let dependencies = self.current_file_source_dependencies(file_id); if dependencies.is_empty() { @@ -509,9 +759,17 @@ impl CallSiteParamIndex { self.file_source_dependencies .entry(file_id) .or_default() - .insert(step.event.source.file_id); + .insert(CallSiteSourceId::Node(step.event.source.clone())); } } + self.contributor_files_by_signature + .entry(contribution.signature_id) + .or_default() + .insert(file_id); + self.contributor_signatures_by_file + .entry(contribution.signature_id.get_file_id()) + .or_default() + .insert(contribution.signature_id); accumulators .entry(contribution.signature_id) .or_default() @@ -558,36 +816,42 @@ impl CallSiteParamIndex { self.rebuild_source_dependents(); } - fn current_file_source_dependencies(&self, file_id: FileId) -> HashSet { + fn current_file_source_dependencies(&self, file_id: FileId) -> HashSet { let contribution_sources = self .file_contributions .get(&file_id) .into_iter() .flatten() .flat_map(|contribution| contribution.param_fact.provenance()) - .map(|step| step.event.source.file_id); + .map(|step| CallSiteSourceId::Node(step.event.source.clone())); let return_sources = self .file_return_consumers .get(&file_id) .into_iter() .flatten() - .map(|consumer| consumer.signature_id.get_file_id()); + .map(|consumer| CallSiteSourceId::Signature(consumer.signature_id)); contribution_sources .chain(return_sources) - .filter(|source_file_id| *source_file_id != file_id) + .filter(|source| source.file_id() != file_id) .collect() } fn rebuild_source_dependents(&mut self) { self.source_dependents.clear(); + self.source_file_dependents.clear(); self.source_path_dependents.clear(); - for (consumer_file_id, source_file_ids) in &self.file_source_dependencies { - for source_file_id in source_file_ids { + for (consumer_file_id, sources) in &self.file_source_dependencies { + for source in sources { self.source_dependents - .entry(*source_file_id) + .entry(source.clone()) + .or_default() + .insert(*consumer_file_id); + let source_file_id = source.file_id(); + self.source_file_dependents + .entry(source_file_id) .or_default() .insert(*consumer_file_id); - if let Some(path) = self.source_paths.get(source_file_id) { + if let Some(path) = self.source_paths.get(&source_file_id) { self.source_path_dependents .entry(path.clone()) .or_default() @@ -636,7 +900,7 @@ impl CallSiteParamIndex { self.file_source_dependencies .entry(consumer.file_id) .or_default() - .insert(signature_file_id); + .insert(CallSiteSourceId::Signature(consumer.signature_id)); } } } @@ -670,7 +934,19 @@ impl LuaIndex for CallSiteParamIndex { self.deferred_contributions .retain(|(file_id, _)| !file_ids.contains(file_id)); for &file_id in file_ids { - self.file_source_signatures.remove(&file_id); + if let Some(signatures) = self.file_source_signatures.remove(&file_id) { + // The oldest surviving entry is the one the stored ids belong + // to: a second removal before any update would otherwise record + // positions the contributions never referred to. + self.previous_source_signatures + .entry(file_id) + .or_insert_with(|| { + signatures + .into_iter() + .map(|(path, signature_id, _)| (path, signature_id)) + .collect() + }); + } self.file_return_consumers.remove(&file_id); self.file_contributions.remove(&file_id); } @@ -682,6 +958,9 @@ impl LuaIndex for CallSiteParamIndex { fn clear(&mut self) { self.file_source_signatures.clear(); + self.previous_source_signatures.clear(); + self.contributor_files_by_signature.clear(); + self.contributor_signatures_by_file.clear(); self.source_signatures_by_path.clear(); self.file_contributions.clear(); self.deferred_contributions.clear(); @@ -694,6 +973,7 @@ impl LuaIndex for CallSiteParamIndex { self.inference_events_by_file.clear(); self.file_source_dependencies.clear(); self.source_dependents.clear(); + self.source_file_dependents.clear(); self.source_paths.clear(); self.source_path_dependents.clear(); self.mutated_params.clear(); @@ -895,4 +1175,189 @@ mod tests { assert!(changed.is_empty()); } + + #[test] + fn a_contribution_follows_the_signature_an_edit_moved() { + let callee = FileId::new(1); + let caller = FileId::new(2); + let before = signature_id(callee, 100); + let after = signature_id(callee, 101); + + let mut index = CallSiteParamIndex::new(); + index.set_files_source_signatures(vec![( + callee, + vec![("m.f".to_string(), before, Vec::new())], + )]); + index.set_files_contributions(vec![(caller, vec![(before, 0, LuaType::String)])]); + + // The callee is re-indexed on its own, as an edit outside the caller's + // expansion re-indexes it: the caller keeps the contribution it made + // against the pre-edit text. + index.remove(callee); + index.set_files_source_signatures(vec![( + callee, + vec![("m.f".to_string(), after, Vec::new())], + )]); + + assert_eq!(index.get_inferred_param(&after, 0), Some(&LuaType::String)); + assert_eq!(index.get_inferred_param(&before, 0), None); + } + + #[test] + fn a_path_that_gained_a_definition_is_left_alone() { + let callee = FileId::new(1); + let caller = FileId::new(2); + let before = signature_id(callee, 100); + + let mut index = CallSiteParamIndex::new(); + index.set_files_source_signatures(vec![( + callee, + vec![("m.f".to_string(), before, Vec::new())], + )]); + index.set_files_contributions(vec![(caller, vec![(before, 0, LuaType::String)])]); + + // Two definitions now share the path, so which one the old id meant is + // a guess, and guessing wrong merges one function's call sites into + // another's. + index.remove(callee); + index.set_files_source_signatures(vec![( + callee, + vec![ + ("m.f".to_string(), signature_id(callee, 101), Vec::new()), + ("m.f".to_string(), signature_id(callee, 220), Vec::new()), + ], + )]); + + assert_eq!(index.get_inferred_param(&before, 0), Some(&LuaType::String)); + assert_eq!( + index.get_inferred_param(&signature_id(callee, 101), 0), + None + ); + } + + #[test] + fn a_park_survives_the_group_analysed_before_it() { + let library = FileId::new(1); + let main = FileId::new(2); + let caller = FileId::new(3); + let before = signature_id(main, 100); + let after = signature_id(main, 101); + + let mut index = CallSiteParamIndex::new(); + index.set_files_source_signatures(vec![ + ( + library, + vec![("lib.f".to_string(), signature_id(library, 10), Vec::new())], + ), + (main, vec![("m.f".to_string(), before, Vec::new())]), + ]); + index.set_files_contributions(vec![(caller, vec![(before, 0, LuaType::String)])]); + + // `analyze` runs this pass once per workspace group, so one batch + // spanning both files installs the library's signatures before the main + // workspace's. The main file's park has to still be there when its own + // group arrives. + index.remove_files(&[library, main]); + index.set_files_source_signatures(vec![( + library, + vec![("lib.f".to_string(), signature_id(library, 10), Vec::new())], + )]); + index.set_files_source_signatures(vec![( + main, + vec![("m.f".to_string(), after, Vec::new())], + )]); + + assert_eq!(index.get_inferred_param(&after, 0), Some(&LuaType::String)); + assert_eq!(index.get_inferred_param(&before, 0), None); + } + + #[test] + fn a_contributor_stays_a_dependent_after_its_contribution_goes() { + let callee = FileId::new(1); + let caller = FileId::new(2); + let signature_id = signature_id(callee, 100); + let callee_files = HashSet::from([callee]); + + let mut index = CallSiteParamIndex::new(); + index.set_files_contributions(vec![(caller, vec![(signature_id, 0, LuaType::String)])]); + assert_eq!(index.collect_contributor_files(&callee_files), vec![caller]); + + // An edit that stops the call from resolving drops the contribution. + // The caller still has to be re-analysed when that edit is taken back + // out, so the edge cannot be derived from the contributions alone. + index.remove(caller); + index.set_files_contributions(vec![(caller, Vec::new())]); + + assert_eq!(index.get_inferred_param(&signature_id, 0), None); + assert_eq!(index.collect_contributor_files(&callee_files), vec![caller]); + } + + #[test] + fn a_contributor_is_reachable_from_the_signature_and_from_its_file() { + let callee = FileId::new(1); + let caller = FileId::new(2); + let other_id = signature_id(callee, 200); + let signature_id = signature_id(callee, 100); + + let mut index = CallSiteParamIndex::new(); + index.set_files_contributions(vec![(caller, vec![(signature_id, 0, LuaType::String)])]); + + assert_eq!( + index.collect_signature_contributor_files(&[signature_id]), + vec![caller] + ); + assert!( + index + .collect_signature_contributor_files(&[other_id]) + .is_empty() + ); + assert_eq!( + index.collect_contributor_files(&HashSet::from([callee])), + vec![caller] + ); + } + + #[test] + fn forgetting_a_removed_callee_clears_both_contributor_views() { + let callee = FileId::new(1); + let caller = FileId::new(2); + let signature_id = signature_id(callee, 100); + + let mut index = CallSiteParamIndex::new(); + index.set_files_contributions(vec![(caller, vec![(signature_id, 0, LuaType::String)])]); + index.forget_removed_files(&HashSet::from([callee])); + + assert!( + index + .collect_signature_contributor_files(&[signature_id]) + .is_empty() + ); + assert!( + index + .collect_contributor_files(&HashSet::from([callee])) + .is_empty() + ); + } + + #[test] + fn forgetting_a_removed_caller_clears_both_contributor_views() { + let callee = FileId::new(1); + let caller = FileId::new(2); + let signature_id = signature_id(callee, 100); + + let mut index = CallSiteParamIndex::new(); + index.set_files_contributions(vec![(caller, vec![(signature_id, 0, LuaType::String)])]); + index.forget_removed_files(&HashSet::from([caller])); + + assert!( + index + .collect_signature_contributor_files(&[signature_id]) + .is_empty() + ); + assert!( + index + .collect_contributor_files(&HashSet::from([callee])) + .is_empty() + ); + } } diff --git a/crates/glua_code_analysis/src/db_index/mod.rs b/crates/glua_code_analysis/src/db_index/mod.rs index 13b4bd751..b5e352388 100644 --- a/crates/glua_code_analysis/src/db_index/mod.rs +++ b/crates/glua_code_analysis/src/db_index/mod.rs @@ -31,7 +31,7 @@ use std::{ use crate::{Emmyrc, FileId, Vfs, profile::Profile}; pub use accessor_func::*; -pub use call_site_param::CallSiteParamIndex; +pub use call_site_param::{CallSiteParamIndex, CallSiteSourceId}; pub(crate) use call_site_param::{CallSiteReturnConsumer, CallSiteReturnConsumerTarget}; pub use declaration::*; pub use dependency::{LuaDependencyIndex, LuaDependencyKind, LuaDependencySite}; diff --git a/crates/glua_code_analysis/src/db_index/type/mod.rs b/crates/glua_code_analysis/src/db_index/type/mod.rs index 91767de60..0dcffbb24 100644 --- a/crates/glua_code_analysis/src/db_index/type/mod.rs +++ b/crates/glua_code_analysis/src/db_index/type/mod.rs @@ -10,7 +10,7 @@ mod types; use super::traits::LuaIndex; use crate::{ - DbIndex, FileId, InFiled, LuaDeclId, LuaMemberOwner, + DbIndex, FileId, InFiled, LuaDeclId, LuaMemberOwner, LuaSignatureId, db_index::r#type::type_decl::LuaTypeIdentifier, }; pub use generic_param::GenericParam; @@ -850,17 +850,34 @@ fn is_guarded_table_bootstrap_branch(db: &DbIndex, typ: &LuaType) -> bool { /// definition sites move as files are indexed, so the file set behind a /// `Decl` key is resolved from the live declaration at query time. #[derive(Debug, Clone, Hash, PartialEq, Eq)] -enum TypeCacheRef { - File(FileId), +pub enum TypeCacheRef { + Table(InFiled), + Instance(InFiled), + Signature(LuaSignatureId), + Module(FileId), Decl(LuaTypeDeclId), } +impl TypeCacheRef { + /// The file the referenced identity lives in, or `None` for a class, whose + /// definition sites are resolved from the live declaration instead. + pub fn file_id(&self) -> Option { + match self { + Self::Table(range) | Self::Instance(range) => Some(range.file_id), + Self::Signature(signature_id) => Some(signature_id.get_file_id()), + Self::Module(file_id) => Some(*file_id), + Self::Decl(_) => None, + } + } +} + /// Reverse map of `referenced thing -> files whose cached types reference it`, /// so incremental expansion is a lookup instead of a scan over every cache. #[derive(Debug, Default, PartialEq, Eq)] struct TypeCacheRefIndex { owner_refs: HashMap>, ref_owners: HashMap>, + refs_by_file: HashMap>, } impl TypeCacheRefIndex { @@ -870,6 +887,12 @@ impl TypeCacheRefIndex { let count = owner_entry.entry(type_ref.clone()).or_insert(0); *count += 1; if *count == 1 { + if let Some(file_id) = type_ref.file_id() { + self.refs_by_file + .entry(file_id) + .or_default() + .insert(type_ref.clone()); + } self.ref_owners .entry(type_ref) .or_default() @@ -882,6 +905,7 @@ impl TypeCacheRefIndex { let Some(owner_entry) = self.owner_refs.get_mut(&owner_file_id) else { return; }; + let mut dropped = Vec::new(); for type_ref in collect_type_cache_refs(typ) { let Some(count) = owner_entry.get_mut(&type_ref) else { continue; @@ -891,17 +915,16 @@ impl TypeCacheRefIndex { continue; } owner_entry.remove(&type_ref); - if let Some(owners) = self.ref_owners.get_mut(&type_ref) { - owners.remove(&owner_file_id); - if owners.is_empty() { - self.ref_owners.remove(&type_ref); - } - } + dropped.push(type_ref); } if owner_entry.is_empty() { self.owner_refs.remove(&owner_file_id); } + + for type_ref in dropped { + self.drop_owner(&type_ref, owner_file_id); + } } fn remove_file(&mut self, owner_file_id: FileId) { @@ -909,18 +932,45 @@ impl TypeCacheRefIndex { return; }; for type_ref in owner_entry.into_keys() { - if let Some(owners) = self.ref_owners.get_mut(&type_ref) { - owners.remove(&owner_file_id); - if owners.is_empty() { - self.ref_owners.remove(&type_ref); - } - } + self.drop_owner(&type_ref, owner_file_id); + } + } + + fn drop_owner(&mut self, type_ref: &TypeCacheRef, owner_file_id: FileId) { + let Some(owners) = self.ref_owners.get_mut(type_ref) else { + return; + }; + owners.remove(&owner_file_id); + if !owners.is_empty() { + return; + } + self.ref_owners.remove(type_ref); + let Some(file_id) = type_ref.file_id() else { + return; + }; + let Some(refs) = self.refs_by_file.get_mut(&file_id) else { + return; + }; + refs.remove(type_ref); + if refs.is_empty() { + self.refs_by_file.remove(&file_id); } } fn owners(&self, type_ref: &TypeCacheRef) -> Option<&HashSet> { self.ref_owners.get(type_ref) } + + fn refs_into_file(&self, file_id: FileId) -> impl Iterator { + self.refs_by_file.get(&file_id).into_iter().flatten() + } + + fn owners_referencing_file(&self, file_id: FileId) -> impl Iterator { + self.refs_into_file(file_id) + .filter_map(|type_ref| self.ref_owners.get(type_ref)) + .flatten() + .copied() + } } fn collect_type_cache_refs(typ: &LuaType) -> HashSet { @@ -928,16 +978,16 @@ fn collect_type_cache_refs(typ: &LuaType) -> HashSet { typ.visit_type(&mut |inner| { match inner { LuaType::TableConst(range) => { - refs.insert(TypeCacheRef::File(range.file_id)); + refs.insert(TypeCacheRef::Table(range.clone())); } LuaType::Instance(instance) => { - refs.insert(TypeCacheRef::File(instance.get_range().file_id)); + refs.insert(TypeCacheRef::Instance(instance.get_range().clone())); } LuaType::Signature(signature_id) => { - refs.insert(TypeCacheRef::File(signature_id.get_file_id())); + refs.insert(TypeCacheRef::Signature(*signature_id)); } LuaType::ModuleRef(file_id) => { - refs.insert(TypeCacheRef::File(*file_id)); + refs.insert(TypeCacheRef::Module(*file_id)); } LuaType::Ref(type_id) | LuaType::Def(type_id) => { refs.insert(TypeCacheRef::Decl(type_id.clone())); @@ -970,6 +1020,7 @@ pub struct LuaTypeIndex { type_writes: u64, definition_facts: HashMap, inference_events_by_file: HashMap>, + support_dependents: HashMap>, support_file_dependents: HashMap>, } @@ -996,6 +1047,7 @@ impl LuaTypeIndex { type_writes: 0, definition_facts: HashMap::default(), inference_events_by_file: HashMap::default(), + support_dependents: HashMap::default(), support_file_dependents: HashMap::default(), } } @@ -1445,6 +1497,20 @@ impl LuaTypeIndex { dependents } + /// The files whose inference read one of `nodes` as supporting evidence. + pub fn files_depending_on_inference_nodes( + &self, + nodes: &[LuaInferenceNodeId], + ) -> HashSet { + let mut dependents = HashSet::default(); + for node in nodes { + if let Some(files) = self.support_dependents.get(node) { + dependents.extend(files.iter().copied()); + } + } + dependents + } + pub fn get_type_cache(&self, owner: &LuaTypeOwner) -> Option<&LuaTypeCache> { self.types.get(owner) } @@ -1519,7 +1585,8 @@ impl LuaTypeIndex { let mut events_by_file: HashMap> = HashMap::default(); - let mut support_file_dependents = HashMap::default(); + let mut support_dependents: HashMap> = + HashMap::default(); for (owner, metadata) in &self.fact_metadata { let Some(cache) = self.types.get(owner) else { @@ -1535,7 +1602,7 @@ impl LuaTypeIndex { owner.get_file_id(), &fact, &mut events_by_file, - &mut support_file_dependents, + &mut support_dependents, ); } @@ -1544,7 +1611,7 @@ impl LuaTypeIndex { definition.file_id(), fact, &mut events_by_file, - &mut support_file_dependents, + &mut support_dependents, ); } @@ -1556,7 +1623,14 @@ impl LuaTypeIndex { (file_id, events.into()) }) .collect(); - self.support_file_dependents = support_file_dependents; + self.support_file_dependents = HashMap::default(); + for (node, dependents) in &support_dependents { + self.support_file_dependents + .entry(node.file_id()) + .or_default() + .extend(dependents.iter().copied()); + } + self.support_dependents = support_dependents; } pub fn iter_type_caches(&self) -> impl Iterator { @@ -1658,9 +1732,8 @@ impl LuaTypeIndex { let source_files: HashSet = map.keys().map(|range| range.file_id).collect(); let candidate_owners: HashSet<&LuaTypeOwner> = source_files .iter() - .filter_map(|file_id| self.cache_refs.owners(&TypeCacheRef::File(*file_id))) - .flatten() - .filter_map(|owner_file_id| self.in_filed_type_owner.get(owner_file_id)) + .flat_map(|file_id| self.cache_refs.owners_referencing_file(*file_id)) + .filter_map(|owner_file_id| self.in_filed_type_owner.get(&owner_file_id)) .flatten() .collect(); @@ -1695,9 +1768,11 @@ impl LuaTypeIndex { let mut dependent_files = HashSet::default(); let mut visited_decls = HashSet::default(); for file_id in file_ids { - if let Some(owners) = self.cache_refs.owners(&TypeCacheRef::File(*file_id)) { - dependent_files.extend(owners.iter().copied().filter(|o| !file_ids.contains(o))); - } + dependent_files.extend( + self.cache_refs + .owners_referencing_file(*file_id) + .filter(|owner_file_id| !file_ids.contains(owner_file_id)), + ); // A file that only *names* a class still has to be re-analysed when // a changed file is one of that class's definition sites: its @@ -1745,6 +1820,29 @@ impl LuaTypeIndex { dependent_files } + /// The files whose cached types reference any of `refs`. + /// + /// Unlike [`files_with_type_caches_referencing_files`](Self::files_with_type_caches_referencing_files) + /// this is the raw reverse lookup: a [`TypeCacheRef::Decl`] answers the + /// files that name the class, without consulting its definition sites. + pub fn files_with_type_caches_referencing(&self, refs: &[TypeCacheRef]) -> HashSet { + let mut dependent_files = HashSet::default(); + for type_ref in refs { + if let Some(owners) = self.cache_refs.owners(type_ref) { + dependent_files.extend(owners.iter().copied()); + } + } + dependent_files + } + + /// The identities declared in `file_id` that some cached type references. + pub fn type_cache_refs_into_file( + &self, + file_id: FileId, + ) -> impl Iterator { + self.cache_refs.refs_into_file(file_id) + } + pub fn files_with_cross_file_type_caches_referencing_files( &self, file_ids: &std::collections::HashSet, @@ -1903,6 +2001,7 @@ impl LuaIndex for LuaTypeIndex { self.decl_write_claims.clear(); self.definition_facts.clear(); self.inference_events_by_file.clear(); + self.support_dependents.clear(); self.support_file_dependents.clear(); } } @@ -1952,7 +2051,7 @@ fn collect_fact_derived_state( owner_file_id: FileId, fact: &LuaTypeFact, events_by_file: &mut HashMap>, - support_file_dependents: &mut HashMap>, + support_dependents: &mut HashMap>, ) { for step in fact.provenance() { events_by_file @@ -1963,8 +2062,8 @@ fn collect_fact_derived_state( fact: fact.clone(), }); for support in step.support.iter() { - support_file_dependents - .entry(support.file_id()) + support_dependents + .entry(support.clone()) .or_default() .insert(owner_file_id); } @@ -2274,6 +2373,7 @@ mod batch_removal_tests { left.inference_events_by_file, right.inference_events_by_file ); + assert_eq!(left.support_dependents, right.support_dependents); assert_eq!(left.support_file_dependents, right.support_file_dependents); assert_eq!(left.cache_refs, right.cache_refs); } diff --git a/crates/glua_code_analysis/src/db_index/type/test.rs b/crates/glua_code_analysis/src/db_index/type/test.rs index 88a3ea7cf..6e406fa83 100644 --- a/crates/glua_code_analysis/src/db_index/type/test.rs +++ b/crates/glua_code_analysis/src/db_index/type/test.rs @@ -7,7 +7,7 @@ mod test { use rowan::TextRange; use crate::db_index::traits::LuaIndex; - use crate::db_index::r#type::LuaTypeIndex; + use crate::db_index::r#type::{LuaTypeIndex, TypeCacheRef}; use crate::db_index::{LuaDeclTypeKind, LuaTypeFlag}; use crate::{ DbIndex, FileId, InFiled, LuaDeclId, LuaDeclLocation, LuaDefinitionId, @@ -415,6 +415,115 @@ mod test { assert_reference_lookup_matches_scan(&index, &files); } + #[test] + fn signature_type_cache_is_found_by_both_the_symbol_and_the_file_lookup() { + let callee = FileId::new(1); + let consumer = FileId::new(2); + let signature_id = LuaSignatureId::new(callee, 5.into()); + let mut index = LuaTypeIndex::new(); + index.bind_type( + owner_in(consumer, 10), + LuaTypeCache::DocType(LuaType::Signature(signature_id)), + ); + + assert_eq!( + index.files_with_type_caches_referencing(&[TypeCacheRef::Signature(signature_id)]), + [consumer].into_iter().collect::>() + ); + assert_eq!( + index.files_with_type_caches_referencing_files( + &[callee].into_iter().collect::>() + ), + [consumer].into_iter().collect::>() + ); + assert_eq!( + index + .type_cache_refs_into_file(callee) + .cloned() + .collect::>(), + [TypeCacheRef::Signature(signature_id)] + .into_iter() + .collect::>() + ); + } + + #[test] + fn removing_the_referencing_file_clears_the_symbol_and_file_lookups() { + let callee = FileId::new(1); + let consumer = FileId::new(2); + let signature_id = LuaSignatureId::new(callee, 5.into()); + let mut index = LuaTypeIndex::new(); + index.bind_type( + owner_in(consumer, 10), + LuaTypeCache::DocType(LuaType::Signature(signature_id)), + ); + + index.remove_files(&[consumer]); + + assert!( + index + .files_with_type_caches_referencing(&[TypeCacheRef::Signature(signature_id)]) + .is_empty() + ); + assert!( + index + .files_with_type_caches_referencing_files( + &[callee].into_iter().collect::>() + ) + .is_empty() + ); + assert_eq!(index.type_cache_refs_into_file(callee).count(), 0); + } + + #[test] + fn removing_the_referenced_file_keeps_the_reference_until_its_owner_rebinds() { + let callee = FileId::new(1); + let consumer = FileId::new(2); + let signature_id = LuaSignatureId::new(callee, 5.into()); + let table_range = InFiled::new(callee, TextRange::new(0.into(), 1.into())); + let mut index = LuaTypeIndex::new(); + index.bind_type( + owner_in(consumer, 10), + LuaTypeCache::DocType(LuaType::Signature(signature_id)), + ); + index.bind_type( + owner_in(consumer, 20), + LuaTypeCache::DocType(LuaType::TableConst(table_range.clone())), + ); + + index.remove_files(&[callee]); + + assert_eq!( + index + .type_cache_refs_into_file(callee) + .cloned() + .collect::>(), + [ + TypeCacheRef::Signature(signature_id), + TypeCacheRef::Table(table_range) + ] + .into_iter() + .collect::>() + ); + assert_eq!( + index.files_with_type_caches_referencing_files( + &[callee].into_iter().collect::>() + ), + [consumer].into_iter().collect::>() + ); + + index.force_bind_type( + owner_in(consumer, 10), + LuaTypeCache::DocType(LuaType::String), + ); + index.force_bind_type( + owner_in(consumer, 20), + LuaTypeCache::DocType(LuaType::String), + ); + + assert_eq!(index.type_cache_refs_into_file(callee).count(), 0); + } + #[test] fn ref_type_dependency_excludes_files_that_contribute_to_the_same_type() { let provider = FileId::new(1); diff --git a/crates/glua_code_analysis/src/lib.rs b/crates/glua_code_analysis/src/lib.rs index 93caa8059..d15e5ab51 100644 --- a/crates/glua_code_analysis/src/lib.rs +++ b/crates/glua_code_analysis/src/lib.rs @@ -2249,6 +2249,11 @@ impl EmmyLuaAnalysis { }) .collect::>(); + self.compilation + .get_db_mut() + .get_call_site_param_index_mut() + .forget_removed_files(&removed_file_ids); + let mut file_ids = expansion; self.add_vgui_forwarding_removal_seed(&removed_file_ids, &mut file_ids); let guard_fact_file_ids = file_ids.iter().copied().collect::>(); @@ -2556,6 +2561,11 @@ impl EmmyLuaAnalysis { .get_db() .get_call_site_param_index() .collect_source_dependents(&expanded); + let contributor_dependents = self + .compilation + .get_db() + .get_call_site_param_index() + .collect_contributor_files(&expanded); let callback_source_paths = expanded .iter() .filter_map(|file_id| self.compilation.get_db().get_vfs().get_file_path(file_id)) @@ -2572,6 +2582,7 @@ impl EmmyLuaAnalysis { .chain(dependent_files) .chain(inference_dependents) .chain(callback_dependents) + .chain(contributor_dependents) .chain(callback_path_dependents) { added |= expanded.insert(file_id); From f27870dee40c10183779defaafd3277e7c7e602c Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:42:50 +0100 Subject: [PATCH 080/159] test: expand determinism index snapshots --- tools/determinism/src/main.rs | 476 +++++++++++++++++++++++++++++++++- 1 file changed, 473 insertions(+), 3 deletions(-) diff --git a/tools/determinism/src/main.rs b/tools/determinism/src/main.rs index 3cb91ae69..32b7ad7b0 100644 --- a/tools/determinism/src/main.rs +++ b/tools/determinism/src/main.rs @@ -148,14 +148,33 @@ //! and report how often it reproduces the cached //! type, split by single- vs multi-writer members //! fresh build a second analysis in-process -//! DET_INDEX_DIFF also diff type caches, members, signatures, class members, -//! super types, net flows and inferred params +//! DET_INDEX_DIFF also diff every index reachable from `DbIndex`: type +//! caches, members, signatures, class members, super types, +//! net flows, inferred params, decls, decl references, +//! modules, globals, diagnostics (and their disable +//! actions), operators, dependency sites, gmod load/hook/ +//! system/realm/scoped-class/network/class metadata, +//! dynamic-field wildcards, accessor-func calls, +//! properties, and metatables. Not covered: flow index +//! (signature casts, special-call effects), the +//! name-keyed accessor-func annotation table, dynamic-field +//! owner/field maps beyond wildcards, and numeric-range +//! population -- none expose a public whole-index +//! iterator //! DET_DUMP_INDEX directory to write each index snapshot to as `