diff --git a/.agents/skills/rust-best-practices/references/chapter_01.md b/.agents/skills/rust-best-practices/references/chapter_01.md index 589bce5fb..04892f383 100644 --- a/.agents/skills/rust-best-practices/references/chapter_01.md +++ b/.agents/skills/rust-best-practices/references/chapter_01.md @@ -2,7 +2,7 @@ ## 1.1 Borrowing Over Cloning -Rust's ownership system encourages **borrow** (`&T`) instead of **cloning** (`T.clone()`). +Rust's ownership system encourages **borrow** (`&T`) instead of **cloning** (`T.clone()`). > ❗ Performance recommendation ### ✅ When to `Clone`: @@ -361,7 +361,7 @@ Well-written Rust code, with expressive types and good naming, often speaks for Still, there are **moments where code alone isn't enough** - when there are performance quirks, external constraints, or non-obvious tradeoffs that require a nudge to the reader. In those cases, a concise comment can prevent hours of head-scratching or searching git history. -### ✅ Good comments +### ✅ Good comments * Safety concerns: ```rust @@ -398,8 +398,8 @@ let subgraph_tls_root_store: RootCertStore = configuration * Wall-of-text explanations: long comments and multiline comments ```rust -// Lorem Ipsum is simply dummy text of the printing and typesetting industry. -// Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, +// Lorem Ipsum is simply dummy text of the printing and typesetting industry. +// Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, // when an unknown printer took a galley fn do_something_odd() { … @@ -480,7 +480,7 @@ There are a few gotchas when calling comments "living documentation": * Many large comments make people avoid reading them. * Team becomes fearful of delete irrelevant comments. -If you find a comment, **don't trust it blindly**. Read it in context. If it's wrong or outdated, fix or remove it. A misleading comment is worse than no comments at all. +If you find a comment, **don't trust it blindly**. Read it in context. If it's wrong or outdated, fix or remove it. A misleading comment is worse than no comments at all. > Comments should bother you - they demand re-verification, just like stale tests. diff --git a/.agents/skills/rust-best-practices/references/chapter_02.md b/.agents/skills/rust-best-practices/references/chapter_02.md index fec146023..19f4c8993 100644 --- a/.agents/skills/rust-best-practices/references/chapter_02.md +++ b/.agents/skills/rust-best-practices/references/chapter_02.md @@ -34,7 +34,7 @@ Potential additions elements to add: > Example at ApolloGraphQL > -> In the `Router` project there is a `xtask` configured for linting that can be executed with `cargo xtask lint`. +> In the `Router` project there is a `xtask` configured for linting that can be executed with `cargo xtask lint`. ## 2.3 Important Clippy Lints to Respect @@ -69,7 +69,7 @@ enum Message { ``` > The fix would be: -> +> > ```rust > // Faster matching is preferred over size efficiency > #[expect(clippy::large_enum_variant)] diff --git a/.agents/skills/rust-best-practices/references/chapter_03.md b/.agents/skills/rust-best-practices/references/chapter_03.md index 074295e24..75829483a 100644 --- a/.agents/skills/rust-best-practices/references/chapter_03.md +++ b/.agents/skills/rust-best-practices/references/chapter_03.md @@ -169,7 +169,7 @@ hello_greet(Cow::Owned("Naomi".to_string())); ## 3.3 Stack vs Heap: Be size-smart! -### ✅ Good Practices +### ✅ Good Practices * Keep small types (`impl Copy`, `usize`, `bool`, etc) **on the stack**. * Avoid passing huge types (`> 512 bytes`) by value or transferring ownership. Prefer pass by reference (e.g. `&T` and `&mut T`). diff --git a/.agents/skills/rust-best-practices/references/chapter_05.md b/.agents/skills/rust-best-practices/references/chapter_05.md index 1d9947b7e..9f9b95758 100644 --- a/.agents/skills/rust-best-practices/references/chapter_05.md +++ b/.agents/skills/rust-best-practices/references/chapter_05.md @@ -14,12 +14,12 @@ In Rust, as in many other languages, tests often show how the functions are mean > In the unit test name we should see the following: > * `unit_of_work`: which *function* we are calling. The **action** that will be executed. This is often be the name of the the test `mod` where the function is being tested. ```rust -#[cfg(test)] -mod test { - mod function_name { - #[test] - fn returns_y_when_x() { ... } - } +#[cfg(test)] +mod test { + mod function_name { + #[test] + fn returns_y_when_x() { ... } + } } ``` > * `expected_behavior`: the set of **assertions** that we need to verify that the test works. @@ -83,9 +83,9 @@ mod test { // IDEs will provide a ▶️ button here let a = setup_a_to_be_xyz(); let b = Some(-5); let expected = MyError::Xyz; - + let result = process(a, b).unwrap_err(); - + assert_eq!(result, expected); } @@ -124,8 +124,8 @@ mod test_thing_parser { fn lowercase_letters_are_valid() { assert!( Thing::parse("abcd").is_ok(), - // Works like `eprintln, format and println` macros - "Thing parse error: {:?}", + // Works like `eprintln, format and println` macros + "Thing parse error: {:?}", Thing::parse("abcd").unwrap_err() ); } @@ -141,7 +141,7 @@ mod test_thing_parser { ### Use very few, ideally one, assertion per test -When there are multiple assertions per test, it's both harder to understand the intended behavior and +When there are multiple assertions per test, it's both harder to understand the intended behavior and often requires many iterations to fix a broken test, as you work through assertions one by one. ❌ Don't include many assertions in one test: @@ -185,19 +185,19 @@ We will deep dive into docs at a later stage, so in this section we will just br ```rust /// Helper function that adds any two numeric values together. -/// This functions reasons about which would be the correct type to parse based on the type +/// This functions reasons about which would be the correct type to parse based on the type /// and the size of the numeric value. -/// +/// /// # Examples -/// +/// /// ```rust /// # use crate_name::generic_add; /// use num::numeric; -/// +/// /// # assert_eq!( /// generic_add(5.2, 4) // => 9.2 /// # , 9.2) -/// +/// /// # assert_eq!( /// generic_add(2, 2.0) // => 4 /// # , 4) @@ -243,7 +243,7 @@ mod unit_of_work_tests { ### Integration Tests -Tests that go under the `tests/` directory, they are entirely external to your library and use the same code as any other code would use, not have access to private and crate level functions, which means they can **only test** functions on your **public API**. +Tests that go under the `tests/` directory, they are entirely external to your library and use the same code as any other code would use, not have access to private and crate level functions, which means they can **only test** functions on your **public API**. > Their purpose is to test whether many parts of the code work together correctly, units of code that work correctly on their own could have problems when integrated. @@ -252,14 +252,14 @@ Tests that go under the `tests/` directory, they are entirely external to your l * if testing binaries, try to break **executable** and **functions** into `src/main.rs` and `src/lib.rs`, respectively. ``` -├── Cargo.lock -├── Cargo.toml -├── src -│ └── lib.rs -└── tests - ├── mod.rs - ├── common - │ └── mod.rs +├── Cargo.lock +├── Cargo.toml +├── src +│ └── lib.rs +└── tests + ├── mod.rs + ├── common + │ └── mod.rs └── integration_test.rs ``` @@ -341,7 +341,7 @@ Snapshot testing compares your output (text, Json, HTML, YAML, etc) against a sa assert_snapshot!("this_is_a_named_snapshot", output); ``` -* Keep snapshots small and clear. +* Keep snapshots small and clear. ```rust // ✅ Best case: assert_snapshot!("app_config/http", whole_app_config.http); @@ -350,7 +350,7 @@ assert_snapshot!("app_config/http", whole_app_config.http); assert_snapshot!("app_config", whole_app_config); // Huge object ``` -> #### 🚨 Avoid snapshotting huge objects +> #### 🚨 Avoid snapshotting huge objects > Huge objects become hard to review and reason about. * Avoid snapshotting simple types (primitives, flat enums, small structs): diff --git a/.agents/skills/rust-best-practices/references/chapter_07.md b/.agents/skills/rust-best-practices/references/chapter_07.md index f7eab2c4f..9e413930f 100644 --- a/.agents/skills/rust-best-practices/references/chapter_07.md +++ b/.agents/skills/rust-best-practices/references/chapter_07.md @@ -1,6 +1,6 @@ # Chapter 7 - Type State Pattern -Models state at compile time, preventing bugs by making illegal states unrepresentable. It takes advantage of the Rust generics and type system to create sub-types that can only be reached if a certain condition is achieved, making some operations illegal at compile time. +Models state at compile time, preventing bugs by making illegal states unrepresentable. It takes advantage of the Rust generics and type system to create sub-types that can only be reached if a certain condition is achieved, making some operations illegal at compile time. > Recently it became the standard design pattern of Rust programming. However, it is not exclusive to Rust, as it is achievable and has inspired other languages to do the same [swift](https://swiftology.io/articles/typestate/) and [typescript](https://catchts.com/type-state). @@ -141,8 +141,8 @@ impl Builder { impl Builder { fn build(self) -> Person { - Person { - name: self.name.unwrap_or_else(|| unreachable!("Name is guarantee to be set")), + Person { + name: self.name.unwrap_or_else(|| unreachable!("Name is guarantee to be set")), age: self.age, email: self.email, } diff --git a/.agents/skills/rust-best-practices/references/chapter_08.md b/.agents/skills/rust-best-practices/references/chapter_08.md index 2134eb2b9..6644401a1 100644 --- a/.agents/skills/rust-best-practices/references/chapter_08.md +++ b/.agents/skills/rust-best-practices/references/chapter_08.md @@ -127,7 +127,7 @@ Use `///` doc comments to document: ```rust /// Loads [`User`] profile from disk -/// +/// /// # Error /// - Returns [`MyError`] if the file is missing [`MyError::FileNotFound`]. /// - Returns [`MyError`] if the content is an invalid Json, [`MyError::InvalidJson`]. @@ -139,9 +139,9 @@ fn load_user(path: &Path) -> Result {...} ```rust /// Returns the square of the integer part of any number. /// Square is limited to `u128`. -/// +/// /// # Examples -/// +/// /// ```rust /// assert_eq!(square(4.3), 16) /// ``` @@ -203,9 +203,9 @@ pub fn add(a: i32, b: i32) -> i32 { Use `//!` when you want to document the **purpose of a module or a crate**. It is places at the top of a `lib.rs` or `mod.rs` file, for example `engine/mod.rs`: ```rust //! This module implements a custom chess engine. -//! +//! //! It handles board state, move generation and check detection. -//! +//! //! # Example //! ``` //! let board = chess::engine::Board::default(); diff --git a/.agents/skills/rust-best-practices/references/chapter_09.md b/.agents/skills/rust-best-practices/references/chapter_09.md index bbef404c7..52bd15fdb 100644 --- a/.agents/skills/rust-best-practices/references/chapter_09.md +++ b/.agents/skills/rust-best-practices/references/chapter_09.md @@ -2,7 +2,7 @@ Many higher level languages hide memory management, typically **passing by value** (copy data) or **passing by reference** (reference to shared data) without worrying about allocation, heap, stack, ownership and lifetimes, it is all delegated to the garbage collector or VM. Here is a comparison on this topic between a few languages: -### 📌 Language Comparison +### 📌 Language Comparison | Language | Value Types | Reference/Pointer Types | Async Model & Types | Manual Memory | |------------ |------------------------------------- |----------------------------------------------------------- |---------------------------------------------------------------------------- |------------------------------ | @@ -178,7 +178,7 @@ use std::{cell::OnceCell, rc::Rc}; #[derive(Debug, Default)] struct MyStruct { distance: usize, - root: Option>>, + root: Option>>, } fn main() { diff --git a/AGENTS.md b/AGENTS.md index 5c1a59eb3..8770cea28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,66 +2,64 @@ ## 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 load rust-best-practice 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 prefilter, index, cache, or parallelize safe read-only work. -- 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: 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). ## 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; `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. -- 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`. 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 (must be +0): `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) 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. +- Before running Samply on Windows, check `tools/samply-bridge.ps1 -Status` and use that bridge when available. ## 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`, 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/Cargo.lock b/Cargo.lock index 8c6884c4d..3a2973a20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,6 +119,15 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object 0.39.1", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -163,7 +172,7 @@ dependencies = [ "cfg-if", "libc", "miniz_oxide", - "object", + "object 0.37.3", "rustc-demangle", "windows-link 0.2.1", ] @@ -897,6 +906,7 @@ dependencies = [ "log", "luars", "percent-encoding", + "rayon", "regex", "reqwest", "rowan", @@ -908,6 +918,7 @@ dependencies = [ "serde_path_to_error", "serde_with", "smol_str", + "stacker", "tokio-util", "url", "walkdir", @@ -977,6 +988,7 @@ dependencies = [ "mimalloc", "notify", "rowan", + "rustc-hash 2.1.1", "serde", "serde_json", "smol_str", @@ -994,6 +1006,7 @@ dependencies = [ "rustc-hash 2.1.1", "serde", "smol_str", + "stacker", ] [[package]] @@ -1764,6 +1777,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1962,6 +1984,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2092,6 +2124,26 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.15" @@ -2216,8 +2268,6 @@ dependencies = [ [[package]] name = "rowan" version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" dependencies = [ "countme", "hashbrown 0.14.5", @@ -2648,6 +2698,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index 6a1fd6613..73d013632 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,9 +78,15 @@ unicode-general-category = "1.0.0" luars = { version = "0.11.0", features = ["serde"] } reqwest = "0.13.1" rustc-hash = "2" +rayon = "1.11" +stacker = "0.1" [patch.crates-io] emmylua_codestyle = { path = "vendor/emmylua_codestyle" } +# Vendored for the iterative `GreenNode` drop and the stored-hash `NodeCache` +# rehash key (deep-tree teardown and cache rehashing overflow small stacks +# otherwise); see `vendor/rowan/Cargo.toml` for provenance. +rowan = { path = "vendor/rowan" } [profile.profiling] inherits = "release" diff --git a/crates/glua_check/src/bin/glua_check.rs b/crates/glua_check/src/bin/glua_check.rs index 51dd1e3c8..a0b5f9e4a 100644 --- a/crates/glua_check/src/bin/glua_check.rs +++ b/crates/glua_check/src/bin/glua_check.rs @@ -10,7 +10,7 @@ static GLOBAL: MiMalloc = MiMalloc; /// thread has a far smaller stack than a spawned one. fn main() -> Result<(), Box> { std::thread::Builder::new() - .stack_size(256 * 1024 * 1024) + .stack_size(glua_code_analysis::ANALYSIS_STACK_SIZE) .spawn(|| { tokio::runtime::Builder::new_multi_thread() .enable_all() diff --git a/crates/glua_check/src/lib.rs b/crates/glua_check/src/lib.rs index 256d35f15..50d93d910 100644 --- a/crates/glua_check/src/lib.rs +++ b/crates/glua_check/src/lib.rs @@ -103,6 +103,11 @@ pub async fn run_check(cmd_args: CmdArgs) -> Result<(), Box bool { - if consumer.ret_idx == 0 { - return false; - } - let LuaType::Variadic(variadic) = return_type else { - return false; - }; - let Some(expected) = variadic.get_type(consumer.ret_idx) else { - return false; + // The first result is read the same way as any later one: a single-valued + // return answers it directly. Skipping it left the batch-derived refresh + // set covering only the tail, so a cold build never revisited a first + // result the walk had already answered from a narrower type, while a warm + // re-index reached it through the consumers its index had retained. + let expected = match return_type { + LuaType::Variadic(variadic) => match variadic.get_type(consumer.ret_idx) { + Some(expected) => expected, + None => return false, + }, + return_type if consumer.ret_idx == 0 => return_type, + _ => return false, }; if expected.is_any() || expected.is_unknown() || expected.is_nil() || expected.is_never() { return false; @@ -363,8 +365,8 @@ fn collect_vgui_named_callback_receiver_types( return; } let red_root = root.syntax().clone(); - let mut candidates: HashMap> = HashMap::new(); - let mut candidate_names = HashSet::new(); + let mut candidates: HashMap> = HashMap::default(); + let mut candidate_names = HashSet::default(); for field in root.syntax().descendants().filter_map(LuaTableField::cast) { let Some(LuaExpr::NameExpr(name_expr)) = field.get_value_expr() else { @@ -418,7 +420,7 @@ fn collect_vgui_named_callback_receiver_types( .or_insert(Some(candidate)); } - let mut references_by_decl: HashMap> = HashMap::new(); + let mut references_by_decl: HashMap> = HashMap::default(); for name_expr in root.syntax().descendants().filter_map(LuaNameExpr::cast) { if !name_expr .get_name_text() @@ -1458,10 +1460,13 @@ fn exact_receiver_key_has_candidate(db: &DbIndex, member_key: &LuaMemberKey) -> let Some(first_member) = members.next() else { return true; }; - member_value_may_have_exact_receiver_signature(db, first_member.get_id(), &mut HashSet::new()) - || members.any(|member| { - member_value_may_have_exact_receiver_signature(db, member.get_id(), &mut HashSet::new()) - }) + member_value_may_have_exact_receiver_signature( + db, + first_member.get_id(), + &mut HashSet::default(), + ) || members.any(|member| { + member_value_may_have_exact_receiver_signature(db, member.get_id(), &mut HashSet::default()) + }) } fn exact_receiver_member_key(index_expr: &glua_parser::LuaIndexExpr) -> Option { @@ -1770,7 +1775,7 @@ fn signature_ids_from_include_returned_table_member( let Some(member_id) = exact_source_member_id(db, cache, &source_expr) else { return Vec::new(); }; - let mut visited = HashSet::new(); + let mut visited = HashSet::default(); let target_file_ids = include_targets_from_member(db, cache, file_id, call_position, member_id, &mut visited); let [target_file_id] = target_file_ids.as_slice() else { diff --git a/crates/glua_code_analysis/src/compilation/analyzer/common/fixpoint_fuse.rs b/crates/glua_code_analysis/src/compilation/analyzer/common/fixpoint_fuse.rs new file mode 100644 index 000000000..76540b810 --- /dev/null +++ b/crates/glua_code_analysis/src/compilation/analyzer/common/fixpoint_fuse.rs @@ -0,0 +1,241 @@ +//! Safety fuses for write-driven fixpoint loops. +//! +//! Several analyzer passes loop until a round moves nothing (settled tail, +//! floored fixpoint, return re-derivation, call-site publishing, guard +//! frontier). A pass that re-moves the same owners every round oscillates +//! forever, so every such loop carries a fuse: without one the process hangs +//! silently with no output (observed: 5+ minute cold-build stall from two +//! passes ping-ponging seven owners). +//! +//! This is deliberately NOT a performance budget (the repo forbids those: no +//! caps, no prefilters that change answers). Healthy loops are +//! wavefront-advancing: each round settles strictly more state than the last, +//! because every round re-derives the same candidates against a strictly more +//! complete database, so the answers stop moving once every hop of the chain +//! has seen the hop before it (CityRP cold settles in single-digit rounds +//! everywhere this is used). +//! +//! Loop termination policy, in order: +//! +//! 1. The loop's own convergence test ends it: a round that moves nothing. +//! 2. Exact state-repetition detection ([`FixpointFuse::observe_boundary`]) is +//! the primary oscillation guard: the same complete post-round boundary +//! state recurring [`FIXPOINT_CYCLE_CONFIRMATIONS`] times is a confirmed +//! cycle, and the loop breaks out. A converging wavefront advances every +//! round, so a repeated state means the loop is re-moving the same owners +//! without settling them. +//! 3. A high absolute round cap ([`FIXPOINT_ROUND_FUSE`]) sits behind both as +//! a pure hang guard. It sits orders of magnitude above healthy behavior +//! (single-digit rounds), so tripping means a non-convergence bug, never a +//! slow workspace. +//! +//! Hang guard versus semantic bound: [`FixpointFuse::trip`] is process +//! survival, not a convergence proof and not a tuning knob. Correctness must +//! come from the loop's own monotonicity (a round that moves nothing ends the +//! loop; a boundary state that repeats is a confirmed cycle — see +//! [`FixpointFuse::observe_boundary`]). Tripping panics in test/debug builds +//! so regressions fail loudly, and in release logs an error and breaks out: +//! degraded diagnostics are strictly better than a hung language server. +//! Never "fix" slowness by raising these bounds; fix the non-monotonicity +//! that trips them. + +use std::collections::HashMap; + +/// Bound shared by every write-driven fixpoint fuse. See module docs. +/// +/// Orders of magnitude above healthy behavior (single-digit rounds), so +/// tripping means a non-convergence bug, never a slow workspace. +pub const FIXPOINT_ROUND_FUSE: usize = 10_000; + +/// Sightings of one non-empty boundary state that confirm a cycle. See +/// [`FixpointFuse::observe_boundary`]. +pub const FIXPOINT_CYCLE_CONFIRMATIONS: usize = 3; + +/// Counts rounds of one fixpoint loop; see module docs. +pub struct FixpointFuse { + name: &'static str, + rounds: usize, + boundary_sightings: HashMap>, +} + +impl FixpointFuse { + pub fn new(name: &'static str) -> Self { + FixpointFuse { + name, + rounds: 0, + boundary_sightings: HashMap::new(), + } + } + + /// Advance one round. Returns true when the loop must stop now: the owner + /// should break out immediately, leaving current state in place. + /// + /// Pure hang guard: this says nothing about whether the loop has + /// converged, only that it has run absurdly long and is therefore broken. + pub fn trip(&mut self) -> bool { + self.rounds += 1; + if self.rounds > FIXPOINT_ROUND_FUSE { + log::error!( + "fixpoint '{}' did not converge after {} rounds; breaking out instead of hanging. This is a hang guard tripping on a non-convergence bug — investigate the loop's monotonicity, do not raise the bound", + self.name, + FIXPOINT_ROUND_FUSE, + ); + debug_assert!( + false, + "fixpoint '{}' did not converge after {} rounds", + self.name, FIXPOINT_ROUND_FUSE, + ); + return true; + } + false + } + + /// Observe the loop's post-round boundary state. Returns true when the + /// loop must stop now: the owner should break out immediately, leaving + /// current state in place. + /// + /// `fingerprint` identifies the round's complete post-round boundary state + /// (every movable owner with its settled type, not just the owners this + /// round moved); `empty` reports whether the round's net was empty. + /// `canonical_state` is that same complete state in canonical + /// (deterministic, comparable) form. The fingerprint is only a lookup + /// accelerator: on a repeat sighting the canonical states are compared + /// for exact equality before anything counts towards a cycle, so the same + /// net delta recurring while another part of the boundary advances — or a + /// plain hash collision — never confirms a cycle. The same exact state + /// seen [`FIXPOINT_CYCLE_CONFIRMATIONS`] times is a confirmed + /// oscillation: a converging wavefront advances every round, so a + /// repeated state means the loop is re-moving the same owners without + /// settling them. + /// + /// Both forms are equality-only: they are compared against previous + /// sightings and never used to order, index, or otherwise decide answers, + /// so a collision can only delay cycle confirmation (the round-count hang + /// guard still bounds the loop), never change an analysis result. + pub fn observe_boundary( + &mut self, + fingerprint: u64, + empty: bool, + canonical_state: &str, + ) -> bool { + if empty { + return false; + } + if !self.record_sighting(fingerprint, canonical_state) { + return false; + } + log::error!( + "fixpoint '{}' revisited the same boundary state {} times; breaking out instead of hanging. This confirms an oscillation bug — investigate the passes' commit gates, do not raise the bound", + self.name, + FIXPOINT_CYCLE_CONFIRMATIONS, + ); + debug_assert!( + false, + "fixpoint '{}' oscillates: same boundary state {} times", + self.name, FIXPOINT_CYCLE_CONFIRMATIONS, + ); + true + } + + /// Records one sighting of an exact boundary state; true once that state + /// reached [`FIXPOINT_CYCLE_CONFIRMATIONS`] sightings. + /// + /// Pure counting with no side effects, so tests can drive the real cycle + /// logic: confirming through [`FixpointFuse::observe_boundary`] trips its + /// `debug_assert` in test builds by design. + fn record_sighting(&mut self, fingerprint: u64, canonical_state: &str) -> bool { + let bucket = self.boundary_sightings.entry(fingerprint).or_default(); + // An index, not a live `&mut`: the `None` arm pushes into the same + // bucket the search just borrowed. + if let Some(idx) = bucket + .iter() + .position(|(state, _)| state == canonical_state) + { + bucket[idx].1 += 1; + bucket[idx].1 >= FIXPOINT_CYCLE_CONFIRMATIONS + } else { + bucket.push((canonical_state.to_owned(), 1)); + FIXPOINT_CYCLE_CONFIRMATIONS <= 1 + } + } + + /// Forget every recorded boundary state. + /// + /// Call when loop state invisible to the fingerprint progressed: the + /// fingerprint covers type-owner state only, so a recurring fingerprint + /// amid dynamic-field, signature-return, or member-key progress is + /// continued convergence, not a cycle. Forgetting here keeps that + /// progress from confirming an oscillation that never happened. + pub fn reset_boundary_sightings(&mut self) { + self.boundary_sightings.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repeated_exact_state_confirms_a_cycle() { + let mut fuse = FixpointFuse::new("test"); + assert!(!fuse.record_sighting(7, "X=string")); + assert!(!fuse.record_sighting(7, "X=string")); + assert!(fuse.record_sighting(7, "X=string")); + } + + /// The same net delta recurring while another part of the boundary + /// advances presents the same fingerprint with a different exact state + /// every round. That is continued convergence and must never break early, + /// no matter how many rounds it takes. + #[test] + fn recurring_fingerprint_with_advancing_state_never_confirms_a_cycle() { + let mut fuse = FixpointFuse::new("test"); + for round in 0..FIXPOINT_ROUND_FUSE { + let canonical = format!("X=string;Y=round{round}"); + assert!(!fuse.record_sighting(7, &canonical)); + } + } + + /// A genuine two-state oscillation still confirms: each exact state is + /// tracked on its own, so alternating states reach the confirmation count + /// on their own sightings. + #[test] + fn alternating_exact_states_confirm_on_their_own_sightings() { + let mut fuse = FixpointFuse::new("test"); + assert!(!fuse.record_sighting(7, "A")); + assert!(!fuse.record_sighting(7, "B")); + assert!(!fuse.record_sighting(7, "A")); + assert!(!fuse.record_sighting(7, "B")); + assert!(fuse.record_sighting(7, "A")); + assert!(fuse.record_sighting(7, "B")); + } + + #[test] + fn non_type_progress_forgets_pending_sightings() { + let mut fuse = FixpointFuse::new("test"); + assert!(!fuse.record_sighting(7, "X=string")); + assert!(!fuse.record_sighting(7, "X=string")); + fuse.reset_boundary_sightings(); + assert!(!fuse.record_sighting(7, "X=string")); + assert!(!fuse.record_sighting(7, "X=string")); + assert!(fuse.record_sighting(7, "X=string")); + } + + #[test] + fn empty_rounds_record_nothing() { + let mut fuse = FixpointFuse::new("test"); + for _ in 0..FIXPOINT_CYCLE_CONFIRMATIONS + 1 { + assert!(!fuse.observe_boundary(7, true, "X=string")); + } + // One genuine sighting after all those empty rounds: if the empties + // had recorded anything this would already confirm. + assert!(!fuse.record_sighting(7, "X=string")); + } + + #[test] + fn non_confirming_rounds_observe_without_confirming() { + let mut fuse = FixpointFuse::new("test"); + assert!(!fuse.observe_boundary(7, false, "X=string")); + assert!(!fuse.observe_boundary(7, false, "X=string")); + } +} 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 deleted file mode 100644 index e93b822b2..000000000 --- a/crates/glua_code_analysis/src/compilation/analyzer/common/migrate_global_member.rs +++ /dev/null @@ -1,1259 +0,0 @@ -use std::collections::HashSet; - -use crate::{DbIndex, GlobalId, InFiled, LuaDeclId, LuaMemberId, LuaMemberOwner, LuaTypeOwner}; -use glua_parser::{LuaAstNode, LuaExpr, LuaIndexExpr, PathTrait}; - -use super::get_owner_id; -use crate::compilation::analyzer::lua::is_guarded_table_assignment_member; - -/// Re-derives the non-overwriting mark before a re-home elects a visible -/// member. -fn restore_non_overwriting_mark(db: &mut DbIndex, member_id: LuaMemberId) { - if is_guarded_table_assignment_member(db, member_id) { - db.get_member_index_mut() - .mark_non_overwriting_assignment_member(member_id); - } -} - -/// The owner a global declaration resolves to, falling back to the table -/// literal it is written with when inference has not reached it yet. -fn declaration_owner(db: &DbIndex, decl_id: LuaDeclId) -> Option { - let resolved = get_owner_id(db, &decl_id.into()); - if matches!(resolved, None | Some(LuaMemberOwner::Element(_))) - && let Some(range) = db.get_decl_index().get_global_initializer_table(&decl_id) - { - return Some(LuaMemberOwner::Element(InFiled::new( - decl_id.file_id, - range, - ))); - } - resolved -} - -/// The nested-path counterpart of [`declaration_owner`]. -fn member_declaration_owner(db: &DbIndex, member_id: LuaMemberId) -> Option { - let resolved = get_owner_id(db, &member_id.into()); - if matches!(resolved, None | Some(LuaMemberOwner::Element(_))) - && let Some(range) = db - .get_decl_index() - .get_global_member_initializer_table(&member_id) - { - return Some(LuaMemberOwner::Element(InFiled::new( - member_id.file_id, - range, - ))); - } - resolved -} - -pub fn migrate_global_members_when_type_resolve( - db: &mut DbIndex, - type_owner: LuaTypeOwner, -) -> Option<()> { - match type_owner { - LuaTypeOwner::Decl(decl_id) => { - migrate_global_member_to_decl(db, decl_id); - } - LuaTypeOwner::Member(member_id) => { - migrate_global_member_to_member(db, member_id); - } - _ => {} - } - Some(()) -} - -pub fn migrate_global_path_members_when_owner_resolved( - db: &mut DbIndex, - global_id: &GlobalId, -) -> Option<()> { - let decl_ids = db - .get_global_index() - .get_global_decl_ids(global_id.get_name())? - .clone(); - - for decl_id in decl_ids { - alias_global_members_to_decl_owner(db, decl_id); - } - - Some(()) -} - -fn alias_global_members_to_decl_owner(db: &mut DbIndex, decl_id: LuaDeclId) -> Option<()> { - let decl = db.get_decl_index().get_decl(&decl_id)?; - if !decl.is_global() { - return None; - } - - let owner_id = get_owner_id(db, &decl_id.into())?; - - let name = decl.get_name(); - let global_id = GlobalId::new(name); - let members = db - .get_member_index() - .get_members(&LuaMemberOwner::GlobalPath(global_id))? - .iter() - .filter(|member| member.get_feature().is_meta_decl()) - .map(|member| member.get_id()) - .collect::>(); - - let member_index = db.get_member_index_mut(); - for member_id in members { - member_index.add_member_alias_to_owner(owner_id.clone(), member_id); - } - - Some(()) -} - -/// Reconciles every global path whose members are still parked on it. -pub fn reconcile_parked_global_path_members(db: &mut DbIndex) { - // Sorted by name, so a parent path is always reconciled before the nested - // paths whose election reads the owners it just settled. - for global_id in db.get_member_index().sorted_global_path_owners() { - let global_path_owner = LuaMemberOwner::GlobalPath(global_id.clone()); - let Some(candidates) = elected_global_owners(db, &global_id) else { - continue; - }; - let Some((_, canonical_owner)) = candidates.first() else { - continue; - }; - if *canonical_owner == global_path_owner { - continue; - } - - // Every member of the global path, and whether it still needs - // re-homing. - let (members, hidden) = { - let member_index = db.get_member_index(); - let visible = member_index - .get_members(&global_path_owner) - .map(|members| { - members - .iter() - .map(|member| member.get_id()) - .collect::>() - }) - .unwrap_or_default(); - let mut hidden = HashSet::new(); - let members = member_index - .get_member_history(&global_path_owner) - .iter() - .map(|member| member.get_id()) - // The same exclusion `migrate_global_path_members` applies, - // and for the same reason: a member scripted-class - // synthesis claimed belongs to that class. Reconciliation - // ran without it, so a derma file's `PANEL` methods were - // aliased onto every panel class the file's `PANEL` - // declaration had been rewritten to. - .filter(|member_id| { - !member_index.has_synthesized_owner(member_id) - && !file_hands_global_to_scripted_class(db, member_id.file_id, &global_id) - }) - .map(|member_id| { - if !visible.contains(&member_id) { - hidden.insert(member_id); - return (member_id, false); - } - let needs_rehome = member_index - .get_member_owner(&member_id) - .is_none_or(|owner| *owner == global_path_owner); - (member_id, needs_rehome) - }) - .collect::>(); - (members, hidden) - }; - if members.is_empty() { - continue; - } - - // After the early-out: the guarded repair is part of reconciling a - // global that still has parked members, not a pass of its own. - alias_guarded_assignment_members_across_candidates(db, &candidates); - rehome_members_onto_their_own_files_table(db, &candidates); - - let declaring_files = declaring_files(db, &global_id); - - for (member_id, needs_rehome) in members { - // A file that declares the global itself owns the members it - // contributes: `marauth = marauth or {}` in two files describes one - // runtime table, but each file's fields belong to the table literal - // that file wrote. Falling back to the elected owner covers files - // that only extend a global they never declare. - let target_owner = match candidates - .iter() - .find(|(file_id, _)| *file_id == member_id.file_id) - { - Some((_, owner)) => Some(owner.clone()), - // See `migrate_global_path_members`: a file that declares - // the global but has not resolved its table keeps its - // members parked rather than sharing a sibling file's - // overwrite slot. - None if declaring_files.contains(&member_id.file_id) => None, - None => Some(canonical_owner.clone()), - }; - - let rehome_target = target_owner.clone().filter(|target_owner| { - needs_rehome - && db - .get_member_index() - .get_member_owner(&member_id) - .is_none_or(|owner| owner != target_owner) - }); - if let Some(target_owner) = rehome_target { - restore_non_overwriting_mark(db, member_id); - let member_index = db.get_member_index_mut(); - member_index.set_member_owner(target_owner.clone(), member_id.file_id, member_id); - member_index.add_member_to_owner(target_owner, member_id); - } - let member_index = db.get_member_index_mut(); - // Aliasing the remaining candidates is what makes a global - // declared once per realm behave like the single table it is at - // runtime, and it has to run for members that already reached a - // concrete owner too. Re-indexing a file rebuilds its members - // from scratch, so the aliases the original migration created - // are gone; gating the repair behind `needs_rehome` meant they - // were only ever rebuilt for members still parked on the global - // path. - for (alias_file_id, alias_owner) in &candidates { - if hidden.contains(&member_id) && *alias_file_id != member_id.file_id { - continue; - } - // Re-indexing one file leaves every other file's aliases in - // place, so on an incremental batch nearly all of these are - // already recorded. Skipping those is not an approximation: - // the alias write is a no-op exactly when - // `alias_to_owner_is_recorded` holds. - if Some(alias_owner) != target_owner.as_ref() - && !member_index.alias_to_owner_is_recorded(alias_owner, member_id) - { - member_index.add_member_alias_to_owner(alias_owner.clone(), 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 -/// local alias, a sibling global whose type resolved to the same literal); -/// those must not take part in this global's ownership repair. The path is -/// normally recorded on the member when declaration analysis parks it; a -/// member the lua pass attached directly carries none, so its own syntax -/// decides — the write site is stable under batching either way. -fn member_targets_global_path(db: &DbIndex, member_id: LuaMemberId, global_id: &GlobalId) -> bool { - if let Some(member) = db.get_member_index().get_member(&member_id) - && let Some(recorded) = member.get_global_id() - { - // The record is the write's full access path (`cityrp.LoadedOnce`); - // this repair belongs to the root global's candidates. - let recorded_root = recorded.get_name().split('.').next().unwrap_or_default(); - return recorded_root == global_id.get_name(); - } - - let Some(tree) = db.get_vfs().get_syntax_tree(&member_id.file_id) else { - return false; - }; - let Some(node) = member_id - .get_syntax_id() - .to_node_from_root(&tree.get_red_root()) - else { - return false; - }; - let Some(index_expr) = LuaIndexExpr::cast(node) else { - return false; - }; - let Some(access_path) = index_expr.get_access_path() else { - return false; - }; - let root = access_path.split('.').next().unwrap_or_default(); - if root != global_id.get_name() { - return false; - } - // The root segment must actually read the global: a local (or parameter) - // of the same name writes somewhere else entirely. A read bound to one of - // the root's own global declarations still counts as the global itself. - match index_expr.get_prefix_expr() { - Some(LuaExpr::NameExpr(name_expr)) => db - .get_reference_index() - .get_local_reference(&member_id.file_id) - .and_then(|reference| reference.get_decl_id(&name_expr.get_range())) - .and_then(|decl_id| db.get_decl_index().get_decl(&decl_id)) - .map(|decl| decl.is_global()) - .unwrap_or(false), - // A deeper index (`a.b.c`) belongs to the nested path's own - // reconciliation, not to the root global's. - _ => false, - } -} - -/// Re-homes members that reached a candidate table *directly*. -/// -/// A write whose prefix inferred to one concrete declaration while sibling -/// declarations of the same global were still unresolved attaches straight to -/// that table instead of parking on the global path, so the parked-member -/// reconciliation above never revisits it — and which table won depends on how -/// far the batch had run when the write was analysed. Applying the same target -/// rule the parked path uses (a declaring file keeps its own table, everyone -/// else belongs to the canonical owner) to every member sitting on a candidate -/// owner makes the outcome a function of the declared candidate set alone. -pub fn reconcile_directly_attached_candidate_members(db: &mut DbIndex) { - for global_id in db.get_global_index().sorted_multi_declaration_globals() { - let Some(candidates) = elected_global_owners(db, &global_id) else { - continue; - }; - if candidates.len() < 2 { - continue; - } - let Some((_, canonical_owner)) = candidates.first() else { - continue; - }; - let declaring_files = declaring_files(db, &global_id); - rehome_directly_attached_candidate_members( - db, - &global_id, - &candidates, - &declaring_files, - canonical_owner, - ); - } -} - -fn rehome_directly_attached_candidate_members( - db: &mut DbIndex, - global_id: &GlobalId, - candidates: &[(crate::FileId, LuaMemberOwner)], - declaring_files: &HashSet, - canonical_owner: &LuaMemberOwner, -) { - if candidates.len() < 2 { - return; - } - - let mut seen = HashSet::new(); - let writers = candidates - .iter() - .flat_map(|(_, owner)| db.get_member_index().get_member_history(owner)) - .filter(|member| seen.insert(member.get_id())) - .filter(|member| { - !db.get_member_index() - .has_synthesized_owner(&member.get_id()) - && !file_hands_global_to_scripted_class(db, member.get_file_id(), global_id) - && member_targets_global_path(db, member.get_id(), global_id) - }) - .map(|member| (member.get_id(), member.get_key().clone())) - .collect::>(); - for (member_id, member_key) in writers { - let Some(current) = db.get_member_index().get_member_owner(&member_id) else { - continue; - }; - let target = match candidates - .iter() - .find(|(file_id, _)| *file_id == member_id.file_id) - { - Some((_, owner)) => owner.clone(), - // See the parked-path rule: a file that declares the global but - // has not resolved its table keeps its members parked rather - // than sharing a sibling's overwrite slot. - 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 contribution_group_owner = db - .get_member_index() - .member_assignment_contributions() - .contribution_group_of(&member_id) - .map(|(owner, _)| owner); - let needs_contribution_move = contribution_group_owner - .as_ref() - .is_some_and(|owner| *owner != target); - if needs_contribution_move - && let Some(contribution) = db - .get_member_index() - .member_assignment_contributions() - .contribution_of(&member_id) - .cloned() - { - db.get_member_index_mut() - .member_assignment_contributions_mut() - .record(target.clone(), member_key.clone(), member_id, contribution); - } - if needs_move { - restore_non_overwriting_mark(db, member_id); - let member_index = db.get_member_index_mut(); - member_index.set_member_owner(target.clone(), member_id.file_id, member_id); - member_index.add_member_to_owner(target.clone(), member_id); - } - let member_index = db.get_member_index_mut(); - // Aliasing the remaining candidates is what makes a global declared - // once per realm behave like the single table it is at runtime, and it - // has to run even when nothing moved: re-indexing a file rebuilds its - // members from scratch, so the aliases the original migration created - // are gone. - for (_, alias_owner) in candidates { - if *alias_owner != target { - member_index.add_member_alias_to_owner(alias_owner.clone(), member_id); - } - } - } -} - -/// Moves a member that landed on a *sibling* file's table literal onto the -/// one its own file declares. -fn rehome_members_onto_their_own_files_table( - db: &mut DbIndex, - candidates: &[(crate::FileId, LuaMemberOwner)], -) { - if candidates.len() < 2 { - return; - } - - let member_index = db.get_member_index(); - let mut seen = HashSet::new(); - let moves = candidates - .iter() - .flat_map(|(_, owner)| member_index.get_member_history(owner)) - .map(|member| member.get_id()) - .filter(|member_id| !member_index.has_synthesized_owner(member_id)) - .filter_map(|member_id| { - // "The table its own file declares" only names one table when the - // file declares the path once. `bullet = {} … bullet.Src = …` - // written twice in one weapon file is two unrelated tables, and - // collapsing the second block's fields onto the first erases them. - let mut own = candidates - .iter() - .filter(|(file_id, _)| *file_id == member_id.file_id); - let (_, target_owner) = own.next()?; - if own.next().is_some() { - return None; - } - let current_owner = member_index.get_member_owner(&member_id)?; - (current_owner != target_owner - && candidates - .iter() - .any(|(_, candidate)| candidate == current_owner) - && seen.insert(member_id)) - .then(|| (member_id, current_owner.clone(), target_owner.clone())) - }) - .collect::>(); - - for (member_id, current_owner, target_owner) in moves { - // A member deferred resolution created never belonged to the table - // it was provisionally placed on, so the move has to take its - // enumerability with it: `set_member_owner` rewrites the current - // owner but leaves the item, and that leftover is a fact the other - // analysis order never produced. Every other member reached its - // owner from a settled fact and is reachable through several tables - // on purpose — detaching those cost real facts. - if db - .get_member_index() - .is_deferred_index_expr_member(&member_id) - { - db.get_member_index_mut() - .detach_member_from_owner(¤t_owner, member_id); - } - restore_non_overwriting_mark(db, member_id); - let member_index = db.get_member_index_mut(); - member_index.set_member_owner(target_owner.clone(), member_id.file_id, member_id); - member_index.add_member_to_owner(target_owner, member_id); - } -} - -/// Makes every guarded `X.k = X.k or …` write reachable through every table -/// literal `X` is declared with. -fn alias_guarded_assignment_members_across_candidates( - db: &mut DbIndex, - candidates: &[(crate::FileId, LuaMemberOwner)], -) { - if candidates.len() < 2 { - return; - } - - let member_index = db.get_member_index(); - let guarded = candidates - .iter() - .flat_map(|(_, owner)| member_index.get_member_history(owner)) - .map(|member| member.get_id()) - .filter(|member_id| member_index.is_non_overwriting_assignment_member(*member_id)) - .collect::>(); - if guarded.is_empty() { - return; - } - - let member_index = db.get_member_index_mut(); - for member_id in guarded { - for (_, owner) in candidates { - member_index.add_member_alias_to_owner(owner.clone(), member_id); - } - } -} - -/// The elected owners of `global_id`, computed purely from current index -/// state. -fn elected_global_owners( - db: &DbIndex, - global_id: &GlobalId, -) -> Option> { - match global_id.get_prev_id() { - Some(parent_id) => { - let declaring_member_ids = declaring_member_ids(db, global_id, parent_id); - - elect_owners(declaring_member_ids.into_iter().filter_map(|member_id| { - let owner = member_declaration_owner(db, member_id)?; - Some(( - global_member_sort_key(db, member_id), - member_id.file_id, - owner, - )) - })) - } - None => { - let decl_ids = db - .get_global_index() - .get_global_decl_ids(global_id.get_name())?; - - elect_owners( - decl_ids - .iter() - .copied() - .filter(|decl_id| { - db.get_decl_index() - .get_decl(decl_id) - .is_some_and(|decl| decl.is_global()) - }) - .filter_map(|decl_id| { - let owner = declaration_owner(db, decl_id)?; - Some((global_decl_sort_key(db, decl_id), decl_id.file_id, owner)) - }), - ) - } - } -} - -fn elect_owners( - candidates: impl Iterator, -) -> Option> { - let mut candidates = candidates.collect::>(); - if candidates.is_empty() { - return None; - } - - candidates.sort_by(|(left, _, _), (right, _, _)| left.cmp(right)); - - let mut elected: Vec<(crate::FileId, LuaMemberOwner)> = Vec::with_capacity(candidates.len()); - for (_, file_id, owner) in candidates { - if !elected.iter().any(|(_, existing)| *existing == owner) { - elected.push((file_id, owner)); - } - } - Some(elected) -} - -fn migrate_global_member_to_decl(db: &mut DbIndex, decl_id: LuaDeclId) -> Option<()> { - let decl = db.get_decl_index().get_decl(&decl_id)?; - if !decl.is_global() { - return None; - } - - let global_id = GlobalId::new(decl.get_name()); - let owners = resolved_global_decl_owners(db, &global_id, decl_id)?; - migrate_global_path_members(db, &global_id, &owners) -} - -/// Every declaration of `global_id` that already carries a resolved type, -/// ordered deterministically with the canonical owner first. -fn resolved_global_decl_owners( - db: &DbIndex, - global_id: &GlobalId, - triggering_decl_id: LuaDeclId, -) -> Option> { - let sibling_decl_ids = db - .get_global_index() - .get_global_decl_ids(global_id.get_name()) - .map(Vec::as_slice) - .unwrap_or_default(); - - // Almost every global is declared exactly once. Keep that path - // allocation free and identical in cost to electing by arrival. - get_owner_id(db, &triggering_decl_id.into())?; - - // Rank every declaration up front so the canonical owner is decided by - // source position rather than by which declaration this event arrived - // after. Declarations that have not resolved an owner yet drop out here and - // re-enter through their own resolution event. - let mut ranked = sibling_decl_ids - .iter() - .copied() - .chain(std::iter::once(triggering_decl_id)) - .filter(|decl_id| { - db.get_decl_index() - .get_decl(decl_id) - .is_some_and(|decl| decl.is_global()) - }) - .map(|decl_id| (global_decl_sort_key(db, decl_id), decl_id)) - .collect::>(); - ranked.sort_by(|(left, _), (right, _)| left.cmp(right)); - ranked.dedup_by(|(_, left), (_, right)| left == right); - - elect_owners(ranked.into_iter().filter_map(|(sort_key, decl_id)| { - let owner = declaration_owner(db, decl_id)?; - Some((sort_key, decl_id.file_id, owner)) - })) -} - -/// Stable ordering key for a global declaration. -/// -/// Keyed on the normalized source path first so the election survives `FileId` -/// renumbering when files are added or removed during a session. -fn global_decl_sort_key(db: &DbIndex, decl_id: LuaDeclId) -> (String, u32, u32) { - ( - normalized_file_path(db, decl_id.file_id), - decl_id.file_id.id, - u32::from(decl_id.position), - ) -} - -fn normalized_file_path(db: &DbIndex, file_id: crate::FileId) -> String { - db.get_vfs() - .get_file_path(&file_id) - .map(|path| crate::vfs::normalize_path_for_ordering(&path.to_string_lossy())) - .unwrap_or_default() -} - -/// Moves the members parked under `GlobalPath(global_id)` onto the -/// canonical owner and aliases them onto every other declaration of the -/// same global. -fn migrate_global_path_members( - db: &mut DbIndex, - global_id: &GlobalId, - owners: &[(crate::FileId, LuaMemberOwner)], -) -> Option<()> { - let (_, canonical_owner) = owners.first()?; - let member_index = db.get_member_index(); - let members = member_index - .get_members(&LuaMemberOwner::GlobalPath(global_id.clone()))? - .iter() - .map(|member| member.get_id()) - // A member that scripted-class synthesis already claimed belongs to - // that class, not to this global. `PANEL` is the case that matters: - // it is a per-file scratch table consumed by `vgui.Register`, but - // its methods stay enumerable under `GlobalPath("PANEL")`, so - // without this every derma file's methods would be re-homed onto - // whichever `PANEL` declaration won the election. - .filter(|member_id| { - !member_index.has_synthesized_owner(member_id) - && !file_hands_global_to_scripted_class(db, member_id.file_id, global_id) - }) - .collect::>(); - - if members.is_empty() { - return Some(()); - } - // Only needed to place members, so it is computed after the early-outs - // above: on a cold build the overwhelming majority of these events find - // nothing parked, and this walks every declaration of the global. - let declaring_files = declaring_files(db, global_id); - - for member_id in members { - // Same rule the end-of-batch reconciliation uses: a file that - // declares the global owns the fields it writes, and only files - // that merely extend a global they never declare fall back to the - // elected owner. - let target_owner = match owners - .iter() - .find(|(file_id, _)| *file_id == member_id.file_id) - { - Some((_, owner)) => owner.clone(), - // The member's own file declares the global but has not resolved a - // table for it yet. Leave it parked rather than re-homing it onto a - // sibling's table; it re-enters through its own declaration's - // resolution event, or through the end-of-batch reconciliation. - None if declaring_files.contains(&member_id.file_id) => continue, - None => canonical_owner.clone(), - }; - - restore_non_overwriting_mark(db, member_id); - let member_index = db.get_member_index_mut(); - member_index.set_member_owner(target_owner.clone(), member_id.file_id, member_id); - member_index.add_member_to_owner(target_owner.clone(), member_id); - for (_, alias_owner) in owners { - if *alias_owner != target_owner { - member_index.add_member_alias_to_owner(alias_owner.clone(), member_id); - } - } - } - - Some(()) -} - -fn migrate_global_member_to_member(db: &mut DbIndex, member_id: LuaMemberId) -> Option<()> { - let member = db.get_member_index().get_member(&member_id)?; - let global_id = member.get_global_id()?.clone(); - let owners = resolved_global_member_owners(db, &global_id, member_id)?; - migrate_global_path_members(db, &global_id, &owners) -} - -/// The nested-path counterpart of [`resolved_global_decl_owners`]. -fn resolved_global_member_owners( - db: &DbIndex, - global_id: &GlobalId, - member_id: LuaMemberId, -) -> Option> { - let Some(parent_id) = global_id.get_prev_id() else { - return get_owner_id(db, &member_id.into()).map(|owner| vec![(member_id.file_id, owner)]); - }; - - let declaring_member_ids = declaring_member_ids(db, global_id, parent_id); - - // See `resolved_global_decl_owners`: the resolution of `member_id` is - // the event being handled, so it must have produced an owner for this - // call to carry information. The owner itself is not used as an answer - // — there is no shortcut for the single-declaration case, because - // returning the triggering member's owner unranked hands the whole path - // to whichever file happened to fire, and every other file's members - // then fall back to it as the canonical owner. - get_owner_id(db, &member_id.into())?; - - // See `resolved_global_decl_owners`: rank every declaring member up front so - // the canonical owner is decided by source position, not by arrival. - let mut ranked = declaring_member_ids - .into_iter() - .chain(std::iter::once(member_id)) - .map(|declaring_id| (global_member_sort_key(db, declaring_id), declaring_id)) - .collect::>(); - ranked.sort_by(|(left, _), (right, _)| left.cmp(right)); - ranked.dedup_by(|(_, left), (_, right)| left == right); - - elect_owners(ranked.into_iter().filter_map(|(sort_key, declaring_id)| { - let owner = member_declaration_owner(db, declaring_id)?; - Some((sort_key, declaring_id.file_id, owner)) - })) -} - -/// The members that declare the nested path `global_id` under `parent_id`. -fn declaring_member_ids( - db: &DbIndex, - global_id: &GlobalId, - parent_id: GlobalId, -) -> Vec { - db.get_member_index() - .get_member_history_for_global_path(&LuaMemberOwner::GlobalPath(parent_id), global_id) -} - -/// Whether `file_id` hands the global `global_id` to a scripted-class -/// registration, i.e. uses it as a scratch table the way derma files use -/// `PANEL = {} … vgui.Register("X", PANEL, …)`. -pub(crate) fn file_hands_global_to_scripted_class( - db: &DbIndex, - file_id: crate::FileId, - global_id: &GlobalId, -) -> bool { - if global_id.get_prev_id().is_some() { - return false; - } - let Some(metadata) = db - .get_gmod_class_metadata_index() - .get_file_metadata(&file_id) - else { - return false; - }; - - metadata - .vgui_register_calls - .iter() - .chain(metadata.vgui_register_table_calls.iter()) - .chain(metadata.derma_define_control_calls.iter()) - .chain(metadata.scripted_ent_register_calls.iter()) - .any(|call| { - call.args - .iter() - .filter_map(|arg| arg.value.as_ref()) - .any(|value| { - matches!(value, crate::GmodClassCallLiteral::NameRef(name) if name == global_id.get_name()) - }) - }) -} - -/// The files that declare `global_id` themselves, whether or not their -/// declaration has resolved an owner yet. -fn declaring_files(db: &DbIndex, global_id: &GlobalId) -> HashSet { - match global_id.get_prev_id() { - Some(parent_id) => declaring_member_ids(db, global_id, parent_id) - .into_iter() - .map(|member_id| member_id.file_id) - .collect(), - None => db - .get_global_index() - .get_global_decl_ids(global_id.get_name()) - .map(|decl_ids| decl_ids.iter().map(|decl_id| decl_id.file_id).collect()) - .unwrap_or_default(), - } -} - -/// Stable ordering key for a member that declares a nested global path. -fn global_member_sort_key(db: &DbIndex, member_id: LuaMemberId) -> (String, u32, u32) { - ( - normalized_file_path(db, member_id.file_id), - member_id.file_id.id, - u32::from(member_id.get_syntax_id().get_range().start()), - ) -} - -#[cfg(test)] -mod tests { - use glua_parser::{LuaSyntaxId, LuaSyntaxKind}; - use rowan::{TextRange, TextSize}; - - use crate::{ - FileId, GlobalId, LuaDecl, LuaDeclExtra, LuaDeclarationTree, LuaMember, LuaMemberFeature, - LuaMemberKey, LuaMemberOwner, LuaType, LuaTypeCache, LuaTypeDeclId, LuaTypeOwner, - }; - - use super::*; - - fn syntax_id(kind: LuaSyntaxKind, start: u32) -> LuaSyntaxId { - LuaSyntaxId::new( - kind.into(), - TextRange::new(TextSize::new(start), TextSize::new(start + 1)), - ) - } - - #[test] - fn alias_global_members_to_decl_owner_only_aliases_meta_members() { - let mut db = DbIndex::new(); - let decl_file = FileId::new(1); - let decl = LuaDecl::new( - "math", - decl_file, - TextRange::new(TextSize::new(0), TextSize::new(4)), - LuaDeclExtra::Global { - kind: LuaSyntaxKind::NameExpr.into(), - }, - None, - ); - let decl_id = decl.get_id(); - let mut decl_tree = LuaDeclarationTree::new(decl_file); - decl_tree.add_decl(decl); - db.get_decl_index_mut().add_decl_tree(decl_tree); - db.get_global_index_mut().add_global_decl("math", decl_id); - - let math_type_id = LuaTypeDeclId::global("mathlib"); - db.get_type_index_mut().bind_type( - LuaTypeOwner::Decl(decl_id), - LuaTypeCache::DocType(LuaType::Ref(math_type_id.clone())), - ); - - let global_owner = LuaMemberOwner::GlobalPath(GlobalId::new("math")); - let meta_member_id = - LuaMemberId::new(syntax_id(LuaSyntaxKind::IndexExpr, 10), FileId::new(2)); - let file_member_id = - LuaMemberId::new(syntax_id(LuaSyntaxKind::IndexExpr, 20), FileId::new(3)); - db.get_member_index_mut().add_member( - global_owner.clone(), - LuaMember::new( - meta_member_id, - LuaMemberKey::Name("Clamp".into()), - LuaMemberFeature::MetaMethodDecl, - Some(GlobalId::new("math.Clamp")), - ), - ); - db.get_member_index_mut().add_member( - global_owner, - LuaMember::new( - file_member_id, - LuaMemberKey::Name("AddonOnly".into()), - LuaMemberFeature::FileMethodDecl, - Some(GlobalId::new("math.AddonOnly")), - ), - ); - - alias_global_members_to_decl_owner(&mut db, decl_id); - - let resolved_owner = LuaMemberOwner::Type(math_type_id); - let member_index = db.get_member_index(); - assert!( - member_index - .get_member_item(&resolved_owner, &LuaMemberKey::Name("Clamp".into())) - .is_some(), - "meta global-path members should be visible on the resolved global owner" - ); - assert!( - member_index - .get_member_item(&resolved_owner, &LuaMemberKey::Name("AddonOnly".into())) - .is_none(), - "non-meta global-path members should not be aliased by the late meta bridge" - ); - } - - fn add_global_decl(db: &mut DbIndex, name: &str, file_id: FileId, start: u32) -> LuaDeclId { - let decl = LuaDecl::new( - name, - file_id, - TextRange::new(TextSize::new(start), TextSize::new(start + 1)), - LuaDeclExtra::Global { - kind: LuaSyntaxKind::NameExpr.into(), - }, - None, - ); - let decl_id = decl.get_id(); - let mut decl_tree = LuaDeclarationTree::new(file_id); - decl_tree.add_decl(decl); - db.get_decl_index_mut().add_decl_tree(decl_tree); - db.get_global_index_mut().add_global_decl(name, decl_id); - decl_id - } - - /// Two files bootstrap `cityrp = cityrp or {}`. The `or` reads the global, - /// so each declaration's *resolved* type is a union over both literals and - /// names whichever arm arrived first — modelled here by both resolving to - /// `first_file`'s. The election must still hand each declaring file the - /// table it actually writes, and keep every member reachable through both. - #[test] - fn election_gives_each_bootstrap_file_its_own_table() { - let mut db = DbIndex::new(); - let first_file = FileId::new(1); - let second_file = FileId::new(2); - - let literal = |file_id| { - InFiled::new( - file_id, - TextRange::new(TextSize::new(10), TextSize::new(12)), - ) - }; - - for file_id in [first_file, second_file] { - let decl_id = add_global_decl(&mut db, "cityrp", file_id, 0); - db.get_decl_index_mut() - .set_global_initializer_table(decl_id, literal(file_id).value); - db.get_type_index_mut().bind_type( - LuaTypeOwner::Decl(decl_id), - LuaTypeCache::InferType(LuaType::TableConst(literal(first_file))), - ); - } - - let key = LuaMemberKey::Name("progresshud".into()); - let member_id = LuaMemberId::new(syntax_id(LuaSyntaxKind::IndexExpr, 20), second_file); - db.get_member_index_mut().add_member( - LuaMemberOwner::GlobalPath(GlobalId::new("cityrp")), - LuaMember::new(member_id, key.clone(), LuaMemberFeature::FileDefine, None), - ); - - reconcile_parked_global_path_members(&mut db); - - assert_eq!( - db.get_member_index().get_member_owner(&member_id), - Some(&LuaMemberOwner::Element(literal(second_file))), - "a bootstrap file's member belongs to the table that file writes" - ); - assert!( - db.get_member_index() - .get_member_item(&LuaMemberOwner::Element(literal(first_file)), &key) - .is_some(), - "and stays reachable through the sibling bootstrap table" - ); - } - - /// Two files write `cityrp.type = cityrp.type or …` and each landed on a - /// different `cityrp = cityrp or {}` literal, because the merged table the - /// Lua pass elected from was still partial in the file analysed first. - /// Neither writer can preserve the other while they sit under different - /// owners, so both have to be aliased onto every literal. - #[test] - fn reconcile_aliases_guarded_assignment_members_onto_every_declared_table() { - let mut db = DbIndex::new(); - let util_file = FileId::new(1); - let shared_file = FileId::new(2); - - for (file_id, start) in [(util_file, 0), (shared_file, 0)] { - let decl_id = add_global_decl(&mut db, "cityrp", file_id, start); - db.get_decl_index_mut().set_global_initializer_table( - decl_id, - TextRange::new(TextSize::new(10), TextSize::new(12)), - ); - } - - let owner_of = |file_id| { - LuaMemberOwner::Element(InFiled::new( - file_id, - TextRange::new(TextSize::new(10), TextSize::new(12)), - )) - }; - - // Each file's `cityrp.type` write, already homed on the literal its own - // Lua pass elected. - let mut writers = Vec::new(); - for file_id in [util_file, shared_file] { - let member_id = LuaMemberId::new(syntax_id(LuaSyntaxKind::IndexExpr, 20), file_id); - db.get_member_index_mut().add_member( - owner_of(file_id), - LuaMember::new( - member_id, - LuaMemberKey::Name("type".into()), - LuaMemberFeature::FileDefine, - None, - ), - ); - db.get_member_index_mut() - .mark_non_overwriting_assignment_member(member_id); - writers.push(member_id); - } - - // Reconciliation is driven by the global still having something parked. - db.get_member_index_mut().add_member( - LuaMemberOwner::GlobalPath(GlobalId::new("cityrp")), - LuaMember::new( - LuaMemberId::new(syntax_id(LuaSyntaxKind::IndexExpr, 30), shared_file), - LuaMemberKey::Name("LoadedOnce".into()), - LuaMemberFeature::FileDefine, - None, - ), - ); - - reconcile_parked_global_path_members(&mut db); - - let member_index = db.get_member_index(); - for file_id in [util_file, shared_file] { - let item = member_index - .get_member_item(&owner_of(file_id), &LuaMemberKey::Name("type".into())) - .expect("expected a `type` item on every declared `cityrp` table"); - let stored = match item { - crate::LuaMemberIndexItem::One(id) => vec![*id], - crate::LuaMemberIndexItem::Many(ids) => ids.clone(), - }; - assert!( - writers.iter().all(|writer| stored.contains(writer)), - "both guarded `cityrp.type` writers should be visible through {file_id:?}, got {item:?}" - ); - } - } - - /// Three files bootstrap `cityrp.configuration = cityrp.configuration or {}`. - /// A nested path elects from its declaring *members*, and only one of them - /// has resolved a type here — the state a partial re-index leaves behind. - /// The other two must still stand for the tables they syntactically write, - /// or the election answers differently than it does on a cold build. - #[test] - fn nested_path_election_sees_declarations_that_have_not_resolved() { - let mut db = DbIndex::new(); - let files = [FileId::new(1), FileId::new(2), FileId::new(3)]; - let parent_owner = LuaMemberOwner::GlobalPath(GlobalId::new("cityrp")); - let nested_id = GlobalId::new("cityrp.configuration"); - - let literal = |file_id| { - InFiled::new( - file_id, - TextRange::new(TextSize::new(40), TextSize::new(42)), - ) - }; - - let mut declaring = Vec::new(); - for file_id in files { - let member_id = LuaMemberId::new(syntax_id(LuaSyntaxKind::IndexExpr, 10), file_id); - db.get_member_index_mut().add_member( - parent_owner.clone(), - LuaMember::new( - member_id, - LuaMemberKey::Name("configuration".into()), - LuaMemberFeature::FileDefine, - Some(nested_id.clone()), - ), - ); - db.get_decl_index_mut() - .set_global_member_initializer_table(member_id, literal(file_id).value); - declaring.push(member_id); - } - - // Only the middle file's declaration has been inferred, and — as the - // `or` chain does in practice — it names the *first* file's literal. - db.get_type_index_mut().bind_type( - LuaTypeOwner::Member(declaring[1]), - LuaTypeCache::InferType(LuaType::TableConst(literal(files[0]))), - ); - - let elected = elected_global_owners(&db, &nested_id).expect("expected an election"); - - assert_eq!( - elected, - files - .iter() - .map(|file_id| (*file_id, LuaMemberOwner::Element(literal(*file_id)))) - .collect::>(), - "every declaring file must stand for the table it writes, resolved or not" - ); - } - - /// A member homed on a *sibling* file's table belongs to the table its own - /// file declares. Which one it reached during analysis depends on how much - /// of the path had resolved at that moment, so the final index has to decide - /// it instead. - #[test] - fn reconcile_rehomes_a_member_onto_its_own_files_table() { - let mut db = DbIndex::new(); - let own_file = FileId::new(1); - let sibling_file = FileId::new(2); - - for file_id in [own_file, sibling_file] { - let decl_id = add_global_decl(&mut db, "cityrp", file_id, 0); - db.get_decl_index_mut().set_global_initializer_table( - decl_id, - TextRange::new(TextSize::new(10), TextSize::new(12)), - ); - } - - let owner_of = |file_id| { - LuaMemberOwner::Element(InFiled::new( - file_id, - TextRange::new(TextSize::new(10), TextSize::new(12)), - )) - }; - - // `own_file`'s field, placed on `sibling_file`'s literal because that is - // what the prefix resolved to when the deferred write was handled. - let member_id = LuaMemberId::new(syntax_id(LuaSyntaxKind::IndexExpr, 20), own_file); - db.get_member_index_mut().add_member( - owner_of(sibling_file), - LuaMember::new( - member_id, - LuaMemberKey::Name("menu".into()), - LuaMemberFeature::FileDefine, - None, - ), - ); - - // Reconciliation is driven by the global still having something parked. - db.get_member_index_mut().add_member( - LuaMemberOwner::GlobalPath(GlobalId::new("cityrp")), - LuaMember::new( - LuaMemberId::new(syntax_id(LuaSyntaxKind::IndexExpr, 30), sibling_file), - LuaMemberKey::Name("LoadedOnce".into()), - LuaMemberFeature::FileDefine, - None, - ), - ); - - reconcile_parked_global_path_members(&mut db); - - assert_eq!( - db.get_member_index().get_member_owner(&member_id), - Some(&owner_of(own_file)), - "a member of a declaring file belongs to that file's own table" - ); - assert!( - db.get_member_index() - .get_members(&owner_of(sibling_file)) - .is_some_and(|members| members.iter().any(|member| member.get_id() == member_id)), - "a member that reached the sibling table from a settled fact stays \ - enumerable through it" - ); - } - - /// The move has to take enumerability with it when the first placement was - /// provisional: leaving the member listed under the sibling table is a fact - /// only the analysis order that mis-placed it ever produced. - #[test] - fn reconcile_detaches_a_deferred_member_from_the_table_it_left() { - let mut db = DbIndex::new(); - let own_file = FileId::new(1); - let sibling_file = FileId::new(2); - - for file_id in [own_file, sibling_file] { - let decl_id = add_global_decl(&mut db, "cityrp", file_id, 0); - db.get_decl_index_mut().set_global_initializer_table( - decl_id, - TextRange::new(TextSize::new(10), TextSize::new(12)), - ); - } - - let owner_of = |file_id| { - LuaMemberOwner::Element(InFiled::new( - file_id, - TextRange::new(TextSize::new(10), TextSize::new(12)), - )) - }; - - let member_id = LuaMemberId::new(syntax_id(LuaSyntaxKind::IndexExpr, 20), own_file); - db.get_member_index_mut().add_member( - owner_of(sibling_file), - LuaMember::new( - member_id, - LuaMemberKey::Name("menu".into()), - LuaMemberFeature::FileDefine, - None, - ), - ); - db.get_member_index_mut() - .mark_deferred_index_expr_member(member_id); - - db.get_member_index_mut().add_member( - LuaMemberOwner::GlobalPath(GlobalId::new("cityrp")), - LuaMember::new( - LuaMemberId::new(syntax_id(LuaSyntaxKind::IndexExpr, 30), sibling_file), - LuaMemberKey::Name("LoadedOnce".into()), - LuaMemberFeature::FileDefine, - None, - ), - ); - - reconcile_parked_global_path_members(&mut db); - - assert_eq!( - db.get_member_index().get_member_owner(&member_id), - Some(&owner_of(own_file)), - "a member of a declaring file belongs to that file's own table" - ); - assert!( - db.get_member_index() - .get_members(&owner_of(sibling_file)) - .is_none_or(|members| members.iter().all(|member| member.get_id() != member_id)), - "a provisionally placed member is not enumerable through the table \ - it was moved off" - ); - } - - /// A declaring file whose own table has not resolved keeps its members - /// parked — but reconciliation is the batch's last pass, so parking may not - /// also cost the member its reachability through the elected table. - #[test] - fn reconcile_aliases_parked_members_of_an_unresolved_declaring_file() { - let mut db = DbIndex::new(); - let resolved_file = FileId::new(1); - let unresolved_file = FileId::new(2); - - let resolved_decl_id = add_global_decl(&mut db, "cityrp", resolved_file, 0); - add_global_decl(&mut db, "cityrp", unresolved_file, 0); - - let table_type_id = LuaTypeDeclId::global("cityrptable"); - db.get_type_index_mut().bind_type( - LuaTypeOwner::Decl(resolved_decl_id), - LuaTypeCache::DocType(LuaType::Ref(table_type_id.clone())), - ); - - let member_id = LuaMemberId::new(syntax_id(LuaSyntaxKind::IndexExpr, 10), unresolved_file); - db.get_member_index_mut().add_member( - LuaMemberOwner::GlobalPath(GlobalId::new("cityrp")), - LuaMember::new( - member_id, - LuaMemberKey::Name("menu".into()), - LuaMemberFeature::FileFieldDecl, - None, - ), - ); - - reconcile_parked_global_path_members(&mut db); - - let member_index = db.get_member_index(); - assert!( - member_index - .get_member_item( - &LuaMemberOwner::Type(table_type_id), - &LuaMemberKey::Name("menu".into()) - ) - .is_some(), - "a parked member must stay reachable through the elected owner" - ); - assert_eq!( - member_index.get_member_owner(&member_id), - Some(&LuaMemberOwner::GlobalPath(GlobalId::new("cityrp"))), - "aliasing must not re-home the member onto a sibling file's table" - ); - } -} 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..95aa828d8 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/common/mod.rs @@ -1,15 +1,16 @@ -mod migrate_global_member; use glua_parser::{LuaAstNode, LuaAstToken, LuaExpr, LuaForRangeStat}; -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}; + +mod fixpoint_fuse; + +pub use fixpoint_fuse::FixpointFuse; 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 @@ -83,9 +84,7 @@ pub enum TypeCacheWriteMode { /// Writes a type cache using the requested low-level write mode. /// /// Call sites fall into doc-annotation, assignment-inferred, and -/// resolved-synthesized families. Authority-based precedence was evaluated and -/// rejected in Phase C2 for lack of evidence; see -/// `.slim/deepwork/indexing-type-source-refactor.md`. +/// resolved-synthesized families. pub fn write_type_cache( db: &mut DbIndex, owner: LuaTypeOwner, @@ -93,9 +92,12 @@ pub fn write_type_cache( mode: TypeCacheWriteMode, ) { match mode { - TypeCacheWriteMode::InsertOnly => db.get_type_index_mut().bind_type(owner, cache), - TypeCacheWriteMode::ForceOverwrite => db.get_type_index_mut().force_bind_type(owner, cache), + TypeCacheWriteMode::InsertOnly => db.get_type_index_mut().bind_type(owner.clone(), cache), + TypeCacheWriteMode::ForceOverwrite => db + .get_type_index_mut() + .force_bind_type(owner.clone(), cache), } + bind_global_path_class(db, &owner); } /// Binds an inferred/declared type and preserves the legacy declaration merge @@ -108,46 +110,355 @@ 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(); if should_replace_uninformative_inferred_cache(&type_owner, decl_type_cache, &type_cache) { db.get_type_index_mut() .force_bind_type(type_owner.clone(), type_cache); - migrate_global_members_when_type_resolve(db, type_owner); + bind_global_path_class(db, &type_owner); } else { - merge_def_type(db, decl_type.clone(), type_cache.as_type().clone(), 0); + // A guarded `X.k = X.k or {}` bootstrap of a path whose + // declaration carries a `---@class` assigns that class's own + // table: the literal canonicalises to the class owner. Merging it + // into the class type states nothing new, and widens `C` to + // `C|table`. + if !incoming_is_the_declared_class_table(db, decl_type, type_cache.as_type()) { + merge_def_type(db, decl_type.clone(), type_cache.as_type().clone(), 0); + } } } 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, type_cache: LuaTypeCache) { + let type_cache = widen_mutable_decl_literal(db, &type_owner, type_cache); + let type_cache = match class_table_seed_type(db, type_cache.as_type()) { + Some(class_type) => LuaTypeCache::InferType(class_type), + None => type_cache, + }; + db.get_type_index_mut() + .force_bind_type(type_owner.clone(), type_cache); + bind_global_path_class(db, &type_owner); +} + +/// The class a table literal belongs to, when it is the table a +/// class-annotated global path was declared with. +/// +/// `X.k = X.k or {}` in a second file assigns the very table the first file +/// annotated, and a reader of `X.k` holds that same table. Seeding the literal +/// instead of the class hands both an empty table where the class stands. +fn class_table_seed_type(db: &DbIndex, typ: &LuaType) -> Option { + let LuaType::TableConst(range) = typ else { + return None; + }; + let class = db + .get_member_index() + .canonical_owner(LuaMemberOwner::Element(range.clone())) + .get_type_id()? + .clone(); + Some(LuaType::Ref(class)) +} + +fn incoming_is_the_declared_class_table( + db: &DbIndex, + declared: &LuaType, + incoming: &LuaType, +) -> bool { + let (LuaType::Def(class) | LuaType::Ref(class)) = declared else { + return false; + }; + let LuaType::TableConst(range) = incoming else { + return false; + }; + db.get_member_index() + .canonical_owner(LuaMemberOwner::Element(range.clone())) + .get_type_id() + == Some(class) +} + +/// Files a global path's members under the class its declaration is annotated +/// with. +/// +/// `---@class oslib` on `os = {}` gives one table two names, and every reader +/// of the annotated type looks its members up on the class. Without this the +/// path's members and the class's are two disjoint sets, and the workspace +/// precedence between two files writing the same key (annotations over the std +/// stub) never gets to apply. +fn bind_global_path_class(db: &mut DbIndex, type_owner: &LuaTypeOwner) -> Option<()> { + let class = match db.get_type_index().get_type_cache(type_owner)?.as_type() { + LuaType::Def(id) | LuaType::Ref(id) => id.clone(), + _ => return None, + }; + let (path, file_id) = match type_owner { + LuaTypeOwner::Decl(decl_id) => { + let decl = db.get_decl_index().get_decl(decl_id)?; + if !decl.is_global() { + return None; + } + (crate::GlobalId::new(decl.get_name()), decl_id.file_id) + } + LuaTypeOwner::Member(member_id) => { + let member = db.get_member_index().get_member(member_id)?; + (member.get_global_id()?.clone(), member_id.file_id) + } + _ => return None, + }; + db.get_member_index_mut() + .set_global_path_class(path, class, file_id); + Some(()) +} + +/// A declaration written more than once holds a primitive over its lifetime, +/// not whichever literal one write happened to carry. +pub(crate) 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, + } +} + +pub(crate) fn mutable_local_name_read_decl( + db: &DbIndex, + file_id: FileId, + expr: &LuaExpr, +) -> Option { + let LuaExpr::NameExpr(name_expr) = expr else { + return None; + }; + let name = name_expr.get_name_text()?; + let decl_id = db + .get_decl_index() + .get_decl_tree(&file_id)? + .find_local_decl(&name, name_expr.get_position())? + .get_id(); + db.get_reference_index() + .get_decl_references(&file_id, &decl_id) + .is_some_and(|references| references.mutable) + .then_some(decl_id) +} + +pub(crate) fn widen_mutable_local_name_copy( + db: &DbIndex, + file_id: FileId, + expr: &LuaExpr, + typ: LuaType, +) -> LuaType { + let Some(decl_id) = mutable_local_name_read_decl(db, file_id, expr) else { + return typ; + }; + widen_mutable_decl_literal( + db, + &LuaTypeOwner::Decl(decl_id), + LuaTypeCache::InferType(typ), + ) + .as_type() + .clone() +} + +/// 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, + /// 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 + /// `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`. +/// +/// 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, + may_narrow_uninformative, + resolved_initializer, + fills_own_default, + } = 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. + if db + .get_decl_index() + .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()); + let seeds = match db.get_type_index().get_type_cache(&type_owner) { + None => true, + Some(existing) => { + let both_inferred = existing.is_infer() && type_cache.is_infer(); + 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 + && is_informative_type(seeded.as_type()) + && 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 { + 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)) => { + // 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, + existing, + claim_may_narrow, + ) + } + } + } + } + }; + if !seeds { + return fallback(db, type_cache); + } + db.get_type_index_mut() + .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 @@ -164,7 +475,7 @@ pub fn bind_resolved_type( { db.get_type_index_mut() .force_bind_type(type_owner.clone(), type_cache); - migrate_global_members_when_type_resolve(db, type_owner); + bind_global_path_class(db, &type_owner); return Some(()); } @@ -272,7 +583,7 @@ fn merge_def_type_with_table( def_id: LuaTypeDeclId, table_range: InFiled, ) -> Option<()> { - let expr_member_owner = LuaMemberOwner::Element(table_range); + let expr_member_owner = LuaMemberOwner::Element(table_range.clone()); let member_index = db.get_member_index_mut(); let expr_member_ids = member_index .get_members(&expr_member_owner)? @@ -280,9 +591,17 @@ fn merge_def_type_with_table( .map(|member| member.get_id()) .collect::>(); let def_owner = LuaMemberOwner::Type(def_id); - for table_member_id in expr_member_ids { + for table_member_id in expr_member_ids.clone() { add_member(db, def_owner.clone(), table_member_id); } + // A literal that initialises a global path keeps its members following the + // path's `---@class` flips even though they now also live on the def. The + // homing note may have dropped that evidence when the def is not the + // path's current canonical owner, so re-stamp here. + if let Some(path) = db.get_member_index().definition_site_path(&table_range) { + db.get_member_index_mut() + .stamp_path_provenance_if_absent(expr_member_ids, &path); + } Some(()) } @@ -296,52 +615,6 @@ pub fn add_member(db: &mut DbIndex, owner: LuaMemberOwner, member_id: LuaMemberI Some(()) } -fn get_owner_id(db: &DbIndex, type_owner: &LuaTypeOwner) -> Option { - let type_cache = db.get_type_index().get_type_cache(type_owner)?; - member_owner_from_type(type_cache.as_type()) -} - -fn member_owner_from_type(typ: &LuaType) -> Option { - match typ { - LuaType::Ref(type_id) | LuaType::Def(type_id) => { - Some(LuaMemberOwner::Type(type_id.clone())) - } - LuaType::TableConst(id) => Some(LuaMemberOwner::Element(id.clone())), - LuaType::Instance(inst) => member_owner_from_type(inst.get_base()) - .or_else(|| Some(LuaMemberOwner::Element(inst.get_range().clone()))), - LuaType::TypeGuard(inner) => member_owner_from_type(inner), - LuaType::Union(union) => preferred_owner_from_types(union.types()), - LuaType::Intersection(intersection) => { - preferred_owner_from_types(intersection.get_types().iter()) - } - LuaType::MultiLineUnion(union) => { - preferred_owner_from_types(union.get_unions().iter().map(|(typ, _)| typ)) - } - _ => None, - } -} - -fn preferred_owner_from_types<'a>( - types: impl Iterator, -) -> Option { - let mut fallback_owner = None; - for typ in types { - let Some(owner) = member_owner_from_type(typ) else { - continue; - }; - - if matches!(owner, LuaMemberOwner::Type(_)) { - return Some(owner); - } - - if fallback_owner.is_none() { - fallback_owner = Some(owner); - } - } - - fallback_owner -} - #[cfg(test)] mod tests { use super::*; 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/compilation/analyzer/decl/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/decl/mod.rs index f3d098540..fb20d4807 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/decl/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/decl/mod.rs @@ -3,7 +3,7 @@ mod exprs; mod members; mod stats; -use std::collections::HashSet; +use rustc_hash::FxHashSet; use crate::{ compilation::analyzer::AnalysisPipeline, @@ -13,13 +13,12 @@ use crate::{ use super::{ AnalyzeContext, - common::{ - TypeCacheWriteMode, migrate_global_path_members_when_owner_resolved, write_type_cache, - }, + common::{TypeCacheWriteMode, write_type_cache}, gmod::ensure_scoped_class_type_decl_for_file, }; use glua_parser::{ - LuaAst, LuaAstNode, LuaChunk, LuaFuncStat, LuaIfStat, LuaSyntaxKind, LuaVarExpr, + LuaAssignStat, LuaAst, LuaAstNode, LuaChunk, LuaFuncStat, LuaNameExpr, LuaSyntaxKind, + LuaVarExpr, }; use rowan::{TextRange, TextSize, WalkEvent}; @@ -83,27 +82,68 @@ impl AnalysisPipeline for DeclAnalysisPipeline { } db.get_decl_index_mut().add_decl_tree(decl_tree); + + let reassignments = + collect_local_reassignments(db, in_filed_tree.file_id, &in_filed_tree.value); + db.get_reference_index_mut() + .set_local_reassignments(in_filed_tree.file_id, reassignments); } } } -/// Records where each branch of `stat` begins and ends, and which `if` they -/// belong to. Writes in different branches of one `if` are alternatives and all -/// of them stay visible; every other pair of writes is successive, so the later -/// one wins. Recorded on the decl walk, which every file gets, so the answer -/// does not depend on how far inference reached. -fn record_if_branch_ranges(analyzer: &mut DeclAnalyzer, stat: &LuaIfStat) { - let if_range = stat.get_range(); - let file_id = analyzer.get_file_id(); - let branches = stat - .get_block() - .map(|block| block.get_range()) - .into_iter() - .chain(stat.get_all_clause().map(|clause| clause.get_range())); - let member_index = analyzer.db.get_member_index_mut(); - for branch in branches { - member_index.add_conditional_branch_range(file_id, branch, if_range); +/// Where each local declared in `file_id` is first reassigned. Derived from the +/// file's own syntax tree, local references and declaration tree, all of which +/// are complete for the file at this point in the decl walk. +fn collect_local_reassignments( + db: &DbIndex, + file_id: FileId, + chunk: &LuaChunk, +) -> rustc_hash::FxHashMap { + let mut positions = rustc_hash::FxHashMap::default(); + let references = db.get_reference_index().get_local_reference(&file_id); + let decl_tree = db.get_decl_index().get_decl_tree(&file_id); + for assign_stat in chunk.syntax().descendants().filter_map(LuaAssignStat::cast) { + let position = assign_stat.get_position(); + + let (vars, _) = assign_stat.get_var_and_expr_list(); + for var in vars { + let LuaVarExpr::NameExpr(name_expr) = var else { + continue; + }; + + let assigned_decl_id = references + .and_then(|refs| refs.get_decl_id(&name_expr.get_range())) + .or_else(|| assignment_name_decl_id(decl_tree, &name_expr)); + let Some(assigned_decl_id) = assigned_decl_id else { + continue; + }; + if assigned_decl_id.file_id != file_id || position <= assigned_decl_id.position { + continue; + } + + positions + .entry(assigned_decl_id) + .and_modify(|first| { + if position < *first { + *first = position; + } + }) + .or_insert(position); + } } + + positions +} + +fn assignment_name_decl_id( + decl_tree: Option<&LuaDeclarationTree>, + name_expr: &LuaNameExpr, +) -> Option { + let name = name_expr.get_name_text()?; + + decl_tree + .and_then(|tree| tree.find_local_decl(&name, name_expr.get_position())) + .map(|decl| decl.get_id()) } fn walk_node_enter(analyzer: &mut DeclAnalyzer, node: LuaAst) { @@ -123,9 +163,6 @@ fn walk_node_enter(analyzer: &mut DeclAnalyzer, node: LuaAst) { analyzer.create_scope(stat.get_range(), LuaScopeKind::LocalOrAssignStat); stats::analyze_assign_stat(analyzer, stat); } - LuaAst::LuaIfStat(stat) => { - record_if_branch_ranges(analyzer, &stat); - } LuaAst::LuaForStat(stat) => { analyzer.create_scope(stat.get_range(), LuaScopeKind::Normal); stats::analyze_for_stat(analyzer, stat); @@ -236,7 +273,7 @@ pub struct DeclAnalyzer<'a> { root: LuaChunk, decl: LuaDeclarationTree, scoped_class_info: Option, - scoped_class_global_names: Option>, + scoped_class_global_names: Option>, seeded_scoped_class_decl: bool, legacy_module_envs: Vec, scopes: Vec, @@ -253,7 +290,8 @@ impl<'a> DeclAnalyzer<'a> { scoped_class_info: Option, ) -> DeclAnalyzer<'a> { let scoped_class_global_names = scoped_class_info.as_ref().map(|info| { - let mut names = HashSet::with_capacity(1 + info.aliases.len()); + let mut names = + FxHashSet::with_capacity_and_hasher(1 + info.aliases.len(), Default::default()); names.insert(info.global_name.clone()); names.extend(info.aliases.iter().cloned()); names @@ -287,17 +325,7 @@ impl<'a> DeclAnalyzer<'a> { } fn add_member(&mut self, owner: LuaMemberOwner, member: LuaMember) -> LuaMemberId { - let global_id = match (&owner, member.get_feature().is_meta_decl()) { - (LuaMemberOwner::GlobalPath(global_id), true) => Some(global_id.clone()), - _ => None, - }; - let member_id = self.db.get_member_index_mut().add_member(owner, member); - - if let Some(global_id) = global_id { - migrate_global_path_members_when_owner_resolved(self.db, &global_id); - } - - member_id + self.db.get_member_index_mut().add_member(owner, member) } pub fn get_decl_tree(self) -> LuaDeclarationTree { diff --git a/crates/glua_code_analysis/src/compilation/analyzer/decl/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/decl/stats.rs index 9ff073ede..11924fef9 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/decl/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/decl/stats.rs @@ -5,8 +5,8 @@ use glua_parser::{ }; use crate::{ - LuaDeclExtra, LuaMemberFeature, LuaMemberId, LuaSemanticDeclId, LuaSignatureId, LuaType, - LuaTypeCache, + GlobalId, InFiled, LuaDeclExtra, LuaMemberFeature, LuaMemberId, LuaSemanticDeclId, + LuaSignatureId, LuaType, LuaTypeCache, compilation::analyzer::common::bind_type, db_index::{ LocalAttribute, LuaDecl, LuaDeclInitializer, LuaMember, LuaMemberKey, LuaMemberOwner, @@ -86,6 +86,40 @@ fn initializer_table_expr(expr: &LuaExpr) -> Option { } } +/// Registers the literal a global path is written with as a definition site of +/// the path, and each of its fields written with a literal as one of the +/// nested path. +/// +/// The `configuration = {}` in `cityrp = cityrp or { configuration = {} }` +/// initialises `cityrp.configuration` exactly as `cityrp.configuration = {}` +/// would. Without the nested site a write through the literal, which is what +/// a re-index resolves the prefix to, and a write through the path, which is +/// what a cold walk files it under, would be two owners for one slot. +fn set_definition_sites( + member_index: &mut crate::LuaMemberIndex, + path: GlobalId, + table_expr: &LuaTableExpr, + file_id: crate::FileId, +) { + member_index.set_definition_site(path.clone(), InFiled::new(file_id, table_expr.get_range())); + for field in table_expr.get_fields() { + let name = match field.get_field_key() { + Some(LuaIndexKey::Name(name)) => name.get_name_text().to_string(), + Some(LuaIndexKey::String(text)) => text.get_value(), + _ => continue, + }; + let Some(nested) = field + .get_value_expr() + .as_ref() + .and_then(initializer_table_expr) + else { + continue; + }; + let nested_path = GlobalId::new(&format!("{}.{name}", path.get_name())); + set_definition_sites(member_index, nested_path, &nested, file_id); + } +} + pub fn analyze_assign_stat(analyzer: &mut DeclAnalyzer, stat: LuaAssignStat) -> Option<()> { // An incomplete statement (`SKIN.Field` with no `=`, common mid-edit) also // parses as an assignment. It defines no member, so registering one here @@ -144,19 +178,18 @@ pub fn analyze_assign_stat(analyzer: &mut DeclAnalyzer, stat: LuaAssignStat) -> value_expr_id, ); - let decl_id = decl.get_id(); analyzer.add_decl(decl); // Record the `{}` this global is written with while the value - // expression is already in hand. The global-member owner - // election needs it to see declarations whose type has not been - // inferred yet; re-deriving it there would mean resolving a - // node per election, which measured 6x slower. + // expression is already in hand: it is a definition site of the + // path, and the path owns the literal's members. if let Some(table_expr) = value_exprs.get(idx).and_then(initializer_table_expr) { - analyzer - .db - .get_decl_index_mut() - .set_global_initializer_table(decl_id, table_expr.get_range()); + set_definition_sites( + analyzer.db.get_member_index_mut(), + GlobalId::new(name), + &table_expr, + file_id, + ); } } LuaVarExpr::IndexExpr(index_expr) => { @@ -189,18 +222,18 @@ pub fn analyze_assign_stat(analyzer: &mut DeclAnalyzer, stat: LuaAssignStat) -> }; let (owner, global_id) = find_index_owner(analyzer, index_expr.clone()); - // The `{}` this nested global path is written with, recorded for - // the same reason the `NameExpr` branch above records a root - // global's: the election that ranks a path's declarations must - // see all of them from decl analysis onward, not only the ones - // inference has already reached. - if global_id.is_some() + // The `{}` this nested global path is written with is a + // definition site of the path, exactly as the `NameExpr` branch + // above records a root global's. + if let Some(global_id) = &global_id && let Some(table_expr) = value_exprs.get(idx).and_then(initializer_table_expr) { - analyzer - .db - .get_decl_index_mut() - .set_global_member_initializer_table(member_id, table_expr.get_range()); + set_definition_sites( + analyzer.db.get_member_index_mut(), + global_id.clone(), + &table_expr, + file_id, + ); } let member = LuaMember::new(member_id, key.clone(), decl_feature, global_id); diff --git a/crates/glua_code_analysis/src/compilation/analyzer/doc/file_generic_index.rs b/crates/glua_code_analysis/src/compilation/analyzer/doc/file_generic_index.rs index 71a339e89..c497f262b 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/doc/file_generic_index.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/doc/file_generic_index.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use rustc_hash::FxHashMap; use rowan::{TextRange, TextSize}; @@ -219,7 +219,7 @@ impl GenericEffectId { #[derive(Debug, Clone, PartialEq, Eq)] pub struct TagGenericParams { - params: HashMap, + params: FxHashMap, is_func: bool, next_index: usize, } @@ -227,7 +227,7 @@ pub struct TagGenericParams { impl TagGenericParams { pub fn new(is_func: bool, start: usize) -> Self { Self { - params: HashMap::new(), + params: FxHashMap::default(), is_func, next_index: start, } diff --git a/crates/glua_code_analysis/src/compilation/analyzer/doc/infer_type.rs b/crates/glua_code_analysis/src/compilation/analyzer/doc/infer_type.rs index b37720151..33acec062 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/doc/infer_type.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/doc/infer_type.rs @@ -15,7 +15,7 @@ use smol_str::SmolStr; use crate::{ AsyncState, DiagnosticCode, GenericParam, GenericTpl, InFiled, LuaAliasCallKind, LuaArrayLen, LuaArrayType, LuaAttributeType, LuaMultiLineUnion, LuaTupleStatus, LuaTypeDeclId, TypeOps, - VariadicType, + VariadicType, analysis_stack_exhausted, db_index::{ AnalyzeError, LuaAliasCallType, LuaConditionalType, LuaFunctionType, LuaGenericType, LuaIndexAccessKey, LuaIntersectionType, LuaMappedType, LuaObjectType, LuaStringTplType, @@ -26,6 +26,9 @@ use crate::{ use super::{DocAnalyzer, normalize_doc_string_const, preprocess_description}; pub fn infer_type(analyzer: &mut DocAnalyzer, node: LuaDocType) -> LuaType { + if analysis_stack_exhausted() { + return LuaType::Unknown; + } match &node { LuaDocType::Name(name_type) => { if let Some(name) = name_type.get_name_text() { @@ -872,3 +875,106 @@ fn infer_index_access_type( LuaAliasCallType::new(LuaAliasCallKind::Index, vec![source_type, key_type]).into(), ) } + +#[cfg(test)] +mod test { + // The analyzer doc walker shares the analysis stack reserve with + // expression inference: suffixed `[]` types parse in a loop (zero parse + // errors on any thread) while `infer_type` recurses per level, so + // `Unknown` here can only come from this walker's guard. + use glua_parser::{LuaAstNode, LuaComment, LuaDocTagParam, LuaParser, ParserConfig}; + + use crate::{DbIndex, FileId, LuaType, WorkspaceId}; + + use super::super::DocAnalyzer; + use super::super::file_generic_index::FileGenericIndex; + use super::infer_type; + + const SMALL_STACK: usize = 2 * 1024 * 1024; + const BIG_STACK: usize = 64 * 1024 * 1024; + + fn array_type_source(depth: usize) -> String { + let mut body = String::from("---@param x T"); + for _ in 0..depth { + body.push_str("[]"); + } + body.push_str("\nlocal dummy = 1\n"); + body + } + + fn infer_first_param_type(body: &str) -> LuaType { + let tree = LuaParser::parse(body, ParserConfig::default()); + assert!( + tree.get_errors().is_empty(), + "array nesting must parse cleanly, got {:?}", + tree.get_errors() + .iter() + .take(3) + .map(|e| &e.message) + .collect::>() + ); + let chunk = tree.get_chunk_node(); + let comment = chunk + .descendants::() + .next() + .expect("comment must exist"); + let doc_type = chunk + .descendants::() + .next() + .expect("param tag must exist") + .get_type() + .expect("param type must exist"); + let mut db = DbIndex::new(); + let mut generic_index = FileGenericIndex::new(); + let mut analyzer = DocAnalyzer::new( + &mut db, + FileId::new(0), + &mut generic_index, + comment, + chunk.syntax().clone(), + WorkspaceId::MAIN, + ); + let ty = infer_type(&mut analyzer, doc_type); + ty + } + + #[test] + fn doc_analyzer_bails_via_stack_reserve_not_parse_errors() { + let body = array_type_source(20000); + let control_body = body.clone(); + std::thread::Builder::new() + .stack_size(SMALL_STACK) + .spawn(move || { + let result = infer_first_param_type(&body); + assert!( + result.is_unknown(), + "deep doc nesting must bail to Unknown, got {result:?}" + ); + + let shallow = infer_first_param_type("---@param x T[]\nlocal dummy = 1\n"); + assert!( + !shallow.is_unknown(), + "shallow doc nesting must infer normally, got {shallow:?}" + ); + }) + .expect("worker thread should spawn") + .join() + .expect("production-sized worker must not overflow its stack"); + + // Control: the same deep nesting resolves to a real type with room, + // so the small-stack `Unknown` comes from stack pressure, not from + // the shape itself. + std::thread::Builder::new() + .stack_size(BIG_STACK) + .spawn(move || { + let result = infer_first_param_type(&control_body); + assert!( + !result.is_unknown(), + "deep doc nesting must infer normally with room, got {result:?}" + ); + }) + .expect("worker thread should spawn") + .join() + .expect("large-stack inference must not overflow its stack"); + } +} diff --git a/crates/glua_code_analysis/src/compilation/analyzer/doc/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/doc/mod.rs index f6f54d706..985bef188 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/doc/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/doc/mod.rs @@ -55,6 +55,7 @@ impl AnalysisPipeline for DocAnalysisPipeline { let workspace_id = context.workspace_id.unwrap_or(WorkspaceId::MAIN); for in_filed_tree in tree_list.iter() { let root = &in_filed_tree.value; + register_property_merge_order(db, in_filed_tree.file_id); let mut generic_index = FileGenericIndex::new(); for comment in root.descendants::() { let mut analyzer = DocAnalyzer::new( @@ -71,6 +72,19 @@ impl AnalysisPipeline for DocAnalysisPipeline { } } +/// A class documented in several files shares one property. Tell the property index how those +/// files order against each other. A file in no known workspace sorts after every ranked one. +fn register_property_merge_order(db: &mut DbIndex, file_id: FileId) { + let module_index = db.get_module_index(); + let workspace_rank = module_index + .get_workspace_id(file_id) + .map(|workspace_id| module_index.get_workspace_kind(workspace_id).merge_rank()) + .unwrap_or(crate::WorkspaceKind::MERGE_RANK_NONE); + let path = db.get_vfs().file_order_key(&file_id); + db.get_property_index_mut() + .set_file_merge_order(file_id, workspace_rank, path); +} + fn analyze_comment(analyzer: &mut DocAnalyzer) -> Option<()> { let comment = analyzer.comment.clone(); for tag in comment.get_doc_tags() { diff --git a/crates/glua_code_analysis/src/compilation/analyzer/doc/type_def_tags.rs b/crates/glua_code_analysis/src/compilation/analyzer/doc/type_def_tags.rs index 4eb5640a1..d77069975 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/doc/type_def_tags.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/doc/type_def_tags.rs @@ -23,6 +23,48 @@ use crate::{ use std::sync::Arc; use std::vec; +/// Files the members of the global path this class is annotated on under the +/// class. +/// +/// `---@class C` above `X = {}` or `X.k = X.k or {}` gives one table two +/// names, and every reader of the annotated type looks the members up on the +/// class. Recorded here, from the tag, because documentation analysis runs for +/// every file before any file resolves: reading it back off the type cache +/// instead would hand the binding to whichever file inference reached first. +fn bind_annotated_global_path_class( + analyzer: &mut DocAnalyzer, + class_decl_id: &LuaTypeDeclId, +) -> Option<()> { + let LuaAst::LuaAssignStat(assign) = analyzer.comment.get_owner()? else { + return None; + }; + let file_id = analyzer.file_id; + let path = match assign.child::()? { + LuaVarExpr::NameExpr(name_expr) => { + let decl_id = LuaDeclId::new(file_id, name_expr.get_position()); + let decl = analyzer.db.get_decl_index().get_decl(&decl_id)?; + if !decl.is_global() { + return None; + } + crate::GlobalId::new(decl.get_name()) + } + LuaVarExpr::IndexExpr(index_expr) => { + let member_id = LuaMemberId::new(index_expr.get_syntax_id(), file_id); + analyzer + .db + .get_member_index() + .get_member(&member_id)? + .get_global_id()? + .clone() + } + }; + analyzer + .db + .get_member_index_mut() + .set_global_path_class(path, class_decl_id.clone(), file_id); + Some(()) +} + pub fn analyze_class(analyzer: &mut DocAnalyzer, tag: LuaDocTagClass) -> Option<()> { let file_id = analyzer.file_id; let name = tag.get_name_token()?.get_name_text().to_string(); @@ -33,6 +75,7 @@ pub fn analyze_class(analyzer: &mut DocAnalyzer, tag: LuaDocTagClass) -> Option< .find_type_decl(file_id, &name)?; let class_decl_id = class_decl.get_id(); + bind_annotated_global_path_class(analyzer, &class_decl_id); analyzer.current_type_id = Some(class_decl_id.clone()); if let Some(generic_params) = tag.get_generic_decl() { let generic_params = get_generic_params(analyzer, generic_params); diff --git a/crates/glua_code_analysis/src/compilation/analyzer/dynamic_field.rs b/crates/glua_code_analysis/src/compilation/analyzer/dynamic_field.rs index d556e8c85..7d80266e7 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/dynamic_field.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/dynamic_field.rs @@ -241,21 +241,28 @@ fn collect_dynamic_fields_for_file( Vec<(DynamicFieldOwner, SmolStr, crate::FileId, rowan::TextRange)>, Vec<(DynamicFieldOwner, crate::FileId, rowan::TextRange)>, Vec, - std::collections::HashSet, + rustc_hash::FxHashSet, Vec, + Vec, ) { let mut collected_unattributed: Vec = Vec::new(); + let mut dynamic_field_retry: Vec = Vec::new(); let mut collected: Vec<(DynamicFieldOwner, SmolStr, crate::FileId, rowan::TextRange)> = Vec::new(); let mut collected_wildcards: Vec<(DynamicFieldOwner, crate::FileId, rowan::TextRange)> = Vec::new(); let mut collected_finite_members = Vec::new(); let mut field_setter_helpers = field_setter_helpers.clone(); + // The records are what this pass is building. A cold build has none yet, + // and a re-index of some files has the rest of the workspace's, with the + // re-indexed files' contributions removed: a partial record that names a + // different table than the complete one will. Neither is read here, so + // both builds decide the same way. let mut cache = crate::LuaInferCache::new( file_id, crate::CacheOptions { analysis_phase: crate::LuaAnalysisPhase::Force, - dynamic_fields_visible: true, + dynamic_fields_visible: false, building_dynamic_field_index: true, }, ); @@ -301,12 +308,20 @@ fn collect_dynamic_fields_for_file( let prefix_type = if let Some(cached_type) = prefix_type_cache.get(&cache_key) { match cached_type { Some(prefix_type) => prefix_type.clone(), - None => continue, + None => { + if should_collect_wildcard { + dynamic_field_retry.push(index_expr.get_syntax_id()); + } + continue; + } } } else { let inferred = infer_expr(db, cache, prefix_expr.clone()).ok(); prefix_type_cache.insert(cache_key, inferred.clone()); let Some(prefix_type) = inferred else { + if should_collect_wildcard { + dynamic_field_retry.push(index_expr.get_syntax_id()); + } continue; }; prefix_type @@ -320,12 +335,20 @@ fn collect_dynamic_fields_for_file( prefix_type }; if should_collect_wildcard { + let before = collected_wildcards.len(); collect_wildcard_for_type( &effective_type, file_id, definition_range, &mut collected_wildcards, ); + // The write is dynamic and real, but the prefix's type held no + // nameable table yet. Whether it does is a property of how far + // the batch had settled, so the site is retried once settling + // is done. See `rederive_settled_dynamic_fields`. + if collected_wildcards.len() == before { + dynamic_field_retry.push(index_expr.get_syntax_id()); + } } if is_dynamic_index_key(&index_expr) && !field_names.names.is_empty() { @@ -371,9 +394,12 @@ fn collect_dynamic_fields_for_file( ); // The write is real but landed on no owner the index can // name, so record that the field exists somewhere even - // though no table claims it. + // though no table claims it — and queue the site for the + // settled retry, which attributes it properly once the + // prefix's type has finished settling. if collected.len() == before { collected_unattributed.push(field_name.clone()); + dynamic_field_retry.push(index_expr.get_syntax_id()); } } } @@ -405,6 +431,7 @@ fn collect_dynamic_fields_for_file( collected_finite_members, cache.take_inferred_guard_dependencies(), collected_unattributed, + dynamic_field_retry, ) } @@ -451,6 +478,7 @@ fn analyze_dynamic_fields( Vec::new(), Default::default(), Vec::new(), + Vec::new(), ); }; collect_dynamic_fields_for_file(db, file_id, &root, mode, &field_setter_helpers) @@ -461,7 +489,14 @@ fn analyze_dynamic_fields( let merge_start = profile_enabled.then(std::time::Instant::now); let mut collected_unattributed: Vec<(SmolStr, crate::FileId)> = Vec::new(); for ( - (file_collected, file_wildcards, file_finite_members, dependencies, file_unattributed), + ( + file_collected, + file_wildcards, + file_finite_members, + dependencies, + file_unattributed, + file_dynamic_field_retry, + ), file_id, ) in per_file.into_iter().zip(file_ids) { @@ -473,6 +508,9 @@ fn analyze_dynamic_fields( .into_iter() .map(|field_name| (field_name, file_id)), ); + for syntax_id in file_dynamic_field_retry { + context.record_settled_dynamic_field_candidate(file_id, syntax_id); + } context.add_inferred_guard_dependencies(file_id, dependencies); } if let (Some(profile), Some(merge_start)) = (profile.as_mut(), merge_start) { @@ -529,6 +567,19 @@ fn analyze_dynamic_fields( profile.propagation_time += propagate_start.elapsed(); } + // A field written on one of a global path's bootstrap literals belongs to + // the path, not to that literal: every file's `X.k = X.k or {}` names a + // different `{}` and every reader of `X.k` has to see all of them. + for (owner, _, _, _) in &mut collected { + *owner = crate::canonical_dynamic_field_owner(db, owner.clone()); + } + for (owner, _, _) in &mut collected_wildcards { + *owner = crate::canonical_dynamic_field_owner(db, owner.clone()); + } + for (owner, _, _, _, _) in &mut collected_finite_members { + *owner = crate::canonical_dynamic_field_owner(db, owner.clone()); + } + let insert_start = profile_enabled.then(std::time::Instant::now); let index = db.get_dynamic_field_index_mut(); for (field_name, file_id) in collected_unattributed { @@ -1361,8 +1412,7 @@ fn get_field_names( may_have_other_string_names: false, }, LuaIndexKey::Expr(expr) => { - let for_range_names = - field_names_from_for_range_pairs_key(db, cache.get_file_id(), expr.clone()); + let for_range_names = field_names_from_for_range_pairs_key(db, cache, expr.clone()); if let Some(for_range_names) = for_range_names { let local_reassignment_positions = local_reassignment_positions.get_or_insert_with(|| { @@ -1659,32 +1709,32 @@ fn infer_integer_const( } } -fn field_names_from_for_range_pairs_key( +/// The single argument of the `pairs(..)` a `for ... in` loop iterates, when +/// `name_expr` names that loop's `var_index`-th variable. +pub(crate) struct ForRangePairsSource { + pub(crate) iter_decl_id: LuaDeclId, + pub(crate) source_expr: LuaExpr, +} + +pub(crate) fn for_range_pairs_source_for_var( db: &DbIndex, file_id: crate::FileId, - key_expr: LuaExpr, -) -> Option { - let LuaExpr::NameExpr(name_expr) = key_expr else { - return None; - }; - let name_text = name_expr.get_name_text()?; - let for_range = name_expr - .syntax() - .ancestors() - .find_map(LuaForRangeStat::cast)?; - - let iter_name = for_range.get_var_name_list().next()?; - if iter_name.get_name_text() != name_text { - return None; - } - let iter_decl_id = LuaDeclId::new(file_id, iter_name.get_position()); - let key_decl_id = db + name_expr: &glua_parser::LuaNameExpr, + var_index: usize, +) -> Option { + let name_decl_id = db .get_reference_index() .get_local_reference(&file_id)? .get_decl_id(&name_expr.get_range())?; - if key_decl_id != iter_decl_id { - return None; - } + let (for_range, iter_decl_id) = name_expr + .syntax() + .ancestors() + .filter_map(LuaForRangeStat::cast) + .find_map(|for_range| { + let iter_name = for_range.get_var_name_list().nth(var_index)?; + let iter_decl_id = LuaDeclId::new(file_id, iter_name.get_position()); + (iter_decl_id == name_decl_id).then_some((for_range, iter_decl_id)) + })?; let mut iter_exprs = for_range.get_expr_list(); let Some(LuaExpr::CallExpr(call_expr)) = iter_exprs.next() else { @@ -1695,24 +1745,51 @@ fn field_names_from_for_range_pairs_key( } let args_list = call_expr.get_args_list()?; - let table_expr = if args_list.is_single_arg_no_parens() { + let source_expr = if args_list.is_single_arg_no_parens() { match args_list.get_single_arg_expr()? { - glua_parser::LuaSingleArgExpr::TableExpr(table_expr) => table_expr, - glua_parser::LuaSingleArgExpr::LiteralExpr(_) => return None, + glua_parser::LuaSingleArgExpr::TableExpr(table_expr) => LuaExpr::TableExpr(table_expr), + glua_parser::LuaSingleArgExpr::LiteralExpr(literal) => LuaExpr::LiteralExpr(literal), } } else { let mut args = args_list.get_args(); - let Some(LuaExpr::TableExpr(table_expr)) = args.next() else { - return None; - }; + let source_expr = args.next()?; if args.next().is_some() { return None; } - table_expr + source_expr }; - let (mut names, may_have_other_string_names) = - field_names_from_pairs_table_expr_keys(&table_expr); + Some(ForRangePairsSource { + iter_decl_id, + source_expr, + }) +} + +fn field_names_from_for_range_pairs_key( + db: &DbIndex, + cache: &mut crate::LuaInferCache, + key_expr: LuaExpr, +) -> Option { + let LuaExpr::NameExpr(name_expr) = key_expr else { + return None; + }; + let ForRangePairsSource { + iter_decl_id, + source_expr, + } = for_range_pairs_source_for_var(db, cache.get_file_id(), &name_expr, 0)?; + + let (mut names, may_have_other_string_names) = match source_expr { + LuaExpr::TableExpr(table_expr) => field_names_from_pairs_table_expr_keys(&table_expr), + // The keys a table was written with are read off its members, not + // off the loop variable's type: that type is the union of those keys + // only until a computed-key write joins the table, when it collapses + // to `string` and names nothing -- and whether that write has joined + // yet is a property of how far the batch has settled. + source_expr => { + let source_type = infer_expr(db, cache, source_expr).ok()?; + field_names_from_member_keys(db, &source_type)? + } + }; names.sort(); names.dedup(); Some(ForRangePairsFieldNames { @@ -1722,7 +1799,100 @@ fn field_names_from_for_range_pairs_key( }) } -fn is_provably_builtin_pairs_call( +/// The names a table's members were written with, read off the syntax of +/// each write, plus whether any member was written through a computed key. +/// +/// A member minted under the name a computed key happened to resolve to does +/// not count as a name the source states: it is one of the "other" names. +fn field_names_from_member_keys( + db: &DbIndex, + source_type: &LuaType, +) -> Option<(Vec, bool)> { + let mut owners = Vec::new(); + if !collect_pairs_source_owners(source_type, &mut owners) { + return None; + } + let member_index = db.get_member_index(); + let mut names = Vec::new(); + let mut may_have_other_string_names = false; + for owner in owners { + let Some(members) = member_index.get_members(&owner) else { + may_have_other_string_names = true; + continue; + }; + for member in members { + match member.get_key() { + LuaMemberKey::Name(name) if member_key_is_written_literally(db, member) => { + names.push(SmolStr::from(name.as_str())); + } + LuaMemberKey::Name(_) | LuaMemberKey::ExprType(_) => { + may_have_other_string_names = true; + } + LuaMemberKey::Integer(_) | LuaMemberKey::None => {} + } + } + } + Some((names, may_have_other_string_names)) +} + +/// The owners whose members a `pairs` loop over `typ` enumerates, or `false` +/// when an arm names no table the index holds. +fn collect_pairs_source_owners(typ: &LuaType, owners: &mut Vec) -> bool { + match typ { + LuaType::TableConst(range) => { + owners.push(LuaMemberOwner::Element(range.clone())); + true + } + LuaType::Instance(instance) => { + owners.push(LuaMemberOwner::Element(instance.get_range().clone())); + true + } + LuaType::Def(type_id) | LuaType::Ref(type_id) => { + owners.push(LuaMemberOwner::Type(type_id.clone())); + true + } + LuaType::MergedTable(merged) => merged + .get_types() + .iter() + .all(|arm| collect_pairs_source_owners(arm, owners)), + LuaType::Union(union) => union + .types() + .filter(|arm| !arm.is_nil()) + .all(|arm| collect_pairs_source_owners(arm, owners)), + _ => false, + } +} + +/// Whether a member's key is spelled literally at its write. +fn member_key_is_written_literally(db: &DbIndex, member: &crate::LuaMember) -> bool { + let Some(root) = db + .get_vfs() + .get_syntax_tree(&member.get_file_id()) + .map(|tree| tree.get_red_root()) + else { + return false; + }; + let Some(node) = member.get_syntax_id().to_node_from_root(&root) else { + return false; + }; + let key = if let Some(index_expr) = glua_parser::LuaIndexExpr::cast(node.clone()) { + index_expr.get_index_key() + } else if let Some(field) = glua_parser::LuaTableField::cast(node) { + field.get_field_key() + } else { + // Declared in an annotation or synthesized: the name is the source's. + return true; + }; + match key { + Some(LuaIndexKey::Name(_) | LuaIndexKey::String(_)) => true, + Some(LuaIndexKey::Expr(LuaExpr::LiteralExpr(literal))) => { + matches!(literal.get_literal(), Some(LuaLiteralToken::String(_))) + } + _ => false, + } +} + +pub(crate) fn is_provably_builtin_pairs_call( db: &DbIndex, file_id: crate::FileId, call_expr: &LuaCallExpr, @@ -1846,6 +2016,133 @@ fn collect_string_const_names(typ: &LuaType, names: &mut Vec) { } } +/// Re-derives the dynamic-field sites whose prefix held no nameable table +/// when the dynamic-field pass ran: wildcards that were silently dropped, and +/// named writes that landed unattributed. +/// +/// That pass runs before the unresolve waves and the settled tail, so a prefix +/// fed by a late-settling fact (a member still widening, an iterator variable +/// over one) answers with nothing — and whether it does depends on how far +/// the batch had settled, not on the source. Taking the answer again after +/// settling makes the field and wildcard sets a property of the code. A named +/// write the retry attributes takes its unattributed record with it, which is +/// what an in-phase attribution would have produced. +/// Returns whether it changed the dynamic-field index at all, so the settled +/// tail knows to run another round: the records it adds feed the same reads +/// (member maps, iterated tables) the other settled passes re-derive from. +pub(crate) fn rederive_settled_dynamic_fields( + db: &mut DbIndex, + context: &mut AnalyzeContext, +) -> bool { + // Kept, not taken: the settled tail runs to a fixpoint and each round + // re-derives the same candidates against what the previous one landed. + let mut candidates = context.settled_dynamic_field_candidates.clone(); + if candidates.is_empty() { + return false; + } + candidates.sort_by_key(|(file_id, syntax_id)| (*file_id, syntax_id.get_range().start())); + candidates.dedup(); + + let mut collected: Vec<(DynamicFieldOwner, SmolStr, crate::FileId, rowan::TextRange)> = + Vec::new(); + let mut collected_wildcards: Vec<(DynamicFieldOwner, crate::FileId, rowan::TextRange)> = + Vec::new(); + let mut attributed_unattributed: Vec<(SmolStr, crate::FileId)> = Vec::new(); + let mut current_file: Option<( + crate::FileId, + glua_parser::LuaChunk, + crate::LuaInferCache, + Option>>, + )> = None; + for (file_id, syntax_id) in candidates { + if current_file + .as_ref() + .is_none_or(|(id, _, _, _)| *id != file_id) + { + let Some(chunk) = db + .get_vfs() + .get_syntax_tree(&file_id) + .map(|tree| tree.get_chunk_node()) + else { + continue; + }; + let cache = crate::LuaInferCache::new( + file_id, + crate::CacheOptions { + analysis_phase: crate::LuaAnalysisPhase::Force, + dynamic_fields_visible: true, + building_dynamic_field_index: true, + }, + ); + current_file = Some((file_id, chunk, cache, None)); + } + let Some((_, root, cache, local_reassignment_positions)) = current_file.as_mut() else { + continue; + }; + let Some(index_expr) = syntax_id + .to_node_from_root(root.syntax()) + .and_then(glua_parser::LuaIndexExpr::cast) + else { + continue; + }; + let Some(prefix_expr) = index_expr.get_prefix_expr() else { + continue; + }; + let Some(definition_range) = index_expr.get_index_key().and_then(|k| k.get_range()) else { + continue; + }; + let Ok(prefix_type) = infer_expr(db, cache, prefix_expr.clone()) else { + continue; + }; + let effective_type = + infer_setmetatable_target_type(db, cache, &prefix_expr, index_expr.get_range()) + .unwrap_or(prefix_type); + + let field_names = + get_field_names(db, cache, root, local_reassignment_positions, &index_expr); + if is_dynamic_index_key(&index_expr) && field_names.may_have_other_string_names { + collect_wildcard_for_type( + &effective_type, + file_id, + definition_range, + &mut collected_wildcards, + ); + } + for field_name in field_names.names { + let before = collected.len(); + collect_for_type( + &effective_type, + &field_name, + file_id, + definition_range, + &mut collected, + ); + if collected.len() > before { + attributed_unattributed.push((field_name, file_id)); + } + } + } + + for (owner, _, _, _) in &mut collected { + *owner = crate::canonical_dynamic_field_owner(db, owner.clone()); + } + for (owner, _, _) in &mut collected_wildcards { + *owner = crate::canonical_dynamic_field_owner(db, owner.clone()); + } + let index = db.get_dynamic_field_index_mut(); + let mut changed = false; + for (owner, field_name, file_id, range) in collected { + changed |= index.add_field(owner, field_name, file_id, range); + } + for (owner, file_id, range) in collected_wildcards { + changed |= index.add_wildcard_definition(owner, file_id, range); + } + for (field_name, file_id) in attributed_unattributed { + changed |= index.remove_unattributed_field(&field_name, file_id); + } + changed +} + fn collect_wildcard_for_type( typ: &LuaType, file_id: crate::FileId, @@ -1874,6 +2171,13 @@ fn collect_wildcard_for_type( collect_wildcard_for_type(t, file_id, range, result); } } + // See `collect_for_type`: a merged table stands for its backing + // literals. + LuaType::MergedTable(merged) => { + for t in merged.get_types() { + collect_wildcard_for_type(t, file_id, range, result); + } + } _ => {} } } @@ -1913,6 +2217,14 @@ fn collect_for_type( collect_for_type(t, field_name, file_id, range, result); } } + // A merged table is the same runtime table seen through several + // backing literals; the write belongs to each of them, exactly as it + // does when inference answers with the literals unmerged. + LuaType::MergedTable(merged) => { + for t in merged.get_types() { + collect_for_type(t, field_name, file_id, range, result); + } + } _ => {} } } @@ -1976,6 +2288,20 @@ fn collect_finite_member_for_type( ); } } + // See `collect_for_type`: a merged table stands for its backing + // literals. + LuaType::MergedTable(merged) => { + for typ in merged.get_types() { + collect_finite_member_for_type( + typ, + member_id, + file_id, + range, + has_finite_domain, + result, + ); + } + } _ => {} } } diff --git a/crates/glua_code_analysis/src/compilation/analyzer/flow/bind_analyze/exprs/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/flow/bind_analyze/exprs/mod.rs index 71c40297f..48c014dd4 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/flow/bind_analyze/exprs/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/flow/bind_analyze/exprs/mod.rs @@ -6,7 +6,7 @@ use glua_parser::{ }; use crate::{ - FlowId, FlowNodeKind, + FlowId, FlowNodeKind, analysis_stack_exhausted, compilation::analyzer::flow::{ bind_analyze::{bind_each_child, exprs::bind_binary_expr::is_binary_logical}, binder::FlowBinder, @@ -21,6 +21,11 @@ pub fn bind_condition_expr( true_target: FlowId, false_target: FlowId, ) { + // Crash-safety guard (not a budget): skip the subtree when the stack + // reserve is exhausted. Must precede all binder state saves/mutations. + if analysis_stack_exhausted() { + return; + } let old_true_target = binder.true_target; let old_false_target = binder.false_target; @@ -37,19 +42,22 @@ pub fn bind_condition_expr( if !is_binary_logical(&condition_expr) { let true_condition = binder.create_node(FlowNodeKind::TrueCondition(condition_expr.to_ptr())); - binder.record_condition_flow_paths(true_condition, &condition_expr); binder.add_antecedent(true_condition, current); binder.add_antecedent(true_target, true_condition); let false_condition = binder.create_node(FlowNodeKind::FalseCondition(condition_expr.to_ptr())); - binder.record_condition_flow_paths(false_condition, &condition_expr); binder.add_antecedent(false_condition, current); binder.add_antecedent(false_target, false_condition); } } pub fn bind_expr(binder: &mut FlowBinder, expr: LuaExpr, current: FlowId) -> FlowId { + // Crash-safety guard (not a budget): skip the subtree when the stack + // reserve is exhausted. No flow nodes means conservative un-narrowed reads. + if analysis_stack_exhausted() { + return current; + } match expr { LuaExpr::NameExpr(name_expr) => bind_name_expr(binder, name_expr, current), LuaExpr::CallExpr(call_expr) => bind_call_expr(binder, call_expr, current), diff --git a/crates/glua_code_analysis/src/compilation/analyzer/flow/bind_analyze/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/flow/bind_analyze/mod.rs index a7ff96d0b..d784c2b6f 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/flow/bind_analyze/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/flow/bind_analyze/mod.rs @@ -6,7 +6,7 @@ mod stats; use glua_parser::{LuaAst, LuaAstNode, LuaBlock, LuaChunk, LuaExpr}; use crate::{ - FlowAntecedent, FlowId, FlowNodeKind, + FlowAntecedent, FlowId, FlowNodeKind, analysis_stack_exhausted, compilation::analyzer::flow::{ bind_analyze::{ comment::bind_comment, @@ -62,6 +62,11 @@ fn bind_each_child(binder: &mut FlowBinder, ast_node: LuaAst, mut current: FlowI } fn bind_node(binder: &mut FlowBinder, node: LuaAst, current: FlowId) -> FlowId { + // Crash-safety guard (not a budget): skip the subtree when the stack + // reserve is exhausted. No flow nodes means conservative un-narrowed reads. + if analysis_stack_exhausted() { + return current; + } match node { LuaAst::LuaBlock(block) => bind_block(binder, block, current), // stat diff --git a/crates/glua_code_analysis/src/compilation/analyzer/flow/bind_analyze/stats.rs b/crates/glua_code_analysis/src/compilation/analyzer/flow/bind_analyze/stats.rs index 513884dfd..e15e4080d 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/flow/bind_analyze/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/flow/bind_analyze/stats.rs @@ -7,7 +7,7 @@ use glua_parser::{ use crate::{ AnalyzeError, AssignVarHint, AssignmentFlowInfo, AssignmentNameTarget, BranchLabelInfo, - DiagnosticCode, FlowId, FlowNodeKind, LuaClosureId, LuaDeclId, + DiagnosticCode, FlowId, FlowNodeKind, LuaClosureId, LuaDeclId, analysis_stack_exhausted, compilation::analyzer::flow::{ bind_analyze::{ bind_block, bind_each_child, bind_node, @@ -367,6 +367,11 @@ pub fn bind_return_stat( } fn bind_top_level_expr(binder: &mut FlowBinder, expr: LuaExpr, current: FlowId) -> FlowId { + // Crash-safety guard (not a budget): skip the subtree when the stack + // reserve is exhausted, before creating any flow nodes for it. + if analysis_stack_exhausted() { + return current; + } bind_expr(binder, expr.clone(), current); let Some(call_expr) = unwrap_top_level_call_expr(&expr) else { diff --git a/crates/glua_code_analysis/src/compilation/analyzer/flow/binder.rs b/crates/glua_code_analysis/src/compilation/analyzer/flow/binder.rs index 4c24290cf..a290236ba 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/flow/binder.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/flow/binder.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use rustc_hash::FxHashMap; use glua_parser::{LuaAstPtr, LuaExpr, LuaNameToken, LuaSyntaxId}; use internment::ArcIntern; @@ -39,7 +39,7 @@ pub struct FlowBinder<'a> { /// immutable `&DbIndex`, enabling parallel per-file binding. The pipeline /// drains these into the diagnostic index sequentially afterward. pub errors: Vec, - pub decl_bind_expr_ref: HashMap>, + pub decl_bind_expr_ref: FxHashMap>, pub start: FlowId, pub unreachable: FlowId, pub loop_label: FlowId, @@ -48,10 +48,10 @@ pub struct FlowBinder<'a> { pub false_target: FlowId, flow_nodes: Vec, multiple_antecedents: Vec>, - labels: HashMap>, + labels: FxHashMap>, goto_stats: Vec, - bindings: HashMap, - branch_label_info: HashMap, + bindings: FxHashMap, + branch_label_info: FxHashMap, assignment_flow_info: Vec, // Counters for tracking modifications inside branch blocks. name_assign_count: u32, @@ -71,17 +71,17 @@ impl<'a> FlowBinder<'a> { errors: Vec::new(), flow_nodes: Vec::new(), multiple_antecedents: Vec::new(), - decl_bind_expr_ref: HashMap::new(), - labels: HashMap::new(), + decl_bind_expr_ref: FxHashMap::default(), + labels: FxHashMap::default(), start: FlowId::default(), unreachable: FlowId::default(), break_target_label: FlowId::default(), - bindings: HashMap::new(), + bindings: FxHashMap::default(), goto_stats: Vec::new(), loop_label: FlowId::default(), true_target: FlowId::default(), false_target: FlowId::default(), - branch_label_info: HashMap::new(), + branch_label_info: FxHashMap::default(), assignment_flow_info: Vec::new(), name_assign_count: 0, index_assign_count: 0, @@ -353,24 +353,6 @@ impl<'a> FlowBinder<'a> { self.record_narrowable_refs(expr); } - pub fn record_condition_flow_paths(&mut self, flow_id: FlowId, expr: &LuaExpr) { - use glua_parser::{LuaAstNode, LuaIndexExpr, PathTrait}; - - for index_expr in expr.syntax().descendants().filter_map(LuaIndexExpr::cast) { - let dynamic = matches!( - index_expr.get_index_key(), - Some(glua_parser::LuaIndexKey::Expr(_)) - ); - if let Some(path) = index_expr.get_access_path().filter(|_| !dynamic) { - self.narrowing_capability - .condition_flows_by_path - .entry(ArcIntern::from(SmolStr::new(path))) - .or_default() - .insert(flow_id); - } - } - } - fn record_narrowable_refs(&mut self, expr: &LuaExpr) { use glua_parser::{LuaAstNode, LuaIndexExpr, LuaNameExpr, PathTrait}; // Record the expr itself if it is a name or index, then recurse into 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..ff98d45e8 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs @@ -1,5 +1,6 @@ +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use std::{ - collections::{HashMap, HashSet, VecDeque}, + collections::VecDeque, hash::{Hash, Hasher}, path::Path, sync::{Arc, Mutex}, @@ -27,10 +28,7 @@ use crate::{ LuaTypeCache, LuaTypeDecl, LuaTypeDeclId, LuaTypeFlag, LuaTypeOwner, compilation::analyzer::{ AnalysisPipeline, AnalyzeContext, - common::{ - TypeCacheWriteMode, add_member, migrate_global_members_when_type_resolve, - write_type_cache, - }, + common::{TypeCacheWriteMode, add_member, write_type_cache}, }, db_index::rebuild_effective_valid_guard_signatures, db_index::{ @@ -41,15 +39,15 @@ use crate::{ GmodLoadRootKind, GmodLoadStatus, GmodNamedSiteMetadata, GmodNetReceiveSiteMetadata, GmodRealm, GmodRealmFileMetadata, GmodRealmRange, GmodScopedClassInfo, GmodStateMask, GmodSystemFileMetadata, GmodTimerKind, GmodTimerSiteMetadata, LuaDependencyKind, - LuaDependencySite, LuaMemberOwner, NetFlowFrame, NetFlowKind, NetOpDescriptor, - NetOpDirection, NetOpEntry, NetReceiveFlow, NetSendFlow, NetSendKind, - TableNumericRangePopulation, WorkspaceKind, + LuaDependencySite, LuaMemberOwner, NetFlowFrame, NetFlowKind, NetHelperNameMemo, + NetNameExpansion, NetOpDescriptor, NetOpDirection, NetOpEntry, NetReceiveFlow, NetSendFlow, + NetSendKind, TableNumericRangePopulation, WorkspaceKind, }, infer_expr, profile::Profile, }; use rowan::{TextRange, TextSize}; -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; use smol_str::SmolStr; mod numeric_range_population; @@ -163,8 +161,8 @@ impl AnalysisPipeline for GmodPreAnalysisPipeline { }); let t0 = do_profile.then(std::time::Instant::now); - let mut branch_realm_ranges: HashMap> = HashMap::new(); - let mut annotation_realms: HashMap = HashMap::new(); + let mut branch_realm_ranges: HashMap> = HashMap::default(); + let mut annotation_realms: HashMap = HashMap::default(); // Wall-clock for the parallel read-only collection pass (hook/system/net // flow/realm/fileparam metadata) and the sequential scoped-class merge. let mut t_collect = std::time::Duration::ZERO; @@ -348,9 +346,14 @@ impl AnalysisPipeline for GmodPreAnalysisPipeline { if let Some(profile) = profile.as_mut() { profile.member_realm_ranges += member_ranges.len(); } - db.get_gmod_infer_index_mut() - .set_member_realm_ranges(file_id, member_ranges); } + // Every analyzed file records an entry, so the index answers for all + // of them and no reader has to walk the tree itself to find out. + // A file whose content never mentions `@realm` cannot carry a realm + // doc tag, so its empty vector is derived, not assumed: the producer + // skips the walk and this records the same answer it would have got. + db.get_gmod_infer_index_mut() + .set_member_realm_ranges(file_id, member_ranges); if let Some(file_params) = file_params && !file_params.is_empty() @@ -461,9 +464,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 @@ -514,13 +514,23 @@ impl AnalysisPipeline for GmodNetworkAnalysisPipeline { }; let file_ids: Vec = tree_list.iter().map(|tree| tree.file_id).collect(); + let collected_files: FxHashSet = file_ids.iter().copied().collect(); let reach = HelperStartReachCache::default(); - let helper_call_sites = crate::profile::phase("gmodnet/helper_call_sites", || { - let op_names = net_operation_names(&annotated_global_call_roles); - let mut names = net_producing_function_names(db, &op_names); - names.extend(op_names); - net_helper_call_sites(db, names) - }); + let resolve_shared = Arc::new(NetResolveShared::default()); + let mut helper_name_memo = db.get_gmod_network_index_mut().take_helper_name_memo(); + let helper_call_sites = { + let db: &DbIndex = db; + crate::profile::phase("gmodnet/helper_call_sites", || { + let op_names = net_operation_names(&annotated_global_call_roles); + let mut names = crate::profile::phase("gmodnet/helper_names", || { + net_producing_function_names(db, &op_names, &mut helper_name_memo) + }); + names.extend(op_names); + net_helper_call_sites(db, names, &collected_files) + }) + }; + db.get_gmod_network_index_mut() + .restore_helper_name_memo(helper_name_memo); let collected = crate::profile::phase("gmodnet/collect_flows", || { super::parallel::map_files_collect(db, &file_ids, |db, file_id| { collect_file_network_flows( @@ -530,6 +540,7 @@ impl AnalysisPipeline for GmodNetworkAnalysisPipeline { &annotated_global_call_roles, &reach, &helper_call_sites, + &resolve_shared, ) }) }); @@ -550,6 +561,7 @@ fn collect_file_network_flows( annotated_global_call_roles: &AnnotatedGmodGlobalCallRoleMap, reach: &HelperStartReachCache, helper_call_sites: &NetHelperCallSites, + resolve_shared: &Arc, ) -> crate::db_index::FileNetworkData { let Some(root) = db .get_vfs() @@ -560,7 +572,7 @@ fn collect_file_network_flows( }; let mut local_fns = LocalFnCache::default(); - let mut net = NetCallResolver::default(); + let mut net = NetCallResolver::new(file_id, Some(resolve_shared.clone())); // 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. @@ -597,6 +609,10 @@ fn collect_file_network_flows( ) } +/// 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). pub struct GmodPostAnalysisPipeline; impl AnalysisPipeline for GmodPostAnalysisPipeline { @@ -633,10 +649,25 @@ impl AnalysisPipeline for GmodPostAnalysisPipeline { // 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) - }); + // + // The earlier passes in this batch already derived these roles. Lua + // analysis since then can have grown the signature index, so they are + // only reusable while the revision they were built at still holds; the + // registry beside them is not wanted here. + let helper_revision = helper_registry_revision(db); + let reusable_roles = context + .gmod_global_call_roles + .as_ref() + .filter(|(revision, _)| *revision == helper_revision) + .map(|(_, roles)| roles.clone()); + let annotated_global_call_roles = match reusable_roles { + Some(roles) => roles, + None => crate::profile::phase("gmodpost/call_roles_and_registry", || { + let (_, roles) = build_call_roles_and_registry(db); + context.gmod_global_call_roles = Some((helper_revision, roles.clone())); + roles + }), + }; crate::profile::phase("gmodpost/scripted_class_calls", || { collect_annotated_scripted_class_calls(db, context, &annotated_global_call_roles) }); @@ -661,6 +692,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) { @@ -730,7 +764,7 @@ fn formatted_hook_prefixes(db: &DbIndex) -> Vec { fn collect_annotated_scripted_class_calls( db: &mut DbIndex, - context: &AnalyzeContext, + context: &mut AnalyzeContext, annotated_global_call_roles: &AnnotatedGmodGlobalCallRoleMap, ) { let prefixes = formatted_hook_prefixes(db); @@ -744,6 +778,9 @@ fn collect_annotated_scripted_class_calls( /// applied afterwards in the original file-then-call order. enum PendingCallSite { VguiParent(GmodVguiParentCallMetadata), + /// A parent call whose receiver the index could not type yet. See + /// [`resolve_deferred_vgui_parent_calls`]. + DeferredVguiParent(LuaSyntaxId), ScriptedClass(GmodScriptedClassCallKind, GmodScriptedClassCallMetadata), Dependency(LuaDependencySite), } @@ -752,7 +789,7 @@ enum PendingCallSite { /// dependency) call sites for every file in one workspace group. fn collect_annotated_call_sites_with( db: &mut DbIndex, - context: &AnalyzeContext, + context: &mut AnalyzeContext, formatted_hook_prefixes: &[String], annotated_global_call_roles: &AnnotatedGmodGlobalCallRoleMap, include_load: bool, @@ -818,6 +855,9 @@ fn collect_annotated_call_sites_with( PendingCallSite::VguiParent(call) => db .get_gmod_class_metadata_index_mut() .add_vgui_parent_call(*file_id, call), + PendingCallSite::DeferredVguiParent(syntax_id) => { + context.record_deferred_vgui_parent_call(*file_id, syntax_id) + } PendingCallSite::ScriptedClass(kind, call) => db .get_gmod_class_metadata_index_mut() .add_call(*file_id, kind, call), @@ -935,7 +975,7 @@ fn build_call_roles_and_registry( let mut signatures_by_file: HashMap> = crate::profile::phase("ccs/signatures_by_file", || { - let mut map: HashMap> = HashMap::new(); + let mut map: HashMap> = HashMap::default(); for (signature_id, _) in db.get_signature_index().iter() { map.entry(signature_id.get_file_id()) .or_default() @@ -1104,10 +1144,10 @@ impl HelperRegistryBuilder { ) }); - let mut globals: HashMap = HashMap::new(); - let mut methods: HashMap = HashMap::new(); - let mut signatures: HashMap = HashMap::new(); - let mut duplicate_methods = HashSet::new(); + let mut globals: HashMap = HashMap::default(); + let mut methods: HashMap = HashMap::default(); + let mut signatures: HashMap = HashMap::default(); + let mut duplicate_methods = HashSet::default(); for (signature_id, file_id, syntax_id, global_id) in self.definitions { let target = (file_id, syntax_id); signatures.entry(signature_id).or_insert(target); @@ -1246,90 +1286,115 @@ fn var_expr_written_name(var_expr: &LuaVarExpr) -> Option { /// /// 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(); +fn net_producing_function_names( + db: &DbIndex, + op_names: &HashSet, + memo: &mut NetHelperNameMemo, +) -> HashSet { + let reference_index = db.get_reference_index(); + let mut names: HashSet = HashSet::default(); + let mut visited_decls: HashSet = HashSet::default(); // 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)) - .collect(); - - while !frontier.is_empty() { - // Grouped so each file's red tree is built once per round rather than - // once per site. - let mut by_file: FxHashMap> = FxHashMap::default(); - for site in frontier.drain(..) { - by_file.entry(site.file_id).or_default().push(site.value); - } + let mut expanded_names: HashSet = op_names.clone(); + let mut pending_names: Vec = op_names.iter().cloned().collect(); + let mut pending_decls: Vec = Vec::new(); - let mut fresh: Vec = Vec::new(); - let mut next: Vec> = Vec::new(); - for (file_id, syntax_ids) in by_file { - let Some(root) = db - .get_vfs() - .get_syntax_tree(&file_id) - .map(|tree| tree.get_red_root()) - else { - continue; + loop { + let expansions = if let Some(name) = pending_names.pop() { + reference_index + .files_referencing_name(&name) + .into_iter() + .map(|file_id| { + let revision = reference_index.file_reference_revision(file_id); + if let Some(expansion) = memo.name(file_id, revision, &name) { + return expansion.clone(); + } + let expansion = expand_sites( + db, + file_id, + &reference_index.name_references_in_file(&name, file_id), + ); + memo.set_name(file_id, revision, name.clone(), expansion.clone()); + expansion + }) + .collect::>() + } else if let Some(decl_id) = pending_decls.pop() { + let file_id = decl_id.file_id; + let revision = reference_index.file_reference_revision(file_id); + let expansion = match memo.decl(file_id, revision, &decl_id) { + Some(expansion) => expansion.clone(), + None => { + let expansion = expand_sites(db, file_id, &decl_read_sites(db, decl_id)); + memo.set_decl(file_id, revision, decl_id, expansion.clone()); + expansion + } }; - for syntax_id in syntax_ids { - let Some(node) = syntax_id.to_node_from_root(&root) else { - continue; - }; - let Some(closure) = node.ancestors().find_map(LuaClosureExpr::cast) else { - continue; - }; - let Some(name) = closure_declared_name(&closure) else { - 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) - { - next.extend(decl_reference_sites(db, decl_id)); + vec![expansion] + } else { + break; + }; + + for expansion in expansions { + // 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. + for decl_id in expansion.locals { + if visited_decls.insert(decl_id) { + pending_decls.push(decl_id); } - if names.insert(name.clone()) { - fresh.push(name); + } + // A newly named function's callers are the next level, and the + // reference index already knows where they are. + for name in expansion.names { + names.insert(name.clone()); + if expanded_names.insert(name.clone()) { + pending_names.push(name); } } } - - // A newly named function's callers are the next level, and the - // reference index already knows where they are. - for name in fresh { - next.extend(name_reference_sites(db, &name)); - } - frontier = next; } names } -/// Every place a name is referenced, from the reference index. -fn name_reference_sites(db: &DbIndex, name: &SmolStr) -> Vec> { - let reference_index = db.get_reference_index(); - let member_key = LuaMemberKey::Name(name.clone()); - reference_index - .get_index_references(&member_key) - .into_iter() - .flatten() - .chain( - reference_index - .get_global_references(name) - .into_iter() - .flatten(), - ) - .collect() +/// One file's contribution for a set of reference sites: the written name of +/// each site's innermost enclosing closure, and that closure's local binding +/// when it has one. +/// +/// Pure in the file's syntax tree and its own declaration references, which is +/// what [`NetHelperNameMemo`] keys on. +fn expand_sites(db: &DbIndex, file_id: FileId, sites: &[LuaSyntaxId]) -> NetNameExpansion { + let mut expansion = NetNameExpansion::default(); + let Some(root) = db + .get_vfs() + .get_syntax_tree(&file_id) + .map(|tree| tree.get_red_root()) + else { + return expansion; + }; + for syntax_id in sites { + let Some(node) = syntax_id.to_node_from_root(&root) else { + continue; + }; + let Some(closure) = node.ancestors().find_map(LuaClosureExpr::cast) else { + continue; + }; + let Some(name) = closure_declared_name(&closure) else { + continue; + }; + if let Some(decl_id) = closure_local_decl_id(file_id, &closure) { + expansion.locals.push(decl_id); + } + expansion.names.push(name); + } + expansion } /// Every place a local declaration is read, from the reference index. -fn decl_reference_sites(db: &DbIndex, decl_id: LuaDeclId) -> Vec> { +fn decl_read_sites(db: &DbIndex, decl_id: LuaDeclId) -> Vec { let Some(references) = db .get_reference_index() .get_decl_references(&decl_id.file_id, &decl_id) @@ -1340,12 +1405,7 @@ fn decl_reference_sites(db: &DbIndex, decl_id: LuaDeclId) -> Vec, } -fn net_helper_call_sites(db: &DbIndex, names: HashSet) -> NetHelperCallSites { +/// `by_file` is filled for `collected_files` only: it is read through +/// [`net_candidate_call_exprs`], and every site that reaches it belongs to the +/// file currently being collected. `names` stays workspace-wide. +fn net_helper_call_sites( + db: &DbIndex, + names: HashSet, + collected_files: &FxHashSet, +) -> NetHelperCallSites { let mut by_file: FxHashMap> = FxHashMap::default(); let reference_index = db.get_reference_index(); for name in &names { @@ -1401,6 +1468,9 @@ fn net_helper_call_sites(db: &DbIndex, names: HashSet) -> NetHelperCall .flatten(), ) { + if !collected_files.contains(&reference.file_id) { + continue; + } by_file .entry(reference.file_id) .or_default() @@ -1435,8 +1505,8 @@ struct FileFunctionMap { impl FileFunctionMap { fn build(root: &LuaChunk) -> Self { - let mut bare: HashMap = HashMap::new(); - let mut duplicate_bare = HashSet::new(); + let mut bare: HashMap = HashMap::default(); + let mut duplicate_bare = HashSet::default(); let mut all_blocks: Vec = Vec::new(); for node in root.syntax().descendants() { if let Some(local_func_stat) = LuaLocalFuncStat::cast(node.clone()) { @@ -1617,7 +1687,7 @@ fn collect_file_gmod_metadata( // 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(); + let mut net = NetCallResolver::new(file_id, None); // Hook metadata collection never expands wrapper chains for send flows, so // this cache stays empty; it exists only to satisfy the shared context. @@ -1965,8 +2035,8 @@ fn resolve_helper_start_message( ctx, site, call_expr, - &HashMap::new(), - &mut HashSet::new(), + &HashMap::default(), + &mut HashSet::default(), ) } @@ -2035,7 +2105,7 @@ fn resolve_helper_send( site: &NetWalkSite, call_expr: &LuaCallExpr, ) -> Option<(NetSendKind, Option)> { - resolve_helper_send_recursive(ctx, site, call_expr, &mut HashSet::new()) + resolve_helper_send_recursive(ctx, site, call_expr, &mut HashSet::default()) } fn resolve_helper_send_recursive( @@ -2217,8 +2287,8 @@ fn collect_unannotated_net_wrapper_send_flows( site: &NetWalkSite, ) -> Vec { let mut flows = Vec::new(); - let mut visited = HashSet::new(); - let empty_bindings = HashMap::new(); + let mut visited = HashSet::default(); + let empty_bindings = HashMap::default(); let calls = net_candidate_call_exprs(ctx.db, site, ctx.helper_call_sites); for call_expr in calls { @@ -2261,7 +2331,7 @@ fn net_candidate_call_exprs( // 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 mut aliases: HashSet = HashSet::default(); let bindings = decl_tree .get_decls() .values() @@ -2486,7 +2556,7 @@ fn helper_call_string_bindings( .parent() .and_then(LuaClosureExpr::cast) else { - return HashMap::new(); + return HashMap::default(); }; let params = get_closure_param_names(&closure); let args = call_expr @@ -2545,7 +2615,7 @@ fn collect_net_receive_flow( call_expr, message_idx, callback_idx, - &HashMap::new(), + &HashMap::default(), call_expr.get_range(), ) } @@ -2608,8 +2678,8 @@ fn collect_unannotated_net_wrapper_receive_flows( site, call_expr, call_expr, - &HashMap::new(), - &mut HashSet::new(), + &HashMap::default(), + &mut HashSet::default(), &mut flows, ); flows @@ -3026,7 +3096,7 @@ fn collect_net_read_ops_from_block( block: LuaBlock, reads: &mut Vec, ) { - let mut visited = HashSet::new(); + let mut visited = HashSet::default(); collect_net_ops_recursive( ctx, site, @@ -3047,7 +3117,7 @@ fn collect_net_write_ops_from_stat( stat: &LuaStat, writes: &mut Vec, ) { - let mut visited = HashSet::new(); + let mut visited = HashSet::default(); collect_net_ops_recursive( ctx, site, @@ -3509,6 +3579,57 @@ enum NetCallRole { Payload(NetOpDescriptor), } +const NET_RESOLVE_SHARDS: usize = 32; + +type NetSiteKey = (FileId, LuaSyntaxId); + +/// Sharded, lock-per-shard memo keyed by call site, shared across the worker +/// threads of one net-analyze pass. +struct ShardedSiteMemo { + shards: [Mutex>; NET_RESOLVE_SHARDS], +} + +impl Default for ShardedSiteMemo { + fn default() -> Self { + ShardedSiteMemo { + shards: std::array::from_fn(|_| Mutex::default()), + } + } +} + +impl ShardedSiteMemo { + fn shard(&self, key: &NetSiteKey) -> &Mutex> { + let mut hasher = rustc_hash::FxHasher::default(); + key.hash(&mut hasher); + &self.shards[hasher.finish() as usize % NET_RESOLVE_SHARDS] + } + + fn get(&self, key: &NetSiteKey) -> Option { + self.shard(key).lock().ok()?.get(key).cloned() + } + + fn insert(&self, key: NetSiteKey, value: V) { + if let Ok(mut shard) = self.shard(&key).lock() { + shard.insert(key, value); + } + } +} + +/// Cross-file call-site resolution shared by every file's [`NetCallResolver`] +/// for the duration of one net-analyze pass. +/// +/// When N caller files expand into the same helper file, each caller's private +/// memo re-resolves that helper's call sites from scratch. The pass holds +/// `&DbIndex` immutably, so a site's role and signature are pure functions of +/// `(file_id, syntax_id, db)`: every thread computes the same answer, and +/// first-write-wins insertion cannot change any result. Nothing collected here +/// is written into an index, and the table is dropped when the pass ends. +#[derive(Default)] +struct NetResolveShared { + role: ShardedSiteMemo>, + signature: ShardedSiteMemo>, +} + /// Resolves [`NetCallRole`] for call expressions, memoizing per call site. /// /// Signature resolution runs type inference, which is far more expensive than @@ -3516,8 +3637,12 @@ enum NetCallRole { /// 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 { + /// File this resolver walks. Its own sites are resolved once regardless, so + /// they stay in the private memo; only cross-file helper sites, which every + /// caller of that helper would otherwise redo, go through `shared`. + owner: FileId, + shared: Option>, caches: HashMap, memo: HashMap<(FileId, LuaSyntaxId), Option>, /// `role` memoises the *role*, but the signature behind it is asked for @@ -3530,6 +3655,24 @@ struct NetCallResolver { } impl NetCallResolver { + fn new(owner: FileId, shared: Option>) -> Self { + NetCallResolver { + owner, + shared, + caches: HashMap::default(), + memo: HashMap::default(), + signature_memo: HashMap::default(), + } + } + + /// The shared table, but only for sites outside the file being walked. + fn cross_file_shared(&self, file_id: FileId) -> Option> { + if file_id == self.owner { + return None; + } + self.shared.clone() + } + fn role( &mut self, db: &DbIndex, @@ -3540,7 +3683,17 @@ impl NetCallResolver { if let Some(cached) = self.memo.get(&key) { return cached.clone(); } - let resolved = self.resolve_uncached(db, file_id, call_expr); + let shared = self.cross_file_shared(file_id); + let resolved = match shared.as_ref().and_then(|shared| shared.role.get(&key)) { + Some(cached) => cached, + None => { + let resolved = self.resolve_uncached(db, file_id, call_expr); + if let Some(shared) = shared { + shared.role.insert(key, resolved.clone()); + } + resolved + } + }; self.memo.insert(key, resolved.clone()); resolved } @@ -3620,8 +3773,20 @@ impl NetCallResolver { if let Some(cached) = self.signature_memo.get(&key) { return *cached; } - - let resolved = self.signature_id_uncached(db, file_id, call_expr); + let shared = self.cross_file_shared(file_id); + let resolved = match shared + .as_ref() + .and_then(|shared| shared.signature.get(&key)) + { + Some(cached) => cached, + None => { + let resolved = self.signature_id_uncached(db, file_id, call_expr); + if let Some(shared) = shared { + shared.signature.insert(key, resolved); + } + resolved + } + }; self.signature_memo.insert(key, resolved); resolved } @@ -3865,7 +4030,6 @@ fn collect_scripted_scope_type_bindings_with( LuaTypeCache::InferType(LuaType::Def(class_decl_id.clone())), TypeCacheWriteMode::ForceOverwrite, ); - migrate_global_members_when_type_resolve(db, decl_id.into()); if let Some(LuaType::TableConst(table_range)) = previous_decl_type { let table_member_owner = LuaMemberOwner::Element(table_range); @@ -4001,8 +4165,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() @@ -4013,7 +4191,7 @@ fn resolve_vgui_parent_relations( file_ids.sort_by_key(|file_id| file_id.id); let _p_cand = crate::profile::PhaseGuard::new("vgui/parent_candidates"); let mut forwarding_parent_candidates = - HashMap::<(LuaTypeDeclId, String), ForwardingParentCandidate>::new(); + HashMap::<(LuaTypeDeclId, String), ForwardingParentCandidate>::default(); for file_id in &file_ids { let calls = db .get_gmod_class_metadata_index() @@ -4156,8 +4334,8 @@ fn resolve_vgui_parent_relations( db.get_gmod_class_metadata_index_mut() .set_vgui_resolved_parent_sources(&resolved_sources_by_file); - let mut direct_parents_by_child = HashMap::>>::new(); - let mut relations_by_child = HashMap::>::new(); + let mut direct_parents_by_child = HashMap::>>::default(); + let mut relations_by_child = HashMap::>::default(); for (_, relations) in &relations_by_file { for relation in relations { for child_type_id in &relation.child_type_ids { @@ -4175,7 +4353,7 @@ fn resolve_vgui_parent_relations( } } - let mut direct_chain_memo = HashMap::new(); + let mut direct_chain_memo = HashMap::default(); let mut resolved_by_file = Vec::new(); for (file_id, relations) in relations_by_file { let mut resolved = Vec::with_capacity(relations.len()); @@ -4279,7 +4457,7 @@ fn index_vgui_forwarding_parent_candidates( db, cache, root, - &HashMap::new(), + &HashMap::default(), &call_expr, &call.parent, ); @@ -4444,7 +4622,7 @@ mod forwarding_parent_candidate_tests { fn identical_forwarding_candidates_remain_consistent() { let key = (LuaTypeDeclId::global("Container"), "Add".to_string()); let parent_type_ids = vec![LuaTypeDeclId::global("DTileLayout")]; - let mut candidates = HashMap::new(); + let mut candidates = HashMap::default(); record_vgui_forwarding_parent_candidate( &mut candidates, @@ -4464,7 +4642,7 @@ mod forwarding_parent_candidate_tests { #[test] fn disagreeing_forwarding_candidates_remain_conflicted() { let key = (LuaTypeDeclId::global("Container"), "Add".to_string()); - let mut candidates = HashMap::new(); + let mut candidates = HashMap::default(); record_vgui_forwarding_parent_candidate( &mut candidates, @@ -4580,7 +4758,7 @@ fn resolve_vgui_direct_parent_chain( type_ids, direct_parents_by_child, memo, - &mut HashSet::new(), + &mut HashSet::default(), ) } @@ -4801,7 +4979,7 @@ fn index_vgui_field_assignment_parents( cache: &mut LuaInferCache, root: &LuaSyntaxNode, ) -> HashMap> { - let mut assignments = HashMap::new(); + let mut assignments = HashMap::default(); for assign in root.descendants().filter_map(LuaAssignStat::cast) { let (vars, exprs) = assign.get_var_and_expr_list(); for (target, value) in vars.iter().zip(exprs) { @@ -5108,7 +5286,7 @@ fn resolve_getmember_network_var_delegations( /// Build a mapping from class_name to all file ids for known scripted entity classes. fn build_class_file_map(db: &DbIndex) -> HashMap> { - let mut map = HashMap::new(); + let mut map = HashMap::default(); let gmod_infer = db.get_gmod_infer_index(); let vfs = db.get_vfs(); let all_file_ids = vfs.get_all_file_ids(); @@ -5119,16 +5297,26 @@ fn build_class_file_map(db: &DbIndex) -> HashMap> { .get_file_path(&file_id) .and_then(|p| p.file_name().and_then(|name| name.to_str())) .is_some_and(|name| name == "init.lua"); - let file_ids = map.entry(info.class_name.clone()).or_insert_with(Vec::new); - if is_init { - file_ids.insert(0, file_id); - } else { - file_ids.push(file_id); - } + let file_ids = map + .entry(info.class_name.clone()) + .or_insert_with(Vec::<(bool, FileId)>::new); + file_ids.push((is_init, file_id)); } } - map + // The per-class vector is replayed into `add_call`, which dedups on a `LuaSyntaxId` carrying + // no file id, so the order decides which file's call survives a collision. File-id order is + // not stable between a cold build and an incremental session; normalized path is. + map.into_iter() + .map(|(class_name, mut file_ids)| { + file_ids + .sort_by_cached_key(|(is_init, file_id)| (!*is_init, vfs.file_order_key(file_id))); + ( + class_name, + file_ids.into_iter().map(|(_, file_id)| file_id).collect(), + ) + }) + .collect() } /// Walk a scripted class file's AST looking for `scripted_ents.GetMember` delegation @@ -5143,7 +5331,7 @@ fn find_and_resolve_getmember_delegations( ) { // Collect local variable names assigned from scripted_ents.GetMember calls. // Map: local_name -> (target_class_name, target_method_name) - let mut getmember_locals: HashMap = HashMap::new(); + let mut getmember_locals: HashMap = HashMap::default(); for node in chunk.syntax().descendants() { // Match: local Name = scripted_ents.GetMember("class", "method") @@ -5495,7 +5683,7 @@ fn synthesize_vgui_registrations( // 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(); + let mut registered_table_regions: HashSet<(LuaDeclId, TextSize)> = HashSet::default(); for file_id in file_ids.iter().copied() { // Borrow first and skip files with no VGUI-relevant calls before paying @@ -5705,7 +5893,8 @@ fn synthesize_vgui_registrations( // 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(); + let mut registrations_by_file: HashMap> = + HashMap::default(); for registration in &vgui_registration_regions { registrations_by_file .entry(registration.file_id) @@ -7991,7 +8180,7 @@ fn synthesize_panel_class_with_id( let member_source_ranges = collect_panel_member_source_ranges(cache, db, file_id, decl_id, &table_range); - let mut table_member_ids = HashSet::new(); + let mut table_member_ids = HashSet::default(); for (source_idx, source_range) in member_source_ranges.iter().enumerate() { let is_initializer_fallback = source_idx > 0; let source_owner = LuaMemberOwner::Element(source_range.clone()); @@ -8162,6 +8351,22 @@ fn bind_inline_vgui_panel_table( .insert(table_range, class_type); } +/// Owners a *global* panel-table variable's members can be sitting on. +/// +/// Decl analysis parks `PANEL.Field` / `function PANEL:Method()` under +/// `GlobalPath("PANEL")`; once the path's `---@class` is recorded, the canonical +/// owner of that path becomes the class, which for GMod workspaces is the +/// annotation `@class PANEL`. Both are checked so the lookup works whichever +/// side of that the member is on. +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); + if db.get_type_index().get_type_decl(&type_decl_id).is_some() { + owners.push(LuaMemberOwner::Type(type_decl_id)); + } + owners +} + /// Collect the candidate `Element` owner ranges that may hold this /// registration region's members, deduped and most-specific first. /// @@ -8176,22 +8381,6 @@ fn bind_inline_vgui_panel_table( /// /// 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); - if db.get_type_index().get_type_decl(&type_decl_id).is_some() { - owners.push(LuaMemberOwner::Type(type_decl_id)); - } - owners -} - fn collect_panel_member_source_ranges( cache: &mut VguiSynthesisCache, db: &DbIndex, @@ -9565,9 +9754,9 @@ impl<'a> AnnotatedGmodCallRoleMap<'a> { ) -> Self { let mut role_map = Self { global_roles, - local_roles_by_decl: HashMap::new(), - local_roles_by_path: HashMap::new(), - local_candidate_names: HashSet::new(), + local_roles_by_decl: HashMap::default(), + local_roles_by_path: HashMap::default(), + local_candidate_names: HashSet::default(), }; for func_stat in root.descendants::() { @@ -9902,21 +10091,107 @@ fn call_expr_local_root_decl_id( } } +/// Whether a call can only be matched to a VGUI parent role through the type +/// of its receiver. +/// +/// 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 semantic inference is reserved for +/// the method names that carry one. +fn inferred_receiver_method_candidate(call_expr: &LuaCallExpr, call_path: &str) -> bool { + matches!(call_expr.get_prefix_expr(), Some(LuaExpr::IndexExpr(_))) + && matches!( + call_path.rsplit('.').next(), + Some("Add" | "AddPanel" | "SetParent") + ) +} + +/// Whether the receiver of a method call has no type yet, as opposed to a type +/// that carries no parent role. +fn receiver_type_undetermined(db: &DbIndex, file_id: FileId, call_expr: &LuaCallExpr) -> bool { + let Some(LuaExpr::IndexExpr(method_index)) = call_expr.get_prefix_expr() else { + return false; + }; + let Some(receiver) = method_index.get_prefix_expr() else { + return false; + }; + let mut cache = LuaInferCache::new(file_id, Default::default()); + match infer_expr(db, &mut cache, receiver) { + Ok(typ) => super::type_is_uninformative(&typ), + Err(_) => true, + } +} + +/// Re-collects the parent calls [`collect_annotated_scripted_class_call_metadata`] +/// deferred, now that the index can type their receivers, and resolves the +/// relations they add. +pub(crate) fn resolve_deferred_vgui_parent_calls(db: &mut DbIndex, context: &mut AnalyzeContext) { + let deferred = std::mem::take(&mut context.deferred_vgui_parent_call_sites); + if deferred.is_empty() { + return; + } + let Some((_, global_roles)) = context.gmod_global_call_roles.clone() else { + return; + }; + let mut file_ids = deferred.keys().copied().collect::>(); + file_ids.sort_by_key(|file_id| file_id.id); + let per_file = super::parallel::map_files_collect(db, &file_ids, |db, file_id| { + let Some(tree) = db.get_vfs().get_syntax_tree(&file_id) else { + return Vec::new(); + }; + let root = tree.get_red_root(); + let chunk = tree.get_chunk_node(); + let annotated_call_roles = + AnnotatedGmodCallRoleMap::build(db, file_id, &chunk, &global_roles); + let mut syntax_ids = deferred[&file_id].iter().copied().collect::>(); + syntax_ids + .sort_by_key(|syntax_id| (syntax_id.get_range().start(), syntax_id.get_range().end())); + let mut pending = Vec::new(); + for syntax_id in syntax_ids { + let Some(call_expr) = syntax_id + .to_node_from_root(&root) + .and_then(LuaCallExpr::cast) + else { + continue; + }; + collect_annotated_scripted_class_call_metadata( + db, + file_id, + &annotated_call_roles, + call_expr, + &mut pending, + ); + } + pending + }); + + let mut added_file_ids = Vec::new(); + for (file_id, pending) in file_ids.iter().zip(per_file) { + for write in pending { + // Every other write for these sites landed on the first scan, and a + // receiver still untyped now stays untyped. + if let PendingCallSite::VguiParent(call) = write { + db.get_gmod_class_metadata_index_mut() + .add_vgui_parent_call(*file_id, call); + added_file_ids.push(*file_id); + } + } + } + added_file_ids.dedup(); + if added_file_ids.is_empty() { + return; + } + resolve_vgui_parent_relations(db, context, &added_file_ids); + crate::compilation::analyzer::rederive_vgui_parent_fallbacks(db, context); +} + fn roles_from_inferred_receiver_method( db: &DbIndex, file_id: FileId, call_expr: &LuaCallExpr, 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(), - Some("Add" | "AddPanel" | "SetParent") - ) - { + if !inferred_receiver_method_candidate(call_expr, call_path) { return None; } let mut cache = LuaInferCache::new(file_id, Default::default()); @@ -10341,6 +10616,17 @@ fn collect_annotated_scripted_class_call_metadata( origin: GmodVguiParentCallOrigin::Annotated, })); } + } else if inferred_receiver_method_candidate(&call_expr, &call_path) + && receiver_type_undetermined(db, file_id, &call_expr) + { + // `self.panel:Add(child)` is a parent call only if `self.panel` is a + // panel, and on a cold build that member is typed after this scan: the + // walk defers it until the dynamic-field index seals. Retried once the + // index has settled, so the relation does not depend on whether a + // retained cache happened to answer. + pending.push(PendingCallSite::DeferredVguiParent( + call_expr.get_syntax_id(), + )); } if let Some((kind, inheritance_roles)) = @@ -11642,7 +11928,7 @@ fn collect_compilefile_execution_environment_flow( &roles, value, reassigned_decls, - &mut HashSet::new(), + &mut HashSet::default(), ) { add_compilefile_flow(&mut flow, destination, source); } @@ -11673,7 +11959,7 @@ fn collect_compilefile_execution_environment_flow( &roles, target_expr, reassigned_decls, - &mut HashSet::new(), + &mut HashSet::default(), ) else { continue; }; @@ -11682,7 +11968,7 @@ fn collect_compilefile_execution_environment_flow( file_id, environment_expr, reassigned_decls, - &mut HashSet::new(), + &mut HashSet::default(), ) else { continue; }; @@ -11714,7 +12000,7 @@ fn collect_compilefile_execution_environment_flow( &roles, arg, reassigned_decls, - &mut HashSet::new(), + &mut HashSet::default(), ) { add_compilefile_flow(&mut flow, *param, source); } @@ -11729,7 +12015,7 @@ fn rebuild_compilefile_execution_environments( updated_source_files: usize, roles_changed: bool, ) { - let mut environments = HashMap::>>::new(); + let mut environments = HashMap::>>::default(); let mut cached_source_files = 0usize; let mut edge_count = 0usize; let mut seed_count = 0usize; @@ -11743,7 +12029,7 @@ fn rebuild_compilefile_execution_environments( seed_count += flow.seeds.len(); site_count += flow.sites.len(); - let mut targets = HashMap::::new(); + let mut targets = HashMap::::default(); let mut queue = VecDeque::new(); for (decl_id, path) in &flow.seeds { let Some(target) = resolve_compilefile_target(db, source_file_id, path) else { @@ -12067,16 +12353,6 @@ fn rebuild_gmod_load_index( let _p_prep = crate::profile::PhaseGuard::new("gmodload/prep"); let file_ids = db.get_vfs().get_all_local_file_ids(); let analyzed_file_ids: HashSet = analyzed_file_ids.iter().copied().collect(); - let previous_realm_metadata: HashMap = file_ids - .iter() - .filter_map(|file_id| { - db.get_gmod_infer_index() - .get_realm_file_metadata(file_id) - .cloned() - .map(|metadata| (*file_id, metadata)) - }) - .collect(); - let resolved_branch_ranges = file_ids .iter() .map(|file_id| { @@ -12085,8 +12361,8 @@ fn rebuild_gmod_load_index( } else if analyzed_file_ids.contains(file_id) { Vec::new() } else { - previous_realm_metadata - .get(file_id) + db.get_gmod_infer_index() + .get_realm_file_metadata(file_id) .map(|metadata| metadata.branch_realm_ranges.clone()) .unwrap_or_default() }; @@ -12098,7 +12374,7 @@ fn rebuild_gmod_load_index( .iter() .map(|file_id| (*file_id, GmodFileLoadInfo::fallback_shared())) .collect::>(); - let mut fallback_masks = HashMap::new(); + let mut fallback_masks = HashMap::default(); for file_id in &file_ids { if let Some(realm) = infer_realm_from_load_path_hint(db, *file_id) { @@ -12112,19 +12388,22 @@ fn rebuild_gmod_load_index( drop(_p_prep); let _p_sites = crate::profile::PhaseGuard::new("gmodload/resolve_sites"); - let dependency_sites = db + let mut dependency_sites = db .get_file_dependencies_index() .iter_dependency_sites() .flat_map(|(_, sites)| sites.iter().cloned()) .map(|site| resolve_load_dependency_site(db, site)) .collect::>(); + // `iter_dependency_sites` walks a `HashMap`, and this vector is the visit order of the + // load fixpoint below, so it decides both the edge order and (through the `states` stamp) + // the edge set. Order it by a property of the source text instead. + dependency_sites.sort_by_cached_key(|site| load_site_sort_key(db, site)); drop(_p_sites); let _p_dyn = crate::profile::PhaseGuard::new("gmodload/dynamic_loaders"); let dynamic_loaders = collect_dynamic_loaders(db, &file_ids, annotated_global_call_roles); drop(_p_dyn); let _p_fix = crate::profile::PhaseGuard::new("gmodload/fixpoint"); - let mut unresolved_edges = Vec::new(); for _ in 0..file_ids.len().max(1) { let mut changed = false; for site in &dependency_sites { @@ -12134,7 +12413,7 @@ fn rebuild_gmod_load_index( &resolved_branch_ranges, site, ); - changed |= apply_load_site(&mut file_infos, &mut unresolved_edges, site, source_states); + changed |= apply_load_site(&mut file_infos, site, source_states); } changed |= apply_dynamic_loaders( &mut file_infos, @@ -12146,6 +12425,19 @@ fn rebuild_gmod_load_index( break; } } + + // Edges are recorded once, after convergence: a site visited before its source's realm was + // established would otherwise record an extra edge stamped with the partial state mask. + let mut unresolved_edges = Vec::new(); + for site in &dependency_sites { + let source_states = source_states_for_load_site( + &file_infos, + &fallback_masks, + &resolved_branch_ranges, + site, + ); + record_load_site_edge(&mut file_infos, &mut unresolved_edges, site, source_states); + } drop(_p_fix); let _p_shadow = crate::profile::PhaseGuard::new("gmodload/shadows_and_publish"); @@ -12161,7 +12453,7 @@ fn mark_main_workspace_load_shadows( file_ids: &[FileId], ) { let module_index = db.get_module_index(); - let mut files_by_load_identity: HashMap> = HashMap::new(); + let mut files_by_load_identity: HashMap> = HashMap::default(); for file_id in file_ids { let Some(info) = file_infos.get(file_id) else { @@ -12444,7 +12736,7 @@ fn collect_dynamic_loaders( file_ids: &[FileId], annotated_global_call_roles: &AnnotatedGmodGlobalCallRoleMap, ) -> Vec { - let mut relative_paths_by_parent: HashMap> = HashMap::new(); + let mut relative_paths_by_parent: HashMap> = HashMap::default(); for file_id in file_ids { let Some(path) = gmod_relative_path(db, *file_id) else { continue; @@ -12630,7 +12922,7 @@ fn collect_dynamic_load_usages( result_kind, collect_dynamic_load_usage_in_block( &block, - HashSet::from([file_name_var]), + HashSet::from_iter([file_name_var]), db, file_id, &wrappers, @@ -12767,7 +13059,7 @@ fn collect_dynamic_wrapper_call_usage( } fn collect_dynamic_load_wrappers(root: &LuaChunk) -> HashMap { - let mut wrappers = HashMap::new(); + let mut wrappers = HashMap::default(); for local_func_stat in root.descendants::() { let Some(name) = local_func_stat @@ -12827,10 +13119,10 @@ fn collect_top_level_dynamic_load_call_aliases( annotated_roles: &AnnotatedGmodCallRoleMap, ) -> HashMap { let Some(block) = root.get_block() else { - return HashMap::new(); + return HashMap::default(); }; - let mut aliases = HashMap::new(); + let mut aliases = HashMap::default(); let mut changed = true; while changed { changed = false; @@ -12869,7 +13161,7 @@ fn collect_dynamic_load_call_aliases( block: &LuaBlock, annotated_roles: &AnnotatedGmodCallRoleMap, ) -> HashMap { - let mut aliases = HashMap::new(); + let mut aliases = HashMap::default(); let mut changed = true; while changed { changed = false; @@ -13538,7 +13830,7 @@ fn expr_references_name(expr: &LuaExpr, expected_name: &str) -> bool { } fn collect_static_string_bindings(root: &LuaChunk) -> HashMap { - let mut bindings = HashMap::new(); + let mut bindings = HashMap::default(); for node in root.syntax().descendants() { if let Some(local_stat) = LuaLocalStat::cast(node.clone()) { let names = local_stat.get_local_name_list().collect::>(); @@ -14139,17 +14431,37 @@ fn source_states_for_load_site( } } -fn apply_load_site( +fn load_site_sort_key( + db: &DbIndex, + site: &LuaDependencySite, +) -> (std::sync::Arc, u32, u32, u8, String) { + let source_path = db.get_vfs().file_order_key(&site.source_file_id); + let kind_rank = match site.kind { + LuaDependencyKind::Require => 0, + LuaDependencyKind::Include => 1, + LuaDependencyKind::CompileFile => 2, + LuaDependencyKind::AddCSLuaFile => 3, + LuaDependencyKind::IncludeCS => 4, + }; + ( + source_path, + site.range.start().into(), + site.range.end().into(), + kind_rank, + site.path.clone().unwrap_or_default(), + ) +} + +fn record_load_site_edge( file_infos: &mut HashMap, unresolved_edges: &mut Vec, site: &LuaDependencySite, source_states: GmodStateMask, -) -> bool { - let edge_kind = GmodLoadEdgeKind::from(site.kind); +) { let edge = GmodLoadEdge { source_file_id: site.source_file_id, target_file_id: site.target_file_id, - kind: edge_kind, + kind: GmodLoadEdgeKind::from(site.kind), states: source_states, path: site.path.clone(), original_expr: Some(site.original_expr.clone()), @@ -14160,6 +14472,21 @@ fn apply_load_site( if !unresolved_edges.contains(&edge) { unresolved_edges.push(edge); } + return; + }; + + file_infos + .entry(target_file_id) + .or_insert_with(GmodFileLoadInfo::fallback_shared) + .add_incoming_edge(edge); +} + +fn apply_load_site( + file_infos: &mut HashMap, + site: &LuaDependencySite, + source_states: GmodStateMask, +) -> bool { + let Some(target_file_id) = site.target_file_id else { return false; }; @@ -14223,7 +14550,6 @@ fn apply_load_site( } } - target_info.add_incoming_edge(edge); changed } diff --git a/crates/glua_code_analysis/src/compilation/analyzer/gmod/numeric_range_population.rs b/crates/glua_code_analysis/src/compilation/analyzer/gmod/numeric_range_population.rs index 03d29fd1e..ce72fba9a 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/gmod/numeric_range_population.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/gmod/numeric_range_population.rs @@ -9,7 +9,7 @@ pub(super) fn collect_numeric_range_table_populations_for_file( let Some(block) = root.get_block() else { return Vec::new(); }; - let mut local_helpers: HashMap = HashMap::new(); + let mut local_helpers: HashMap = HashMap::default(); let mut populations = Vec::new(); let mut cache = LuaInferCache::new(file_id, Default::default()); @@ -113,7 +113,7 @@ fn numeric_range_populations_from_outer_call( ) -> Option> { let block = outer_closure.get_block()?; let mut populations = Vec::new(); - let mut populated_tables: HashSet = HashSet::new(); + let mut populated_tables: HashSet = HashSet::default(); for stat in block.get_stats() { match stat { @@ -656,7 +656,7 @@ fn pre_loop_local_stat_is_harmless( &expr, helpers, protected_names, - &mut HashSet::new(), + &mut HashSet::default(), ) }) } @@ -710,7 +710,13 @@ fn pre_loop_call_is_harmless( protected_names: &HashSet, active_helpers: &mut HashSet, ) -> bool { - if call_write_effect_is_allowed(db, cache, call_expr, protected_names, &mut HashSet::new()) { + if call_write_effect_is_allowed( + db, + cache, + call_expr, + protected_names, + &mut HashSet::default(), + ) { return true; } let Some(helper_name) = call_expr_name(call_expr) else { @@ -937,7 +943,7 @@ fn branchy_local_stat_is_safe( &expr, helpers, protected_names, - &mut HashSet::new(), + &mut HashSet::default(), ) }) } @@ -991,7 +997,7 @@ fn branchy_assignment_is_safe( expr, helpers, protected_names, - &mut HashSet::new(), + &mut HashSet::default(), ) }) } @@ -1093,7 +1099,7 @@ fn condition_calls_are_safe( &condition, helpers, protected_names, - &mut HashSet::new(), + &mut HashSet::default(), ) }) } @@ -1392,7 +1398,7 @@ fn numeric_range_rhs_is_safe( return true; } - let mut active_helpers = HashSet::new(); + let mut active_helpers = HashSet::default(); calls.into_iter().all(|call_expr| { numeric_range_call_is_safe( db, @@ -1450,17 +1456,22 @@ fn numeric_range_call_is_safe( ); } - call_write_effect_is_allowed(db, cache, call_expr, protected_names, &mut HashSet::new()) - && call_args_are_safe( - db, - cache, - file_id, - containing_closure, - call_expr, - helpers, - protected_names, - active_helpers, - ) + call_write_effect_is_allowed( + db, + cache, + call_expr, + protected_names, + &mut HashSet::default(), + ) && call_args_are_safe( + db, + cache, + file_id, + containing_closure, + call_expr, + helpers, + protected_names, + active_helpers, + ) } fn call_write_effect_overlaps_protected_names( @@ -1671,7 +1682,7 @@ fn population_bounded_write_roots( helpers: &HashMap, ) -> Vec { let mut roots = Vec::new(); - let mut active_helpers = HashSet::new(); + let mut active_helpers = HashSet::default(); collect_closure_bounded_write_roots( db, cache, 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..52b03d32e 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 @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use rustc_hash::FxHashSet; use rustc_hash::FxHashMap; @@ -60,7 +60,7 @@ impl InferCacheManager { &mut self, file_id: FileId, pending_type_decls: Vec, - guard_dependencies: HashSet, + guard_dependencies: FxHashSet, ) { let infer_cache = self.get_infer_cache(file_id); for pending in pending_type_decls { @@ -111,7 +111,7 @@ impl InferCacheManager { pending } - pub fn clear_files(&mut self, file_ids: &HashSet) { + pub fn clear_files(&mut self, file_ids: &FxHashSet) { for file_id in file_ids { if let Some(infer_cache) = self.infer_map.get_mut(file_id) { infer_cache.clear(); @@ -119,7 +119,27 @@ impl InferCacheManager { } } - pub fn clear_files_deferred_results(&mut self, file_ids: &HashSet) { + 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_iter_var_results(&mut self, file_ids: &FxHashSet) { + 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_undetermined_flow_results(&mut self, file_id: FileId) { + if let Some(infer_cache) = self.infer_map.get_mut(&file_id) { + infer_cache.clear_undetermined_flow_results(); + } + } + + pub fn clear_files_deferred_results(&mut self, file_ids: &FxHashSet) { for file_id in file_ids { if let Some(infer_cache) = self.infer_map.get_mut(file_id) { infer_cache.clear_deferred_inference_results(); @@ -127,14 +147,55 @@ impl InferCacheManager { } } + /// Drops every narrowing answer memoized for these files, including + /// successful ones. + /// + /// A successful flow answer survives `clear_files_deferred_results`, but + /// narrowing a name reads the type of whatever it was derived from — so + /// once a settled pass moves a value, every flow answer that read it is + /// void. The settled assign replay re-reads moved declarations through + /// flow narrowing, so it voids them up front; otherwise the first round's + /// answer keeps winning over the settled declaration type. + pub fn clear_files_flow_results(&mut self, file_ids: &FxHashSet) { + for file_id in file_ids { + if let Some(infer_cache) = self.infer_map.get_mut(file_id) { + infer_cache.clear_flow_results(); + } + } + } + pub fn drain_inferred_guard_dependencies( &mut self, - ) -> Vec<(FileId, HashSet)> { + ) -> Vec<(FileId, FxHashSet)> { self.infer_map .iter_mut() .map(|(file_id, cache)| (*file_id, cache.take_inferred_guard_dependencies())) .collect() } + + pub fn drain_inferred_return_reads( + &mut self, + ) -> Vec<( + crate::LuaSignatureId, + crate::db_index::read_set::InferredReturnReadRecord, + )> { + self.infer_map + .values_mut() + .flat_map(|cache| cache.take_inferred_return_reads()) + .collect() + } + + pub fn drain_missed_member_reads( + &mut self, + ) -> Vec<( + FileId, + FxHashSet<(crate::LuaMemberOwner, crate::LuaMemberKey)>, + )> { + self.infer_map + .iter_mut() + .map(|(file_id, cache)| (*file_id, cache.take_missed_member_reads())) + .collect() + } } #[cfg(test)] 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..ba7c682cc 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, @@ -58,13 +58,26 @@ pub(super) fn stabilize_unknown_locals( .is_none_or(|cache| { cache.is_infer() && matches!(cache.as_type(), LuaType::Unknown | LuaType::Never) }); - if !uninformative { + // A fact this pass published earlier was read off whatever the + // slots its uses feed held at the time; a later run re-derives it + // against what they hold now. + let contextual = !uninformative + && db + .get_type_index() + .get_type_fact(&(*decl_id).into()) + .is_some_and(|fact| { + fact.provenance().iter().any(|step| { + step.event.kind == crate::LuaInferenceProvenanceKind::ContextualUnknown + }) + }); + if !uninformative && !contextual { continue; } candidates.push((file_id, *decl_id, decl_references.clone())); } } 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(); @@ -79,6 +92,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) @@ -106,6 +133,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; } @@ -162,6 +194,121 @@ 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. +/// +/// 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, @@ -281,14 +428,14 @@ pub(super) fn stabilize_unguarded_children( if !db.get_emmyrc().gmod.enabled { return Vec::new(); } - let mut scores = HashMap::::new(); + let mut scores = HashMap::::default(); let mut deferred_definitions = FxHashSet::::default(); let mut nested_scores = - HashMap::::new(); + HashMap::::default(); let mut sources = HashMap::<(LuaDefinitionId, crate::LuaTypeDeclId), InFiled>::new( ); - let mut initializer_refinements = HashMap::::new(); + let mut initializer_refinements = HashMap::::default(); let mut declared_path_bases = DeclaredPathBases::default(); let subtype_index_start = profile.as_ref().map(|_| std::time::Instant::now()); let direct_subtype_members = precompute_direct_subtype_members(db); @@ -543,7 +690,7 @@ pub(super) fn stabilize_unguarded_children( .entry(definition.clone()) .or_insert_with(|| UnguardedChildCandidates { parent_type: base_type.clone(), - children: HashMap::new(), + children: HashMap::default(), }) .children .entry(child_id.clone()) @@ -942,7 +1089,7 @@ fn collect_member_path_unguarded_child_evidence( .entry(target) .or_insert_with(|| NestedUnguardedChildCandidates { parent_type: current.clone(), - children: HashMap::new(), + children: HashMap::default(), receivers: FxHashSet::default(), source: source.clone(), }); @@ -1046,11 +1193,16 @@ pub(super) fn publish_inferred_positive_guards( }; let signature_id = LuaSignatureId::from_closure(file_id, &closure); let owner = inferred_guard_owner(db, signature_id, &closure); + let mut return_changed = false; if let Some(signature) = db.get_signature_index_mut().get_mut(&signature_id) && signature.resolve_return == SignatureReturnStatus::UnResolve { signature.return_docs = returns; signature.resolve_return = SignatureReturnStatus::InferResolve; + return_changed = true; + } + if return_changed { + db.get_signature_index_mut().note_return_write(signature_id); } if let Some(owner) = owner { db.get_signature_index_mut() @@ -1397,7 +1549,7 @@ fn precompute_direct_subtype_members(db: &crate::DbIndex) -> DirectSubtypeMember let mut candidates = FxHashMap::>>::default(); - for child in type_index.get_all_types() { + for child in type_index.iter_type_decls() { let child_id = child.get_id(); let owner = LuaMemberOwner::Type(child_id.clone()); let members = member_index.get_members(&owner); diff --git a/crates/glua_code_analysis/src/compilation/analyzer/local_inference/solver.rs b/crates/glua_code_analysis/src/compilation/analyzer/local_inference/solver.rs index c171334a3..604ac0006 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/local_inference/solver.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/local_inference/solver.rs @@ -23,7 +23,7 @@ pub(crate) fn solve_local_inference_graph( nodes: &FxHashMap>, ) -> LocalInferenceSolveResult { let components = strongly_connected_components(nodes); - let mut resolved = HashMap::::new(); + let mut resolved = HashMap::::default(); loop { let mut progress = false; diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/closure.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/closure.rs index 389cb8311..a9a7c49ec 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/closure.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/closure.rs @@ -2046,22 +2046,16 @@ fn analyze_return( body: Some(block.clone()), return_points: return_points.clone(), }); - let return_correlations = analyze_return_correlations( + let (return_correlations, returns) = derive_inferred_return_with_reads( analyzer.db, analyzer .context .infer_manager .get_infer_cache(analyzer.file_id), + *signature_id, &return_points, ); - let returns = match analyze_return_point( - analyzer.db, - analyzer - .context - .infer_manager - .get_infer_cache(analyzer.file_id), - &return_points, - ) { + let returns = match returns { Ok(returns) => returns, Err(InferFailReason::None) => { vec![LuaDocReturnInfo { @@ -2091,13 +2085,41 @@ fn analyze_return( .get_or_create(*signature_id); signature.resolve_return = SignatureReturnStatus::InferResolve; - signature.return_docs = returns; signature.set_return_correlations(return_correlations); + analyzer + .db + .get_signature_index_mut() + .note_return_write(*signature_id); Some(()) } +pub fn derive_inferred_return_with_reads( + db: &DbIndex, + cache: &mut LuaInferCache, + signature_id: LuaSignatureId, + return_points: &Vec, +) -> ( + Vec, + Result, InferFailReason>, +) { + crate::db_index::read_set::arm(); + let correlations = analyze_return_correlations(db, cache, return_points); + let returns = analyze_return_point(db, cache, return_points); + let mut reads = crate::db_index::read_set::disarm(); + reads.signatures.remove(&signature_id); + cache.record_inferred_return_reads( + signature_id, + crate::db_index::read_set::InferredReturnReadRecord { + reads, + type_epoch: db.get_type_index().type_writes(), + return_epoch: db.get_signature_index().return_writes(), + }, + ); + (correlations, returns) +} + fn signature_has_uninformative_return(signature: &crate::LuaSignature) -> bool { let return_type = signature.get_return_type(); return_type.is_any() || return_type.is_unknown() @@ -2160,6 +2182,12 @@ pub fn analyze_return_point( match point { LuaReturnPoint::Expr(expr) => { let expr_type = infer_expr(db, cache, expr.clone())?; + let expr_type = super::super::common::widen_mutable_local_name_copy( + db, + cache.get_file_id(), + expr, + expr_type, + ); return_type = Some(match return_type { Some(current) => union_return_expr(db, current, expr_type), None => expr_type, @@ -2169,6 +2197,12 @@ pub fn analyze_return_point( let mut multi_return = vec![]; for expr in exprs { let expr_type = infer_expr(db, cache, expr.clone())?; + let expr_type = super::super::common::widen_mutable_local_name_copy( + db, + cache.get_file_id(), + expr, + expr_type, + ); multi_return.push(expr_type); } let typ = LuaType::Variadic(VariadicType::Multi(multi_return).into()); @@ -2314,11 +2348,15 @@ fn union_return_expr(db: &DbIndex, left: LuaType, right: LuaType) -> LuaType { { left.clone() } + // A branch returning `any` must not swallow the branches that return + // something concrete, so the `any` stays an arm instead of absorbing + // the union. It is not rewritten to `unknown`: `unknown` marks a read + // that has not settled, and this one has. (LuaType::Any, right) if should_union_any_as_unknown(right) => { - LuaType::from_vec(vec![LuaType::Unknown, right.clone()]) + LuaType::from_vec_structural(vec![right.clone(), LuaType::Any]) } (left, LuaType::Any) if should_union_any_as_unknown(left) => { - LuaType::from_vec(vec![left.clone(), LuaType::Unknown]) + LuaType::from_vec_structural(vec![left.clone(), LuaType::Any]) } (LuaType::Unknown, LuaType::Unknown) => LuaType::Unknown, (LuaType::Unknown, _) | (_, LuaType::Unknown) => { 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..f56d49e76 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 @@ -1,12 +1,14 @@ use glua_parser::{LuaAstNode, LuaAstToken, LuaExpr, LuaForRangeStat}; -use std::collections::{HashMap, HashSet}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use crate::{ - DbIndex, FileId, InferFailReason, LuaAliasCallKind, LuaAliasCallType, LuaDeclId, LuaInferCache, - LuaMemberKey, LuaMemberOwner, LuaObjectType, LuaOperatorMetaMethod, LuaType, LuaTypeCache, - TplContext, TypeOps, TypeSubstitutor, VariadicType, + DbIndex, FileId, FuncGenericBinding, FuncGenericBindingState, GenericTplId, InferFailReason, + LuaAliasCallKind, LuaAliasCallType, LuaDeclId, LuaInferCache, LuaMemberKey, LuaMemberOwner, + LuaObjectType, LuaOperatorMetaMethod, LuaType, LuaTypeCache, TplContext, TypeOps, + TypeSubstitutor, VariadicType, compilation::analyzer::{ common::{TypeCacheWriteMode, write_type_cache}, + is_provably_builtin_pairs_call, unresolve::UnResolveIterVar, }, get_member_map, infer_expr, instantiate_doc_function, tpl_pattern_match_args, @@ -57,6 +59,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 +86,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()); @@ -104,11 +112,27 @@ pub fn iterates_table_member_map( is_global_pairs_call(db, file_id, &call_expr) } +#[derive(Debug, Clone)] +pub struct IterVarInference { + pub types: VariadicType, + pub settled_overrides: Vec>, +} + +impl IterVarInference { + pub fn get_type(&self, idx: usize) -> Option<&LuaType> { + self.types.get_type(idx) + } + + pub fn contain_tpl(&self) -> bool { + self.types.contain_tpl() + } +} + pub fn infer_for_range_iter_expr_func( db: &DbIndex, cache: &mut LuaInferCache, iter_exprs: &[LuaExpr], -) -> Result { +) -> Result { if iter_exprs.is_empty() { return Err(InferFailReason::None); } @@ -124,9 +148,22 @@ pub fn infer_for_range_iter_expr_func( if let Some(iter_types) = try_infer_pairs_iter_types_from_table_members(db, cache, &iter_exprs[0], &first_expr_type)? { - return Ok(iter_types); + return Ok(IterVarInference { + types: iter_types, + settled_overrides: Vec::new(), + }); } + let pairs_bindings = if let Some(LuaExpr::CallExpr(call_expr)) = iter_exprs.first() + && is_provably_builtin_pairs_call(db, cache.get_file_id(), call_expr) + { + cache + .get_generic_call_bindings(&call_expr.get_syntax_id()) + .map(|bindings| bindings.to_vec()) + } else { + None + }; + let doc_function = match first_expr_type { LuaType::DocFunction(func) => func, LuaType::Signature(sig_id) => { @@ -189,7 +226,12 @@ pub fn infer_for_range_iter_expr_func( }; let Some(status_param) = status_param else { - return Ok(doc_function.get_variadic_ret()); + let types = doc_function.get_variadic_ret(); + let settled_overrides = compute_proven_settled_overrides(pairs_bindings.as_deref(), &types); + return Ok(IterVarInference { + types, + settled_overrides, + }); }; let mut substitutor = TypeSubstitutor::new(); let mut context = TplContext { @@ -215,7 +257,45 @@ pub fn infer_for_range_iter_expr_func( doc_function }; - Ok(instantiate_func.get_variadic_ret()) + let types = instantiate_func.get_variadic_ret(); + let settled_overrides = compute_proven_settled_overrides(pairs_bindings.as_deref(), &types); + Ok(IterVarInference { + types, + settled_overrides, + }) +} + +fn compute_proven_settled_overrides( + bindings: Option<&[FuncGenericBinding]>, + types: &VariadicType, +) -> Vec> { + let Some(bindings) = bindings else { + return Vec::new(); + }; + if !bindings + .iter() + .any(|b| matches!(b.state, FuncGenericBindingState::Unbound { .. })) + { + return Vec::new(); + } + let slot_count = match types { + VariadicType::Multi(types) => types.len(), + VariadicType::Base(_) => 1, + }; + (0..slot_count) + .map(|idx| { + let want = GenericTplId::Func(idx as u32); + bindings.iter().find_map(|b| { + if b.id != want { + return None; + } + match &b.state { + FuncGenericBindingState::Unbound { fallback } => Some(fallback.clone()), + FuncGenericBindingState::Bound => None, + } + }) + }) + .collect() } fn try_infer_pairs_iter_types_from_table_members( @@ -333,7 +413,7 @@ fn try_infer_pairs_iter_types_from_table_members( fn infer_table_projection_key_type(db: &DbIndex, inner: &LuaType) -> LuaType { let mut pending = vec![inner.clone()]; - let mut visited_types = HashSet::new(); + let mut visited_types = HashSet::default(); let mut key_type = None; while let Some(typ) = pending.pop() { @@ -415,11 +495,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; @@ -442,12 +525,12 @@ fn try_compact_record_values(db: &DbIndex, values: &[LuaType]) -> Option = HashMap::new(); + let mut fields: HashMap = HashMap::default(); for value in values { let Some(member_map) = get_member_map(db, value) else { continue; }; - let mut present_keys = HashSet::new(); + let mut present_keys = HashSet::default(); for (key, member_infos) in member_map.iter() { if matches!(key, LuaMemberKey::None) || member_infos.is_empty() { continue; diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/collection.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/collection.rs index 0386de204..5caacf3ac 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/collection.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/collection.rs @@ -1,5 +1,6 @@ use crate::{ - DbIndex, FileId, InFiled, LuaArrayType, LuaMemberKey, LuaTypeCache, LuaTypeOwner, TypeOps, + DbIndex, FileId, GlobalId, InFiled, LuaArrayType, LuaMemberKey, LuaTypeCache, LuaTypeOwner, + TypeOps, compilation::analyzer::common::{TypeCacheWriteMode, write_type_cache}, db_index::{LuaDeclId, LuaMemberId, LuaMemberOwner, LuaType}, semantic::member_key_matches_type, @@ -178,7 +179,7 @@ pub(in crate::compilation::analyzer::lua) fn record_member_collection_assignment ); } -pub(in crate::compilation::analyzer) fn is_member_realm_compatible( +fn is_member_realm_compatible( db: &DbIndex, current_member_id: LuaMemberId, related_member_id: LuaMemberId, @@ -550,6 +551,71 @@ pub(in crate::compilation::analyzer::lua) fn expr_access_path( } } +/// The largest number of alias hops [`alias_target_global_path`] follows. +const ALIAS_TARGET_WALK_DEPTH: u8 = 4; + +/// The registered global path a member write's prefix names through local +/// aliases, read purely syntactically. +/// +/// `local Repair = Glide.Repair` names the same table as `Glide.Repair`, so a +/// write through `Repair` is a path write for provenance purposes. A local +/// declaration qualifies only when its initializer is itself a plain name or +/// index expression — a call, a table literal, or a function may produce a +/// table the path does not name. The terminal path must already be a +/// registered global path, and no inference runs: this answers while the +/// write is being filed, before any type is settled, and without gating on +/// which class currently wins the path. +pub(in crate::compilation::analyzer) fn alias_target_global_path( + db: &DbIndex, + file_id: FileId, + prefix_expr: &LuaExpr, +) -> Option { + alias_target_global_path_depth(db, file_id, prefix_expr, ALIAS_TARGET_WALK_DEPTH) +} + +fn alias_target_global_path_depth( + db: &DbIndex, + file_id: FileId, + expr: &LuaExpr, + depth: u8, +) -> Option { + if depth == 0 { + return None; + } + match expr { + LuaExpr::IndexExpr(index_expr) => { + registered_global_path(db, &index_expr.get_access_path()?) + } + LuaExpr::NameExpr(name_expr) => { + let decl = db + .get_reference_index() + .get_local_reference(&file_id) + .and_then(|file_refs| file_refs.get_decl_id(&name_expr.get_range())) + .and_then(|decl_id| db.get_decl_index().get_decl(&decl_id)); + match decl { + Some(decl) if decl.is_local() => { + let initializer = decl.get_initializer()?.get_expr_syntax_id(); + let root = db + .get_vfs() + .get_syntax_tree(&decl.get_file_id())? + .get_red_root(); + let initializer = LuaExpr::cast(initializer.to_node_from_root(&root)?)?; + alias_target_global_path_depth(db, decl.get_file_id(), &initializer, depth - 1) + } + _ => registered_global_path(db, &name_expr.get_access_path()?), + } + } + _ => None, + } +} + +fn registered_global_path(db: &DbIndex, path: &str) -> Option { + let path = GlobalId::new(path); + db.get_member_index() + .is_known_global_path(&path) + .then_some(path) +} + pub(in crate::compilation::analyzer::lua) fn is_literal_integer_one(expr: &LuaExpr) -> bool { let LuaExpr::LiteralExpr(literal_expr) = expr else { return false; diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/mod.rs index 363da6683..0a3855e6c 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/mod.rs @@ -1,6 +1,5 @@ mod cache; mod collection; -mod scalar; pub(in crate::compilation::analyzer::lua) use crate::widen_related_assignment_type; pub(in crate::compilation::analyzer::lua) use cache::{ @@ -8,21 +7,16 @@ pub(in crate::compilation::analyzer::lua) use cache::{ WideningCacheLookup, lookup_widening_cache, member_assignment_state_mask, member_assignment_state_masks_compatible, record_widening_cache, }; +pub(in crate::compilation::analyzer) use collection::alias_target_global_path; pub(in crate::compilation::analyzer) use collection::resolve_index_expr_member_owner_for_file; pub(in crate::compilation::analyzer::lua) use collection::{ direct_local_prefix_has_declared_type, direct_local_table_prefix_member_owner, flush_pending_dynamic_key_collection_widening_for_members, get_widened_member_assignment_collection_type, is_collection_append_write, - is_member_realm_compatible, record_member_collection_assignment_widening_cache, - widen_existing_member_collection_type, + record_member_collection_assignment_widening_cache, widen_existing_member_collection_type, }; #[cfg(test)] pub(in crate::compilation::analyzer::lua) use collection::{ get_cached_widened_member_collection_assignment_type, record_pending_dynamic_key_collection_widening, }; -pub(in crate::compilation::analyzer::lua) use scalar::{ - MemberAssignmentWideningDecision, MemberAssignmentWideningState, - decide_member_assignment_widening, merge_member_assignment_widening_state, - union_member_assignment_widening, -}; 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 deleted file mode 100644 index a4631d999..000000000 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/member_write_policy/scalar.rs +++ /dev/null @@ -1,263 +0,0 @@ -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, -}; - -#[derive(Debug, Clone)] -pub(in crate::compilation::analyzer::lua) struct MemberAssignmentWideningState { - pub(in crate::compilation::analyzer::lua) no_table_literal_widen_type: LuaType, - pub(in crate::compilation::analyzer::lua) table_literal_widen_type: LuaType, - pub(in crate::compilation::analyzer::lua) doc_type: Option, - pub(in crate::compilation::analyzer::lua) all_table_assignment_merge_types: bool, - class_bootstrap_type: Option, - class_bootstrap_compatible: bool, -} - -pub(in crate::compilation::analyzer::lua) enum MemberAssignmentWideningDecision { - Widened(LuaType), - ClassBootstrapRejected, - NoPreviousAssignments, -} - -impl MemberAssignmentWideningState { - pub(in crate::compilation::analyzer::lua) fn from_assigned_type( - assigned_type: &LuaType, - doc_type: Option, - ) -> Self { - let (class_bootstrap_type, class_bootstrap_compatible) = - class_bootstrap_cache_state(assigned_type); - - Self { - no_table_literal_widen_type: widen_related_assignment_type(assigned_type, false), - table_literal_widen_type: widen_related_assignment_type(assigned_type, true), - doc_type, - all_table_assignment_merge_types: is_table_assignment_merge_type(assigned_type), - class_bootstrap_type, - class_bootstrap_compatible, - } - } - - pub(in crate::compilation::analyzer::lua) fn from_type_cache(cache: &LuaTypeCache) -> Self { - Self::from_assigned_type( - cache.as_type(), - cache.is_doc().then(|| cache.as_type().clone()), - ) - } -} - -pub(in crate::compilation::analyzer::lua) fn merge_member_assignment_widening_state( - db: &DbIndex, - state: &mut MemberAssignmentWideningState, - 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.table_literal_widen_type = TypeOps::Union.apply( - db, - &state.table_literal_widen_type, - &new_state.table_literal_widen_type, - ); - if let Some(doc_type) = new_state.doc_type { - state.doc_type = Some(match state.doc_type.take() { - Some(current) => TypeOps::Union.apply(db, ¤t, &doc_type), - None => doc_type, - }); - } - state.all_table_assignment_merge_types &= new_state.all_table_assignment_merge_types; - merge_class_bootstrap_cache_state( - state, - assigned_type, - new_state.class_bootstrap_type, - new_state.class_bootstrap_compatible, - ); -} - -pub(in crate::compilation::analyzer::lua) fn decide_member_assignment_widening<'a>( - db: &DbIndex, - incoming_type: &LuaType, - allow_table_literal_widening: bool, - previous_states: impl IntoIterator, -) -> MemberAssignmentWideningDecision { - let previous_states = previous_states.into_iter().collect::>(); - if previous_states.is_empty() { - return MemberAssignmentWideningDecision::NoPreviousAssignments; - } - - if let Some(doc_type) = merge_assignment_types( - db, - previous_states - .iter() - .filter_map(|state| state.doc_type.as_ref()), - ) { - return MemberAssignmentWideningDecision::Widened(doc_type); - } - - if !matches!( - incoming_type, - LuaType::Union(_) | LuaType::Intersection(_) | LuaType::MultiLineUnion(_) - ) && let Some(class_type) = prefer_class_assignment_type(incoming_type) - { - if !is_class_bootstrap_compatible_type(incoming_type, &class_type) { - return MemberAssignmentWideningDecision::ClassBootstrapRejected; - } - - let class_bootstrap_compatible = previous_states.iter().all(|state| { - state.class_bootstrap_compatible - && state - .class_bootstrap_type - .as_ref() - .is_none_or(|cached_class| is_same_class_type(cached_class, &class_type)) - }); - if class_bootstrap_compatible { - return MemberAssignmentWideningDecision::Widened(class_type); - } - - 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); - 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 - } - }), - ) - .expect("previous states are non-empty"); - let incoming_type = widen_related_assignment_type(incoming_type, should_widen_table_literals); - - MemberAssignmentWideningDecision::Widened(TypeOps::Union.apply( - db, - &previous_type, - &incoming_type, - )) -} - -pub(in crate::compilation::analyzer::lua) fn union_member_assignment_widening<'a>( - db: &DbIndex, - incoming_type: &LuaType, - 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); - 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 - } - }), - ) else { - return incoming_type; - }; - - TypeOps::Union.apply(db, &previous_type, &incoming_type) -} - -fn class_bootstrap_cache_state(typ: &LuaType) -> (Option, bool) { - if let Some(class_type) = prefer_class_assignment_type(typ) { - let compatible = is_class_bootstrap_compatible_type(typ, &class_type); - return (Some(class_type), compatible); - } - - (None, is_class_neutral_bootstrap_type(typ)) -} - -fn merge_class_bootstrap_cache_state( - state: &mut MemberAssignmentWideningState, - assigned_type: &LuaType, - assigned_class_type: Option, - assigned_class_compatible: bool, -) { - if !state.class_bootstrap_compatible { - return; - } - - match (&state.class_bootstrap_type, assigned_class_type) { - (_, Some(class_type)) => { - state.class_bootstrap_compatible = assigned_class_compatible - && state - .class_bootstrap_type - .as_ref() - .is_none_or(|current_class| is_same_class_type(current_class, &class_type)); - if state.class_bootstrap_compatible && state.class_bootstrap_type.is_none() { - state.class_bootstrap_type = Some(class_type); - } - } - (Some(class_type), None) => { - state.class_bootstrap_compatible = - is_class_bootstrap_compatible_type(assigned_type, class_type); - } - (None, None) => { - state.class_bootstrap_compatible = is_class_neutral_bootstrap_type(assigned_type); - } - } -} - -fn merge_assignment_types<'a>( - db: &DbIndex, - types: impl Iterator, -) -> Option { - let mut result = None; - for typ in types { - result = Some(match result { - Some(current) => TypeOps::Union.apply(db, ¤t, typ), - None => typ.clone(), - }); - } - result -} - -#[cfg(test)] -mod tests { - use rowan::{TextRange, TextSize}; - - use crate::{FileId, InFiled, db_index::LuaType}; - - use super::*; - - fn table_const(start: u32, end: u32) -> LuaType { - LuaType::TableConst(InFiled::new( - FileId::new(0), - TextRange::new(TextSize::new(start), TextSize::new(end)), - )) - } - - #[test] - fn assignment_table_literal_widening_recurses_into_union_members() { - let typ = LuaType::from_vec(vec![table_const(1, 2), LuaType::String]); - - let widened = widen_related_assignment_type(&typ, true); - - let LuaType::Union(union) = widened else { - panic!("expected widened union"); - }; - assert!(union.types().any(|typ| matches!(typ, LuaType::Table))); - assert!(union.types().any(|typ| matches!(typ, LuaType::String))); - assert!( - !union - .types() - .any(|typ| matches!(typ, LuaType::TableConst(_))) - ); - } -} diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/metatable.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/metatable.rs index 8a05532bd..d070ad933 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/metatable.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/metatable.rs @@ -25,10 +25,13 @@ pub fn analyze_setmetatable(analyzer: &mut LuaAnalyzer, call_expr: LuaCallExpr) let Some(metatable_range) = resolve_metatable_backing_table(analyzer, &metatable) else { return Some(()); }; + let writer_sort_key = writer_sort_key(analyzer); analyzer.db.get_metatable_index_mut().add( InFiled::new(file_id, table.get_range()), metatable_range.clone(), + file_id, + writer_sort_key.clone(), ); if let Some(binding) = setmetatable_factory_binding( @@ -45,10 +48,12 @@ pub fn analyze_setmetatable(analyzer: &mut LuaAnalyzer, call_expr: LuaCallExpr) } if let Some(backing_table) = resolve_metatable_backing_table(analyzer, &table) { - analyzer - .db - .get_metatable_index_mut() - .add(backing_table, metatable_range.clone()); + analyzer.db.get_metatable_index_mut().add( + backing_table, + metatable_range.clone(), + file_id, + writer_sort_key, + ); } let metatable_table = match metatable { @@ -65,6 +70,12 @@ pub fn analyze_setmetatable(analyzer: &mut LuaAnalyzer, call_expr: LuaCallExpr) Some(()) } +/// Ordering key for the writing file, so several writers of one table literal resolve the same +/// way in a cold build and an incremental session. +fn writer_sort_key(analyzer: &LuaAnalyzer) -> std::sync::Arc { + analyzer.db.get_vfs().file_order_key(&analyzer.file_id) +} + fn setmetatable_factory_binding( analyzer: &mut LuaAnalyzer, call_expr: &LuaCallExpr, 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..7f3d5c93e 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/mod.rs @@ -5,44 +5,47 @@ pub(in crate::compilation::analyzer) mod func_body; mod member_write_policy; mod metatable; mod module; -mod settled_contributions; mod stats; use rustc_hash::FxHashMap; use std::sync::Arc; use closure::analyze_closure; -pub use closure::{analyze_return_correlations, analyze_return_point}; +pub use closure::{analyze_return_point, derive_inferred_return_with_reads}; use for_range_stat::analyze_for_range_stat; pub use for_range_stat::{infer_for_range_iter_expr_func, iterates_table_member_map}; pub use func_body::LuaReturnPoint; use glua_parser::{LuaAst, LuaAstNode, LuaExpr}; +pub(in crate::compilation::analyzer) use member_write_policy::alias_target_global_path; pub(in crate::compilation::analyzer) use member_write_policy::resolve_index_expr_member_owner_for_file; use member_write_policy::{ - DynamicKeyCollectionWideningKey, MemberAssignmentWideningCacheKey, - MemberAssignmentWideningState, MemberWideningCache, + DynamicKeyCollectionWideningKey, MemberAssignmentWideningCacheKey, MemberWideningCache, }; use metatable::analyze_setmetatable; 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 use stats::is_member_assignment_in_conditional_branch; use stats::{ - analyze_assign_stat, analyze_func_stat, analyze_local_func_stat, analyze_local_stat, - analyze_table_field, flush_pending_dynamic_key_collection_widenings, + GuardedSlotMemo, analyze_assign_stat, analyze_func_stat, analyze_local_func_stat, + analyze_local_stat, analyze_table_field, flush_pending_dynamic_key_collection_widenings, }; 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, + canonical_guarded_table_bootstrap_type, resettle_guarded_table_bootstraps, +}; +pub(crate) use stats::{ + dominating_guarded_table_bootstrap_range, is_guarded_table_definition_site, +}; +pub(crate) use stats::{expr_fills_own_default, expr_reads_out_of_decl}; +pub(in crate::compilation::analyzer) use stats::{ + has_multiple_distinct_index_expr_member_owners, is_guarded_table_assignment_index_expr, + mark_resolved_member_assignment, }; use log::info; use std::time::{Duration, Instant}; use crate::{ - Emmyrc, FileId, InferFailReason, LuaDeclId, LuaMemberOwner, + Emmyrc, FileId, InferFailReason, LuaDeclId, LuaMemberId, LuaMemberKey, LuaMemberOwner, compilation::analyzer::{ AnalysisPipeline, lua::call::{analyze_call, build_special_call_direct_matcher}, @@ -75,7 +78,7 @@ impl AnalysisPipeline for LuaAnalysisPipeline { let scripted_scope_files = if gmod_enabled { context.get_or_compute_scripted_scope_files(db) } else { - Arc::new(std::collections::HashSet::new()) + Arc::new(rustc_hash::FxHashSet::default()) }; let file_dependency = db.get_file_dependencies_index().get_file_dependencies(); @@ -419,14 +422,27 @@ struct LuaAnalyzer<'a> { gmod_enabled: bool, is_scripted_class_scope: bool, special_call_direct_matcher: &'a call::SpecialCallDirectMatcher, - member_assignment_widening_cache: FxHashMap< - MemberAssignmentWideningCacheKey, - MemberWideningCache, - >, + /// Whether the last [`Self::infer_expr`] merged sibling members under a + /// computed key. + sibling_merge_read: bool, member_collection_assignment_widening_cache: FxHashMap>, pending_dynamic_key_collection_widenings: FxHashMap, guarded_table_assignment_type_cache: FxHashMap, + /// Whether each member is a guarded `x.y = x.y or {}` self-assignment. + /// + /// The answer derives from the member's own statement syntax alone, which + /// cannot change while the member is indexed (an edit forgets and + /// recreates it), and a `LuaAnalyzer` lives for one file pass over a + /// stable VFS — so each member's syntax is walked once per pass instead + /// of once per writer sharing its slot. + guarded_table_assignment_member_check_cache: FxHashMap, + /// Per-`(owner, key)` guarded-bootstrap slot memo for this file pass. + /// + /// The canonical bootstrap used to walk the whole slot on every write; N + /// same-key appends were O(N^2). The memo re-walks only appended entries, + /// so the pass costs O(N) total for identical answers. + guarded_table_assignment_slot_cache: FxHashMap<(LuaMemberOwner, LuaMemberKey), GuardedSlotMemo>, direct_local_table_member_owner_cache: FxHashMap>, literal_index_member_owner_cache: FxHashMap, /// A closure's own `return` statements (excluding nested closures'). @@ -450,10 +466,12 @@ impl LuaAnalyzer<'_> { gmod_enabled, is_scripted_class_scope, special_call_direct_matcher, - member_assignment_widening_cache: FxHashMap::default(), + sibling_merge_read: false, member_collection_assignment_widening_cache: FxHashMap::default(), pending_dynamic_key_collection_widenings: FxHashMap::default(), guarded_table_assignment_type_cache: FxHashMap::default(), + guarded_table_assignment_member_check_cache: FxHashMap::default(), + guarded_table_assignment_slot_cache: FxHashMap::default(), direct_local_table_member_owner_cache: FxHashMap::default(), literal_index_member_owner_cache: FxHashMap::default(), closure_own_returns_cache: FxHashMap::default(), @@ -469,6 +487,9 @@ impl LuaAnalyzer<'_> { impl LuaAnalyzer<'_> { pub fn infer_expr(&mut self, expr: &LuaExpr) -> Result { let cache = self.context.infer_manager.get_infer_cache(self.file_id); - infer_expr(self.db, cache, expr.clone()) + let merges_before = cache.sibling_merge_reads; + let result = infer_expr(self.db, cache, expr.clone()); + self.sibling_merge_read = cache.sibling_merge_reads > merges_before; + result } } diff --git a/crates/glua_code_analysis/src/compilation/analyzer/lua/settled_contributions.rs b/crates/glua_code_analysis/src/compilation/analyzer/lua/settled_contributions.rs deleted file mode 100644 index 8cfe6fd62..000000000 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/settled_contributions.rs +++ /dev/null @@ -1,166 +0,0 @@ -use std::collections::HashSet; - -use crate::{ - DbIndex, FileId, LuaTypeCache, LuaTypeOwner, - db_index::{LuaMemberId, LuaType, MemberAssignmentContributionKey}, -}; - -use super::member_write_policy::{ - MemberAssignmentWideningDecision, MemberAssignmentWideningState, - decide_member_assignment_widening, is_member_realm_compatible, - union_member_assignment_widening, -}; -use super::stats::is_assignment_file_define_member; - -/// Re-derives member assignment widenings from the complete writer set. -pub(in crate::compilation::analyzer) fn rederive_contributed_member_assignments( - db: &mut DbIndex, - analyzed_files: &HashSet, -) { - let store_keys = db - .get_member_index() - .member_assignment_contributions() - .keys_for_files(analyzed_files); - if store_keys.is_empty() { - return; - } - - let mut updates = Vec::new(); - for store_key in &store_keys { - collect_group_updates(db, store_key, &mut updates); - } - - for (member_id, widened_type) in updates { - db.get_type_index_mut().force_bind_type( - LuaTypeOwner::Member(member_id), - LuaTypeCache::InferType(widened_type), - ); - } -} - -/// The canonical merge for one owner/key group. -/// -/// Every answer is computed from the recorded contributions alone and applied -/// afterwards, so no member's result can depend on another's being written -/// first. -fn collect_group_updates( - db: &DbIndex, - store_key: &MemberAssignmentContributionKey, - updates: &mut Vec<(LuaMemberId, LuaType)>, -) -> Option<()> { - let member_index = db.get_member_index(); - let group = member_index - .member_assignment_contributions() - .contributions(store_key)?; - if group.len() < 2 { - return Some(()); - } - - let (owner, key) = store_key; - // The merge gives up when the group holds anything other than plain - // assignment writers. Ask the unpruned history rather than the visible - // members, which `retain_only_member_for_owner_key` has already shrunk. - if member_index - .get_current_owner_members_for_key(owner, key) - .iter() - .any(|member| !is_assignment_file_define_member(db, member.get_id())) - { - return Some(()); - } - - let mut contributions = group - .iter() - .filter(|(member_id, _)| member_index.get_member_owner(member_id) == Some(owner)) - .map(|(member_id, contribution)| (*member_id, contribution)) - .collect::>(); - if contributions.len() < 2 { - return Some(()); - } - contributions.sort_by_key(|(member_id, _)| sort_key(*member_id)); - - for (member_id, contribution) in &contributions { - // A guarded bootstrap keeps its siblings visible and runs its own - // literal-preserving merge, so it is not what the pruning destroyed. - if contribution.guarded_bootstrap || contribution.preserve_table_literals { - continue; - } - // "We could not tell" is a report about the batch, not about the write, - // so it is not evidence this pass can merge or overwrite with. - if is_uninformative(&contribution.source_type) { - continue; - } - // Only an inferred assignment cache is this pass' to rewrite: a doc type - // outranks inference, and anything else in the slot was put there by an - // authority this pass has no evidence to overrule. - let current_type = match db.get_type_index().get_type_cache(&(*member_id).into()) { - Some(cache) if cache.is_infer() => cache.as_type().clone(), - _ => continue, - }; - - // Only earlier writers are evidence for this one. The lua pass merges a - // write with the siblings that already carry a type, which is the batch's - // stand-in for "written before"; taking it from the writer set instead - // keeps the first writer as narrow as it is today. - let previous_states = contributions - .iter() - .take_while(|(other_id, _)| other_id != member_id) - .filter(|(other_id, _)| is_member_realm_compatible(db, *member_id, *other_id)) - .filter_map(|(other_id, other)| { - // The sibling's cache is the settled answer where it exists; the - // recorded contribution stands in for the writers the merge - // could not see, not for the ones it could. - let state = match db.get_type_index().get_type_cache(&(*other_id).into()) { - Some(cache) if is_uninformative(cache.as_type()) => return None, - Some(cache) => MemberAssignmentWideningState::from_type_cache(cache), - None if is_uninformative(&other.bound_type) => return None, - None => MemberAssignmentWideningState::from_assigned_type( - &other.bound_type, - other.doc_type.clone(), - ), - }; - Some(state) - }) - .collect::>(); - if previous_states.is_empty() { - continue; - } - - let widened_type = match decide_member_assignment_widening( - db, - &contribution.source_type, - true, - previous_states.iter(), - ) { - MemberAssignmentWideningDecision::Widened(widened_type) => widened_type, - MemberAssignmentWideningDecision::ClassBootstrapRejected => { - union_member_assignment_widening( - db, - &contribution.source_type, - true, - previous_states.iter(), - ) - } - MemberAssignmentWideningDecision::NoPreviousAssignments => continue, - }; - - if widened_type != current_type { - updates.push((*member_id, widened_type)); - } - } - - Some(()) -} - -/// A type that says the batch had no answer rather than what the answer is. -fn is_uninformative(typ: &LuaType) -> bool { - typ.is_unknown() || typ.is_any() -} - -fn sort_key(member_id: LuaMemberId) -> (u32, u32, u32) { - let range = member_id.get_syntax_id().get_range(); - ( - member_id.file_id.id, - range.start().into(), - range.end().into(), - ) -} 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..8f826f8ba 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/lua/stats.rs @@ -1,26 +1,27 @@ -use std::collections::HashSet; +use rustc_hash::FxHashSet; use crate::{ - CacheEntry, DbIndex, InFiled, InferFailReason, LuaMemberKey, LuaSemanticDeclId, LuaSignatureId, - LuaTypeCache, LuaTypeOwner, LuaUnionType, TypeOps, + CacheEntry, DbIndex, GlobalId, InFiled, InferFailReason, LuaMemberKey, LuaSemanticDeclId, + LuaSignatureId, LuaTypeCache, LuaTypeOwner, 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, widen_mutable_local_name_copy, + write_type_cache, }, gmod::name_expr_resolves_to_scoped_authoring_table, unresolve::{UnResolveDecl, UnResolveMember}, }, db_index::{ LuaDeclId, LuaMember, LuaMemberFeature, LuaMemberId, LuaMemberOwner, LuaType, - MemberAssignmentContribution, member_id_sort_key, + member_id_sort_key, }, - semantic::{merge_open_table_types, remove_false_or_nil}, + semantic::{merge_open_table_types, pairs_iter_value_registry_path, remove_false_or_nil}, }; use glua_parser::{ - BinaryOperator, LuaAssignStat, LuaAstNode, LuaClosureExpr, LuaExpr, LuaFuncStat, LuaIndexExpr, - LuaIndexKey, LuaLiteralToken, LuaLocalFuncStat, LuaLocalStat, LuaNameExpr, LuaSyntaxKind, - LuaTableExpr, LuaTableField, LuaVarExpr, PathTrait, + BinaryOperator, LuaAssignStat, LuaAstNode, LuaBinaryExpr, LuaClosureExpr, LuaExpr, LuaFuncStat, + LuaIfStat, LuaIndexExpr, LuaIndexKey, LuaLiteralToken, LuaLocalFuncStat, LuaLocalStat, + LuaNameExpr, LuaTableExpr, LuaTableField, LuaVarExpr, PathTrait, }; use rustc_hash::FxHashMap; @@ -30,16 +31,13 @@ use crate::{GmodStateMask, LuaArrayType}; use super::{ LuaAnalyzer, member_write_policy::{ - MemberAssignmentWideningCacheKey, MemberAssignmentWideningDecision, - MemberAssignmentWideningState, WideningCacheLookup, decide_member_assignment_widening, + MemberAssignmentWideningCacheKey, alias_target_global_path, direct_local_prefix_has_declared_type, direct_local_table_prefix_member_owner, flush_pending_dynamic_key_collection_widening_for_members, get_widened_member_assignment_collection_type, is_collection_append_write, - is_member_realm_compatible, lookup_widening_cache, member_assignment_state_mask, - member_assignment_state_masks_compatible, merge_member_assignment_widening_state, - record_member_collection_assignment_widening_cache, record_widening_cache, - resolve_index_expr_member_owner_for_file, union_member_assignment_widening, - widen_existing_member_collection_type, widen_related_assignment_type, + record_member_collection_assignment_widening_cache, + resolve_index_expr_member_owner_for_file, widen_existing_member_collection_type, + widen_related_assignment_type, }, }; @@ -88,11 +86,39 @@ 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) { + let reads_multi_decl_global = initializer_reads_through_multi_decl_global(analyzer, &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_settled_decl_initializer_reinfer(decl_id); + // The names past the expression list bind this call's extra + // return values, so their caches read through the same call and + // need the same settled re-infer. They take no type here — the + // multi-return binding does that. + if i + 1 == expr_count { + for tail_name in name_list.iter().skip(expr_count) { + let tail_decl_id = LuaDeclId::new(analyzer.file_id, tail_name.get_position()); + analyzer + .context + .request_settled_decl_initializer_reinfer(tail_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 reads_multi_decl_global { analyzer .context - .request_uninformative_local_decl_reinfer(decl_id); + .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 { @@ -197,11 +223,22 @@ 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_multi_decl_global && 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_type( + bind_decl_write( analyzer.db, - decl_id.into(), + decl_id, LuaTypeCache::InferType(expr_type), + initializer_decl_write(analyzer, decl_id, &expr), ); if retry_uninformative { let unresolve = UnResolveDecl { @@ -261,20 +298,14 @@ pub fn analyze_local_stat(analyzer: &mut LuaAnalyzer, local_stat: LuaLocalStat) let position = name.get_position(); let decl_id = LuaDeclId::new(analyzer.file_id, position); let ret_type = variadic.get_type(i - expr_count + 1); - if let Some(ret_type) = ret_type { - bind_type( - analyzer.db, - decl_id.into(), - LuaTypeCache::InferType(ret_type.clone()), - ); - } else { - write_type_cache( - analyzer.db, - decl_id.into(), - LuaTypeCache::InferType(LuaType::Nil), - TypeCacheWriteMode::InsertOnly, - ); - } + let ret_type = ret_type.cloned().unwrap_or(LuaType::Nil); + let write = initializer_decl_write(analyzer, decl_id, last_expr); + bind_decl_write( + analyzer.db, + decl_id, + LuaTypeCache::InferType(ret_type), + write, + ); } return Some(()); } else { @@ -289,7 +320,13 @@ pub fn analyze_local_stat(analyzer: &mut LuaAnalyzer, local_stat: LuaLocalStat) } else { LuaType::Any }; - bind_type(analyzer.db, decl_id.into(), LuaTypeCache::InferType(typ)); + let write = initializer_decl_write(analyzer, decl_id, last_expr); + bind_decl_write( + analyzer.db, + decl_id, + LuaTypeCache::InferType(typ), + write, + ); } return Some(()); } @@ -300,10 +337,12 @@ pub fn analyze_local_stat(analyzer: &mut LuaAnalyzer, local_stat: LuaLocalStat) let position = name.get_position(); let decl_id = LuaDeclId::new(analyzer.file_id, position); if last_expr_is_call { - bind_type( + let write = initializer_decl_write(analyzer, decl_id, last_expr); + bind_decl_write( analyzer.db, - decl_id.into(), + decl_id, LuaTypeCache::InferType(LuaType::Unknown), + write, ); } let unresolve = UnResolveDecl { @@ -419,10 +458,9 @@ fn should_defer_weak_gmod_dynamic_index_alias( fn is_weak_dynamic_index_alias_type(expr_type: &LuaType) -> bool { match expr_type { LuaType::Any | LuaType::Unknown => true, - LuaType::Union(union) => match union.as_ref() { - LuaUnionType::Nullable(inner) => inner.is_any() || inner.is_unknown(), - LuaUnionType::Multi(_) => false, - }, + LuaType::Union(union) => union + .nullable_inner() + .is_some_and(|inner| inner.is_any() || inner.is_unknown()), _ => false, } } @@ -497,10 +535,46 @@ fn get_var_owner(analyzer: &mut LuaAnalyzer, var: LuaVarExpr) -> LuaTypeOwner { } } +/// Whether the write goes through a computed index (`t[k].field = v`). +/// +/// Which entry `t[k]` names is not knowable statically, so the declaration walk +/// files such a write under the path's placeholder segment (`t.[]`). Re-homing +/// it onto whichever concrete entry the prefix *type* happened to resolve to +/// makes ownership a function of how far inference had run when the write was +/// analysed, which differs between a cold build and a warm re-index. +fn prefix_path_has_computed_segment(prefix_expr: &LuaExpr) -> bool { + let mut current = prefix_expr.clone(); + loop { + let LuaExpr::IndexExpr(index_expr) = current else { + return false; + }; + if matches!( + index_expr.get_index_key(), + Some(LuaIndexKey::Expr(_) | LuaIndexKey::Idx(_)) + ) { + return true; + } + match index_expr.get_prefix_expr() { + Some(next) => current = next, + None => return false, + } + } +} + fn set_index_expr_owner(analyzer: &mut LuaAnalyzer, var_expr: LuaVarExpr) -> Option<()> { let index_expr = LuaIndexExpr::cast(var_expr.syntax().clone())?; let prefix_expr = index_expr.get_prefix_expr()?; + if prefix_path_has_computed_segment(&prefix_expr) { + let member_id = LuaMemberId::new(index_expr.get_syntax_id(), analyzer.file_id); + if matches!( + analyzer.db.get_member_index().get_member_owner(&member_id), + Some(LuaMemberOwner::GlobalPath(_)) + ) { + return Some(()); + } + } + if let Some((member_owner, set_owner_only)) = try_resolve_scoped_class_prefix_member_owner(analyzer, &prefix_expr) { @@ -513,6 +587,16 @@ fn set_index_expr_owner(analyzer: &mut LuaAnalyzer, var_expr: LuaVarExpr) -> Opt return Some(()); } + // A write through the value variable of `for _, v in pairs(REG)` states a + // field on *every* value of `REG`, so it belongs on the registry's wildcard + // segment rather than on whichever entry the prefix type had settled to. + if let Some(path) = pairs_iter_value_registry_path(analyzer.db, analyzer.file_id, &prefix_expr) + { + let member_owner = LuaMemberOwner::GlobalPath(GlobalId::new(&path)); + apply_index_expr_member_owner(analyzer, index_expr, member_owner, false); + return Some(()); + } + if let Some(member_owner) = cached_literal_index_prefix_member_owner(analyzer, &prefix_expr) { apply_index_expr_member_owner(analyzer, index_expr, member_owner, false); return Some(()); @@ -579,10 +663,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, } } @@ -613,14 +704,14 @@ fn should_skip_ambiguous_unknown_key_table_owner( pub(in crate::compilation::analyzer) fn has_multiple_distinct_index_expr_member_owners( typ: &LuaType, ) -> bool { - let mut owners = HashSet::new(); + let mut owners = FxHashSet::default(); collect_distinct_index_expr_member_owners(typ, &mut owners); owners.len() > 1 } fn collect_distinct_index_expr_member_owners( typ: &LuaType, - owners: &mut HashSet, + owners: &mut FxHashSet, ) -> bool { match typ { LuaType::TableConst(in_file_range) => { @@ -654,14 +745,16 @@ fn collect_distinct_index_expr_member_owners( } false } - LuaType::MergedTable(merged_table) => { - for typ in merged_table.get_types() { - if collect_distinct_index_expr_member_owners(typ, owners) { - return true; - } - } - false - } + // The settled form of one slot's several bootstrap literals is one + // table, not a choice between alternatives: a write through it lands on + // the arm the writing file prefers. Counting every arm made the answer + // depend on whether the slot had settled yet -- the walk reads the one + // literal it has reached on a cold build and the merge on a re-index. + LuaType::MergedTable(merged_table) => merged_table + .get_types() + .iter() + .find_map(representative_index_expr_member_owner) + .is_some_and(|owner| insert_index_expr_member_owner(owners, owner)), LuaType::MultiLineUnion(union) => { for (typ, _) in union.get_unions() { if collect_distinct_index_expr_member_owners(typ, owners) { @@ -674,8 +767,37 @@ fn collect_distinct_index_expr_member_owners( } } +/// The first owner a type names, in the type's canonical arm order. +fn representative_index_expr_member_owner(typ: &LuaType) -> Option { + match typ { + LuaType::TableConst(in_file_range) => Some(LuaMemberOwner::Element(in_file_range.clone())), + LuaType::Def(type_id) | LuaType::Ref(type_id) => { + Some(LuaMemberOwner::Type(type_id.clone())) + } + LuaType::Instance(instance) => Some(LuaMemberOwner::Element(instance.get_range().clone())), + LuaType::TableOf(inner) => representative_index_expr_member_owner(inner), + LuaType::TypeGuard(inner) => representative_index_expr_member_owner(inner), + LuaType::Union(union) => union + .types() + .find_map(representative_index_expr_member_owner), + LuaType::Intersection(intersection) => intersection + .get_types() + .iter() + .find_map(representative_index_expr_member_owner), + LuaType::MergedTable(merged_table) => merged_table + .get_types() + .iter() + .find_map(representative_index_expr_member_owner), + LuaType::MultiLineUnion(union) => union + .get_unions() + .iter() + .find_map(|(typ, _)| representative_index_expr_member_owner(typ)), + _ => None, + } +} + fn insert_index_expr_member_owner( - owners: &mut HashSet, + owners: &mut FxHashSet, owner: LuaMemberOwner, ) -> bool { owners.insert(owner); @@ -755,6 +877,16 @@ fn apply_index_expr_member_owner_with_guarded( ) -> Option<()> { let index_key = index_expr.get_index_key()?; let member_id = LuaMemberId::new(index_expr.get_syntax_id(), analyzer.file_id); + // A prefix that is a local alias of a known global path is a path write + // for provenance purposes, so the member follows the path's `---@class` + // flips. Only computed when the owner resolved to a class: path and + // Element owners already carry their provenance through the homing note. + let alias_path = match &member_owner { + LuaMemberOwner::Type(_) => index_expr + .get_prefix_expr() + .and_then(|prefix| alias_target_global_path(&analyzer.db, analyzer.file_id, &prefix)), + _ => None, + }; if analyzer .db @@ -777,8 +909,6 @@ fn apply_index_expr_member_owner_with_guarded( LuaMemberFeature::FileDefine }; let member = LuaMember::new(member_id, member_key, decl_feature, None); - let guarded_file_define = - guarded_table_assignment && matches!(decl_feature, LuaMemberFeature::FileDefine); if guarded_table_assignment { analyzer .db @@ -786,7 +916,16 @@ fn apply_index_expr_member_owner_with_guarded( .mark_non_overwriting_assignment_member(member_id); } let member_index = analyzer.db.get_member_index_mut(); - member_index.add_member(member_owner, member); + member_index.add_member(member_owner.clone(), member); + if let Some(alias_path) = alias_path { + member_index.home_alias_member_with_provenance( + member_owner, + member_id.file_id, + member_id, + alias_path, + false, + ); + } // `add_member` already records the enclosing function scope for // `FileDefine` index-expr members (via // `assignment_file_define_scope_for_member`). For other features @@ -796,9 +935,6 @@ fn apply_index_expr_member_owner_with_guarded( .enclosing_function_scope_range(analyzer.file_id, member_id.get_position()); member_index.set_member_function_scope_range(member_id, function_scope); } - if guarded_table_assignment && !guarded_file_define { - preserve_guarded_table_assignment_members(analyzer.db, member_id); - } return Some(()); } @@ -815,22 +951,25 @@ fn apply_index_expr_member_owner_with_guarded( .enclosing_function_scope_range(analyzer.file_id, member_id.get_position()); { let member_index = analyzer.db.get_member_index_mut(); - member_index.set_member_owner(member_owner, member_id.file_id, member_id); + match alias_path { + Some(alias_path) => { + member_index.home_alias_member_with_provenance( + member_owner, + member_id.file_id, + member_id, + alias_path, + true, + ); + } + None => { + member_index.set_member_owner_only(member_owner, member_id.file_id, member_id); + } + } member_index.set_member_function_scope_range(member_id, function_scope); } - if guarded_table_assignment { - preserve_guarded_table_assignment_members(analyzer.db, member_id); - } return Some(()); } - let guarded_existing_file_define = guarded_table_assignment - && analyzer - .db - .get_member_index() - .get_member(&member_id) - .is_some_and(|member| member.get_feature() == LuaMemberFeature::FileDefine); - if guarded_table_assignment { analyzer .db @@ -846,9 +985,6 @@ fn apply_index_expr_member_owner_with_guarded( .db .get_member_index_mut() .set_member_function_scope_range(member_id, function_scope); - if guarded_table_assignment && !guarded_existing_file_define { - preserve_guarded_table_assignment_members(analyzer.db, member_id); - } Some(()) } @@ -876,6 +1012,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, @@ -889,9 +1038,27 @@ pub fn analyze_assign_stat(analyzer: &mut LuaAnalyzer, assign_stat: LuaAssignSta continue; } + if let LuaTypeOwner::Decl(decl_id) = &type_owner + && is_call_or_index_expr(expr) + && analyzer + .db + .get_decl_index() + .get_decl(decl_id) + .and_then(|decl| decl.get_initializer()) + .is_some_and(|initializer| { + initializer.get_ret_idx() == 0 + && initializer.get_expr_syntax_id() == expr.get_syntax_id() + }) + { + analyzer + .context + .request_settled_decl_initializer_reinfer(*decl_id); + } + let declared_empty_table_type = declared_empty_table_assignment_type(analyzer, &var, expr); set_index_expr_owner(analyzer, var.clone()); + analyzer.sibling_merge_read = false; let expr_type = match declared_empty_table_type .map(Ok) .unwrap_or_else(|| analyzer.infer_expr(expr)) @@ -1019,23 +1186,71 @@ pub fn analyze_assign_stat(analyzer: &mut LuaAnalyzer, assign_stat: LuaAssignSta } } - if analyzer.gmod_enabled - && matches!( - expr, - LuaExpr::CallExpr(_) | LuaExpr::IndexExpr(_) | LuaExpr::NameExpr(_) - ) - && type_contains_nominal_reference(&expr_type) - && let LuaTypeOwner::Member(member_id) = &type_owner + if let LuaTypeOwner::Member(member_id) = &type_owner + && ((analyzer.gmod_enabled + && matches!( + expr, + LuaExpr::CallExpr(_) | LuaExpr::IndexExpr(_) | LuaExpr::NameExpr(_) + ) + && type_contains_nominal_reference(&expr_type)) + || crate::compilation::analyzer::union_has_unsettled_arm(&expr_type)) { analyzer .context .request_member_initializer_reinfer(*member_id); } - let expr_type = member_assignment_or_source_type(analyzer, &type_owner, expr, expr_type); + // A read whose receiver an `if x.k then` guard narrowed answers from the + // set of types that own `k`, and that set grows as the batch walks the + // files declaring them. See `reads_field_exist_guarded_member`. + if let LuaTypeOwner::Member(member_id) = &type_owner + && reads_field_exist_guarded_member(expr) + { + analyzer + .context + .request_guarded_member_read_reinfer(*member_id); + } + if let LuaTypeOwner::Member(member_id) = &type_owner + && analyzer.sibling_merge_read + { + analyzer + .context + .request_sibling_merge_read_reinfer(*member_id); + } + + 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); + let guarded_bootstrap_canonical = 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)), + 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 + // this one shares a table with are not visible until here. The slot + // cannot have moved since `assign_merge_type_owner_and_expr_type` + // derived this same member's canonical above — binding a type, + // recording a candidate and marking writers touch neither the history + // index nor owners nor keys — so that answer is reused, not rederived. + if let LuaTypeOwner::Member(member_id) = &type_owner + && is_guarded_table_assignment_member(analyzer.db, *member_id) + && let Some(canonical) = guarded_bootstrap_canonical + { + expr_type = canonical; + } update_literal_index_member_owner_cache(analyzer, &var, &expr_type); } @@ -1050,12 +1265,32 @@ pub fn analyze_assign_stat(analyzer: &mut LuaAnalyzer, assign_stat: LuaAssignSta let var = var_list.get(i)?; let type_owner = get_var_owner(analyzer, var.clone()); set_index_expr_owner(analyzer, var.clone()); - assign_merge_type_owner_and_expr_type( + let _ = 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, + )), + 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, + )), + }, ); } } else { @@ -1063,12 +1298,32 @@ pub fn analyze_assign_stat(analyzer: &mut LuaAnalyzer, assign_stat: LuaAssignSta let var = var_list.get(i)?; let type_owner = get_var_owner(analyzer, var.clone()); set_index_expr_owner(analyzer, var.clone()); - assign_merge_type_owner_and_expr_type( + let _ = 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, + )), + 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, + )), + }, ); } } @@ -1260,6 +1515,21 @@ fn should_skip_nil_table_shape_assignment( return false; }; + let Some(index_key) = index_expr.get_index_key() else { + return false; + }; + let cache = analyzer + .context + .infer_manager + .get_infer_cache(analyzer.file_id); + let member_key = match LuaMemberKey::from_index_key_or_unknown(analyzer.db, cache, &index_key) { + Ok(member_key) => member_key, + Err(_) => return index_key.is_expr(), + }; + if member_key.is_expr() { + return true; + } + let Some(prefix_expr) = index_expr.get_prefix_expr() else { return false; }; @@ -1268,6 +1538,15 @@ 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; + } + if !is_table_shape_cleanup_type(&prefix_type) { return false; } @@ -1282,22 +1561,6 @@ fn should_skip_nil_table_shape_assignment( return false; }; - let Some(index_key) = index_expr.get_index_key() else { - return false; - }; - - let cache = analyzer - .context - .infer_manager - .get_infer_cache(analyzer.file_id); - let Ok(member_key) = LuaMemberKey::from_index_key_or_unknown(analyzer.db, cache, &index_key) - else { - return false; - }; - if member_key.is_expr() { - return true; - } - let member_id = LuaMemberId::new(index_expr.get_syntax_id(), analyzer.file_id); !analyzer .db @@ -1417,10 +1680,140 @@ 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) +} + +fn initializer_decl_write(analyzer: &LuaAnalyzer, decl_id: LuaDeclId, expr: &LuaExpr) -> DeclWrite { + let may_improve = may_improve_after_resolve(expr); + DeclWrite { + position: expr.get_position(), + may_improve_after_resolve: may_improve, + reads_out_of_decl: expr_reads_out_of_decl(analyzer.db, analyzer.file_id, decl_id, expr), + may_narrow_uninformative: may_improve, + resolved_initializer: false, + fills_own_default: expr_fills_own_default(analyzer.db, analyzer.file_id, decl_id, expr), + } +} + +/// Whether `expr` reads an access path that an enclosing `if` tested for +/// existence. +/// +/// `if self.k then ... self.k ... end` narrows the receiver to the types that +/// own `k`, which `collect_field_exist_narrow_candidates` reads straight off the +/// member index. Which of those owners have been walked when the read is taken +/// is a property of how far the batch has run, not of the source, so the answer +/// is provisional until every file declaring `k` has landed. +/// +/// Syntax only -- the paths are compared as written, so this costs one ancestor +/// walk per member assignment and never asks for a type. +fn reads_field_exist_guarded_member(expr: &LuaExpr) -> bool { + let read_paths = expr + .descendants::() + .filter_map(|index_expr| index_expr.get_access_path()) + .collect::>(); + if read_paths.is_empty() { + return false; + } + + expr.ancestors::().any(|if_stat| { + if_stat + .get_condition_expr() + .into_iter() + .chain( + if_stat + .get_all_clause() + .filter_map(|clause| clause.get_condition_expr()), + ) + .any(|condition| { + condition + .descendants::() + .filter_map(|index_expr| index_expr.get_access_path()) + .any(|guarded| read_paths.contains(&guarded)) + }) + }) +} + +/// 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() +} + +/// 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 { + analyzer.context.record_vgui_parent_fallback_file(file_id); + } +} + +/// 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`. +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 { - 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 @@ -1522,13 +1915,48 @@ fn should_defer_pending_local_alias( analyzer.context.has_pending_decl_unresolve(decl_id) } +/// 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) +} + /// 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. -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; } @@ -1540,11 +1968,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 { @@ -1553,10 +1986,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); } @@ -1581,7 +2013,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( @@ -1598,7 +2030,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; } @@ -1655,7 +2087,8 @@ fn assign_merge_type_owner_and_expr_type( expr_type: &LuaType, idx: usize, preserve_table_literals: bool, -) -> Option<()> { + write: DeclWrite, +) -> Option { let mut expr_type = expr_type.clone(); if let LuaType::Variadic(multi) = expr_type { expr_type = multi.get_type(idx).unwrap_or(&LuaType::Nil).clone(); @@ -1674,129 +2107,101 @@ fn assign_merge_type_owner_and_expr_type( expr_type = bootstrap_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; - if !dynamic_expr_key_member { - if let Some(widened_type) = - get_widened_member_assignment_collection_type(analyzer, &type_owner, &expr_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) + if is_guarded_table_assignment_member(analyzer.db, *member_id) => { - expr_type = widened_type; - } - if matches!(type_owner, LuaTypeOwner::Member(_)) { - source_type = Some(expr_type.clone()); + canonical_guarded_table_bootstrap_type( + analyzer.db, + *member_id, + Some(GuardedTableAssignmentCaches { + member_check: &mut analyzer.guarded_table_assignment_member_check_cache, + slot: &mut analyzer.guarded_table_assignment_slot_cache, + }), + ) } + _ => None, + }; - match get_cached_widened_member_assignment_type( - analyzer, - &type_owner, - &expr_type, - preserve_table_literals, - ) { - Some(Some(widened_type)) => { - expr_type = widened_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, - &type_owner, - &expr_type, - preserve_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, - expr_type.clone(), - preserve_table_literals, - ); - } - if let Some(widened_type) = widened { - expr_type = widened_type; - } - } - } + // 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); + // Returned for the post-merge re-derivation at the assign site, which + // would otherwise walk the same unchanged slot a second time. + let canonical_used = canonical_guarded_bootstrap.clone(); + if let Some(canonical) = canonical_guarded_bootstrap { + expr_type = canonical; + } else if !dynamic_expr_key_member + && let Some(widened_type) = + get_widened_member_assignment_collection_type(analyzer, &type_owner, &expr_type) + { + expr_type = widened_type; } if is_global_decl_owner(analyzer, &type_owner) { 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) { let guarded_table_assignment = preserve_table_literals || is_guarded_table_assignment_member(analyzer.db, *member_id); - let conditional_branch_assignment = - is_member_assignment_in_conditional_branch(analyzer.db, *member_id); - if !dynamic_expr_key_member { - record_member_assignment_contribution( - analyzer, - *member_id, - &expr_type, - source_type, - guarded_table_assignment, - preserve_table_literals, - ); - } if guarded_table_assignment { - let already_preserved = analyzer - .db - .get_member_index() - .is_non_overwriting_assignment_member(*member_id); - if !already_preserved { - analyzer - .db - .get_member_index_mut() - .mark_non_overwriting_assignment_member(*member_id); - preserve_guarded_table_assignment_members(analyzer.db, *member_id); - } - } else if conditional_branch_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 - .db - .get_member_index_mut() - .mark_conditional_branch_assignment_member(*member_id); - } else if !dynamic_expr_key_member - && analyzer - .db - .get_member_index() - .member_function_scope_range(*member_id) - .is_none() + .context + .record_settled_guarded_bootstrap_candidate(*member_id); + } + if guarded_table_assignment + || is_member_assignment_in_conditional_branch(analyzer.db, *member_id) { analyzer .db .get_member_index_mut() - .retain_only_member_for_owner_key(*member_id); + .mark_non_overwriting_assignment_member(*member_id); } } if !dynamic_expr_key_member { - record_member_assignment_widening_cache( - analyzer, - &type_owner, - &expr_type, - preserve_table_literals, - ); record_member_collection_assignment_widening_cache(analyzer, &type_owner, &expr_type); } - Some(()) + canonical_used } fn member_assignment_or_source_type( @@ -1808,6 +2213,8 @@ fn member_assignment_or_source_type( if !matches!(type_owner, LuaTypeOwner::Member(_)) { return fallback_type; } + let fallback_type = + widen_mutable_local_name_copy(analyzer.db, analyzer.file_id, expr, fallback_type); let Some(arms) = top_level_or_expr_arms(expr) else { return fallback_type; @@ -1872,6 +2279,12 @@ fn collect_or_expr_arms(expr: &LuaExpr, arms: &mut Vec) -> Option<()> { Some(()) } +/// Whether the write's key is computed. +/// +/// Read off the syntax, not off the member's key: a computed key whose +/// expression the walk could resolve is minted under the name it resolved +/// to, and whether it could resolve is a property of how far the batch had +/// got when the write was walked. fn is_dynamic_expr_key_member_assignment( analyzer: &LuaAnalyzer, type_owner: &LuaTypeOwner, @@ -1879,11 +2292,20 @@ fn is_dynamic_expr_key_member_assignment( let LuaTypeOwner::Member(member_id) = type_owner else { return false; }; - analyzer + let Some(root) = analyzer .db - .get_member_index() - .get_member(member_id) - .is_some_and(|member| member.get_key().is_expr()) + .get_vfs() + .get_syntax_tree(&member_id.file_id) + .map(|tree| tree.get_red_root()) + else { + return false; + }; + member_id + .get_syntax_id() + .to_node_from_root(&root) + .and_then(LuaIndexExpr::cast) + .and_then(|index_expr| index_expr.get_index_key()) + .is_some_and(|key| matches!(key, LuaIndexKey::Expr(_))) } fn is_global_decl_owner(analyzer: &LuaAnalyzer, type_owner: &LuaTypeOwner) -> bool { @@ -1898,140 +2320,6 @@ fn is_global_decl_owner(analyzer: &LuaAnalyzer, type_owner: &LuaTypeOwner) -> bo .is_some_and(|decl| decl.is_global()) } -fn get_cached_widened_member_assignment_type( - analyzer: &mut LuaAnalyzer, - type_owner: &LuaTypeOwner, - incoming_type: &LuaType, - _preserve_table_literals: bool, -) -> Option> { - let LuaTypeOwner::Member(member_id) = type_owner else { - return None; - }; - if !is_assignment_file_define_member(analyzer.db, *member_id) { - return None; - } - - let member_index = analyzer.db.get_member_index(); - let owner = member_index.get_member_owner(member_id)?.clone(); - let key = member_index.get_member(member_id)?.get_key().clone(); - let visible_count = member_index.visible_member_count_for_owner_key(&owner, &key); - let cache_key = MemberAssignmentWideningCacheKey { owner, key }; - - let cache = match lookup_widening_cache( - &analyzer.member_assignment_widening_cache, - &cache_key, - visible_count, - ) { - WideningCacheLookup::FirstSighting => return Some(None), - WideningCacheLookup::Fallback => return None, - WideningCacheLookup::Hit(cache) => cache, - }; - - let current_state_mask = member_assignment_state_mask(analyzer, *member_id); - let compatible_states = cache - .by_state_mask - .iter() - .filter(|(state_mask, _)| { - member_assignment_state_masks_compatible(analyzer, current_state_mask, **state_mask) - }) - .map(|(_, state)| state.clone()) - .collect::>(); - if compatible_states.is_empty() { - return Some(None); - } - - match decide_member_assignment_widening( - analyzer.db, - incoming_type, - true, - compatible_states.iter(), - ) { - MemberAssignmentWideningDecision::Widened(widened_type) => Some(Some(widened_type)), - MemberAssignmentWideningDecision::ClassBootstrapRejected => None, - MemberAssignmentWideningDecision::NoPreviousAssignments => Some(None), - } -} - -/// 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, - bound_type: &LuaType, - source_type: Option, - guarded_bootstrap: bool, - preserve_table_literals: bool, -) { - record_member_assignment_contribution_in( - analyzer.db, - member_id, - bound_type, - source_type, - guarded_bootstrap, - preserve_table_literals, - ); -} - -fn record_member_assignment_contribution_in( - db: &mut DbIndex, - member_id: LuaMemberId, - bound_type: &LuaType, - source_type: Option, - guarded_bootstrap: bool, - preserve_table_literals: bool, -) { - let doc_type = db - .get_type_index() - .get_type_cache(&member_id.into()) - .filter(|cache| cache.is_doc()) - .map(|cache| cache.as_type().clone()); - let contribution = MemberAssignmentContribution { - bound_type: bound_type.clone(), - source_type: source_type.unwrap_or_else(|| bound_type.clone()), - doc_type, - guarded_bootstrap, - preserve_table_literals, - }; - db.get_member_index_mut() - .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. -pub(in crate::compilation::analyzer) fn record_resolved_member_assignment_contribution( - db: &mut DbIndex, - member_id: LuaMemberId, - bound_type: &LuaType, -) { - if !is_assignment_file_define_member(db, member_id) { - return; - } - if db - .get_member_index() - .member_assignment_contributions() - .contribution_of(&member_id) - .is_some() - { - return; - } - let guarded_bootstrap = is_guarded_table_assignment_member(db, member_id); - record_member_assignment_contribution_in( - db, - member_id, - bound_type, - None, - guarded_bootstrap, - false, - ); -} - /// Applies the visibility marks a write earns from its own syntax, for a write /// the walk did not get to classify. /// @@ -2049,97 +2337,17 @@ pub(in crate::compilation::analyzer) fn mark_resolved_member_assignment( if !is_assignment_file_define_member(db, member_id) { return; } - if is_guarded_table_assignment_member(db, member_id) { - if !db - .get_member_index() - .is_non_overwriting_assignment_member(member_id) - { - db.get_member_index_mut() - .mark_non_overwriting_assignment_member(member_id); - preserve_guarded_table_assignment_members(db, member_id); - } - } else if is_member_assignment_in_conditional_branch(db, member_id) { + if is_guarded_table_assignment_member(db, member_id) + || is_member_assignment_in_conditional_branch(db, member_id) + { db.get_member_index_mut() - .mark_conditional_branch_assignment_member(member_id); - } -} - -fn record_member_assignment_widening_cache( - analyzer: &mut LuaAnalyzer, - type_owner: &LuaTypeOwner, - assigned_type: &LuaType, - _preserve_table_literals: bool, -) { - let LuaTypeOwner::Member(member_id) = type_owner else { - return; - }; - if !is_assignment_file_define_member(analyzer.db, *member_id) { - return; + .mark_non_overwriting_assignment_member(member_id); } - - let member_index = analyzer.db.get_member_index(); - let Some(owner) = member_index.get_member_owner(member_id).cloned() else { - return; - }; - let Some(key) = member_index - .get_member(member_id) - .map(|member| member.get_key().clone()) - else { - return; - }; - let visible_count = member_index.visible_member_count_for_owner_key(&owner, &key); - let state_mask = member_assignment_state_mask(analyzer, *member_id); - let cache_key = MemberAssignmentWideningCacheKey { owner, key }; - let doc_type = analyzer - .db - .get_type_index() - .get_type_cache(&(*member_id).into()) - .filter(|cache| cache.is_doc()) - .map(|cache| cache.as_type().clone()); - let new_state = MemberAssignmentWideningState::from_assigned_type(assigned_type, doc_type); - let db = &*analyzer.db; - record_widening_cache( - &mut analyzer.member_assignment_widening_cache, - cache_key, - visible_count, - state_mask, - new_state, - |state, new_state| { - merge_member_assignment_widening_state(db, state, new_state, assigned_type); - }, - ); -} - -pub(in crate::compilation::analyzer) fn preserve_guarded_table_assignment_members( - db: &mut DbIndex, - member_id: LuaMemberId, -) { - let Some(member_ids) = guarded_table_assignment_member_ids_for_owner_key(db, member_id) else { - return; - }; - - db.get_member_index_mut() - .preserve_members_for_owner_key(member_id, member_ids); } -/// 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 { +/// Whether this write sits inside a branch or loop body, so it may not run +/// and does not overwrite the writers before it in the same scope. +pub 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; }; @@ -2151,154 +2359,17 @@ fn is_member_assignment_in_conditional_branch(db: &DbIndex, member_id: LuaMember node.ancestors().any(|ancestor| { matches!( ancestor.kind().into(), - LuaSyntaxKind::IfStat - | LuaSyntaxKind::ElseIfClauseStat - | LuaSyntaxKind::ElseClauseStat - | LuaSyntaxKind::WhileStat - | LuaSyntaxKind::RepeatStat - | LuaSyntaxKind::ForStat - | LuaSyntaxKind::ForRangeStat + glua_parser::LuaSyntaxKind::IfStat + | glua_parser::LuaSyntaxKind::ElseIfClauseStat + | glua_parser::LuaSyntaxKind::ElseClauseStat + | glua_parser::LuaSyntaxKind::WhileStat + | glua_parser::LuaSyntaxKind::RepeatStat + | glua_parser::LuaSyntaxKind::ForStat + | glua_parser::LuaSyntaxKind::ForRangeStat ) }) } -fn guarded_table_assignment_member_ids_for_owner_key( - db: &DbIndex, - member_id: LuaMemberId, -) -> Option> { - let member_index = db.get_member_index(); - 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(); - - 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) { - return None; - } - - member_ids.push(related_member_id); - } - - (member_ids.len() >= 2).then_some(member_ids) -} - -/// Widens a member assignment against its same-owner/key siblings. -pub(in crate::compilation::analyzer) fn get_widened_member_assignment_type( - db: &DbIndex, - type_owner: &LuaTypeOwner, - incoming_type: &LuaType, - preserve_table_literals: bool, - skipped_uncached_sibling: &mut bool, -) -> Option { - let LuaTypeOwner::Member(member_id) = type_owner else { - return None; - }; - if !is_assignment_file_define_member(db, *member_id) { - return None; - } - - let member_index = db.get_member_index(); - let owner = member_index.get_member_owner(member_id)?.clone(); - let key = member_index.get_member(member_id)?.get_key().clone(); - let related_members = if preserve_table_literals { - let related_member_ids = guarded_table_assignment_member_ids_for_owner_key(db, *member_id)?; - related_member_ids - .into_iter() - .filter_map(|related_member_id| member_index.get_member(&related_member_id)) - .collect() - } else { - member_index.get_members_for_owner_key(&owner, &key) - }; - if related_members.len() < 2 { - return None; - } - - let mut previous_states = Vec::new(); - let mut saw_previous_assignment = false; - - for related_member in related_members { - let related_member_id = related_member.get_id(); - 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) - { - continue; - } - if !is_member_realm_compatible(db, *member_id, related_member_id) { - continue; - } - saw_previous_assignment = true; - - if !is_assignment_file_define_member(db, related_member_id) { - return None; - } - - let existing_state = match db - .get_type_index() - .get_type_cache(&related_member_id.into()) - .cloned() - { - Some(existing_cache) => MemberAssignmentWideningState::from_type_cache(&existing_cache), - None => match guarded_table_bootstrap_member_type(db, related_member_id, false) { - Some(bootstrap_type) => { - MemberAssignmentWideningState::from_assigned_type(&bootstrap_type, None) - } - None => { - *skipped_uncached_sibling = true; - continue; - } - }, - }; - - previous_states.push(existing_state); - } - - if !saw_previous_assignment { - return None; - } - - let widened_type = match decide_member_assignment_widening( - db, - incoming_type, - !preserve_table_literals, - previous_states.iter(), - ) { - MemberAssignmentWideningDecision::Widened(widened_type) => widened_type, - MemberAssignmentWideningDecision::ClassBootstrapRejected => { - union_member_assignment_widening( - db, - incoming_type, - !preserve_table_literals, - 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, - }; - - Some(if preserve_table_literals { - crate::prune_redundant_guarded_table_bootstrap_type(db, widened_type) - } else { - widened_type - }) -} - pub(super) fn flush_pending_dynamic_key_collection_widenings(analyzer: &mut LuaAnalyzer) { let pending = std::mem::take(&mut analyzer.pending_dynamic_key_collection_widenings); let mut pending_by_owner: FxHashMap> = @@ -2325,10 +2396,7 @@ pub(super) fn is_assignment_file_define_member( ) -> bool { db.get_member_index() .get_member(&member_id) - .is_some_and(|member| { - member.get_feature() == LuaMemberFeature::FileDefine - && member.get_syntax_id().get_kind() == glua_parser::LuaSyntaxKind::IndexExpr - }) + .is_some_and(crate::LuaMember::is_assignment_define) } pub(in crate::compilation::analyzer) fn is_guarded_table_assignment_member( @@ -2342,11 +2410,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( @@ -2355,6 +2419,108 @@ pub(in crate::compilation::analyzer) fn is_guarded_table_assignment_index_expr( guarded_table_assignment_bootstrap_range(index_expr, false).is_some() } +pub(crate) fn is_guarded_table_definition_site( + db: &DbIndex, + site: &InFiled, +) -> bool { + let Some(tree) = db.get_vfs().get_syntax_tree(&site.file_id) else { + return false; + }; + let root = tree.get_red_root(); + let mut node = match root.covering_element(site.value) { + rowan::NodeOrToken::Node(node) => Some(node), + rowan::NodeOrToken::Token(token) => token.parent(), + }; + + while let Some(current) = node { + if current.text_range() == site.value && LuaTableExpr::cast(current.clone()).is_some() { + node = Some(current); + break; + } + if !current.text_range().contains_range(site.value) { + return false; + } + node = current.parent(); + } + + let Some(mut node) = node else { + return false; + }; + while let Some(parent) = node.parent() { + if LuaClosureExpr::cast(parent.clone()).is_some() { + return false; + } + if let Some(binary_expr) = LuaBinaryExpr::cast(parent.clone()) + && let Some(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()); + if access_path.is_some_and(|access_path| { + guarded_assignment_table_arm_range( + &LuaExpr::BinaryExpr(binary_expr), + &access_path, + false, + ) + .is_some_and(|guarded_range| guarded_range.contains_range(site.value)) + }) { + return true; + } + } + node = parent; + } + + false +} + +/// 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. @@ -2448,14 +2614,348 @@ fn guarded_table_bootstrap_member_type( 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)?; + 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(); + guarded_bootstrap_range_for_node( + member_id.get_syntax_id().to_node_from_root(&root)?, + empty_only, + ) +} + +/// 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(first) = members.first().copied() else { + continue; + }; + let Some(canonical) = canonical_guarded_table_bootstrap_type(db, first, None) 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, None).unwrap_or(members); + 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, + ); + } + } +} + +/// Per-`(owner, key)` memo for +/// [`canonical_guarded_table_bootstrap_type`]'s per-write path. +/// +/// One [`LuaAnalyzer`](super::LuaAnalyzer) lives for one file pass, and within +/// that pass a guarded slot only grows by append: removals, rekeys and +/// definition-site remaps run at batch boundaries with no analyzer alive; +/// rekeying only touches `Expr` keys while guarded bootstraps file under name +/// keys; and any owner move files the member under a different canonical slot, +/// which misses this memo instead of reading it. Guardedness is a pure +/// function of the member's own immutable syntax, and the canonical type a +/// pure function of the minimum member's syntax, so re-walking only the entries +/// appended since the last visit answers exactly what a full walk would. A hit +/// additionally verifies the member that decides the answer (the minimum while +/// clean, the first non-guard once poisoned) is still homed; any other doubt — +/// a shorter history than recorded, or a flown decider — rebuilds from +/// scratch. Appends can never un-poison a slot, so a poisoned memo only +/// advances its length and never walks again. +/// +/// Homing caveat: the history index keeps ids a later owner move left behind, +/// so neither the raw length nor the decider check above proves the filtered +/// set is unchanged — a rehomed-away writer leaves the length identical, and a +/// rehomed-back writer appends nothing when its id is already listed. The memo +/// therefore records [`LuaMemberIndex::homing_revision`] at walk time and +/// suffix-folds only on a revision match; any mismatch rebuilds from scratch. +/// Pure appends (`add_member` first filings, `add_member_to_owner`, +/// `add_member_alias_to_owner`) leave the revision alone, so the O(N) suffix +/// path survives them. +#[derive(Debug, Clone)] +pub(in crate::compilation::analyzer) struct GuardedSlotMemo { + history_len: usize, + filtered_count: usize, + all_guarded: bool, + min_member: Option, + poison: Option, + canonical: Option, + homing_revision: u64, +} + +/// The per-pass caches +/// [`canonical_guarded_table_bootstrap_type`] threads through its per-write +/// path. Settle-phase callers pass `None` and keep the full walk. +pub(in crate::compilation::analyzer) struct GuardedTableAssignmentCaches<'a> { + pub member_check: &'a mut FxHashMap, + pub slot: &'a mut FxHashMap<(LuaMemberOwner, LuaMemberKey), GuardedSlotMemo>, +} + +/// Every writer of this member's slot when all of them are `x.y = x.y or {}` +/// guards. +/// +/// Walks the history slot in insertion order with no sort: the callers only +/// need the set (one takes its minimum, the other overwrites each member), +/// and sorting it on every write was O(k log k) per write. Bails on the +/// first non-guard, so the outcome never depends on the walk order. +fn guarded_table_assignment_member_ids_for_owner_key( + db: &DbIndex, + member_id: LuaMemberId, + mut guard_cache: Option<&mut FxHashMap>, +) -> Option> { + let member_index = db.get_member_index(); + 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(); + + for related_member_id in + member_index.get_current_owner_member_ids_for_key_unsorted(&owner, &key) + { + let guarded = if let Some(cache) = guard_cache.as_mut() { + if let Some(&hit) = cache.get(&related_member_id) { + hit + } else { + let guarded = is_guarded_table_assignment_member(db, related_member_id); + cache.insert(related_member_id, guarded); + guarded + } + } else { + is_guarded_table_assignment_member(db, related_member_id) + }; + if !guarded { + return None; + } + + member_ids.push(related_member_id); + } + + (member_ids.len() >= 2).then_some(member_ids) +} + +/// 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. +pub(in crate::compilation::analyzer) fn canonical_guarded_table_bootstrap_type( + db: &crate::DbIndex, + member_id: LuaMemberId, + caches: Option>, +) -> Option { + if let Some(caches) = caches { + return memoized_canonical_guarded_table_bootstrap_type( + db, + member_id, + caches.member_check, + caches.slot, + ); + } + // No second guard filter: the walk above only returns when every writer + // passed, so filtering again would re-walk each member's syntax. + let canonical = guarded_table_assignment_member_ids_for_owner_key(db, member_id, None)? + .into_iter() + .min_by_key(|candidate| member_id_sort_key(*candidate))?; + + guarded_table_bootstrap_member_type(db, canonical, false) +} + +/// Per-write [`canonical_guarded_table_bootstrap_type`]: same answer as the +/// full walk, visiting only the history entries appended since the last call +/// for this slot. Each member is guard-checked once per pass and the minimum +/// is folded incrementally, so N same-key appends cost O(N) total. +fn memoized_canonical_guarded_table_bootstrap_type( + db: &crate::DbIndex, + member_id: LuaMemberId, + member_check: &mut FxHashMap, + slot_cache: &mut FxHashMap<(LuaMemberOwner, LuaMemberKey), GuardedSlotMemo>, +) -> Option { + let member_index = db.get_member_index(); + let owner = member_index.canonical_owner(member_index.get_member_owner(&member_id)?.clone()); + let key = member_index.get_member(&member_id)?.get_key().clone(); + let history_len = member_index.owner_key_history_len(&owner, &key); + let homing_revision = member_index.homing_revision(); + let slot = (owner.clone(), key.clone()); + + let homed = |db: &crate::DbIndex, id: LuaMemberId| { + db.get_member_index() + .get_member_owner(&id) + .is_some_and(|current| *current == owner) + && db + .get_member_index() + .get_member(&id) + .is_some_and(|member| member.get_key() == &key) + }; + + let prior = slot_cache.get(&slot).cloned(); + // Fast path: nothing appended since the last visit, nothing rehomed under + // it (revision match), and the member that decides the answer is still + // homed under this slot. + if let Some(memo) = &prior + && memo.history_len == history_len + && memo.homing_revision == homing_revision + && memo + .all_guarded + .then_some(memo.min_member) + .flatten() + .or(memo.poison) + .is_none_or(|decider| homed(db, decider)) + { + return memo.canonical.clone(); + } + + // Poisoned slots stay poisoned while the first non-guard is still homed: + // appends cannot remove it, so only the length advances — no walk. Still + // gated on the homing revision: a move under the slot could have removed + // the poison and re-added history entries at the same raw length, which + // the homed check alone cannot see when the poison itself stayed. + if let Some(memo) = &prior + && !memo.all_guarded + && memo.history_len < history_len + && memo.homing_revision == homing_revision + && memo.poison.is_some_and(|poison| homed(db, poison)) + { + if let Some(memo) = slot_cache.get_mut(&slot) { + memo.history_len = history_len; + } + return None; + } + + // Trusted prefix (a still-clean slot that only grew by append with no + // rehoming under it: the suffix holds exactly the appended members) or a + // rebuild from scratch on any doubt. + let (skip, mut all_guarded, mut filtered_count, mut min_member, base_min, base_canonical) = + match &prior { + Some(memo) + if memo.all_guarded + && memo.history_len < history_len + && memo.homing_revision == homing_revision => + { + ( + memo.history_len, + true, + memo.filtered_count, + memo.min_member, + memo.min_member, + memo.canonical.clone(), + ) + } + _ => (0, true, 0, None, None, None), + }; + let mut poison: Option = None; + + for suffix_id in db + .get_member_index() + .get_current_owner_member_ids_for_key_unsorted_from(&owner, &key, skip) + { + filtered_count += 1; + let guarded = if let Some(&hit) = member_check.get(&suffix_id) { + hit + } else { + let guarded = is_guarded_table_assignment_member(db, suffix_id); + member_check.insert(suffix_id, guarded); + guarded + }; + if guarded { + min_member = Some(match min_member { + Some(min) if member_id_sort_key(min) <= member_id_sort_key(suffix_id) => min, + _ => suffix_id, + }); + } else { + all_guarded = false; + if poison.is_none() { + poison = Some(suffix_id); + } + } + } + + let canonical = if !all_guarded || filtered_count < 2 { + None + } else { + match min_member { + // Reuse only a real answer: a `None` base means the gate above + // failed last time (fewer than two writers) or the bootstrap + // lookup did, so the same minimum needs a fresh derivation now. + Some(min) if Some(min) == base_min && base_canonical.is_some() => base_canonical, + Some(min) => guarded_table_bootstrap_member_type(db, min, false), + None => None, + } + }; + + slot_cache.insert( + slot, + GuardedSlotMemo { + history_len, + filtered_count, + all_guarded, + min_member, + poison, + canonical: canonical.clone(), + homing_revision, + }, + ); + canonical +} + fn merge_type_owner_and_unresolve_expr( analyzer: &mut LuaAnalyzer, type_owner: LuaTypeOwner, @@ -2594,7 +3094,13 @@ pub fn analyze_table_field(analyzer: &mut LuaAnalyzer, field: LuaTableField) -> if field.is_assign_field() || is_shaped_array_value_field(&field) { let value_expr = field.get_value_expr()?; let member_id = LuaMemberId::new(field.get_syntax_id(), analyzer.file_id); - let value_type = match analyzer.infer_expr(&value_expr.clone()) { + let inferred = analyzer.infer_expr(&value_expr.clone()); + if analyzer.sibling_merge_read { + analyzer + .context + .request_sibling_merge_read_reinfer(member_id); + } + let value_type = match inferred { Ok(value_type) => match value_type { LuaType::Def(ref_id) => LuaType::Ref(ref_id), other => { @@ -2706,12 +3212,20 @@ fn special_assign_pattern( } } - assign_merge_type_owner_and_expr_type( + let _ = assign_merge_type_owner_and_expr_type( analyzer, type_owner, &expr_type, 0, guarded_table_expr, + DeclWrite { + position: assign_stat_range.start(), + may_improve_after_resolve: false, + reads_out_of_decl: false, + may_narrow_uninformative: false, + resolved_initializer: false, + fills_own_default: false, + }, ); } Err(_) => return None, @@ -2935,7 +3449,7 @@ mod tests { use glua_parser::LuaSyntaxId; use rowan::{TextRange, TextSize}; - use crate::{DbIndex, FileId, InFiled, LuaMergedTableType, LuaTypeDeclId, LuaUnionType}; + use crate::{DbIndex, FileId, InFiled, LuaMergedTableType, LuaUnionType}; use super::*; @@ -2947,559 +3461,96 @@ 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"; - let tree = glua_parser::LuaParser::parse(source, glua_parser::ParserConfig::default()); - - let ranges = tree - .get_chunk_node() - .descendants::() - .filter_map(|index_expr| guarded_table_assignment_bootstrap_range(&index_expr, false)) - .collect::>(); - - assert_eq!(ranges.len(), 1, "only the bootstrap assignment qualifies"); - assert_eq!(&source[ranges[0]], "{}"); - } - - fn member_id_at(start: u32) -> LuaMemberId { - member_id_at_file(FileId::new(0), start) - } - - fn member_id_at_file(file_id: FileId, start: u32) -> LuaMemberId { - let range = TextRange::new(TextSize::new(start), TextSize::new(start + 1)); - LuaMemberId::new( - LuaSyntaxId::new(LuaSyntaxKind::IndexExpr.into(), range), - file_id, - ) - } - - fn add_typed_file_define_member( - db: &mut DbIndex, - owner: LuaMemberOwner, - member_id: LuaMemberId, - key: LuaMemberKey, - typ: LuaType, - ) { - db.get_member_index_mut().add_member( - owner, - LuaMember::new(member_id, key, LuaMemberFeature::FileDefine, None), - ); - db.get_type_index_mut().bind_type( - LuaTypeOwner::Member(member_id), - LuaTypeCache::InferType(typ), - ); - } - - fn with_analyzer(db: &mut DbIndex, run: impl FnOnce(&mut LuaAnalyzer<'_>) -> T) -> T { - with_analyzer_config(db, false, run) - } - - fn with_analyzer_config( - db: &mut DbIndex, - gmod_enabled: bool, - run: impl FnOnce(&mut LuaAnalyzer<'_>) -> T, - ) -> T { - let mut context = crate::compilation::analyzer::AnalyzeContext::new(); - let matcher = super::super::call::SpecialCallDirectMatcher::default(); - let mut analyzer = LuaAnalyzer::new( - db, - FileId::new(0), - &mut context, - gmod_enabled, - false, - &matcher, - ); - run(&mut analyzer) - } - - #[test] - fn duplicate_table_owner_is_not_ambiguous() { - let table = table_const(1, 2); - let typ = LuaMergedTableType::new(vec![table.clone(), table]).into(); - - assert!(!has_multiple_distinct_index_expr_member_owners(&typ)); - } - - #[test] - fn distinct_table_owners_are_ambiguous() { - let typ = LuaType::Union( - LuaUnionType::from_vec(vec![table_const(1, 2), table_const(3, 4)]).into(), - ); - - assert!(has_multiple_distinct_index_expr_member_owners(&typ)); - } - - #[test] - fn member_assignment_widening_uses_cache_for_sequential_same_key_members() { - let mut db = DbIndex::new(); - let owner = LuaMemberOwner::Element(InFiled::new( - FileId::new(0), - TextRange::new(TextSize::new(10), TextSize::new(11)), - )); - let key = LuaMemberKey::from("field"); - let first_member = member_id_at(1); - let second_member = member_id_at(3); - add_typed_file_define_member( - &mut db, - owner.clone(), - first_member, - key.clone(), - LuaType::Integer, - ); - - with_analyzer(&mut db, |analyzer| { - record_member_assignment_widening_cache( - analyzer, - &LuaTypeOwner::Member(first_member), - &LuaType::Integer, - false, - ); - add_typed_file_define_member(analyzer.db, owner, second_member, key, LuaType::String); - - let widened = get_cached_widened_member_assignment_type( - analyzer, - &LuaTypeOwner::Member(second_member), - &LuaType::String, - false, - ) - .expect("sequential owner/key cache should be usable") - .expect("second same-key assignment should widen with cached prior type"); - - assert_eq!( - widened, - TypeOps::Union.apply(analyzer.db, &LuaType::Integer, &LuaType::String) - ); - }); - } - - #[test] - fn member_assignment_widening_cache_tracks_many_same_key_members() { - let mut db = DbIndex::new(); - let owner = LuaMemberOwner::Element(InFiled::new( - FileId::new(0), - TextRange::new(TextSize::new(10), TextSize::new(11)), - )); - let key = LuaMemberKey::from("field"); - - with_analyzer(&mut db, |analyzer| { - let mut cache_hits = 0; - for i in 0..512 { - let member_id = member_id_at(i * 2 + 1); - add_typed_file_define_member( - analyzer.db, - owner.clone(), - member_id, - key.clone(), - LuaType::String, - ); - - if i > 0 { - assert!( - get_cached_widened_member_assignment_type( - analyzer, - &LuaTypeOwner::Member(member_id), - &LuaType::String, - false, - ) - .is_some(), - "cache should stay enabled at member {i}" - ); - cache_hits += 1; - } - - record_member_assignment_widening_cache( - analyzer, - &LuaTypeOwner::Member(member_id), - &LuaType::String, - false, - ); - } - - assert_eq!(cache_hits, 511); - }); - } - - #[test] - fn member_assignment_widening_cache_tracks_many_preserved_table_literal_members() { - let mut db = DbIndex::new(); - let owner = LuaMemberOwner::Element(InFiled::new( - FileId::new(0), - TextRange::new(TextSize::new(10), TextSize::new(11)), - )); - let key = LuaMemberKey::from("field"); - - with_analyzer(&mut db, |analyzer| { - let mut cache_hits = 0; - for i in 0..512 { - let member_id = member_id_at(i * 2 + 1); - let table_type = table_const(i * 2 + 1000, i * 2 + 1001); - add_typed_file_define_member( - analyzer.db, - owner.clone(), - member_id, - key.clone(), - table_type.clone(), - ); - - if i > 0 { - let cached_type = get_cached_widened_member_assignment_type( - analyzer, - &LuaTypeOwner::Member(member_id), - &table_type, - true, - ) - .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}"); - cache_hits += 1; - } - - record_member_assignment_widening_cache( - analyzer, - &LuaTypeOwner::Member(member_id), - &table_type, - true, - ); - } - - assert_eq!(cache_hits, 511); - }); - } - - #[test] - fn member_assignment_widening_cache_tracks_many_same_class_bootstrap_members() { - let mut db = DbIndex::new(); - let owner = LuaMemberOwner::Element(InFiled::new( - FileId::new(0), - TextRange::new(TextSize::new(10), TextSize::new(11)), - )); - let key = LuaMemberKey::from("field"); - let class_type = LuaType::Def(LuaTypeDeclId::global("ClassType")); - - with_analyzer(&mut db, |analyzer| { - let mut cache_hits = 0; - for i in 0..512 { - let member_id = member_id_at(i * 2 + 1); - add_typed_file_define_member( - analyzer.db, - owner.clone(), - member_id, - key.clone(), - class_type.clone(), - ); - - if i > 0 { - let cached_type = get_cached_widened_member_assignment_type( - analyzer, - &LuaTypeOwner::Member(member_id), - &class_type, - false, - ) - .expect("class bootstrap cache should stay enabled") - .expect("same class bootstrap should return cached class type"); - assert_eq!(cached_type, class_type, "unexpected type at member {i}"); - cache_hits += 1; - } - - record_member_assignment_widening_cache( - analyzer, - &LuaTypeOwner::Member(member_id), - &class_type, - false, - ); - } - - assert_eq!(cache_hits, 511); - }); - } - - #[test] - fn member_assignment_widening_cache_rejects_different_class_bootstrap_members() { - let mut db = DbIndex::new(); - let owner = LuaMemberOwner::Element(InFiled::new( - FileId::new(0), - TextRange::new(TextSize::new(10), TextSize::new(11)), - )); - let key = LuaMemberKey::from("field"); - let first_class = LuaType::Def(LuaTypeDeclId::global("FirstClass")); - let second_class = LuaType::Def(LuaTypeDeclId::global("SecondClass")); - let first_member = member_id_at(1); - let second_member = member_id_at(3); - - add_typed_file_define_member( - &mut db, - owner.clone(), - first_member, - key.clone(), - first_class.clone(), - ); - - with_analyzer(&mut db, |analyzer| { - record_member_assignment_widening_cache( - analyzer, - &LuaTypeOwner::Member(first_member), - &first_class, - false, - ); - add_typed_file_define_member( - analyzer.db, - owner, - second_member, - key, - second_class.clone(), - ); - - assert!( - get_cached_widened_member_assignment_type( - analyzer, - &LuaTypeOwner::Member(second_member), - &second_class, - false, - ) - .is_none(), - "different class bootstraps must fall back to the full compatibility scan" - ); - }); - } - - #[test] - fn member_assignment_widening_fallback_preserves_doc_authority() { - let mut db = DbIndex::new(); - let owner = LuaMemberOwner::Element(InFiled::new( - FileId::new(0), - TextRange::new(TextSize::new(10), TextSize::new(11)), - )); - let key = LuaMemberKey::from("field"); - let doc_type = LuaType::Def(LuaTypeDeclId::global("DocType")); - let first_member = member_id_at(1); - let second_member = member_id_at(3); - let third_member = member_id_at(5); - - add_typed_file_define_member( - &mut db, - owner.clone(), - first_member, - key.clone(), - LuaType::Integer, - ); - db.get_type_index_mut().force_bind_type( - LuaTypeOwner::Member(first_member), - LuaTypeCache::DocType(doc_type.clone()), - ); - - with_analyzer(&mut db, |analyzer| { - record_member_assignment_widening_cache( - analyzer, - &LuaTypeOwner::Member(first_member), - &LuaType::Integer, - false, - ); - add_typed_file_define_member( - analyzer.db, - owner.clone(), - second_member, - key.clone(), - LuaType::String, - ); - add_typed_file_define_member(analyzer.db, owner, third_member, key, LuaType::Boolean); - - assert_eq!( - get_cached_widened_member_assignment_type( - analyzer, - &LuaTypeOwner::Member(third_member), - &LuaType::Boolean, - false, - ), - None, - "visible-count mismatch should force the fallback scan" - ); - - let widened = get_widened_member_assignment_type( - analyzer.db, - &LuaTypeOwner::Member(third_member), - &LuaType::Boolean, - false, - &mut false, - ) - .expect("fallback scan should find prior same-key assignments"); - - assert_eq!(widened, doc_type); - }); - } - - #[test] - fn member_assignment_widening_fallback_rejects_different_class_bootstrap() { - let mut db = DbIndex::new(); - let owner = LuaMemberOwner::Element(InFiled::new( - FileId::new(0), - TextRange::new(TextSize::new(10), TextSize::new(11)), - )); - let key = LuaMemberKey::from("field"); - let first_class = LuaType::Def(LuaTypeDeclId::global("FirstClass")); - let second_class = LuaType::Def(LuaTypeDeclId::global("SecondClass")); - let first_member = member_id_at(1); - let second_member = member_id_at(3); - let third_member = member_id_at(5); - - add_typed_file_define_member( - &mut db, - owner.clone(), - first_member, - key.clone(), - first_class.clone(), - ); - - with_analyzer(&mut db, |analyzer| { - record_member_assignment_widening_cache( - analyzer, - &LuaTypeOwner::Member(first_member), - &first_class, - false, - ); - add_typed_file_define_member( - analyzer.db, - owner.clone(), - second_member, - key.clone(), - second_class.clone(), - ); - add_typed_file_define_member( - analyzer.db, - owner, - third_member, - key, - second_class.clone(), - ); + /// 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"; + let tree = glua_parser::LuaParser::parse(source, glua_parser::ParserConfig::default()); - assert_eq!( - get_cached_widened_member_assignment_type( - analyzer, - &LuaTypeOwner::Member(third_member), - &second_class, - false, - ), - None, - "visible-count mismatch should force the fallback scan" - ); + let ranges = tree + .get_chunk_node() + .descendants::() + .filter_map(|index_expr| guarded_table_assignment_bootstrap_range(&index_expr, false)) + .collect::>(); - let widened = get_widened_member_assignment_type( - analyzer.db, - &LuaTypeOwner::Member(third_member), - &second_class, - false, - &mut false, - ) - .expect("fallback scan should widen incompatible class assignments"); - let expected = TypeOps::Union.apply(analyzer.db, &first_class, &second_class); + assert_eq!(ranges.len(), 1, "only the bootstrap assignment qualifies"); + assert_eq!(&source[ranges[0]], "{}"); + } - assert_eq!(widened, expected); - assert_ne!(widened, second_class); - }); + fn member_id_at(start: u32) -> LuaMemberId { + member_id_at_file(FileId::new(0), start) } - #[test] - fn member_assignment_widening_cache_and_fallback_match_plain_scalars() { - let key = LuaMemberKey::from("field"); - let first_member = member_id_at(1); - let second_member = member_id_at(3); - let third_member = member_id_at(5); + fn member_id_at_file(file_id: FileId, start: u32) -> LuaMemberId { + let range = TextRange::new(TextSize::new(start), TextSize::new(start + 1)); + LuaMemberId::new( + LuaSyntaxId::new(glua_parser::LuaSyntaxKind::IndexExpr.into(), range), + file_id, + ) + } - let mut cached_db = DbIndex::new(); - let cached_owner = LuaMemberOwner::Element(InFiled::new( - FileId::new(0), - TextRange::new(TextSize::new(10), TextSize::new(11)), - )); - add_typed_file_define_member( - &mut cached_db, - cached_owner.clone(), - first_member, - key.clone(), - LuaType::Integer, + fn add_typed_file_define_member( + db: &mut DbIndex, + owner: LuaMemberOwner, + member_id: LuaMemberId, + key: LuaMemberKey, + typ: LuaType, + ) { + db.get_member_index_mut().add_member( + owner, + LuaMember::new(member_id, key, LuaMemberFeature::FileDefine, None), ); - let cached_widened = with_analyzer(&mut cached_db, |analyzer| { - record_member_assignment_widening_cache( - analyzer, - &LuaTypeOwner::Member(first_member), - &LuaType::Integer, - false, - ); - add_typed_file_define_member( - analyzer.db, - cached_owner, - second_member, - key.clone(), - LuaType::String, - ); + db.get_type_index_mut().bind_type( + LuaTypeOwner::Member(member_id), + LuaTypeCache::InferType(typ), + ); + } - get_cached_widened_member_assignment_type( - analyzer, - &LuaTypeOwner::Member(second_member), - &LuaType::String, - false, - ) - .expect("sequential same-key assignment should use cache") - .expect("cached scalar assignment should widen") - }); + fn with_analyzer(db: &mut DbIndex, run: impl FnOnce(&mut LuaAnalyzer<'_>) -> T) -> T { + with_analyzer_config(db, false, run) + } - let mut fallback_db = DbIndex::new(); - let fallback_owner = LuaMemberOwner::Element(InFiled::new( + fn with_analyzer_config( + db: &mut DbIndex, + gmod_enabled: bool, + run: impl FnOnce(&mut LuaAnalyzer<'_>) -> T, + ) -> T { + let mut context = crate::compilation::analyzer::AnalyzeContext::new(); + let matcher = super::super::call::SpecialCallDirectMatcher::default(); + let mut analyzer = LuaAnalyzer::new( + db, FileId::new(0), - TextRange::new(TextSize::new(10), TextSize::new(11)), - )); - add_typed_file_define_member( - &mut fallback_db, - fallback_owner.clone(), - first_member, - key.clone(), - LuaType::Integer, + &mut context, + gmod_enabled, + false, + &matcher, ); - let fallback_widened = with_analyzer(&mut fallback_db, |analyzer| { - record_member_assignment_widening_cache( - analyzer, - &LuaTypeOwner::Member(first_member), - &LuaType::Integer, - false, - ); - add_typed_file_define_member( - analyzer.db, - fallback_owner.clone(), - second_member, - key.clone(), - LuaType::String, - ); - add_typed_file_define_member( - analyzer.db, - fallback_owner, - third_member, - key, - LuaType::String, - ); + run(&mut analyzer) + } - assert_eq!( - get_cached_widened_member_assignment_type( - analyzer, - &LuaTypeOwner::Member(third_member), - &LuaType::String, - false, - ), - None, - "visible-count mismatch should force the fallback scan" - ); + #[test] + fn duplicate_table_owner_is_not_ambiguous() { + let table = table_const(1, 2); + let typ = LuaMergedTableType::new(vec![table.clone(), table]).into(); - get_widened_member_assignment_type( - analyzer.db, - &LuaTypeOwner::Member(third_member), - &LuaType::String, - false, - &mut false, - ) - .expect("fallback scalar assignment should widen") - }); + assert!(!has_multiple_distinct_index_expr_member_owners(&typ)); + } - assert_eq!(cached_widened, fallback_widened); + #[test] + fn merged_bootstrap_literals_are_one_owner() { + let typ = LuaMergedTableType::new(vec![table_const(1, 2), table_const(3, 4)]).into(); + + assert!(!has_multiple_distinct_index_expr_member_owners(&typ)); + } + + #[test] + fn distinct_table_owners_are_ambiguous() { + let typ = LuaType::Union( + LuaUnionType::from_vec(vec![table_const(1, 2), table_const(3, 4)]).into(), + ); + + assert!(has_multiple_distinct_index_expr_member_owners(&typ)); } #[test] @@ -3913,26 +3964,351 @@ mod tests { }); } + /// The classification reads the write's syntax: a computed key the walk + /// resolved to a name is still a computed key. #[test] fn expr_key_members_are_detected_as_dynamic_assignments() { - let mut db = DbIndex::new(); - let member_id = member_id_at(1); - add_typed_file_define_member( - &mut db, - LuaMemberOwner::Element(InFiled::new( - FileId::new(0), - TextRange::new(TextSize::new(10), TextSize::new(11)), - )), - member_id, - LuaMemberKey::ExprType(LuaType::String), - LuaType::Table, + let mut ws = crate::VirtualWorkspace::new(); + let file_id = ws.def_file( + "lua/keys.lua", + "local t = {}\nlocal k = \"named\"\nt[k] = 1\nt.plain = 2\n", ); + let db = ws.analysis.compilation.get_db_mut(); + let chunk = db + .get_vfs() + .get_syntax_tree(&file_id) + .expect("the file's tree") + .get_chunk_node(); + let writes = chunk + .descendants::() + .map(|index_expr| { + ( + index_expr.syntax().text().to_string(), + LuaMemberId::new(index_expr.get_syntax_id(), file_id), + ) + }) + .collect::>(); + let computed = writes + .iter() + .find(|(text, _)| text == "t[k]") + .expect("t[k]") + .1; + let plain = writes + .iter() + .find(|(text, _)| text == "t.plain") + .expect("t.plain") + .1; - with_analyzer(&mut db, |analyzer| { + with_analyzer(db, |analyzer| { assert!(is_dynamic_expr_key_member_assignment( analyzer, - &LuaTypeOwner::Member(member_id) + &LuaTypeOwner::Member(computed) + )); + assert!(!is_dynamic_expr_key_member_assignment( + analyzer, + &LuaTypeOwner::Member(plain) )); }); } + + fn guarded_slot_owner_key(db: &DbIndex, probe: LuaMemberId) -> (LuaMemberOwner, LuaMemberKey) { + let member_index = db.get_member_index(); + let owner = member_index.canonical_owner( + member_index + .get_member_owner(&probe) + .expect("probe member owner") + .clone(), + ); + let key = member_index + .get_member(&probe) + .expect("probe member") + .get_key() + .clone(); + (owner, key) + } + + fn guarded_slot_member_ids( + db: &DbIndex, + owner: &LuaMemberOwner, + key: &LuaMemberKey, + ) -> Vec { + let mut ids = db + .get_member_index() + .get_current_owner_member_ids_for_key_unsorted(owner, key); + ids.sort_by_key(|id| member_id_sort_key(*id)); + ids + } + + fn memoized_guarded_bootstrap( + db: &DbIndex, + member_id: LuaMemberId, + member_check: &mut FxHashMap, + slot_cache: &mut FxHashMap<(LuaMemberOwner, LuaMemberKey), GuardedSlotMemo>, + ) -> Option { + canonical_guarded_table_bootstrap_type( + db, + member_id, + Some(GuardedTableAssignmentCaches { + member_check, + slot: slot_cache, + }), + ) + } + + /// A rehomed-away non-deciding writer leaves the old slot's raw history + /// length and its deciding minimum untouched, so a length-plus-decider + /// memo would answer stale. The homing revision forces a rebuild. + #[test] + fn guarded_slot_memo_rebuilds_after_non_deciding_writer_rehomed_away() { + let mut ws = crate::VirtualWorkspace::new(); + ws.def_file("lua/rehome_a.lua", "T = T or {}\nT.slot = T.slot or {}\n"); + ws.def_file("lua/rehome_b.lua", "T.slot = T.slot or {}\n"); + + let expected_owner = LuaMemberOwner::GlobalPath(GlobalId::new("T")); + let expected_key = LuaMemberKey::from("slot"); + let (owner, key) = { + let db = ws.analysis.compilation.get_db(); + let probe = guarded_slot_member_ids(db, &expected_owner, &expected_key) + .first() + .copied() + .expect("two guarded writers indexed"); + guarded_slot_owner_key(db, probe) + }; + assert_eq!(owner, expected_owner); + assert_eq!(key, expected_key); + + let mut ids = { + let db = ws.analysis.compilation.get_db(); + guarded_slot_member_ids(db, &owner, &key) + }; + assert_eq!(ids.len(), 2, "slot starts with two guarded writers"); + let min_guard = ids[0]; + let other_guard = ids[1]; + { + let db = ws.analysis.compilation.get_db(); + assert!(is_guarded_table_assignment_member(db, min_guard)); + assert!(is_guarded_table_assignment_member(db, other_guard)); + } + + let mut member_check: FxHashMap = FxHashMap::default(); + let mut slot_cache: FxHashMap<(LuaMemberOwner, LuaMemberKey), GuardedSlotMemo> = + FxHashMap::default(); + let first = { + let db = ws.analysis.compilation.get_db(); + memoized_guarded_bootstrap(db, min_guard, &mut member_check, &mut slot_cache) + } + .expect("two guards answer with the minimum literal"); + let (revision_before, len_before) = { + let db = ws.analysis.compilation.get_db(); + ( + db.get_member_index().homing_revision(), + db.get_member_index().owner_key_history_len(&owner, &key), + ) + }; + assert_eq!(len_before, 2); + + // Append-only control: an unrelated file adds no homing change, so the + // revision is untouched and the memo still answers the same literal. + ws.def_file("lua/rehome_unrelated.lua", "local unrelated = 1\n"); + { + let db = ws.analysis.compilation.get_db(); + assert_eq!( + db.get_member_index().homing_revision(), + revision_before, + "append-only edit must not bump the homing revision" + ); + let again = + memoized_guarded_bootstrap(db, min_guard, &mut member_check, &mut slot_cache); + assert_eq!(again, Some(first.clone())); + } + + // Rehome the non-deciding writer away, mimicking the mid-pass owner + // move `add_member` performs after `set_member_owner`. + let other_file = { + let db = ws.analysis.compilation.get_db(); + db.get_member_index() + .get_member(&other_guard) + .expect("other guard") + .get_file_id() + }; + let away_owner = LuaMemberOwner::GlobalPath(GlobalId::new("OtherNS")); + { + let db = ws.analysis.compilation.get_db_mut(); + db.get_member_index_mut() + .set_member_owner(away_owner.clone(), other_file, other_guard); + db.get_member_index_mut() + .add_member_to_owner(away_owner.clone(), other_guard); + } + + // Pin the fast-path preconditions the stale memo would trust. + let revision_after = ws + .analysis + .compilation + .get_db() + .get_member_index() + .homing_revision(); + assert_ne!( + revision_after, revision_before, + "a real owner move must bump the homing revision" + ); + { + let db = ws.analysis.compilation.get_db(); + let member_index = db.get_member_index(); + assert_eq!( + member_index.owner_key_history_len(&owner, &key), + len_before, + "history retains the departed writer at the same raw length" + ); + assert_eq!( + member_index.get_member_owner(&min_guard), + Some(&owner), + "the deciding minimum is still homed under the old slot" + ); + assert_eq!( + member_index + .get_member(&min_guard) + .expect("min guard") + .get_key(), + &key + ); + ids = guarded_slot_member_ids(db, &owner, &key); + assert_eq!(ids, vec![min_guard], "only the minimum is still homed"); + assert_eq!( + canonical_guarded_table_bootstrap_type(db, min_guard, None), + None, + "one remaining writer is below the two-writer gate" + ); + let rebuilt = + memoized_guarded_bootstrap(db, min_guard, &mut member_check, &mut slot_cache); + assert_eq!( + rebuilt, None, + "memoized query must rebuild past the stale two-guard answer" + ); + } + } + + /// A historical plain writer moved back into a memoized clean slot appends + /// nothing when its id is already listed, so length and decider checks + /// still pass while the slot is poisoned. The revision forces a rebuild. + #[test] + fn guarded_slot_memo_rebuilds_after_historical_plain_writer_rehomed_in() { + let mut ws = crate::VirtualWorkspace::new(); + ws.def_file("lua/poison_a.lua", "U = U or {}\nU.slot = U.slot or {}\n"); + ws.def_file("lua/poison_b.lua", "U.slot = U.slot or {}\n"); + ws.def_file("lua/poison_plain.lua", "U.slot = 1\n"); + + let expected_owner = LuaMemberOwner::GlobalPath(GlobalId::new("U")); + let expected_key = LuaMemberKey::from("slot"); + let (owner, key) = { + let db = ws.analysis.compilation.get_db(); + let probe = guarded_slot_member_ids(db, &expected_owner, &expected_key) + .first() + .copied() + .expect("guards plus plain indexed"); + guarded_slot_owner_key(db, probe) + }; + assert_eq!(owner, expected_owner); + assert_eq!(key, expected_key); + + let (min_guard, plain_id) = { + let db = ws.analysis.compilation.get_db(); + let member_index = db.get_member_index(); + let mut all = member_index.get_current_owner_member_ids_for_key_unsorted(&owner, &key); + all.sort_by_key(|id| member_id_sort_key(*id)); + assert_eq!(all.len(), 3, "two guards plus one plain writer"); + let plain = all + .iter() + .copied() + .find(|id| !is_guarded_table_assignment_member(db, *id)) + .expect("one plain writer"); + let guards: Vec<_> = all.iter().copied().filter(|id| *id != plain).collect(); + assert_eq!(guards.len(), 2); + assert!(is_guarded_table_assignment_member(db, guards[0])); + assert!(is_guarded_table_assignment_member(db, guards[1])); + let min_guard = guards + .iter() + .copied() + .min_by_key(|id| member_id_sort_key(*id)) + .expect("minimum guard"); + (min_guard, plain) + }; + let plain_file = { + let db = ws.analysis.compilation.get_db(); + db.get_member_index() + .get_member(&plain_id) + .expect("plain member") + .get_file_id() + }; + + // Move the plain writer away so the slot memoizes clean. + let away_owner = LuaMemberOwner::GlobalPath(GlobalId::new("OtherU")); + { + let db = ws.analysis.compilation.get_db_mut(); + db.get_member_index_mut() + .set_member_owner(away_owner.clone(), plain_file, plain_id); + db.get_member_index_mut() + .add_member_to_owner(away_owner.clone(), plain_id); + } + let mut member_check: FxHashMap = FxHashMap::default(); + let mut slot_cache: FxHashMap<(LuaMemberOwner, LuaMemberKey), GuardedSlotMemo> = + FxHashMap::default(); + { + let db = ws.analysis.compilation.get_db(); + let filtered = guarded_slot_member_ids(db, &owner, &key); + assert_eq!(filtered.len(), 2, "plain writer moved away"); + assert!( + memoized_guarded_bootstrap(db, min_guard, &mut member_check, &mut slot_cache) + .is_some(), + "two remaining guards answer clean" + ); + } + let (revision_clean, len_clean) = { + let db = ws.analysis.compilation.get_db(); + ( + db.get_member_index().homing_revision(), + db.get_member_index().owner_key_history_len(&owner, &key), + ) + }; + + // Move the historical plain writer back: its id is already listed in + // the old slot's history, so the raw length does not grow. + { + let db = ws.analysis.compilation.get_db_mut(); + db.get_member_index_mut() + .set_member_owner(owner.clone(), plain_file, plain_id); + db.get_member_index_mut() + .add_member_to_owner(owner.clone(), plain_id); + } + + let db = ws.analysis.compilation.get_db(); + let member_index = db.get_member_index(); + assert_ne!( + member_index.homing_revision(), + revision_clean, + "rehoming the plain writer back must bump the revision" + ); + assert_eq!( + member_index.owner_key_history_len(&owner, &key), + len_clean, + "history already listed the returning id, raw length is equal" + ); + assert_eq!( + member_index.get_member_owner(&min_guard), + Some(&owner), + "the deciding minimum is still homed under the slot" + ); + let filtered = guarded_slot_member_ids(db, &owner, &key); + assert_eq!(filtered.len(), 3, "both guards plus the returned plain"); + assert!(filtered.contains(&plain_id)); + assert_eq!( + canonical_guarded_table_bootstrap_type(db, min_guard, None), + None, + "a homed plain writer poisons the slot" + ); + let rebuilt = memoized_guarded_bootstrap(db, min_guard, &mut member_check, &mut slot_cache); + assert_eq!( + rebuilt, None, + "memoized query must rebuild to poisoned instead of the stale clean answer" + ); + } } diff --git a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs index 5d69c292b..6f73c21b8 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs @@ -12,13 +12,18 @@ pub(crate) mod parallel; mod setmetatable_factory; pub(crate) mod unresolve; -pub(crate) use lua::{dominating_guarded_table_bootstrap_range, infer_for_range_iter_expr_func}; - -use std::{ - collections::{HashMap, HashSet}, - sync::Arc, +pub(crate) use common::FixpointFuse; +pub(crate) use dynamic_field::for_range_pairs_source_for_var; +pub(crate) use dynamic_field::is_provably_builtin_pairs_call; +pub use lua::is_member_assignment_in_conditional_branch; +pub(crate) use lua::{ + dominating_guarded_table_bootstrap_range, infer_for_range_iter_expr_func, + is_guarded_table_definition_site, }; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; +use std::sync::Arc; + use crate::{ AsyncState, FileId, GmodScopedClassInfo, InFiled, InferFailReason, LuaDeclId, LuaDefinitionId, LuaFunctionType, LuaInferenceNodeId, LuaInferredGuardOwner, LuaMember, LuaMemberFeature, @@ -30,12 +35,12 @@ use crate::{ semantic::infer_expr_fact_with_cache, }; use glua_parser::{ - BinaryOperator, LuaAstNode, LuaCallExpr, LuaChunk, LuaClosureExpr, LuaExpr, LuaNameExpr, - LuaSyntaxId, LuaSyntaxNode, + LuaAstNode, LuaCallExpr, LuaChunk, LuaClosureExpr, LuaExpr, LuaIndexExpr, LuaNameExpr, + LuaSyntaxId, LuaSyntaxNode, LuaTableField, }; use infer_cache_manager::InferCacheManager; use lua::LuaReturnPoint; -use unresolve::{UnResolve, UnResolveReturn}; +use unresolve::{UnResolve, UnResolveIterVar, UnResolveReturn}; pub(crate) fn infer_closure_body_function_type( db: &DbIndex, @@ -181,13 +186,11 @@ pub fn analyze(db: &mut DbIndex, need_analyzed_files: Vec>) { run_analysis::(db, &mut context); let call_site_return_invalidation_changed = context.call_site_return_invalidation_changed; - let local_inference_changed = local_inference::stabilize_unknown_locals(db, &mut context); let late_guard_retries = context.inferred_guard_candidates.len(); let late_guard_stats = stabilize_inferred_positive_guards(db, &mut context); let inferred_guard_changed = late_guard_stats.changed; - let late_inference_changed = call_site_return_invalidation_changed - || local_inference_changed - || inferred_guard_changed; + let late_inference_changed = + call_site_return_invalidation_changed || inferred_guard_changed; if infer_dynamic_fields { run_analysis::(db, &mut context); @@ -206,6 +209,14 @@ pub fn analyze(db: &mut DbIndex, need_analyzed_files: Vec>) { refresh_initializer_caches(db, &mut context); } + // The receiver of a `panel:Add(child)` written through a member is typed + // by the passes above, so the parent calls the scripted-class scan had + // to defer can be collected now. + { + let _p = Profile::new("resolve_deferred_vgui_parent_calls"); + gmod::resolve_deferred_vgui_parent_calls(db, &mut context); + } + context.resolve_call_site_return_consumers(db); // Unguarded-child inference is a fallback. Run it only after dynamic @@ -216,16 +227,11 @@ pub fn analyze(db: &mut DbIndex, need_analyzed_files: Vec>) { false, &mut unguarded_child_sites, ); - let late_child_local_changed = if late_child_sources.is_empty() { - false - } else { - local_inference::stabilize_unknown_locals(db, &mut context) - }; let late_child_returns = context.requeue_inferred_returns_for_sources(db, &late_child_sources); if !late_child_sources.is_empty() { context.infer_manager.clear(); - if late_child_local_changed || late_child_returns != 0 { + if late_child_returns != 0 { run_analysis::(db, &mut context); setmetatable_factory::synthesize_setmetatable_factory_members( db, @@ -235,45 +241,21 @@ pub fn analyze(db: &mut DbIndex, need_analyzed_files: Vec>) { refresh_initializer_caches(db, &mut context); } - { + let attached_settled_members = { let _p = Profile::new("attach_settled_index_expr_members"); - attach_settled_index_expr_members(db, &mut context); - } - - { - let _p = Profile::new("rederive_settled_inferred_returns"); - rederive_settled_inferred_returns(db, &mut context); - } - - { - 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`. - { - let _p = Profile::new("reconcile_parked_global_path_members"); - common::reconcile_parked_global_path_members(db); - } - - // Writes that inferred their prefix to one concrete declaration of a - // multi-declaration global attach directly to that table and never - // park, so which table won depends on batch composition. Re-apply the - // ownership rule to them now that every declaration stands. See - // `reconcile_directly_attached_candidate_members`. - { - let _p = Profile::new("reconcile_directly_attached_candidate_members"); - common::reconcile_directly_attached_candidate_members(db); - } - - // 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. + attach_settled_index_expr_members(db, &mut context) + }; + // Every source of facts has run by now, so this is the only run that + // may answer a blocked item with a floor. + context.finalize_unresolves = true; + if attached_settled_members + || !context.force_finalized.is_empty() + || !context.unresolves.is_empty() { - let _p = Profile::new("rederive_contributed_member_assignments"); - let analyzed_files = context.analyzed_file_ids(); - lua::rederive_contributed_member_assignments(db, &analyzed_files); + let _p = Profile::new("finalize_unresolves"); + requeue_force_finalized(db, &mut context); + setmetatable_factory::synthesize_setmetatable_factory_members(db, &workspace_file_ids); + refresh_initializer_caches(db, &mut context); } // Every settled pass above refines the types the member attach retry @@ -284,13 +266,169 @@ pub fn analyze(db: &mut DbIndex, need_analyzed_files: Vec>) { let _p = Profile::new("attach_settled_index_expr_members (late)"); attach_settled_index_expr_members(db, &mut context); } + context.force_finalized.clear(); + + { + let _p = Profile::new("resettle_guarded_table_bootstraps"); + lua::resettle_guarded_table_bootstraps( + db, + std::mem::take(&mut context.settled_guarded_bootstrap_candidates), + ); + } - // The late attach can still place members straight onto whichever - // candidate table its prefix resolved to, so the direct-attached - // repair has to see its results too. + // A loop over a global has to enumerate the table every realm's file + // contributed to, so the copies of that global settle first; and 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. + // + // Run to a fixpoint. Each pass reads one immutable snapshot of the + // database, so an answer corrected in this round is invisible to the + // reads that feed it until the next one -- and the chain runs from a + // member map to the table a read answers with, to the loop variable + // that enumerates it, to the key a dynamic write is filed under. + // Looping rounds is what lets the later hops see the earlier ones. + // + // The loop ends when a round moves no type write and no non-type + // state either (dynamic-field records, inferred signature returns, + // expression-member keys). Each round re-derives the same candidates + // against a strictly more complete database than the last, so the + // answers stop moving once every hop of the chain has seen the hop + // before it. On CityRP cold that is four rounds, three of them + // productive. That convergence is measured on the net boundary state + // below, never on any pass's mid-round report. Two guards watch the + // loop itself (see `common::FixpointFuse`): exact state-repetition + // detection is primary — the same complete post-round boundary state + // recurring confirms an oscillation bug and breaks out — and a high + // absolute round cap sits behind it as a pure hang guard. Neither is + // a round cap on healthy behavior: a converging wavefront advances + // every round and never trips either one. { - let _p = Profile::new("reconcile_directly_attached_candidate_members (late)"); - common::reconcile_directly_attached_candidate_members(db); + let _p = Profile::new("settled_tail_fixpoint"); + // `None` on the first round: every candidate is re-derived once. + // After that a candidate is only worth re-deriving when a cache it + // actually read has since moved, which is what each pass records as + // it runs. See [`crate::db_index::read_set`]. + let mut moved_owners: Option> = None; + let late_resolved_decls = std::mem::take(&mut context.late_resolved_decls); + queue_settled_decl_dependents(db, &mut context, &late_resolved_decls); + // Termination safety nets; see `common::FixpointFuse` (fuses, not + // performance budgets): `observe_boundary` confirms oscillation + // from the exact post-round state, and `trip` bounds total rounds + // as a pure hang guard. + let mut fuse = common::FixpointFuse::new("settled_tail_fixpoint"); + loop { + let _round = Profile::new("settled_tail_round"); + if fuse.trip() { + break; + } + // Whole-tail convergence reads the net boundary state only. + // Each pass below reports its own gross moves, and mid-round + // reports can ping-pong — one pass moving an owner the next + // moves back — while the boundary holds still. Snapshotting + // every movable owner first and diffing after cancels that: + // continuation, dependent queueing, and the next round's + // read-set filter all see only owners whose cached type + // actually differs across the round boundary. + let boundary_before = snapshot_settled_tail_boundary(db, &context); + let global_owners = rederive_settled_global_reads(db, &mut context); + // Runs inside the loop: the records it adds are read by the + // iterated-table answers below, and its own prefixes read the + // loop variables those answers settle — each direction needs + // the other's previous round. + let dynamic_fields_moved = + dynamic_field::rederive_settled_dynamic_fields(db, &mut context); + let (_iter_moved, _iter_var_owners) = + rederive_settled_iter_vars(db, &mut context, moved_owners.as_ref()); + let returns_moved = rederive_settled_inferred_returns(db, &mut context); + if returns_moved { + // Side effects only: this resolves the return targets the + // boundary snapshot covers, so the net diff below already + // sees the owners it moves. + context.resolve_call_site_return_consumers(db); + } + // Assignments onto locals that read a decl the passes above + // just moved (`selected = key` after the loop variable + // settled): re-derived through the same source-order claim a + // direct commit would have applied. + let _assign_owners = rederive_settled_assign_reads(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 -- and re-taking the keys the moved types were minted + // into. + let refresh_owners = if returns_moved { + None + } else { + moved_owners.as_ref().map(|previous| { + let mut owners = previous.clone(); + owners.extend(global_owners.iter().cloned()); + owners + }) + }; + let _initializer_owners = + refresh_settled_initializer_caches(db, &mut context, refresh_owners.as_ref()); + // The net boundary diff: owners whose cached type actually + // differs across this round. Mid-round ping-pong cancels here. + let net_moved = diff_settled_tail_boundary(db, &boundary_before); + queue_settled_decl_dependents(db, &mut context, &net_moved); + let member_keys_moved = refresh_settled_expr_type_member_keys(db, &mut context); + // Dynamic-field records, inferred signature returns, and member + // keys are not type-owner state, so the boundary fingerprint + // below cannot see them move. A recurring fingerprint amid that + // progress is continued convergence, not a cycle: forget the + // sightings instead of confirming an oscillation that never + // happened. + let non_type_progressed = + dynamic_fields_moved || returns_moved || member_keys_moved; + if non_type_progressed { + fuse.reset_boundary_sightings(); + } + if !net_moved.is_empty() { + // Exact cycle detection over the COMPLETE post-round + // movable-owner state, not just this round's net delta: a + // delta that recurs while another part of the boundary + // advances presents a different complete state every + // round and must not break the loop. The fingerprint is + // only a lookup accelerator; the fuse confirms byte-exact + // canonical-state equality on a repeat sighting. + let canonical = canonical_settled_tail_state(db, &context); + if fuse.observe_boundary( + fingerprint_settled_tail_state(&canonical), + false, + &format!("{:?}", canonical), + ) { + break; + } + } + if net_moved.is_empty() && !non_type_progressed { + break; + } + // A dynamic-field record is not a type owner — nor are an + // inferred signature return payload or a member key — so the + // read-set filter cannot see them move; re-derive every + // candidate next round instead of a filtered subset. + moved_owners = if non_type_progressed { + None + } else { + Some(net_moved) + }; + } + context.settled_iter_var_candidates.clear(); + context.settled_global_read_candidates.clear(); + context.settled_multi_decl_global_read_candidates.clear(); + context.settled_dynamic_field_candidates.clear(); + context.settled_sibling_merge_read_candidates.clear(); + context.settled_decl_copy_candidates.clear(); + context.settled_assign_candidates.clear(); + } + + { + // A guess from a local's uses is a fallback for a local inference + // cannot type, so it is taken only once inference has finished: + // taken earlier it stands in for an answer a later pass lands. + let _p = Profile::new("local inference stabilize"); + local_inference::stabilize_unknown_locals(db, &mut context); } // Net flows are collected last: the collector resolves wrappers through @@ -298,6 +436,8 @@ pub fn analyze(db: &mut DbIndex, need_analyzed_files: Vec>) { // the gmod pre-pass runs. See `GmodNetworkAnalysisPipeline`. run_analysis::(db, &mut context); + context.persist_inferred_return_dependencies(db); + for (consumer_file_id, owners) in context.infer_manager.drain_inferred_guard_dependencies() { context.add_inferred_guard_dependencies(consumer_file_id, owners); @@ -306,13 +446,38 @@ pub fn analyze(db: &mut DbIndex, need_analyzed_files: Vec>) { db.get_signature_index_mut() .set_inferred_guard_dependencies(consumer_file_id, owners); } - + let mut missed_member_reads = context + .infer_manager + .drain_missed_member_reads() + .into_iter() + .collect::>(); + for reader_file_id in &workspace_file_ids { + let reads = context + .settled_read_sets + .remove(reader_file_id) + .unwrap_or_default(); + missed_member_reads + .entry(*reader_file_id) + .or_default() + .extend(reads.missing_member_slots); + db.get_member_index_mut().set_missed_member_reads( + *reader_file_id, + missed_member_reads + .remove(reader_file_id) + .unwrap_or_default(), + ); + db.get_type_index_mut() + .set_settled_reads(*reader_file_id, reads.type_owners); + db.get_signature_index_mut() + .set_settled_reads(*reader_file_id, reads.signatures); + db.get_signature_index_mut() + .snapshot_payload_refs(*reader_file_id); + } if std::env::var_os("GLUALS_PROFILE").is_some() { eprintln!( - "[profile] member_assignment_contributions entries={}", - db.get_member_index() - .member_assignment_contributions() - .entry_count(), + "[profile] member_initializer_candidates={} guarded_member_read_candidates={}", + context.member_initializer_reinfer_candidates.len(), + context.guarded_member_read_candidates.len(), ); eprintln!( "[profile] inferred_guard candidates={} candidate_attempts={} candidate_iterations={} early_published={} late_retries={} late_published={} pending={} early_signature_owners={} early_member_owners={}", @@ -328,15 +493,19 @@ pub fn analyze(db: &mut DbIndex, need_analyzed_files: Vec>) { ); } } + + db.flush_inference_derived_state(); } /// Retries the index-expression member attaches `set_index_expr_owner` /// dropped. -fn attach_settled_index_expr_members(db: &mut DbIndex, context: &mut AnalyzeContext) { +/// Whether any candidate was placed. +fn attach_settled_index_expr_members(db: &mut DbIndex, context: &mut AnalyzeContext) -> bool { let mut candidates = std::mem::take(&mut context.settled_member_attach_candidates); if candidates.is_empty() { - return; + return false; } + let mut attached = false; candidates.sort_by_key(|candidate| (candidate.file_id, candidate.value.get_range().start())); candidates.dedup(); let mut retry = Vec::new(); @@ -388,85 +557,1138 @@ fn attach_settled_index_expr_members(db: &mut DbIndex, context: &mut AnalyzeCont // a candidate that fails now can succeed once they have. Keep it // queued for the next attempt instead. retry.push(candidate); + } else { + attached = true; } } context.settled_member_attach_candidates = retry; + attached } -/// Re-resolves inferred returns that settled on `any`/`unknown`. -fn rederive_settled_inferred_returns(db: &mut DbIndex, context: &mut AnalyzeContext) { - let mut candidates = context - .inferred_return_candidates - .iter() - .filter(|return_| { - db.get_signature_index() - .get(&return_.signature_id) - .is_some_and(|signature| { - signature.resolve_return == crate::SignatureReturnStatus::InferResolve && { - let current = signature.get_return_type(); - current.is_any() || current.is_unknown() - } - }) +/// Re-derives the items the unresolve force wave settled. +/// +/// The force wave reads each item against the members indexed by then. A +/// member the settled attach places afterwards is one those reads could not +/// see, so a type read through it settled to what a missing member reads as. +/// A re-index of the same file runs with that member in place, so a cold build +/// has to derive these once more with it in place too, or the two disagree. +fn requeue_force_finalized(db: &mut DbIndex, context: &mut AnalyzeContext) { + let unresolves = std::mem::take(&mut context.force_finalized); + if unresolves.is_empty() && context.unresolves.is_empty() { + return; + } + for unresolve in unresolves { + if let unresolve::UnResolve::Return(return_) = &unresolve + && let Some(signature) = db.get_signature_index_mut().get_mut(&return_.signature_id) + && signature.resolve_return == crate::SignatureReturnStatus::InferResolve + { + signature.resolve_return = crate::SignatureReturnStatus::UnResolve; + } + context.add_unresolve(unresolve, InferFailReason::None); + } + context.infer_manager.clear(); + run_analysis::(db, context); +} + +/// Re-resolves body-inferred returns after every type and member input settles. +fn rederive_settled_inferred_returns(db: &mut DbIndex, context: &mut AnalyzeContext) -> bool { + let mut changed = false; + let mut fuse = common::FixpointFuse::new("rederive_settled_inferred_returns"); + loop { + if fuse.trip() { + break; + } + context.drain_inferred_return_reads(); + let mut candidates = context + .inferred_return_candidates + .iter() + .filter(|return_| { + db.get_signature_index() + .get(&return_.signature_id) + .is_some_and(|signature| { + signature.resolve_return == crate::SignatureReturnStatus::InferResolve + && (unresolve::is_unsettled_inferred_return( + &signature.get_return_type(), + ) || context + .inferred_return_reads_are_stale(db, return_.signature_id)) + }) + }) + .cloned() + .collect::>(); + if candidates.is_empty() { + break; + } + candidates.sort_by_key(|return_| (return_.file_id, return_.signature_id.get_position())); + candidates.dedup_by(|left, right| left.signature_id == right.signature_id); + + let writes_before = db.get_signature_index().return_writes(); + let mut reasons = HashMap::default(); + let mut forced = candidates + .into_iter() + .map(UnResolve::from) + .collect::>(); + unresolve::settle_floored_fixpoint( + db, + &mut context.infer_manager, + &mut reasons, + &mut forced, + &mut Vec::new(), + &mut context.late_resolved_decls, + false, + ); + context.drain_inferred_return_reads(); + if db.get_signature_index().return_writes() == writes_before { + break; + } + changed = true; + } + 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, + reads: crate::db_index::read_set::InferenceReadSet, + pending_type_decls: Vec, + guard_dependencies: HashSet, + updates: Vec, +} + +impl IterVarRefreshResult { + fn new(file_id: FileId) -> Self { + Self { + file_id, + reads: Default::default(), + pending_type_decls: Vec::new(), + guard_dependencies: HashSet::default(), + 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. +/// +/// 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, + moved_owners: Option<&HashSet>, +) -> (bool, HashSet) { + // Kept, not taken: the settled tail runs to a fixpoint and each round + // re-derives the same candidates against what the previous one landed. + let mut candidates = context.settled_iter_var_candidates.clone(); + if candidates.is_empty() { + return (false, HashSet::default()); + } + candidates.sort_by_key(|iter_var| { + ( + iter_var.file_id, + iter_var + .iter_vars + .first() + .map(glua_parser::LuaAstToken::get_position), + ) + }); + + let mut candidates_by_file = HashMap::>::default(); + for iter_var in candidates { + let candidate = SettledIterVarCandidate::new(&iter_var); + candidates_by_file + .entry(iter_var.file_id) + .or_default() + .push(candidate); + } + + let mut file_ids = candidates_by_file.keys().copied().collect::>(); + file_ids.retain(|file_id| context.settled_file_reads_moved(*file_id, moved_owners)); + file_ids.sort_unstable(); + if file_ids.is_empty() { + return (false, HashSet::default()); + } + 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. 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, + crate::CacheOptions { + analysis_phase, + dynamic_fields_visible, + building_dynamic_field_index: false, + }, + ); + let mut result = IterVarRefreshResult::new(file_id); + crate::db_index::read_set::arm(); + 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.reads = crate::db_index::read_set::disarm(); + result.pending_type_decls = infer_cache.take_pending_str_tpl_type_decls(); + result.guard_dependencies = infer_cache.take_inferred_guard_dependencies(); + result + }); + + // Only a write that changes the cached type moved anything; landing the + // answer already cached would otherwise re-derive its readers every round. + let mut written = HashSet::default(); + for result in results { + context.record_settled_reads(result.file_id, result.reads); + context.infer_manager.merge_inference_side_effects( + result.file_id, + result.pending_type_decls, + result.guard_dependencies, + ); + for update in result.updates { + let before = db + .get_type_index() + .get_type_cache(&update.owner) + .map(|cache| cache.as_type().clone()); + common::write_type_cache(db, update.owner.clone(), update.cache.clone(), update.mode); + if db + .get_type_index() + .get_type_cache(&update.owner) + .map(|cache| cache.as_type().clone()) + != before + { + written.insert(update.owner.clone()); + } + // 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)); + } + } + } + (!written.is_empty(), written) +} + +/// Cheap prefilter for [`refresh_settled_expr_type_member_keys`]: whether `member` +/// may hold a stale dynamic key, decided without reading any syntax tree. +/// +/// An `ExprType` key is always minted from a key expression. A `Name`/`Integer` +/// key on an index or table-field node may instead be a const-folded one +/// (`keyed[kb]` filed as `Name("s")` while `kb` narrowed to that literal); the +/// per-member pass resolves the node and keeps only those whose key syntax +/// really is an expression, so static writes (`t.field`, `t["lit"]`, `{ key = v }`) +/// never pay for re-inference. +fn is_settled_member_rekey_candidate(member: &LuaMember) -> bool { + let key = member.get_key(); + if key.is_expr() { + return true; + } + if !(key.is_name() || key.is_integer()) { + return false; + } + let kind = member.get_id().get_syntax_id().get_kind(); + LuaIndexExpr::can_cast(kind) || LuaTableField::can_cast(kind) +} + +/// Re-files dynamically keyed writes under the key their key expression infers +/// to now. +/// +/// `t[k] = v` is filed under the type `k` inferred to at the moment the walk +/// reached the write, so a key taken off a member map the walk had only partly +/// built names a slot that selects the wrong writers. The member's own id is +/// its identity, so once the settled passes have moved the types the key was +/// minted from, the member moves to the slot the settled key names instead of a +/// second key being minted and the first left orphaned. +/// +/// A key that narrowed to a literal at the walk is filed const-folded — a +/// `Name`/`Integer` key minted from a key *expression* (`keyed[kb]` filed as +/// `Name("s")` while `kb` narrowed to that literal) — so candidacy is the key +/// syntax, not the current key variant: any member whose index key is an +/// expression is re-minted, whatever slot the walk filed it under. +/// +/// Returns whether any member key moved. A rekeyed member-map reader may answer +/// differently now, so the settled tail continues the loop — and re-derives +/// every candidate unfiltered next round — when it does. +fn refresh_settled_expr_type_member_keys(db: &mut DbIndex, context: &mut AnalyzeContext) -> bool { + let mut file_ids = context + .analyzed_file_ids() + .into_iter() + .filter(|file_id| { + db.get_member_index() + .get_file_members(*file_id) + .iter() + .any(|member| is_settled_member_rekey_candidate(member)) }) - .cloned() .collect::>(); - if candidates.is_empty() { - return; + if file_ids.is_empty() { + return false; } - candidates.sort_by_key(|return_| (return_.file_id, return_.signature_id.get_position())); + file_ids.sort_unstable(); - // Only the candidate files are re-inferred, so only their caches are stale. - let candidate_files = candidates + let analysis_phase = context.infer_manager.current_phase(); + let dynamic_fields_visible = context.infer_manager.dynamic_fields_visible(); + + // Read-only per file, applied on the caller thread in file and source + // order, like every other settled pass. + 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 Some(root) = db + .get_vfs() + .get_syntax_tree(&file_id) + .map(|tree| tree.get_red_root()) + else { + return Vec::new(); + }; + + let mut members = db + .get_member_index() + .get_file_members(file_id) + .into_iter() + .filter(|member| is_settled_member_rekey_candidate(member)) + .map(|member| (member.get_id(), member.get_key().clone())) + .collect::>(); + members.sort_by_key(|(member_id, _)| member_id.get_syntax_id().get_range().start()); + + let mut updates = Vec::new(); + for (member_id, current_key) in members { + let Some(node) = member_id.get_syntax_id().to_node_from_root(&root) else { + continue; + }; + let index_key = if let Some(index_expr) = LuaIndexExpr::cast(node.clone()) { + index_expr.get_index_key() + } else if let Some(table_field) = LuaTableField::cast(node) { + table_field.get_field_key() + } else { + None + }; + let Some(index_key) = index_key else { + continue; + }; + // A const-folded `Name`/`Integer` member shares its node kind with + // static writes (`t.field` is an index expression too), and only an + // expression key can have folded. Anything else keeps its slot + // without paying for re-inference. + if !current_key.is_expr() && !index_key.is_expr() { + continue; + } + let Ok(settled_key) = + LuaMemberKey::from_index_key_or_unknown(db, &mut infer_cache, &index_key) + else { + continue; + }; + // Only ever a better-resolved key. A key expression that still + // answers `nil`, `unknown`, `any` or an unbound template ref has + // not settled -- it is a read that has not resolved, and filing a + // write under it would move the write off the wildcard key that + // dynamic reads do match and onto one nothing selects. + if settled_key == current_key + || matches!(&settled_key, LuaMemberKey::ExprType(typ) + if !crate::db_index::is_informative_type(typ) || typ.contain_tpl()) + { + continue; + } + updates.push((member_id, settled_key)); + } + updates + }); + + let mut moved = false; + for updates in results { + for (member_id, settled_key) in updates { + if db + .get_member_index_mut() + .rekey_member(member_id, settled_key) + .is_some() + { + moved = true; + } + } + } + moved +} + +/// 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, +) -> HashSet { + let standard = context.settled_global_read_candidates.clone(); + let multi_decl = context.settled_multi_decl_global_read_candidates.clone(); + if standard.is_empty() && multi_decl.is_empty() { + return HashSet::default(); + } + + let files = standard .iter() - .map(|return_| return_.file_id) + .chain(multi_decl.iter()) + .map(|(decl_id, _)| decl_id.file_id) .collect::>(); - context.infer_manager.clear_files(&candidate_files); - - for mut return_ in candidates { - let cache = context.infer_manager.get_infer_cache(return_.file_id); - let _ = unresolve::try_resolve_return_point(db, cache, &mut return_); + let before = standard + .iter() + .chain(multi_decl.iter()) + .map(|(decl_id, _)| LuaTypeOwner::Decl(*decl_id)) + .collect::>() + .into_iter() + .map(|owner| { + let typ = db + .get_type_index() + .get_type_cache(&owner) + .map(|cache| cache.as_type().clone()); + (owner, typ) + }) + .collect::>(); + let generic_initializer_candidates = context.settled_decl_initializer_candidates.clone(); + context.infer_manager.clear_files_deferred_results(&files); + + // `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 { + if generic_initializer_candidates.contains(&decl_id) { + continue; + } + 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 may_improve = initializer_may_improve_after_resolve(&expr); + let is_initializer = db + .get_decl_index() + .get_decl(&decl_id) + .and_then(|decl| decl.get_initializer()) + .is_some_and(|initializer| { + initializer.get_expr_syntax_id() == LuaSyntaxId::from_node(expr.syntax()) + }); + let write = common::DeclWrite { + position: expr.get_position(), + may_improve_after_resolve: may_improve, + reads_out_of_decl: lua::expr_reads_out_of_decl(db, decl_id.file_id, decl_id, &expr), + may_narrow_uninformative: if is_initializer { + may_improve + } else { + initializer_reads_through_call_or_index(&expr) + }, + resolved_initializer: is_initializer && may_improve, + fills_own_default: lua::expr_fills_own_default(db, decl_id.file_id, decl_id, &expr), + }; + 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; + } + if db + .get_type_index() + .get_type_cache(&type_owner) + .is_some_and(|cached| cached.as_type() == &settled) + { + continue; + } + if allow_unsubsumed_swap && is_initializer { + write_type_cache( + db, + type_owner, + LuaTypeCache::InferType(settled), + TypeCacheWriteMode::ForceOverwrite, + ); + } else { + common::bind_decl_write(db, decl_id, LuaTypeCache::InferType(settled), write); + } + } } + let moved = before + .into_iter() + .filter_map(|(owner, before)| { + let after = db + .get_type_index() + .get_type_cache(&owner) + .map(|cache| cache.as_type()); + (after != before.as_ref()).then_some(owner) + }) + .collect::>(); + moved } -/// 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) { - let candidates = std::mem::take(&mut context.settled_member_widening_candidates); +/// Every owner the settled tail can move in one round: the assignment, +/// global, iterator-variable, initializer, copy, member, and call-site +/// return-target candidates. +/// +/// The tail converges on the net boundary state over this set (see +/// [`snapshot_settled_tail_boundary`]), so it must cover every owner any +/// pass of the round can write. +fn settled_tail_movable_owners(context: &AnalyzeContext) -> HashSet { + let mut owners = HashSet::default(); + owners.extend( + context + .settled_assign_candidates + .iter() + .map(|(decl_id, _, _)| LuaTypeOwner::Decl(*decl_id)), + ); + owners.extend( + context + .settled_global_read_candidates + .iter() + .chain(context.settled_multi_decl_global_read_candidates.iter()) + .map(|(decl_id, _)| LuaTypeOwner::Decl(*decl_id)), + ); + owners.extend( + context + .settled_iter_var_candidates + .iter() + .flat_map(|iter_var| { + iter_var.iter_vars.iter().map(|var_name| { + LuaDeclId::new( + iter_var.file_id, + glua_parser::LuaAstToken::get_position(var_name), + ) + }) + }) + .map(LuaTypeOwner::Decl), + ); + owners.extend( + context + .settled_decl_initializer_candidates + .iter() + .map(|decl_id| LuaTypeOwner::Decl(*decl_id)), + ); + owners.extend(context.settled_decl_copy_candidates.iter().cloned()); + owners.extend( + context + .member_initializer_reinfer_candidates + .iter() + .chain(context.guarded_member_read_candidates.iter()) + .chain(context.settled_sibling_merge_read_candidates.iter()) + .map(|member_id| LuaTypeOwner::Member(*member_id)), + ); + owners.extend( + context + .call_site_return_targets + .iter() + .map(|(_, owner, _, _)| owner.clone()), + ); + owners +} + +/// Snapshots the cached type of every owner the settled tail can move. +/// +/// Taken at the top of each tail round; [`diff_settled_tail_boundary`] diffs +/// it at the bottom. Mid-round reports cancel in that diff, so the loop +/// converges on what actually changed across the boundary. +fn snapshot_settled_tail_boundary( + db: &DbIndex, + context: &AnalyzeContext, +) -> HashMap> { + settled_tail_movable_owners(context) + .into_iter() + .map(|owner| { + let cached = db + .get_type_index() + .get_type_cache(&owner) + .map(|cache| cache.as_type().clone()); + (owner, cached) + }) + .collect() +} + +/// Owners whose cached type differs from the round's opening snapshot. +/// +/// Owners the round never wrote compare equal and drop out, and so does an +/// owner one pass moved and a later pass moved back: only the net boundary +/// state remains. +fn diff_settled_tail_boundary( + db: &DbIndex, + before: &HashMap>, +) -> HashSet { + before + .iter() + .filter(|(owner, cached)| { + db.get_type_index() + .get_type_cache(owner) + .map(|cache| cache.as_type()) + != cached.as_ref() + }) + .map(|(owner, _)| owner.clone()) + .collect() +} + +/// Canonical post-round state of every owner the settled tail can move: +/// sorted owner→type pairs. +/// +/// [`FixpointFuse::observe_boundary`] takes this alongside the fingerprint. +/// The fingerprint (a u64 over this same state) is only a lookup accelerator: +/// on a repeat sighting the fuse confirms exact equality of this state before +/// calling it a cycle, so the same net delta recurring while another part of +/// the boundary advances — or a plain hash collision — can never break the +/// loop early. The sort keeps both forms deterministic across hash-map and +/// thread-pool orderings; neither form ever decides an answer, only whether +/// the loop has stopped converging. +fn canonical_settled_tail_state( + db: &DbIndex, + context: &AnalyzeContext, +) -> Vec<(u8, u32, u32, Option)> { + let mut entries = settled_tail_movable_owners(context) + .into_iter() + .map(|owner| { + let kind = match owner { + LuaTypeOwner::Decl(_) => 0u8, + LuaTypeOwner::Member(_) => 1u8, + LuaTypeOwner::SyntaxId(_) => 2u8, + }; + let cached = db + .get_type_index() + .get_type_cache(&owner) + .map(|cache| format!("{:?}", cache.as_type())); + ( + kind, + owner.get_file_id().id, + u32::from(owner.get_position()), + cached, + ) + }) + .collect::>(); + entries.sort(); + entries +} + +/// Equality-only fingerprint of [`canonical_settled_tail_state`] for +/// [`FixpointFuse::observe_boundary`]: compared for equality only, never used +/// to order, index, or otherwise decide answers (see the fuse docs). +fn fingerprint_settled_tail_state(canonical: &[(u8, u32, u32, Option)]) -> u64 { + use std::hash::{Hash, Hasher}; + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + canonical.hash(&mut hasher); + hasher.finish() +} + +/// Re-derives local assignments whose right-hand side reads a local the +/// settled tail has since moved (`selected = key` after the loop variable +/// settled). +/// +/// The walk bound these writes to whatever the source held when the file was +/// walked, and unlike `local x = ` copies they read no initializer cache +/// of their own the read-set filter could see move. +/// +/// Each write goes through [`bind_decl_write`](common::bind_decl_write), so +/// the replay keeps the source-order claim and write-authority semantics a +/// direct commit would have applied: an earlier writer still owns the slot, +/// and only this assignment's own right-hand side is re-read — never the +/// initializer alone, and never every write to a mutable local at once. +/// +/// One round commits at most one write per declaration, picked from a stable +/// pre-round snapshot with the claim holder first and the earliest +/// widening/superseding write after it, so the same cache is never replaced +/// twice in a row. A commit lands when the settled answer structurally widens +/// what the round opened with (a union holding every cached arm, a primitive +/// collapsing the cached literals, or a merge covering both at once), when it +/// supersedes it by the cache's own replacement order, or through the +/// sideways/placeholder leg ([`settled_assign_write_committable`]): the claim +/// holder replaces its own contribution with any informative, leak-free +/// settled answer — even a structurally unrelated one, which is what a +/// multi-declaration global merge is; a floored placeholder (`unknown`, `nil`, +/// `never`) or a leaked template parameter takes any informative, leak-free +/// settled answer from any write; and an informative settled answer displaces +/// `any`. The settled answer must always say something leak-free: the walk +/// never trades an answer for `nil`, `unknown`, or `any`, and never seeds a +/// leak, so neither does the replay — in particular the holder cannot move +/// backward (`any -> nil`). Anything else keeps the walk-time answer, exactly +/// as builds without this pass do. +/// +/// A trailing variadic right-hand side supplies one slot per extra target +/// (`first, second = echo(key)`): each queued target carries its return index +/// and selects that slot out of the settled answer, falling back to `nil` +/// exactly as the walk does. Extra slots over a non-variadic answer are +/// skipped: the walk answered those without reading the right-hand side's +/// type, so there is nothing to re-derive. +fn rederive_settled_assign_reads( + db: &mut DbIndex, + context: &mut AnalyzeContext, +) -> HashSet { + let mut candidates = context.settled_assign_candidates.clone(); if candidates.is_empty() { - return; - } - let mut candidates = candidates.into_iter().collect::>(); - candidates.sort_by_key(|(member_id, _)| (member_id.file_id, member_id.get_position())); + return HashSet::default(); + } + candidates.sort_by_key(|(decl_id, expr, ret_idx)| { + ( + decl_id.file_id, + decl_id.position, + expr.get_range().start(), + *ret_idx, + ) + }); - for (member_id, (assigned_type, preserve_table_literals)) in candidates { - // Only an inferred assignment cache is this pass' to rewrite: a doc type - // outranks inference, and anything else reaching the slot was written by - // an authority this pass has no evidence to overrule. - if !db + // Stable pre-round snapshot: the claim holder and the cached answer every + // commit decision below reads. Deciding against the live cache would let + // an earlier commit of this same round elect the next winner, replacing + // one cache twice in a row. + let mut snapshots = HashMap::, Option)>::default(); + for (decl_id, _, _) in &candidates { + snapshots.entry(*decl_id).or_insert_with(|| { + let cached = db + .get_type_index() + .get_type_cache(&LuaTypeOwner::Decl(*decl_id)) + .map(|cache| cache.as_type().clone()); + let claim = db + .get_type_index() + .decl_write_claim(decl_id) + .map(|(position, _)| position); + (cached, claim) + }); + } + + let files = candidates + .iter() + .map(|(decl_id, _, _)| decl_id.file_id) + .collect::>(); + context.infer_manager.clear_files_deferred_results(&files); + // The replay re-reads moved declarations through flow narrowing, and a + // successful flow answer survives the deferred-results clearing above. + // Without voiding those answers the first round's narrowing keeps serving + // the pre-settled declaration type, so a write whose source settles later + // (a loop variable widened by a rekeyed member key) replays stale forever. + context.infer_manager.clear_files_flow_results(&files); + + // The settled slot type per candidate, in candidate order. `None` is not + // a verdict on the declaration, only on this write: another write to the + // same declaration may still win the round. + let mut settled = Vec::>::with_capacity(candidates.len()); + for (decl_id, expr, ret_idx) in &candidates { + let type_owner = LuaTypeOwner::Decl(*decl_id); + if db .get_type_index() - .get_type_cache(&member_id.into()) - .is_some_and(|cache| cache.is_infer()) + .get_type_cache(&type_owner) + .is_some_and(|cached| cached.is_doc()) { + settled.push(None); continue; } + let cache = context.infer_manager.get_infer_cache(decl_id.file_id); + let Ok(inferred) = crate::semantic::infer_expr(db, cache, expr.clone()) else { + settled.push(None); + continue; + }; + match inferred { + LuaType::Variadic(multi) => { + settled.push(Some( + multi.get_type(*ret_idx).cloned().unwrap_or(LuaType::Nil), + )); + } + inferred => { + if *ret_idx > 0 { + settled.push(None); + } else { + settled.push(Some(inferred)); + } + } + } + } - let type_owner = LuaTypeOwner::Member(member_id); - let Some(widened_type) = lua::get_widened_member_assignment_type( + // At most one winning write per declaration: the claim holder first (the + // only write allowed a sideways replace of its own contribution), then + // the earliest committable write in source order. + let mut winners = Vec::<(LuaDeclId, LuaExpr, LuaType)>::new(); + let mut group_start = 0; + while group_start < candidates.len() { + let group_decl = candidates[group_start].0; + let mut group_end = group_start + 1; + while group_end < candidates.len() && candidates[group_end].0 == group_decl { + group_end += 1; + } + let (cached, claim) = &snapshots[&group_decl]; + let mut order: Vec = (group_start..group_end).collect(); + if let Some(claim) = claim + && let Some(holder) = order + .iter() + .position(|idx| candidates[*idx].1.get_position() == *claim) + { + let holder = order.remove(holder); + order.insert(0, holder); + } + for idx in order { + let Some(settled_type) = &settled[idx] else { + continue; + }; + if cached.as_ref().is_some_and(|cached| cached == settled_type) { + continue; + } + let (_, rhs, _) = &candidates[idx]; + let holder = claim + .as_ref() + .copied() + .is_some_and(|claim| claim == rhs.get_position()); + // An assignment is never the declaration's own initializer, but + // spell out the same write the file walk would have committed so + // a later competing write faces identical arbitration. + // + // Settle-only monotonicity: the tail loops until no round moves, + // so a replay must never trade sideways or backwards. Walk-time + // arbitration can displace a settled answer and lose it back next + // round (observed: the same owners alternating every round, + // hanging cold builds), so only a replay that widens the cached + // answer, supersedes it by the cache's own replacement order, or + // passes the sideways/placeholder gate below may commit. See + // [`settled_assign_write_committable`]. + let committable = + settled_assign_write_committable(holder, settled_type, cached.as_ref()); + if committable { + let (_, rhs, _) = &candidates[idx]; + winners.push((group_decl, rhs.clone(), settled_type.clone())); + break; + } + } + group_start = group_end; + } + + for (decl_id, rhs, settled_type) in winners { + let type_owner = LuaTypeOwner::Decl(decl_id); + let may_improve = initializer_may_improve_after_resolve(&rhs); + let write = common::DeclWrite { + position: rhs.get_position(), + may_improve_after_resolve: may_improve, + reads_out_of_decl: lua::expr_reads_out_of_decl(db, decl_id.file_id, decl_id, &rhs), + may_narrow_uninformative: initializer_reads_through_call_or_index(&rhs), + resolved_initializer: false, + fills_own_default: lua::expr_fills_own_default(db, decl_id.file_id, decl_id, &rhs), + }; + let before_commit = db + .get_type_index() + .get_type_cache(&type_owner) + .map(|cache| cache.as_type().clone()); + common::bind_decl_write( db, - &type_owner, - &assigned_type, - preserve_table_literals, - &mut false, - ) else { + decl_id, + LuaTypeCache::InferType(settled_type.clone()), + write, + ); + let refused = db + .get_type_index() + .get_type_cache(&type_owner) + .map(|cache| cache.as_type().clone()) + == before_commit; + if refused { + // The ordered write refused the slot — a later holder owns it, or + // the fallback only merges — but this round's arbitration picked + // this write, so it replaces the previous contribution outright + // instead of re-merging it. + common::write_type_cache( + db, + type_owner, + LuaTypeCache::InferType(settled_type), + common::TypeCacheWriteMode::ForceOverwrite, + ); + } + } + let moved = snapshots + .into_iter() + .filter_map(|(decl_id, (before, _))| { + let owner = LuaTypeOwner::Decl(decl_id); + let after = db + .get_type_index() + .get_type_cache(&owner) + .map(|cache| cache.as_type()); + (after != before.as_ref()).then_some(owner) + }) + .collect::>(); + moved +} + +/// Commit gate for one settled assign-replay write: whether `settled_type` +/// may replace `cached` this round. +/// +/// `None` seeds an empty slot, which always takes the read. Otherwise a write +/// commits when the settled answer structurally widens the cached one, when +/// it supersedes it by the cache's own replacement order, or through the +/// sideways/placeholder leg below. +pub(crate) fn settled_assign_write_committable( + holder: bool, + settled_type: &LuaType, + cached: Option<&LuaType>, +) -> bool { + let Some(cached) = cached else { + return true; + }; + union_widens_cached_type(settled_type, cached) + || settled_widens_cached_literals(settled_type, cached) + || settled_covers_cached_type(settled_type, cached) + || LuaTypeCache::InferType(settled_type.clone()) + .supersedes(&LuaTypeCache::InferType(cached.clone())) + || settled_assign_sideways_committable(holder, settled_type, cached) +} + +/// Sideways/placeholder leg of the settled assign-replay commit gate. +/// +/// Three explicit clauses, in order: +/// +/// 1. Holder sideways: the write holds the declaration's current write claim, +/// so the slot keeps this write's own contribution. Any informative, +/// leak-free settled answer replaces it — even a structurally unrelated +/// one, which is what a multi-declaration global merge is. The informative +/// bar is what rejects backward moves: `any -> nil` cannot commit here +/// because `nil` says nothing about the value. +/// +/// 2. Placeholder/leak replacement: a floored placeholder or a leaked template +/// parameter is not an answer at all, so the settled read takes it the way +/// the walk seeds an empty slot. A placeholder is `unknown`, `nil`, or +/// `never` ([`is_undetermined_type`](crate::db_index::is_undetermined_type)) +/// — deliberately not bare "uninformative", so `any`, which states the +/// value is unconstrained, is never treated as an unfinished placeholder. +/// Any write may take these, which is what lets a deferred multi-target +/// (`first, second = echo(key)`) land its settled slot over the floored +/// placeholder — neither widening nor `supersedes` relates the two — and +/// what lets a leaked template resolve to concrete. +/// +/// 3. Informative over `any`: `any` is authoritative, not unfinished, so only +/// a genuinely informative settled type displaces it. (`supersedes` ranks +/// inside the uninformative band but never lets an informative type beat +/// `any`; this clause is that ranking.) +fn settled_assign_sideways_committable(holder: bool, settled: &LuaType, cached: &LuaType) -> bool { + // A settled answer that says nothing, or one carrying a leak, never + // commits through this leg: the walk never trades an answer for `nil`, + // `unknown`, or `any`, and never seeds a slot with a leak. + if !crate::db_index::is_informative_type(settled) + || crate::db_index::leaks_unsubstituted_tpl(settled) + { + return false; + } + if holder { + return true; + } + if crate::db_index::is_undetermined_type(cached) + || crate::db_index::leaks_unsubstituted_tpl(cached) + { + return true; + } + !crate::db_index::is_informative_type(cached) && !crate::db_index::is_undetermined_type(cached) +} + +/// Re-derives decls whose `panel:GetParent()` read was answered against a vgui +/// parent chain state that has since settled differently. +/// +/// 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) { + // Kept on the context: the deferred parent calls resolve their relations + // after this first runs, and the same readers have to be re-derived again. + let files = context.vgui_parent_fallback_files.clone(); + 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; }; + let mut decl_ids = db + .get_type_index() + .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() { + 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 { + 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, + }; + 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)); + } + } + } +} - db.get_type_index_mut() - .force_bind_type(type_owner, LuaTypeCache::InferType(widened_type)); +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, + } +} + +/// 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. #[derive(Default)] struct InferredGuardFixedPointStats { attempts: usize, @@ -537,21 +1759,256 @@ fn resolve_early_member_owners(db: &mut DbIndex, context: &mut AnalyzeContext) - common::add_member(db, LuaMemberOwner::Type(type_id), member_id); resolved += 1; } - resolved + resolved +} + +/// Which pass is re-reading the initializer caches. +#[derive(Clone, Copy)] +enum InitializerPass<'a> { + /// The full pass, which reads every candidate once. + Full, + /// The settled tail, which only re-reads a candidate whose evidence moved. + /// `moved` is `None` on the first round, before any read set exists. + Settled { + moved: Option<&'a HashSet>, + }, +} + +impl<'a> InitializerPass<'a> { + /// Whether the index has settled, so an answer taken now is final rather + /// than one the rest of the batch may still improve. + fn is_settled(self) -> bool { + matches!(self, InitializerPass::Settled { .. }) + } + + /// The owners the previous round moved, or `None` to re-read everything. + fn moved(self) -> Option<&'a HashSet> { + match self { + InitializerPass::Full => None, + InitializerPass::Settled { moved } => moved, + } + } +} + +fn refresh_initializer_caches(db: &mut DbIndex, context: &mut AnalyzeContext) { + let decl_copies = HashSet::default(); + let mut written = + refresh_decl_initializer_caches(db, context, InitializerPass::Full, &decl_copies); + written.extend(refresh_member_initializer_caches( + db, + context, + InitializerPass::Full, + &decl_copies, + )); + queue_settled_decl_dependents(db, context, &written); +} + +/// [`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, + moved_owners: Option<&HashSet>, +) -> HashSet { + // Members first: a declaration that reads one of them takes its answer from + // whatever the member holds when the read is taken. + let pass = InitializerPass::Settled { + moved: moved_owners, + }; + let decl_copies = std::mem::take(&mut context.settled_decl_copy_candidates); + let mut written = refresh_member_initializer_caches(db, context, pass, &decl_copies); + written.extend(refresh_decl_initializer_caches( + db, + context, + pass, + &decl_copies, + )); + written +} + +/// Queues the writes whose value reads a local the settled tail just moved. +/// +/// `local time = ...` and the loop variable `d` are re-read against the +/// settled index above, but `self.ReloadDelay = time` and +/// `d.data = istable(d.data) and d.data or {}` were bound during the walk to +/// what the local held then, and read no cache of their own the read-set +/// filter could see move. A re-index binds them from the settled local +/// directly, so they are re-derived here for the next round to take the same +/// answer. +fn queue_settled_decl_dependents( + db: &DbIndex, + context: &mut AnalyzeContext, + written: &HashSet, +) { + let mut decl_ids = written + .iter() + .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 file_id = decl_id.file_id; + let Some(references) = db + .get_reference_index() + .get_decl_references(&file_id, &decl_id) + else { + continue; + }; + let Some(root) = db + .get_vfs() + .get_syntax_tree(&file_id) + .map(|tree| tree.get_red_root()) + else { + continue; + }; + for cell in references.cells.iter().filter(|cell| !cell.is_write) { + let Some(name_expr) = root + .covering_element(cell.range) + .ancestors() + .find(|node| node.text_range() == cell.range) + .and_then(LuaNameExpr::cast) + else { + continue; + }; + // The nearest statement the read sits in: a value expression of + // an assignment or a local declaration. + let Some(stat) = name_expr.syntax().ancestors().skip(1).find(|node| { + glua_parser::LuaAssignStat::can_cast(node.kind().into()) + || glua_parser::LuaLocalStat::can_cast(node.kind().into()) + }) else { + continue; + }; + let contains_read = + |expr: &LuaExpr| expr.get_range().contains_range(name_expr.get_range()); + if let Some(assign_stat) = glua_parser::LuaAssignStat::cast(stat.clone()) { + let (vars, exprs) = assign_stat.get_var_and_expr_list(); + let Some(idx) = exprs.iter().position(contains_read) else { + continue; + }; + let rhs = exprs[idx].clone(); + // A trailing call supplies one slot per extra target + // (`first, second = echo(key)`): every target the read's + // right-hand side supplies queues with its return index, so + // the replay selects that slot out of the settled variadic + // answer instead of replaying only the paired target. + let trailing_extras = (idx + 1 == exprs.len() && vars.len() > exprs.len()) + .then(|| idx + 1..vars.len()) + .into_iter() + .flatten(); + for var_pos in std::iter::once(idx).chain(trailing_extras) { + let ret_idx = var_pos - idx; + match vars.get(var_pos) { + Some(glua_parser::LuaVarExpr::IndexExpr(index_expr)) => { + let member_id = LuaMemberId::new( + LuaSyntaxId::from_node(index_expr.syntax()), + file_id, + ); + if db.get_member_index().get_member(&member_id).is_none() { + continue; + } + context + .member_initializer_reinfer_candidates + .insert(member_id); + context + .settled_decl_copy_candidates + .insert(LuaTypeOwner::Member(member_id)); + } + Some(glua_parser::LuaVarExpr::NameExpr(target)) => { + // An assignment onto a local reads no initializer cache + // of its own, so the read-set filter cannot see it move. + // Queue the write itself: the settled tail re-derives it + // through `bind_decl_write`, which keeps the source-order + // claim and write-authority semantics a direct commit + // would have applied. + // + // Globals have no local reference; fall back to the same + // identity `get_var_owner` binds the walk-time write to + // (a decl-index entry at the use-site position, else + // the use-site position itself) so the replay writes + // the owner the walk claimed. + let maybe_decl_id = LuaDeclId::new(file_id, target.get_position()); + let target_decl_id = + if db.get_decl_index().get_decl(&maybe_decl_id).is_some() { + maybe_decl_id + } else { + db.get_reference_index() + .get_local_reference(&file_id) + .and_then(|refs| refs.get_decl_id(&target.get_range())) + .unwrap_or(maybe_decl_id) + }; + if target_decl_id == decl_id { + continue; + } + // A read out of the assigned decl never seeds it; the + // walk refuses such writes outright, so there is nothing + // to replay here either. + if lua::expr_reads_out_of_decl(db, file_id, target_decl_id, &rhs) { + continue; + } + if !context.settled_assign_candidates.iter().any( + |(queued_decl, queued_expr, queued_ret_idx)| { + *queued_decl == target_decl_id + && queued_expr.get_range() == rhs.get_range() + && *queued_ret_idx == ret_idx + }, + ) { + context.settled_assign_candidates.push(( + target_decl_id, + rhs.clone(), + ret_idx, + )); + } + } + _ => {} + } + } + } else if let Some(local_stat) = glua_parser::LuaLocalStat::cast(stat) { + let Some(value) = local_stat.get_value_exprs().find(contains_read) else { + continue; + }; + let Some(local_name) = local_stat.get_local_name_by_value(value) else { + continue; + }; + let copy_decl_id = LuaDeclId::new(file_id, local_name.get_position()); + // A local written again later holds the merge of every write; + // re-reading the initializer alone would drop the others. + if db + .get_reference_index() + .get_decl_references(&file_id, ©_decl_id) + .is_some_and(|references| references.cells.iter().any(|cell| cell.is_write)) + { + continue; + } + context + .settled_decl_initializer_candidates + .insert(copy_decl_id); + context + .settled_decl_copy_candidates + .insert(LuaTypeOwner::Decl(copy_decl_id)); + } + } + } } -fn refresh_initializer_caches(db: &mut DbIndex, context: &mut AnalyzeContext) { - refresh_local_decl_initializer_caches(db, context); - refresh_member_initializer_caches(db, context); -} - -fn refresh_local_decl_initializer_caches(db: &mut DbIndex, context: &mut AnalyzeContext) { - if context.uninformative_local_decl_candidates.is_empty() { - return; +fn refresh_decl_initializer_caches( + db: &mut DbIndex, + context: &mut AnalyzeContext, + pass: InitializerPass<'_>, + decl_copies: &HashSet, +) -> HashSet { + if context.settled_decl_initializer_candidates.is_empty() { + return HashSet::default(); } - let mut candidates_by_file = HashMap::>::new(); - for decl_id in &context.uninformative_local_decl_candidates { + let mut candidates_by_file = HashMap::>::default(); + for decl_id in &context.settled_decl_initializer_candidates { candidates_by_file .entry(decl_id.file_id) .or_default() @@ -561,9 +2018,19 @@ fn refresh_local_decl_initializer_caches(db: &mut DbIndex, context: &mut Analyze candidates.sort_by_key(|decl_id| decl_id.position); } let mut file_ids = candidates_by_file.keys().copied().collect::>(); + file_ids.retain(|file_id| { + context.settled_file_reads_moved(*file_id, pass.moved()) + || candidates_by_file[file_id] + .iter() + .any(|decl_id| decl_copies.contains(&LuaTypeOwner::Decl(*decl_id))) + }); file_ids.sort(); + if file_ids.is_empty() { + return HashSet::default(); + } let analysis_phase = context.infer_manager.current_phase(); let dynamic_fields_visible = context.infer_manager.dynamic_fields_visible(); + let pass_is_settled = pass.is_settled(); // Initializer inference reads the stabilized indexes and records candidate // cache writes without mutating the database. Process that read-only work @@ -586,12 +2053,24 @@ fn refresh_local_decl_initializer_caches(db: &mut DbIndex, context: &mut Analyze return InitializerRefreshResult::new(file_id); }; let mut result = InitializerRefreshResult::new(file_id); + crate::db_index::read_set::arm(); for decl_id in &candidates_by_file[&file_id] { let type_owner = (*decl_id).into(); let current_cache = db.get_type_index().get_type_cache(&type_owner).cloned(); if current_cache.as_ref().is_some_and(LuaTypeCache::is_doc) { continue; } + // A slot with no cache belongs to a deferred item that has not + // resolved yet. Mid-batch a seed here would be read against whatever + // the index held at this pass and then hold the item's real answer + // out, so the full pass leaves it alone. Once the index has settled + // there is no later answer to hold out, and skipping instead strands + // the slot: `stabilize_unknown_locals` takes it for genuinely + // unresolved and derives it from a use, so an initializer that + // settled facts can answer loses to usage context. + if current_cache.is_none() && !pass_is_settled { + continue; + } let current_is_uninformative = type_cache_is_uninformative(current_cache.as_ref()); let current_fact = db.get_type_index().get_type_fact(&type_owner); let target_node = LuaInferenceNodeId::TypeOwner(type_owner.clone()); @@ -611,7 +2090,40 @@ fn refresh_local_decl_initializer_caches(db: &mut DbIndex, context: &mut Analyze 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 = + pass.is_settled() && common::reads_settling_iter_var(db, file_id, &expr); + let copies_settled_decl = pass.is_settled() && decl_copies.contains(&type_owner); + if !copies_settled_iter_var + && !copies_settled_decl + && !initializer_reads_through_call_or_index(&expr) + { + continue; + } + // A slot whose writers had not all landed answers with a flat + // union instead of the merge, and that answer was cached as if it + // were final. See [`cached_provisional_slot_union`]. + let cached_a_provisional_slot_union = !current_is_uninformative + && current_cache + .as_ref() + .is_some_and(|current| cached_provisional_slot_union(current.as_type())); + // An `unknown` arm is the read saying it could not settle that part + // of the union yet. See [`union_has_unsettled_arm`]. + let cached_an_unsettled_union = !current_is_uninformative + && current_cache + .as_ref() + .is_some_and(|current| union_has_unsettled_arm(current.as_type())); + if !copies_settled_iter_var + && !copies_settled_decl + && pass.is_settled() + && !current_is_uninformative + && !can_refine_nominal_type + && !can_upgrade_authority + && !cached_a_provisional_slot_union + && !cached_an_unsettled_union + { continue; } @@ -625,11 +2137,25 @@ fn refresh_local_decl_initializer_caches(db: &mut DbIndex, context: &mut Analyze // the batch, not of the source: cold cached `false` for // `local on = LocalPlayer()._flag or false` where re-analysing the // same unchanged file cached `true`. - let inferred_fact = select_result_fact( - infer_expr_fact_with_cache(db, &mut infer_cache, expr.clone()), - ret_idx, - ); + let inferred_full_fact = infer_expr_fact_with_cache(db, &mut infer_cache, expr.clone()); + let resolved_variadic_tail = + ret_idx > 0 && selects_resolved_result(inferred_full_fact.typ(), ret_idx); + let inferred_fact = select_result_fact(inferred_full_fact, ret_idx); + let inferred_fact = if copies_settled_iter_var { + inferred_fact.with_runtime_type(widen_direct_literal_copy(inferred_fact.typ())) + } else { + inferred_fact + }; let inferred_type = inferred_fact.typ().clone(); + // An `unknown` arm says this read has not settled yet; it is no + // evidence against an answer that has. A re-index walks the same + // read once everything has landed and never caches the arm. + if union_has_unsettled_arm(&inferred_type) + && !current_is_uninformative + && !cached_an_unsettled_union + { + continue; + } // Only asked when nothing else would let the settled read through // and it actually disagrees with the cache, so the second inference @@ -659,10 +2185,12 @@ fn refresh_local_decl_initializer_caches(db: &mut DbIndex, context: &mut Analyze .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 + && !cached_an_unsettled_union { continue; } @@ -674,7 +2202,12 @@ fn refresh_local_decl_initializer_caches(db: &mut DbIndex, context: &mut Analyze // decided by arrival order. Canonicalize to `unknown`, // which is the honest answer and is opaque to the checkers, // so it neither silences a real report nor invents one. - if is_bottom(&inferred_type) + if copies_settled_decl { + result.updates.push(InitializerCacheUpdate::Overwrite { + owner: type_owner, + fact: inferred_fact, + }); + } else if is_bottom(&inferred_type) && current_cache .as_ref() .is_some_and(|current| is_bottom(current.as_type())) @@ -684,6 +2217,63 @@ fn refresh_local_decl_initializer_caches(db: &mut DbIndex, context: &mut Analyze owner: type_owner, fact: inferred_fact.with_runtime_type(LuaType::Unknown), }); + } else if is_bottom(&inferred_type) + && resolved_variadic_tail + && current_cache + .as_ref() + .is_none_or(|current| current.as_type().is_unknown()) + { + // The walk cached `unknown` because the read had not + // resolved yet; the settled read resolves it to a bottom. + // A re-index walks the same read against the settled state + // and caches that bottom directly, so leaving `unknown` + // here would make the cache depend on which path derived + // it. `Bind` refuses an uninformative replacement, so this + // goes through the force path. + result.updates.push(InitializerCacheUpdate::Overwrite { + owner: type_owner, + fact: inferred_fact, + }); + } else if pass.is_settled() + && !is_bottom(&inferred_type) + && current_cache.as_ref().is_some_and(|current| { + inferred_type.is_any() + || (current_is_uninformative && !is_bottom(current.as_type())) + }) + { + // The settled read found a writer the walk had not seen + // yet, and that writer reads as `any`. A re-index walks + // the same read against the settled slot and caches `any` + // directly, so the walk's early answer has to go the same + // way here or the two builds disagree. `unknown` is the + // walk's placeholder for the same unfinished read. + result.updates.push(InitializerCacheUpdate::Overwrite { + owner: type_owner, + fact: inferred_fact, + }); + } else if pass.is_settled() + && 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. + // + // Settled passes only. The rule is a one-way ratchet: it + // lets a more permissive answer replace the cache and never + // the reverse, so a full pass placing `any` outranks every + // settled round that answers `nil`, and the transient one + // stands. Which pass read the more complete index is a + // property of the batch — a warm re-index kept `any` for + // `report.data[1]` where a cold build kept `nil`. + result.updates.push(InitializerCacheUpdate::Bind { + owner: type_owner, + fact: inferred_fact, + }); } continue; } @@ -713,18 +2303,32 @@ fn refresh_local_decl_initializer_caches(db: &mut DbIndex, context: &mut Analyze && current_cache.as_ref().is_some_and(|current| { is_strict_nominal_refinement(db, &inferred_type, current.as_type()) }); - let is_settled_widening = current_cache + let is_settled_widening = current_cache.as_ref().is_some_and(|current| { + union_widens_cached_type(&inferred_type, current.as_type()) + || settled_widens_cached_literals(&inferred_type, current.as_type()) + }); + // A cache carrying an unsubstituted template parameter records that + // the generic call it came from never got instantiated, and the + // settled read has now instantiated it. `Bind` weighs the two as + // types, and the leak reads as a real one, so this goes through the + // force path. + let cached_a_tpl_leak = current_cache .as_ref() - .is_some_and(|current| union_widens_arm(&inferred_type, current.as_type())); - if current_is_uninformative { + .is_some_and(|current| crate::db_index::leaks_unsubstituted_tpl(current.as_type())); + if current_is_uninformative && !cached_a_tpl_leak { result.updates.push(InitializerCacheUpdate::Bind { owner: type_owner, fact: inferred_fact, }); - } else if has_stronger_declared_authority + } else if cached_a_tpl_leak + || copies_settled_iter_var + || copies_settled_decl + || has_stronger_declared_authority || is_nominal_refinement || is_settled_widening || cached_a_blind_dynamic_field_read + || cached_a_provisional_slot_union + || cached_an_unsettled_union { result.updates.push(InitializerCacheUpdate::Overwrite { owner: type_owner, @@ -732,6 +2336,7 @@ fn refresh_local_decl_initializer_caches(db: &mut DbIndex, context: &mut Analyze }); } } + result.reads = crate::db_index::read_set::disarm(); result.pending_type_decls = infer_cache.take_pending_str_tpl_type_decls(); result.guard_dependencies = infer_cache.take_inferred_guard_dependencies(); result @@ -739,6 +2344,7 @@ fn refresh_local_decl_initializer_caches(db: &mut DbIndex, context: &mut Analyze let mut updates = Vec::new(); for result in results { + context.record_settled_reads(result.file_id, result.reads); context.infer_manager.merge_inference_side_effects( result.file_id, result.pending_type_decls, @@ -746,26 +2352,60 @@ fn refresh_local_decl_initializer_caches(db: &mut DbIndex, context: &mut Analyze ); updates.extend(result.updates); } - apply_initializer_cache_updates(db, updates); + apply_initializer_cache_updates(db, updates) } -/// Whether the re-derived type is a union that already contains the cached one. +/// Whether the cached answer is the flat union a member slot falls back to when +/// one of its writers had no type yet. /// -/// 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, +/// `resolve_member_type`'s `All` arm merges a slot's writers into one table only +/// when every one of them already reads as a table assignment; a sibling the +/// walk has not reached flips it to a plain union of the table literals +/// instead. What decided that is how far the batch had got, not the source, so +/// the read is provisional and worth taking again once every writer has landed. +/// +/// A settled merge answers `MergedTable`, never this shape, so re-reading is +/// idempotent: the repaired cache no longer matches. +/// +/// An arm carrying no type of its own is not one of the slot's writers: the +/// read adds `nil` for a branch it found no member on and `any` for a sibling +/// it could not resolve. Letting one veto the shape left the repair unable to +/// see the very unions it exists for — a double dynamic index over a table +/// several files write reads as `nil|any|<2052 literals>`. +fn cached_provisional_slot_union(typ: &LuaType) -> bool { + let LuaType::Union(union) = typ else { + return false; + }; + let mut count = 0; + for component in union.types() { + if !crate::db_index::is_informative_type(component) { + continue; + } + if !matches!(component, LuaType::TableConst(_)) { + return false; + } + count += 1; } + count > 1 +} + +/// Whether the cached answer is a union one of whose arms never settled. +/// +/// An `unknown` arm is not a value the slot can hold; it is the read reporting +/// that the contributor behind that arm had not resolved when the answer was +/// taken. Whether it had is a property of how far the batch got, so the read is +/// provisional and worth taking again once everything has landed. +pub(crate) fn union_has_unsettled_arm(typ: &LuaType) -> bool { + matches!(typ, LuaType::Union(union) if union.types().any(|arm| matches!(arm, LuaType::Unknown))) } -/// Whether the re-derived union contains everything the cached type holds, plus -/// more — the union-to-union counterpart of [`union_widens_arm`]. +/// Whether the re-derived type is a union that already contains everything the +/// cached one holds, plus more. /// -/// 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. +/// 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. 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; @@ -781,6 +2421,60 @@ pub(crate) fn union_widens_cached_type(inferred: &LuaType, current: &LuaType) -> } } +/// Whether the settled answer is the primitive every arm of the cached union +/// widens to. +/// +/// The cache then lists the literals this batch had reached when the read was +/// taken, and the settled read answers with the primitive the whole writer set +/// shares — `""|"Public Area"|"Public Property"` against `string` for a map +/// table several files write. Which literals were in it is decided by how far +/// the batch had run, so the collapsed answer replaces them. +/// [`union_widens_cached_type`] covers the same snapshot where the settled +/// answer is itself a union; this is that answer already collapsed. +/// +/// Every arm has to be a literal the settled answer is the primitive *of*. +/// An arm carrying no type of its own is not one: `supersedes` would take a +/// `nil` arm as replaceable and the collapse would drop it, turning +/// `string|nil` into `string` and silencing every nil check on the slot. +fn settled_widens_cached_literals(inferred: &LuaType, current: &LuaType) -> bool { + let LuaType::Union(union) = current else { + return false; + }; + let mut arms = union.types().peekable(); + arms.peek().is_some() + && arms.all(|arm| { + crate::db_index::is_informative_type(arm) + && LuaTypeCache::InferType(inferred.clone()) + .supersedes(&LuaTypeCache::InferType(arm.clone())) + }) +} + +/// Whether the settled answer is the cached one with more of the writer set +/// folded in: every arm the cache holds is either an arm of the settled answer +/// or a literal that one of its arms widens to. +/// +/// [`union_widens_cached_type`] and [`settled_widens_cached_literals`] each +/// cover one shape of this; a sibling merge produces both at once, `"a"|"b"` +/// against `string|number` when a computed-key writer joins widened literal +/// entries. A `nil` arm is covered only by a `nil` arm, so the collapse never +/// drops one. +fn settled_covers_cached_type(inferred: &LuaType, current: &LuaType) -> bool { + fn arms(typ: &LuaType) -> Vec { + match typ { + LuaType::Union(union) => known_arms(union), + other => vec![other.clone()], + } + } + let inferred_arms = arms(inferred); + let current_arms = arms(current); + !current_arms.is_empty() + && current_arms.iter().all(|cached| { + inferred_arms.iter().any(|settled| { + settled == cached || crate::db_index::widens_primitive(settled, cached) + }) + }) +} + /// The union arms that carry information. An `unknown` arm stands for a type /// that has not settled yet, so it can neither widen a cache nor block one. fn known_arms(union: &LuaUnionType) -> Vec { @@ -791,13 +2485,49 @@ fn known_arms(union: &LuaUnionType) -> Vec { .collect() } -fn refresh_member_initializer_caches(db: &mut DbIndex, context: &mut AnalyzeContext) { - if context.member_initializer_reinfer_candidates.is_empty() { - return; +fn widen_direct_literal_copy(typ: &LuaType) -> LuaType { + if matches!( + typ, + LuaType::IntegerConst(_) + | LuaType::FloatConst(_) + | LuaType::StringConst(_) + | LuaType::BooleanConst(_) + ) { + crate::widen_literal_type_for_assignment(typ) + } else { + typ.clone() + } +} + +fn refresh_member_initializer_caches( + db: &mut DbIndex, + context: &mut AnalyzeContext, + pass: InitializerPass<'_>, + decl_copies: &HashSet, +) -> HashSet { + // A sibling merge is only worth re-reading once the writers it merged have + // settled, which the full pass runs ahead of. + let no_sibling_merges = HashSet::default(); + let sibling_merges = if pass.is_settled() { + &context.settled_sibling_merge_read_candidates + } else { + &no_sibling_merges + }; + if context.member_initializer_reinfer_candidates.is_empty() + && context.guarded_member_read_candidates.is_empty() + && sibling_merges.is_empty() + { + return HashSet::default(); } - let mut candidates_by_file = HashMap::>::new(); - for member_id in &context.member_initializer_reinfer_candidates { + let guarded = &context.guarded_member_read_candidates; + let mut candidates_by_file = HashMap::>::default(); + for member_id in context + .member_initializer_reinfer_candidates + .iter() + .chain(guarded) + .chain(sibling_merges) + { candidates_by_file .entry(member_id.file_id) .or_default() @@ -805,9 +2535,21 @@ fn refresh_member_initializer_caches(db: &mut DbIndex, context: &mut AnalyzeCont } for candidates in candidates_by_file.values_mut() { candidates.sort_by_key(LuaMemberId::get_position); + // The two candidate sets overlap: a guarded read can also be a nominal + // one, and re-deriving it twice would only repeat the same answer. + candidates.dedup(); } let mut file_ids = candidates_by_file.keys().copied().collect::>(); + file_ids.retain(|file_id| { + context.settled_file_reads_moved(*file_id, pass.moved()) + || candidates_by_file[file_id] + .iter() + .any(|member_id| decl_copies.contains(&LuaTypeOwner::Member(*member_id))) + }); file_ids.sort(); + if file_ids.is_empty() { + return HashSet::default(); + } let analysis_phase = context.infer_manager.current_phase(); let dynamic_fields_visible = context.infer_manager.dynamic_fields_visible(); @@ -828,6 +2570,7 @@ fn refresh_member_initializer_caches(db: &mut DbIndex, context: &mut AnalyzeCont return InitializerRefreshResult::new(file_id); }; let mut result = InitializerRefreshResult::new(file_id); + crate::db_index::read_set::arm(); for member_id in &candidates_by_file[&file_id] { let type_owner = LuaTypeOwner::Member(*member_id); let Some(current_cache) = db.get_type_index().get_type_cache(&type_owner).cloned() @@ -835,27 +2578,101 @@ 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; }; - let Ok(inferred_type) = crate::infer_expr(db, &mut infer_cache, expr) else { + // 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 = + pass.is_settled() && common::reads_settling_iter_var(db, file_id, &expr); + let copies_settled_decl = pass.is_settled() && decl_copies.contains(&type_owner); + let widens_mutable_local_literal = pass.is_settled() + && common::mutable_local_name_read_decl(db, file_id, &expr).is_some() + && matches!( + current_cache.as_type(), + LuaType::IntegerConst(_) + | LuaType::StringConst(_) + | LuaType::BooleanConst(_) + | LuaType::FloatConst(_) + ); + let reads_field_exist_guard = guarded.contains(member_id); + let reads_sibling_merge = sibling_merges.contains(member_id); + // An `unknown` arm is the walk saying one writer of the slot it + // read had not landed yet. See [`union_has_unsettled_arm`]. + let cached_an_unsettled_union = + pass.is_settled() && union_has_unsettled_arm(current_cache.as_type()); + if !copies_settled_iter_var + && !copies_settled_decl + && !widens_mutable_local_literal + && !reads_field_exist_guard + && !reads_sibling_merge + && !current_is_uninformative + && !cached_an_unsettled_union + && single_nominal_type_id(current_cache.as_type()).is_none() + { continue; + } + let canonical_guarded_bootstrap = + lua::canonical_guarded_table_bootstrap_type(db, *member_id, None); + let inferred_type = if let Some(canonical) = &canonical_guarded_bootstrap { + canonical.clone() + } else { + let Ok(inferred_type) = crate::infer_expr(db, &mut infer_cache, expr.clone()) + else { + continue; + }; + if copies_settled_iter_var { + widen_direct_literal_copy(&inferred_type) + } else { + common::widen_mutable_local_name_copy(db, file_id, &expr, inferred_type) + } }; - if inferred_type == *current_cache.as_type() { + if inferred_type == *current_cache.as_type() + || (union_has_unsettled_arm(&inferred_type) + && !current_is_uninformative + && !cached_an_unsettled_union) + { continue; } - let takes_inferred_type = if current_is_uninformative { + let takes_inferred_type = if canonical_guarded_bootstrap.is_some() + || copies_settled_iter_var + || copies_settled_decl + || widens_mutable_local_literal + || cached_an_unsettled_union + { + true + } else if reads_field_exist_guard + && union_widens_cached_type(&inferred_type, current_cache.as_type()) + { + // The guard's narrowing has since seen every type that owns the + // key, so the settled answer is the cached one with the owners + // the walk had not reached folded in. + true + } else if reads_sibling_merge + && settled_covers_cached_type(&inferred_type, current_cache.as_type()) + { + // The merge has since seen every writer, each holding its + // settled type, so the settled answer is the cached snapshot + // with the rest of the writer set folded in. + 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 - // question, asked once the facts exist. + // question, asked once the facts exist. A settled `any` is + // that answer too: a re-index walks the same read against the + // settled index and caches `any` directly. !type_is_uninformative(&inferred_type) + || (pass.is_settled() + && inferred_type.is_any() + && current_cache.as_type().is_unknown()) + || (pass.is_settled() + && LuaTypeCache::InferType(inferred_type.clone()) + .supersedes(¤t_cache)) } else { is_strict_nominal_refinement(db, &inferred_type, current_cache.as_type()) }; @@ -868,6 +2685,7 @@ fn refresh_member_initializer_caches(db: &mut DbIndex, context: &mut AnalyzeCont fact: LuaTypeFact::certain(inferred_type), }); } + result.reads = crate::db_index::read_set::disarm(); result.pending_type_decls = infer_cache.take_pending_str_tpl_type_decls(); result.guard_dependencies = infer_cache.take_inferred_guard_dependencies(); result @@ -875,6 +2693,7 @@ fn refresh_member_initializer_caches(db: &mut DbIndex, context: &mut AnalyzeCont let mut updates = Vec::new(); for result in results { + context.record_settled_reads(result.file_id, result.reads); context.infer_manager.merge_inference_side_effects( result.file_id, result.pending_type_decls, @@ -882,11 +2701,12 @@ fn refresh_member_initializer_caches(db: &mut DbIndex, context: &mut AnalyzeCont ); updates.extend(result.updates); } - apply_initializer_cache_updates(db, updates); + apply_initializer_cache_updates(db, updates) } struct InitializerRefreshResult { file_id: FileId, + reads: crate::db_index::read_set::InferenceReadSet, pending_type_decls: Vec, guard_dependencies: HashSet, updates: Vec, @@ -896,8 +2716,9 @@ impl InitializerRefreshResult { fn new(file_id: FileId) -> Self { Self { file_id, + reads: Default::default(), pending_type_decls: Vec::new(), - guard_dependencies: HashSet::new(), + guard_dependencies: HashSet::default(), updates: Vec::new(), } } @@ -918,7 +2739,38 @@ enum InitializerCacheUpdate { }, } -fn apply_initializer_cache_updates(db: &mut DbIndex, updates: Vec) { +impl InitializerCacheUpdate { + fn owner(&self) -> &LuaTypeOwner { + match self { + InitializerCacheUpdate::Bind { owner, .. } + | InitializerCacheUpdate::Overwrite { owner, .. } + | InitializerCacheUpdate::ReplaceFact { owner, .. } => owner, + } + } +} + +/// Applies the updates and returns the owners whose cache actually changed. +/// +/// A re-derivation that lands the answer already cached moved nothing, and +/// counting it would re-derive its readers every round without end. +fn apply_initializer_cache_updates( + db: &mut DbIndex, + updates: Vec, +) -> HashSet { + let owners = updates + .iter() + .map(InitializerCacheUpdate::owner) + .cloned() + .collect::>(); + let cached_type = |db: &DbIndex, owner: &LuaTypeOwner| { + db.get_type_index() + .get_type_cache(owner) + .map(|cache| cache.as_type().clone()) + }; + let before = owners + .iter() + .map(|owner| cached_type(db, owner)) + .collect::>(); let mut fact_updates = Vec::with_capacity(updates.len()); for update in updates { match update { @@ -943,6 +2795,24 @@ fn apply_initializer_cache_updates(db: &mut DbIndex, updates: Vec bool { + result_idx == 0 || matches!(typ, LuaType::Variadic(_)) } fn select_result_fact(fact: LuaTypeFact, result_idx: usize) -> LuaTypeFact { @@ -959,6 +2829,9 @@ fn select_result_fact(fact: LuaTypeFact, result_idx: usize) -> LuaTypeFact { fn member_initializer_expr(root: &LuaSyntaxNode, member_id: LuaMemberId) -> Option { let node = member_id.get_syntax_id().to_node_from_root(root)?; + if let Some(field) = LuaTableField::cast(node.clone()) { + return field.get_value_expr(); + } let index_expr = glua_parser::LuaIndexExpr::cast(node)?; let assign_stat = index_expr.get_parent::()?; let (vars, exprs) = assign_stat.get_var_and_expr_list(); @@ -1011,20 +2884,43 @@ pub(crate) fn initializer_reads_through_call_or_index(expr: &LuaExpr) -> bool { LuaExpr::ParenExpr(paren) => paren .get_expr() .is_some_and(|inner| initializer_reads_through_call_or_index(&inner)), - LuaExpr::BinaryExpr(binary) => { - matches!( - binary.get_op_token().map(|op| op.get_op()), - Some(BinaryOperator::OpOr | BinaryOperator::OpAnd) - ) && binary.get_exprs().is_some_and(|(left, right)| { - initializer_reads_through_call_or_index(&left) - || initializer_reads_through_call_or_index(&right) - }) - } + // `CT + self.a / self.b` reads through the index reads it combines + // exactly as `self.a or self.b` does: the operator adds no fact of its + // own, so the answer is a placeholder until those reads settle. + LuaExpr::BinaryExpr(binary) => binary.get_exprs().is_some_and(|(left, right)| { + initializer_reads_through_call_or_index(&left) + || initializer_reads_through_call_or_index(&right) + }), + LuaExpr::UnaryExpr(unary) => unary + .get_expr() + .is_some_and(|inner| initializer_reads_through_call_or_index(&inner)), + _ => false, + } +} + +/// 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, } } -fn local_initializer_expr( +pub(crate) fn local_initializer_expr( db: &DbIndex, root: &LuaSyntaxNode, decl_id: LuaDeclId, @@ -1051,15 +2947,7 @@ fn is_bottom(typ: &LuaType) -> bool { } fn type_is_uninformative(typ: &LuaType) -> bool { - match typ { - LuaType::Any | LuaType::Unknown | LuaType::Nil | LuaType::Never => true, - LuaType::Union(union) => union.types().all(type_is_uninformative), - LuaType::MultiLineUnion(union) => union - .get_unions() - .iter() - .all(|(typ, _)| type_is_uninformative(typ)), - _ => false, - } + !crate::db_index::is_informative_type(typ) || crate::db_index::leaks_unsubstituted_tpl(typ) } fn synthesize_accessorfunc_members(db: &mut DbIndex, file_ids: &[FileId]) { @@ -1181,7 +3069,7 @@ fn module_analyze( return vec![]; } - let mut file_tree_map: HashMap>> = HashMap::new(); + let mut file_tree_map: HashMap>> = HashMap::default(); for in_filed_tree in need_analyzed_files { let file_id = in_filed_tree.file_id; if let Some(path) = db.get_vfs().get_file_path(&file_id).cloned() { @@ -1250,11 +3138,43 @@ pub struct AnalyzeContext { gmod_global_call_roles: Option<(u64, Arc)>, unresolves: Vec<(UnResolve, InferFailReason)>, inferred_return_candidates: Vec, + inferred_return_reads: + HashMap, 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, + settled_decl_initializer_candidates: HashSet, member_initializer_reinfer_candidates: HashSet, + /// Member writes whose value read through a field-exist guard. See + /// [`AnalyzeContext::request_guarded_member_read_reinfer`]. + guarded_member_read_candidates: HashSet, + /// Members whose value was answered by merging a table's sibling members + /// under a computed key. That merge reads whichever writers the batch had + /// indexed, with the types they held at the time; the settled passes widen + /// those writers and attach the cross-file ones, so the value is re-read + /// once they have. Cleared with the other settled candidate sets. + settled_sibling_merge_read_candidates: HashSet, + /// Writes whose value is a local the settled tail has since moved, by + /// name. The walk bound them to what the local held then, so they take + /// the settled re-read outright. See [`queue_settled_decl_copies`]. + settled_decl_copy_candidates: HashSet, + /// Local assignments whose right-hand side reads a local the settled tail + /// has since moved (`selected = key`). Unlike member writes and immutable + /// locals, these read no cache of their own, so the settled tail replays + /// the write itself through `bind_decl_write`. Queued by + /// [`queue_settled_decl_dependents`], re-derived by + /// [`rederive_settled_assign_reads`]. Each entry carries the return index + /// the target takes out of a trailing variadic right-hand side + /// (`first, second = echo(key)` queues `(first, rhs, 0)` and + /// `(second, rhs, 1)`); a lone right-hand side always queues index `0`. + settled_assign_candidates: Vec<(LuaDeclId, LuaExpr, usize)>, + /// Locals the unresolve waves settled after the walk bound their readers. + /// See [`queue_settled_decl_dependents`]. + pub(crate) late_resolved_decls: HashSet, infer_manager: InferCacheManager, inferred_guard_dependencies: HashMap>, inferred_guard_candidates: Vec>, @@ -1264,10 +3184,45 @@ pub struct AnalyzeContext { /// because the prefix carried no owner information yet. See /// [`attach_settled_index_expr_members`]. settled_member_attach_candidates: Vec>, - /// Member assignments whose widening ran against an incomplete sibling set, - /// with the type each one actually assigned. See - /// [`rewiden_settled_member_assignments`]. - settled_member_widening_candidates: HashMap, + /// Items the unresolve force wave settled. Members the settled attach + /// adds afterwards can change them, so they are re-derived once it has. + /// See [`requeue_force_finalized`]. + pub(crate) force_finalized: Vec, + /// Whether the unresolve pipeline may force and floor. Off, a run only + /// settles what the index can answer and hands the rest back; on, the + /// run is the terminal one and iterates its floors to a fixpoint. + pub(crate) finalize_unresolves: bool, + /// 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, + /// Per file, the cached facts its settled re-derivations actually read. + /// Later rounds use the type-owner subset; incremental edits persist all + /// three dependency kinds. + /// See [`crate::db_index::read_set`]. + settled_read_sets: HashMap, + /// 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)>, + /// Dynamic-write sites whose prefix held no nameable table when the + /// dynamic-field pass ran. See + /// [`dynamic_field::rederive_settled_dynamic_fields`]. + pub(crate) settled_dynamic_field_candidates: Vec<(FileId, glua_parser::LuaSyntaxId)>, + /// 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, + /// Parent calls whose receiver had no type when the scripted-class scan ran, + /// per file. See [`gmod::resolve_deferred_vgui_parent_calls`]. + deferred_vgui_parent_call_sites: HashMap>, call_site_return_invalidation_changed: bool, pub workspace_id: Option, } @@ -1276,24 +3231,41 @@ impl AnalyzeContext { pub fn new() -> Self { Self { tree_list: Vec::new(), - metas: HashSet::new(), + metas: HashSet::default(), scripted_scope_files: None, scripted_scope_infos: None, gmod_global_call_roles: None, unresolves: Vec::new(), inferred_return_candidates: Vec::new(), + inferred_return_reads: HashMap::default(), pending_call_site_return_consumers: Vec::new(), pending_call_site_definition_refreshes: Vec::new(), - pending_unresolve_decl_ids: HashSet::new(), - uninformative_local_decl_candidates: HashSet::new(), - member_initializer_reinfer_candidates: HashSet::new(), + call_site_return_targets: Vec::new(), + call_site_return_definition_refreshes: HashMap::default(), + pending_unresolve_decl_ids: HashSet::default(), + settled_decl_initializer_candidates: HashSet::default(), + member_initializer_reinfer_candidates: HashSet::default(), + guarded_member_read_candidates: HashSet::default(), + settled_sibling_merge_read_candidates: HashSet::default(), + settled_decl_copy_candidates: HashSet::default(), + settled_assign_candidates: Vec::new(), + late_resolved_decls: HashSet::default(), infer_manager: InferCacheManager::new(), - inferred_guard_dependencies: HashMap::new(), + inferred_guard_dependencies: HashMap::default(), inferred_guard_candidates: Vec::new(), early_callable_signatures: Vec::new(), early_member_owner_candidates: Vec::new(), settled_member_attach_candidates: Vec::new(), - settled_member_widening_candidates: HashMap::new(), + force_finalized: Vec::new(), + finalize_unresolves: true, + settled_guarded_bootstrap_candidates: Vec::new(), + settled_iter_var_candidates: Vec::new(), + settled_read_sets: HashMap::default(), + settled_global_read_candidates: Vec::new(), + settled_multi_decl_global_read_candidates: Vec::new(), + settled_dynamic_field_candidates: Vec::new(), + vgui_parent_fallback_files: HashSet::default(), + deferred_vgui_parent_call_sites: HashMap::default(), call_site_return_invalidation_changed: false, workspace_id: None, } @@ -1318,6 +3290,100 @@ impl AnalyzeContext { self.inferred_return_candidates.push(return_); } + fn drain_inferred_return_reads(&mut self) { + self.inferred_return_reads + .extend(self.infer_manager.drain_inferred_return_reads()); + } + + fn persist_inferred_return_dependencies(&mut self, db: &mut DbIndex) { + self.drain_inferred_return_reads(); + for (signature_id, record) in std::mem::take(&mut self.inferred_return_reads) { + let source_file_id = signature_id.get_file_id(); + let mut dependencies = record + .reads + .type_owners + .into_iter() + .map(|owner| owner.get_file_id()) + .chain( + record + .reads + .signatures + .into_iter() + .map(|signature| signature.get_file_id()), + ) + .collect::>(); + dependencies.remove(&source_file_id); + db.get_signature_index_mut() + .set_inferred_return_dependencies(signature_id, dependencies); + } + } + + fn inferred_return_reads_are_stale(&self, db: &DbIndex, signature_id: LuaSignatureId) -> bool { + let Some(record) = self.inferred_return_reads.get(&signature_id) else { + return false; + }; + record + .reads + .type_owners + .iter() + .any(|owner| db.get_type_index().type_write_version(owner) > record.type_epoch) + || record.reads.signatures.iter().any(|dependency| { + db.get_signature_index().return_write_version(dependency) > record.return_epoch + }) + || record + .reads + .missing_member_slots + .iter() + .any(|(owner, key)| { + !db.get_member_index() + .get_members_for_owner_key(owner, key) + .is_empty() + || matches!(key, LuaMemberKey::Name(name) + if crate::dynamic_field_owner_of(db, owner).is_some_and( + |dynamic_owner| { + !db + .get_dynamic_field_index() + .field_definitions(&dynamic_owner, name.as_str()) + .is_empty() + }, + )) + }) + } + + /// Whether `file_id`'s settled re-derivation could answer differently now. + /// + /// `None` is the first round, where nothing has been recorded yet and every + /// candidate runs. After that a file is worth re-deriving only if one of the + /// caches it read last time is among the ones the previous round moved -- or + /// if it has no recorded read set, which means it has not run yet. + fn settled_file_reads_moved( + &self, + file_id: FileId, + moved_owners: Option<&HashSet>, + ) -> bool { + let Some(moved_owners) = moved_owners else { + return true; + }; + match self.settled_read_sets.get(&file_id) { + Some(reads) => reads + .type_owners + .iter() + .any(|owner| moved_owners.contains(owner)), + None => true, + } + } + + fn record_settled_reads( + &mut self, + file_id: FileId, + reads: crate::db_index::read_set::InferenceReadSet, + ) { + self.settled_read_sets + .entry(file_id) + .or_default() + .extend(reads); + } + pub(crate) fn analyzed_file_ids(&self) -> HashSet { self.tree_list.iter().map(|tree| tree.file_id).collect() } @@ -1325,14 +3391,63 @@ 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_member_widening_candidate( + /// 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); + } + + /// Remembers a dynamic write whose prefix had no nameable table type yet, + /// so its wildcard can be taken again once the batch has settled. + pub(crate) fn record_settled_dynamic_field_candidate( + &mut self, + file_id: FileId, + syntax_id: glua_parser::LuaSyntaxId, + ) { + self.settled_dynamic_field_candidates + .push((file_id, syntax_id)); + } + + /// 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_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_deferred_vgui_parent_call( &mut self, - member_id: LuaMemberId, - assigned_type: LuaType, - preserve_table_literals: bool, + file_id: FileId, + syntax_id: LuaSyntaxId, ) { - self.settled_member_widening_candidates - .insert(member_id, (assigned_type, preserve_table_literals)); + self.deferred_vgui_parent_call_sites + .entry(file_id) + .or_default() + .insert(syntax_id); + } + + pub(crate) fn record_settled_guarded_bootstrap_candidate(&mut self, member_id: LuaMemberId) { + self.settled_guarded_bootstrap_candidates.push(member_id); } pub fn add_inferred_guard_candidate(&mut self, candidate: InFiled) { @@ -1437,59 +3552,114 @@ impl AnalyzeContext { count } - 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; + fn resolve_call_site_return_consumers(&mut self, db: &mut DbIndex) -> HashSet { + 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 HashSet::default(); + } - 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, - ) + // 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. + let mut moved = false; + let mut fuse = common::FixpointFuse::new("resolve_call_site_return_consumers"); + loop { + if fuse.trip() { + break; + } + 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 full = infer_expr_fact_with_cache(db, cache, expr.clone()); + // Publishing the fabricated `nil` would overwrite whatever the + // walk derived with a record that this consumer's call is still + // unresolved. Which consumers run at all depends on which + // signatures this batch requeued, so that overwrite lands on a + // warm re-index and not on a cold build. Leaving the slot alone + // keeps both on the answer the walk reached. + if !selects_resolved_result(full.typ(), *ret_idx) { + continue; } - _ => 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())); + let fact = select_result_fact(full, *ret_idx); + // Same reasoning one step further. A declaration whose slot an + // ordered write claimed has had its writers arbitrated already: + // `bind_decl_write` ranks `any` against the informative ones and + // leaves two uninformative answers to the type index's own + // order. A result that loses that ranking would overrule the + // arbitration only on the runs where the batch happened to + // requeue this consumer's signature. + if !crate::db_index::is_informative_type(fact.typ()) + && matches!(owner, LuaTypeOwner::Decl(decl_id) + if db.get_type_index().decl_write_claim(decl_id).is_some()) + && db + .get_type_index() + .get_type_cache(owner) + .is_some_and(|current| { + !LuaTypeCache::InferType(fact.typ().clone()).supersedes(current) + }) + { + continue; + } + 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; + } + moved = true; + } + if moved { + self.call_site_return_targets + .iter() + .map(|(_, owner, _, _)| owner.clone()) + .collect() + } else { + HashSet::default() } - db.publish_inference_facts(fact_updates); - count } fn invalidate_inferred_returns_for_sources( @@ -1559,14 +3729,30 @@ impl AnalyzeContext { self.pending_unresolve_decl_ids.contains(&decl_id) } - pub fn request_uninformative_local_decl_reinfer(&mut self, decl_id: LuaDeclId) { - self.uninformative_local_decl_candidates.insert(decl_id); + pub fn request_settled_decl_initializer_reinfer(&mut self, decl_id: LuaDeclId) { + self.settled_decl_initializer_candidates.insert(decl_id); } pub fn request_member_initializer_reinfer(&mut self, member_id: LuaMemberId) { self.member_initializer_reinfer_candidates.insert(member_id); } + /// Queues a member write whose value read a member the enclosing `if` + /// tested for existence. + /// + /// That guard narrows the receiver to the types owning the key, taken + /// straight off the member index, so the read answers from however many of + /// those types the batch had walked. Re-derived once they all stand. + pub fn request_guarded_member_read_reinfer(&mut self, member_id: LuaMemberId) { + self.guarded_member_read_candidates.insert(member_id); + } + + /// Queues a member write whose value merged a table's sibling members + /// under a computed key. See `settled_sibling_merge_read_candidates`. + pub fn request_sibling_merge_read_reinfer(&mut self, member_id: LuaMemberId) { + self.settled_sibling_merge_read_candidates.insert(member_id); + } + fn add_inferred_guard_dependencies( &mut self, file_id: FileId, @@ -1612,7 +3798,7 @@ impl AnalyzeContext { .map(|in_filed_tree| in_filed_tree.file_id) .collect::>(); self.scripted_scope_files = Some(Arc::new(file_ids)); - self.scripted_scope_infos = Some(Arc::new(HashMap::new())); + self.scripted_scope_infos = Some(Arc::new(HashMap::default())); return; } @@ -1627,6 +3813,7 @@ impl AnalyzeContext { .collect::>(); let (scripted_scope_files, scoped_matches) = scopes.scan_scripted_class_scope_files(file_paths); + let scripted_scope_files: HashSet<_> = scripted_scope_files.into_iter().collect(); let scripted_scope_infos = scoped_matches .into_iter() .map(|(file_id, scope_match)| { @@ -1723,7 +3910,10 @@ fn return_point_contains_range(point: &LuaReturnPoint, range: rowan::TextRange) #[cfg(test)] mod union_widening_tests { - use super::union_widens_cached_type; + use super::{ + cached_provisional_slot_union, settled_widens_cached_literals, union_has_unsettled_arm, + union_widens_cached_type, + }; use crate::LuaType; fn union(arms: Vec) -> LuaType { @@ -1763,6 +3953,46 @@ mod union_widening_tests { assert!(!union_widens_cached_type(&settled, &cached)); } + #[test] + fn spots_a_provisional_slot_union_beside_an_uninformative_arm() { + let literals = |extra: Vec| { + let mut arms = vec![ + LuaType::TableConst(crate::InFiled::new( + crate::FileId::new(1), + rowan::TextRange::new(0.into(), 1.into()), + )), + LuaType::TableConst(crate::InFiled::new( + crate::FileId::new(1), + rowan::TextRange::new(2.into(), 3.into()), + )), + ]; + arms.extend(extra); + union(arms) + }; + // The read's own `nil`/`any` arms do not stop the rest being the + // provisional flat-literal shape. + assert!(cached_provisional_slot_union(&literals(vec![]))); + assert!(cached_provisional_slot_union(&literals(vec![LuaType::Nil]))); + assert!(cached_provisional_slot_union(&literals(vec![ + LuaType::Nil, + LuaType::Any + ]))); + // A real non-table arm still does. + assert!(!cached_provisional_slot_union(&literals(vec![ + LuaType::String + ]))); + // And one literal is not the shape, however many placeholders sit + // beside it. + assert!(!cached_provisional_slot_union(&union(vec![ + LuaType::TableConst(crate::InFiled::new( + crate::FileId::new(1), + rowan::TextRange::new(0.into(), 1.into()), + )), + LuaType::Nil, + LuaType::Any, + ]))); + } + #[test] fn rejects_an_equal_union() { let settled = union(vec![LuaType::Number, LuaType::Any]); @@ -1770,6 +4000,49 @@ mod union_widening_tests { assert!(!union_widens_cached_type(&settled, &cached)); } + #[test] + fn collapses_a_cached_literal_union_to_its_primitive() { + let cached = union(vec![ + LuaType::StringConst(smol_str::SmolStr::new("").into()), + LuaType::StringConst(smol_str::SmolStr::new("Public Area").into()), + ]); + assert!(settled_widens_cached_literals(&LuaType::String, &cached)); + } + + #[test] + fn keeps_a_cached_union_the_settled_primitive_does_not_cover() { + // A `nil` arm has no primitive to widen to, so collapsing would drop it. + let with_nil = union(vec![ + LuaType::StringConst(smol_str::SmolStr::new("a").into()), + LuaType::Nil, + ]); + assert!(!settled_widens_cached_literals(&LuaType::String, &with_nil)); + // A literal of another primitive is not covered either. + let mixed = union(vec![ + LuaType::StringConst(smol_str::SmolStr::new("a").into()), + LuaType::IntegerConst(1), + ]); + assert!(!settled_widens_cached_literals(&LuaType::String, &mixed)); + // And a non-union cache is never this shape. + assert!(!settled_widens_cached_literals( + &LuaType::String, + &LuaType::StringConst(smol_str::SmolStr::new("a").into()) + )); + } + + #[test] + fn spots_the_unsettled_arm_of_a_cached_union() { + assert!(union_has_unsettled_arm(&union(vec![ + LuaType::String, + LuaType::Unknown + ]))); + assert!(!union_has_unsettled_arm(&union(vec![ + LuaType::String, + LuaType::Nil + ]))); + assert!(!union_has_unsettled_arm(&LuaType::Unknown)); + } + #[test] fn rejects_a_non_union_settled_type() { assert!(!union_widens_cached_type( diff --git a/crates/glua_code_analysis/src/compilation/analyzer/parallel.rs b/crates/glua_code_analysis/src/compilation/analyzer/parallel.rs index 28ab4ffdb..3143d78fe 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/parallel.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/parallel.rs @@ -4,8 +4,8 @@ //! collection) process each file independently: they read only the file's own //! AST plus pre-existing immutable `&DbIndex` state, and produce a per-file //! result that is merged back into the db sequentially afterward. These helpers -//! run the per-file work across a small thread pool using `std::thread::scope`, -//! mirroring the existing parallel diagnostics path. +//! run the per-file work on rayon's persistent global thread pool, so a cold run +//! pays for thread creation once instead of once per pass. //! //! Safety model: //! - `&DbIndex` is shared immutably across worker threads. The diagnostics phase @@ -17,27 +17,53 @@ //! - Results are written back to the db on the caller's thread in deterministic //! file order, preserving identical behavior to the sequential version. +use std::sync::Once; use std::sync::atomic::{AtomicUsize, Ordering}; +use rayon::prelude::*; + use crate::db_index::DbIndex; use crate::{FileId, profile::Profile}; -/// Below this many files, `thread::scope` spawn/join and atomic dispatch cost -/// more than the per-file work itself saves, so the batch runs inline. Picked -/// from the profiled cost of one pass over a handful of small files versus -/// spawning/parking a worker pool for it. +/// Below this many files, dispatch and cross-thread handoff cost more than the +/// per-file work itself saves, so the batch runs inline. Picked from the +/// profiled cost of one pass over a handful of small files versus fanning it +/// out to a worker pool. const MIN_PARALLEL_FILES: usize = 8; -/// Number of worker threads to use for per-file analysis passes. Capped at 16 to -/// match the diagnostics path and avoid oversubscription on large machines. -fn worker_count(file_count: usize) -> usize { +/// Whether this batch is better run on the caller's thread than handed to the +/// pool: too few files to pay the dispatch, or nothing to run in parallel on. +/// +/// The pool's size is [`init_pool`]'s business; this only decides whether to +/// reach for it at all. +fn runs_inline(file_count: usize) -> bool { if file_count < MIN_PARALLEL_FILES { - return 1; + return true; } - let cores = std::thread::available_parallelism() + std::thread::available_parallelism() .map(|n| n.get()) - .unwrap_or(1); - cores.clamp(1, 16).min(file_count) + .unwrap_or(1) + <= 1 +} + +/// Size rayon's global pool to the same 16-thread cap the scoped pool used, and +/// give its workers the stack analysis needs. +/// +/// Workers run the same recursive inference and type-graph walks the caller's +/// thread does, so they get [`crate::ANALYSIS_STACK_SIZE`] rather than the +/// default. `build_global` is a no-op error if something already initialized the +/// pool, in which case the workers are not ours to size. +pub(crate) fn init_pool() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + let cores = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(1); + let _ = rayon::ThreadPoolBuilder::new() + .num_threads(cores.clamp(1, 16)) + .stack_size(crate::ANALYSIS_STACK_SIZE) + .build_global(); + }); } /// Run `f` for every file id concurrently and collect the results into a `Vec` @@ -52,66 +78,43 @@ where F: Fn(&DbIndex, FileId) -> T + Sync, { let n = file_ids.len(); - let workers = worker_count(n); - - if workers <= 1 { + if runs_inline(n) { return file_ids.iter().map(|&id| f(db, id)).collect(); } let _p = Profile::cond_new("parallel map_files", n > 1); - - // Pre-fill the output so workers can write by index without coordination. - // Each slot is written exactly once by exactly one worker, so we use a raw - // pointer wrapper guarded by the disjoint-index invariant. - let mut results: Vec> = (0..n).map(|_| None).collect(); - let slots = SlotsPtr(results.as_mut_ptr()); - let next = AtomicUsize::new(0); + init_pool(); // Longest-processing-time-first dispatch. Per-file cost spans orders of - // magnitude, so handing work out in slice order let a large file drawn last - // run alone while every other worker idled. Only the order in which slots - // are claimed changes; each still holds its own file's result, so callers - // see the same index-aligned `Vec` as before. + // magnitude, so feeding work in slice order let a large file drawn last run + // alone while every other worker idled. Only the order in which files are + // picked up changes; each result carries its own index, so callers see the + // same index-aligned `Vec` as before. let dispatch = dispatch_order(db, file_ids); let report_step = if crate::progress::is_active() { (n / 50).max(1) } else { 0 }; + let done = AtomicUsize::new(0); - std::thread::scope(|scope| { - for _ in 0..workers { - let next = &next; - let f = &f; - let slots = &slots; - let dispatch = &dispatch; - scope.spawn(move || { - loop { - let seq = next.fetch_add(1, Ordering::Relaxed); - if seq >= n { - break; - } - if report_step != 0 && seq.is_multiple_of(report_step) { - crate::progress::advance_current_phase(seq, n, "files"); - } - let idx = dispatch[seq]; - let file_id = file_ids[idx]; - let value = f(db, file_id); - // SAFETY: each `idx` is handed to exactly one worker via the - // atomic counter, so writes target disjoint slots and never - // alias. The `Vec` outlives the scope. - unsafe { - slots.0.add(idx).write(Some(value)); - } + let mut computed: Vec<(usize, T)> = dispatch + .par_iter() + .map(|&idx| { + if report_step != 0 { + let seq = done.fetch_add(1, Ordering::Relaxed); + if seq.is_multiple_of(report_step) { + crate::progress::advance_current_phase(seq, n, "files"); } - }); - } - }); + } + (idx, f(db, file_ids[idx])) + }) + .collect(); - results - .into_iter() - .map(|slot| slot.expect("slot written")) - .collect() + // Each result carries the index it was computed for, so putting them back in + // input order is a sort rather than a scatter into pre-filled slots. + computed.sort_unstable_by_key(|(idx, _)| *idx); + computed.into_iter().map(|(_, value)| value).collect() } /// Indices into `file_ids`, ordered largest source first so the long poles are @@ -128,12 +131,3 @@ fn dispatch_order(db: &DbIndex, file_ids: &[FileId]) -> Vec { }); order } - -/// Wrapper making a `*mut Option` shareable across the scoped threads. Safe -/// because workers only write disjoint indices (enforced by the atomic counter). -struct SlotsPtr(*mut Option); - -// SAFETY: the pointer is only used to write disjoint slots from worker threads; -// `T: Send` ensures the written values can cross threads. -unsafe impl Sync for SlotsPtr {} -unsafe impl Send for SlotsPtr {} diff --git a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/check_reason.rs b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/check_reason.rs index c775f3f60..a73fbdca3 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/check_reason.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/check_reason.rs @@ -1,9 +1,7 @@ use rustc_hash::FxHashMap; -use glua_parser::LuaAstNode; - use crate::{ - DbIndex, InFiled, InferFailReason, LuaDocReturnInfo, LuaType, LuaTypeCache, ReturnTypeKind, + DbIndex, InferFailReason, LuaDocReturnInfo, LuaType, LuaTypeCache, ReturnTypeKind, SignatureReturnStatus, compilation::analyzer::{ common::{TypeCacheWriteMode, write_type_cache}, @@ -127,14 +125,14 @@ pub fn resolve_as_unknown( TypeCacheWriteMode::InsertOnly, ); } - InferFailReason::UnResolveExpr(expr) => { - let key = InFiled::new(expr.file_id, expr.value.get_syntax_id()); - write_type_cache( - db, - key.into(), - LuaTypeCache::InferType(LuaType::Unknown), - TypeCacheWriteMode::InsertOnly, - ); + // An expression's slot in the type index is the `@as` cast slot that + // `infer_expr` consults before inferring anything. Flooring it would + // make a placeholder read as a cast of the expression to `unknown`, + // permanently and however its operands settle. The blocked items stay + // blocked instead; an expression that never infers leaves them typed + // as nothing rather than as a fabricated answer. + InferFailReason::UnResolveExpr(_) => { + return Some(()); } InferFailReason::UnResolveSignatureReturn(signature_id) => { // Same deferral as the member-type arm above, for the same @@ -156,6 +154,8 @@ pub fn resolve_as_unknown( return_kind: ReturnTypeKind::default(), }]; signature.resolve_return = SignatureReturnStatus::InferResolve; + db.get_signature_index_mut() + .note_return_write(*signature_id); } } InferFailReason::UnResolveModuleExport(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..5d1091b91 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/mod.rs @@ -4,8 +4,8 @@ mod resolve; mod resolve_closure; use rustc_hash::FxHashMap; +use rustc_hash::FxHashSet; use std::cmp::Ordering; -use std::collections::HashSet; use std::time::Duration; use crate::{ @@ -32,7 +32,11 @@ 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::is_unsettled_inferred_return; +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, @@ -124,6 +128,7 @@ impl AnalysisPipeline for UnResolveAnalysisPipeline { let _p = Profile::cond_new("resolve analyze", context.tree_list.len() > 1); let log_enabled = log::log_enabled!(log::Level::Info) || unresolve_profile_enabled(); let mut infer_manager = std::mem::take(&mut context.infer_manager); + let finalize = context.finalize_unresolves; let mat_start = log_enabled.then(std::time::Instant::now); materialize_pending_str_tpl_type_decls(db, &mut infer_manager); @@ -153,12 +158,24 @@ impl AnalysisPipeline for UnResolveAnalysisPipeline { } let mut loop_count = 0; + let mut force_finalized = Vec::new(); + let mut provisional = Vec::new(); + let mut late_resolved_decls = rustc_hash::FxHashSet::default(); while !reason_resolve.is_empty() { let iter_start = log_enabled.then(std::time::Instant::now); let resolve_start = log_enabled.then(std::time::Instant::now); let profile = crate::profile::phase("unresolve/try_resolve", || { - try_resolve(db, &mut infer_manager, &mut reason_resolve, log_enabled) + try_resolve( + db, + &mut infer_manager, + &mut reason_resolve, + log_enabled, + finalize && loop_count >= 1, + &mut force_finalized, + &mut provisional, + &mut late_resolved_decls, + ) }); if let Some(resolve_start) = resolve_start { log::info!( @@ -203,6 +220,13 @@ impl AnalysisPipeline for UnResolveAnalysisPipeline { ); } + // A settling run has no answer for what is left: the waves above + // already ran until nothing moved, and the items go back to the + // context for the run after the next fact source. + if !finalize { + break; + } + if loop_count == 0 { infer_manager.set_force(); } @@ -233,8 +257,27 @@ impl AnalysisPipeline for UnResolveAnalysisPipeline { loop_count += 1; } + if finalize && std::env::var_os("GLUALS_NO_FIXPOINT").is_none() { + settle_floored_fixpoint( + db, + &mut infer_manager, + &mut reason_resolve, + &mut force_finalized, + &mut provisional, + &mut late_resolved_decls, + log_enabled, + ); + } + // Applied once per pipeline run rather than per resolution: every apply // rebuilds the index's whole derived contribution state. + context.force_finalized.extend(force_finalized); + context.late_resolved_decls.extend(late_resolved_decls); + context.unresolves.extend( + provisional + .into_iter() + .map(|unresolve| (unresolve, InferFailReason::None)), + ); let changed_signatures = db .get_call_site_param_index_mut() .flush_deferred_contributions(); @@ -279,6 +322,85 @@ impl AnalysisPipeline for UnResolveAnalysisPipeline { } } +/// Re-derives what settled against a floor until nothing moves. +/// +/// An item that resolved once the wave had begun flooring read placeholders +/// for the items still blocked then. Those items settle afterwards, and what +/// read them holds a stale answer, as does whatever read *that*. Each round +/// re-derives every item the previous round settled; a round that changes no +/// type cache and no inferred return has reached the fixpoint. +pub(super) fn settle_floored_fixpoint( + db: &mut DbIndex, + infer_manager: &mut InferCacheManager, + reason_resolve: &mut FxHashMap>, + force_finalized: &mut Vec, + provisional: &mut Vec, + late_resolved_decls: &mut rustc_hash::FxHashSet, + log_enabled: bool, +) { + let profile_enabled = std::env::var_os("GLUALS_PROFILE").is_some(); + let mut round = 0usize; + let mut fuse = super::common::FixpointFuse::new("settle_floored_fixpoint"); + while !force_finalized.is_empty() { + if fuse.trip() { + force_finalized.clear(); + break; + } + let writes_before = + db.get_type_index().type_writes() + db.get_signature_index().return_writes(); + // A return is re-derived from an unresolved status so the apply rule + // lets a settled answer move; what it held is kept, because a + // re-derivation that fails (a self-recursive body reads its own + // return) must leave the answer in place rather than nothing. + let mut held_returns = Vec::new(); + for unresolve in force_finalized.drain(..) { + if let UnResolve::Return(return_) = &unresolve + && let Some(signature) = db.get_signature_index_mut().get_mut(&return_.signature_id) + && signature.resolve_return == crate::SignatureReturnStatus::InferResolve + { + signature.resolve_return = crate::SignatureReturnStatus::UnResolve; + held_returns.push((return_.signature_id, signature.return_docs.clone())); + } + reason_resolve + .entry(InferFailReason::None) + .or_default() + .push(unresolve); + } + infer_manager.clear(); + let attempted: usize = reason_resolve.values().map(Vec::len).sum(); + try_resolve( + db, + infer_manager, + reason_resolve, + log_enabled, + true, + force_finalized, + provisional, + late_resolved_decls, + ); + for (signature_id, return_docs) in held_returns { + if let Some(signature) = db.get_signature_index_mut().get_mut(&signature_id) + && signature.resolve_return == crate::SignatureReturnStatus::UnResolve + { + signature.resolve_return = crate::SignatureReturnStatus::InferResolve; + signature.return_docs = return_docs; + } + } + let moved = db.get_type_index().type_writes() + db.get_signature_index().return_writes() + - writes_before; + round += 1; + if profile_enabled { + eprintln!( + "[profile] finalize round {round}: attempted={attempted} moved={moved} requeued={}", + force_finalized.len() + ); + } + if moved == 0 { + force_finalized.clear(); + } + } +} + /// Re-derives the inferred returns invalidated by the deferred-contribution /// flush. /// @@ -292,7 +414,7 @@ fn requeue_flushed_call_site_returns( context: &mut AnalyzeContext, infer_manager: &mut InferCacheManager, reason_resolve: &mut FxHashMap>, - changed_signatures: &HashSet, + changed_signatures: &FxHashSet, log_enabled: bool, ) { let consumers = db @@ -323,7 +445,16 @@ fn requeue_flushed_call_site_returns( // dynamic-field visibility live on the manager and survive it. infer_manager.clear(); - let _ = try_resolve(db, infer_manager, &mut requeued, log_enabled); + let _ = try_resolve( + db, + infer_manager, + &mut requeued, + log_enabled, + true, + &mut context.force_finalized, + &mut Vec::new(), + &mut context.late_resolved_decls, + ); materialize_pending_str_tpl_type_decls(db, infer_manager); // Whatever is still unresolved rejoins the retained set, so it leaves this @@ -403,11 +534,31 @@ 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, reason_resolve: &mut FxHashMap>, profile_enabled: bool, + after_floor: bool, + force_finalized: &mut Vec, + provisional: &mut Vec, + late_resolved_decls: &mut rustc_hash::FxHashSet, ) -> Option { let mut profile = profile_enabled.then(TryResolveProfile::default); let mut cached_sorted_keys: Option> = None; @@ -425,7 +576,7 @@ fn try_resolve( // therefore not progress, and is parked instead. Every wave now either // resolves an item or retires an `(item, reason)` pair, both of which are // finite, so the loop terminates. - let mut requeued: HashSet<(UnResolveIdentity, InferFailReason)> = HashSet::new(); + let mut requeued: FxHashSet<(UnResolveIdentity, InferFailReason)> = FxHashSet::default(); // Waves have no file count to report, so they report what is still deferred. let initial_outstanding: usize = reason_resolve.values().map(Vec::len).sum(); loop { @@ -441,7 +592,7 @@ fn try_resolve( let mut to_be_remove = Vec::new(); let mut retain_unresolve = Vec::new(); let mut parked = Vec::new(); - let mut retry_file_ids = HashSet::new(); + let mut retry_file_ids = FxHashSet::default(); // Only re-sort keys when the set of reason groups has changed. // This avoids cloning and sorting on every inner loop iteration. @@ -473,7 +624,36 @@ 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 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. 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_undetermined_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) { profile.record_attempt( @@ -486,8 +666,45 @@ fn try_resolve( match resolve_result { Ok(_) => { changed = true; + // A local this wave settles had a placeholder during + // the walk, and the writes that read it then hold that + // placeholder's answer. See + // `queue_settled_decl_dependents`. + match &unresolve { + UnResolve::Decl(decl) => { + late_resolved_decls.insert(decl.decl_id.into()); + } + UnResolve::IterDecl(iter_var) => { + for var in &iter_var.iter_vars { + late_resolved_decls.insert( + LuaDeclId::new(iter_var.file_id, var.get_position()).into(), + ); + } + } + _ => {} + } + // An item settled once the wave had begun flooring + // blocked reasons, or under the force shims, was read + // against those placeholders; what settles afterwards + // can change it. See `requeue_force_finalized`. + if after_floor || cache.get_config().analysis_phase.is_force() { + force_finalized.push(unresolve); + } else if resolved_value_is_uninformative(db, &unresolve) { + // Resolving to `unknown` is not an answer: the + // input the item needed was itself still a + // placeholder. The item is tried again in the + // next wave, once that input may have landed. + provisional.push(unresolve); + } + } + Err(reason @ (InferFailReason::None | InferFailReason::RecursiveInfer)) => { + // Nothing names what the item is waiting for, but a + // settling run has no floor to give it either; it is + // kept for the run after the next fact source. + if !cache.get_config().analysis_phase.is_force() { + retain_unresolve.push((unresolve, reason)); + } } - Err(InferFailReason::None | InferFailReason::RecursiveInfer) => {} Err(InferFailReason::FieldNotFound) => { if !cache.get_config().analysis_phase.is_force() { retain_unresolve.push((unresolve, InferFailReason::FieldNotFound)); @@ -808,6 +1025,9 @@ enum UnResolveDiscriminator { None, ParamIdx(usize), Owner(LuaSemanticDeclId), + /// The write a deferred local binding came from: a local with several + /// deferred writes carries one item per write. + Write(u32), } /// Identifies an unresolve item across waves: the same syntax position in the @@ -816,8 +1036,11 @@ enum UnResolveDiscriminator { type UnResolveIdentity = (u8, u32, u32, UnResolveDiscriminator); fn unresolve_identity(unresolve: &UnResolve) -> UnResolveIdentity { - let (file_id, position) = unresolve.sort_key(); + let (file_id, position) = unresolve.sort_key_site(); let discriminator = match unresolve { + UnResolve::Decl(d) => { + UnResolveDiscriminator::Write(u32::from(d.expr.syntax().text_range().start())) + } UnResolve::ClosureParams(d) => UnResolveDiscriminator::ParamIdx(d.param_idx), UnResolve::ClosureReturn(d) => UnResolveDiscriminator::ParamIdx(d.param_idx), UnResolve::CallSiteContribution(d) => UnResolveDiscriminator::ParamIdx(d.param_idx), @@ -832,6 +1055,43 @@ fn unresolve_identity(unresolve: &UnResolve) -> UnResolveIdentity { ) } +/// Whether the item's resolution left its target holding no type information: +/// `unknown`, `never`, or nothing at all. +fn resolved_value_is_uninformative(db: &DbIndex, unresolve: &UnResolve) -> bool { + fn cache_is_uninformative(db: &DbIndex, owner: &crate::LuaTypeOwner) -> bool { + db.get_type_index() + .get_type_cache(owner) + .is_none_or(|cache| { + cache.is_infer() && (cache.as_type().is_unknown() || cache.as_type().is_never()) + }) + } + match unresolve { + UnResolve::Decl(decl) => cache_is_uninformative(db, &decl.decl_id.into()), + UnResolve::Member(member) => { + cache_is_uninformative(db, &member.member_id.into()) + || (member.expr.is_some() + && db + .get_type_index() + .get_type_cache(&member.member_id.into()) + .is_some_and(|cache| cache.is_infer() && cache.as_type().is_any())) + } + UnResolve::IterDecl(iter_var) => iter_var.iter_vars.iter().any(|var| { + cache_is_uninformative( + db, + &LuaDeclId::new(iter_var.file_id, var.get_position()).into(), + ) + }), + UnResolve::Return(return_) => db + .get_signature_index() + .get(&return_.signature_id) + .is_some_and(|signature| { + signature.resolve_return == crate::SignatureReturnStatus::InferResolve + && is_unsettled_inferred_return(&signature.get_return_type()) + }), + _ => false, + } +} + fn unresolve_stable_cmp(a: &UnResolve, b: &UnResolve) -> Ordering { unresolve_kind_rank(a) .cmp(&unresolve_kind_rank(b)) @@ -883,10 +1143,35 @@ impl UnResolve { } } - /// 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. - fn sort_key(&self) -> (u32, u32) { + /// 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, write_position) + /// for stable ordering. This ensures unresolves are processed in a consistent + /// order regardless of FxHashMap iteration order or other non-deterministic + /// sources during collection. A local with several deferred writes carries + /// one item per write, and which of them binds last decides the cache, so + /// the write's own position breaks the tie in source order. + fn sort_key(&self) -> (u32, u32, u32) { + let (file_id, position) = self.sort_key_site(); + let write_position = match self { + UnResolve::Decl(d) => u32::from(d.expr.syntax().text_range().start()), + _ => 0, + }; + (file_id, position, write_position) + } + + fn sort_key_site(&self) -> (u32, u32) { match self { UnResolve::Decl(d) => (d.file_id.id, u32::from(d.decl_id.position)), UnResolve::IterDecl(d) => ( @@ -999,7 +1284,7 @@ impl From for UnResolve { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct UnResolveIterVar { pub file_id: FileId, pub iter_exprs: Vec, @@ -1130,13 +1415,40 @@ mod tests { use glua_parser::{LuaAstNode, LuaExpr, LuaIndexExpr, LuaParser, ParserConfig}; use rowan::TextSize; - use crate::{FileId, InferFailReason, LuaDeclId, LuaMemberId, LuaTypeDeclId}; + use crate::{ + DbIndex, FileId, InferFailReason, LuaDeclId, LuaMemberId, LuaType, LuaTypeCache, + LuaTypeDeclId, + }; use super::{ UnResolve, UnResolveIterVar, UnResolveMember, partition_pre_dynamic_unresolves, - sorted_reason_keys, + resolved_value_is_uninformative, sorted_reason_keys, }; + #[test] + fn resolved_any_member_value_remains_provisional() { + let tree = LuaParser::parse("owner.field = source.value", ParserConfig::default()); + let index_exprs = tree + .get_chunk_node() + .descendants::() + .collect::>(); + let target = &index_exprs[0]; + let file_id = FileId::new(1); + let member_id = LuaMemberId::new(target.get_syntax_id(), file_id); + let candidate = UnResolveMember { + file_id, + member_id, + expr: Some(LuaExpr::IndexExpr(index_exprs[1].clone())), + prefix: None, + ret_idx: 0, + }; + let mut db = DbIndex::default(); + db.get_type_index_mut() + .force_bind_type(member_id.into(), LuaTypeCache::InferType(LuaType::Any)); + + assert!(resolved_value_is_uninformative(&db, &candidate.into())); + } + #[test] fn reason_group_order_is_stable_across_hashmap_insertion_order() { let reasons = [ 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..8f37635fb 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve.rs @@ -12,27 +12,27 @@ use internment::ArcIntern; use rowan::TextSize; use crate::{ - DbIndex, FileId, InFiled, InferFailReason, LuaDeclId, LuaDeclOrMemberId, LuaDeclTypeKind, - LuaDocReturnInfo, LuaInferenceConfidence, LuaInferenceEventId, LuaInferenceNodeId, - LuaInferenceProvenanceKind, LuaInferenceStep, LuaMember, LuaMemberId, LuaMemberInfo, - LuaMemberKey, LuaOperator, LuaOperatorMetaMethod, LuaOperatorOwner, LuaSemanticDeclId, LuaType, - LuaTypeCache, LuaTypeDecl, LuaTypeDeclId, LuaTypeFact, LuaTypeFlag, LuaTypeOwner, - OperatorFunction, RenderLevel, ReturnTypeKind, SemanticDeclLevel, SignatureReturnStatus, - TypeOps, VariadicType, + DbIndex, FileId, GlobalId, InFiled, InferFailReason, LuaDeclId, LuaDeclOrMemberId, + LuaDeclTypeKind, LuaDocReturnInfo, LuaInferenceConfidence, LuaInferenceEventId, + LuaInferenceNodeId, LuaInferenceProvenanceKind, LuaInferenceStep, LuaMember, LuaMemberId, + LuaMemberInfo, LuaMemberKey, LuaOperator, LuaOperatorMetaMethod, LuaOperatorOwner, + LuaSemanticDeclId, LuaType, LuaTypeCache, LuaTypeDecl, LuaTypeDeclId, LuaTypeFact, LuaTypeFlag, + LuaTypeOwner, OperatorFunction, RenderLevel, ReturnTypeKind, SemanticDeclLevel, + SignatureReturnStatus, TypeOps, VariadicType, compilation::analyzer::{ call_site_params::{ exact_receiver_type_is_usable, infer_supported_call_site_arg_type, 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::{ - analyze_return_correlations, analyze_return_point, compute_module_semantic_id, - has_multiple_distinct_index_expr_member_owners, infer_for_range_iter_expr_func, - is_guarded_table_assignment_index_expr, preserve_guarded_table_assignment_members, - resolve_index_expr_member_owner_for_file, + alias_target_global_path, compute_module_semantic_id, + derive_inferred_return_with_reads, has_multiple_distinct_index_expr_member_owners, + infer_for_range_iter_expr_func, is_guarded_table_assignment_index_expr, + mark_resolved_member_assignment, resolve_index_expr_member_owner_for_file, }, unresolve::UnResolveSpecialCall, }, @@ -163,20 +163,47 @@ pub fn try_resolve_decl( return Err(InferFailReason::UnResolveIterTemplate); } - // 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`, - // `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) { - bind_resolved_type(db, decl_id.into(), LuaTypeCache::InferType(expr_type)); - } else { - bind_type(db, decl_id.into(), LuaTypeCache::InferType(expr_type)); - } + // 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, + fills_own_default: crate::compilation::analyzer::lua::expr_fills_own_default( + db, + decl.file_id, + decl_id, + &expr, + ), + }, + ); Ok(()) } @@ -192,6 +219,7 @@ fn create_deferred_index_expr_member( prefix_type: &LuaType, owner: LuaMemberOwner, member_id: LuaMemberId, + alias_path: Option, ) -> Option<()> { let root = db .get_vfs() @@ -214,31 +242,61 @@ fn create_deferred_index_expr_member( LuaMemberFeature::FileDefine }; let guarded = is_guarded_table_assignment_index_expr(&index_expr); + let member = LuaMember::new(member_id, member_key, feature, None); + { + let member_index = db.get_member_index_mut(); + member_index.add_member(owner.clone(), member); + // The same alias provenance the immediate path records: a write + // through an alias of a global path must follow the path's + // `---@class` flips even though its first home came from a prefix + // type read mid-fixpoint. + if let Some(alias_path) = alias_path { + member_index.home_alias_member_with_provenance( + owner, + member_id.file_id, + member_id, + alias_path, + false, + ); + } + // The owner above came from a prefix type read mid-fixpoint, so this + // placement is provisional: the post-settle re-home is the authority on + // where the member ends up, and it needs to know it may detach this one. + member_index.mark_deferred_index_expr_member(member_id); + // `add_member` records the enclosing function scope for `FileDefine` + // index-expr members only; for the rest it stores `None`. Same follow-up + // the Lua pass does in `apply_index_expr_member_owner_with_guarded`. + if !matches!(feature, LuaMemberFeature::FileDefine) { + let function_scope = member_index + .enclosing_function_scope_range(member_id.file_id, member_id.get_position()); + member_index.set_member_function_scope_range(member_id, function_scope); + } + } + // Marks must run after the member exists: `mark_non_overwriting_assignment_member` + // readmits co-writers through the member's current owner, which is a no-op + // before insertion, and `mark_resolved_member_assignment` requires the member + // to exist. The explicit guarded mark preserves `MetaDefine` bootstrap + // semantics where `mark_resolved_member_assignment` early-returns; the shared + // call covers the conditional/loop-body policy. if guarded { db.get_member_index_mut() .mark_non_overwriting_assignment_member(member_id); } - let member = LuaMember::new(member_id, member_key, feature, None); - let member_index = db.get_member_index_mut(); - member_index.add_member(owner, member); - // The owner above came from a prefix type read mid-fixpoint, so this - // placement is provisional: the post-settle re-home is the authority on - // where the member ends up, and it needs to know it may detach this one. - member_index.mark_deferred_index_expr_member(member_id); - // `add_member` records the enclosing function scope for `FileDefine` - // index-expr members only; for the rest it stores `None`. Same follow-up - // the Lua pass does in `apply_index_expr_member_owner_with_guarded`. - if !matches!(feature, LuaMemberFeature::FileDefine) { - let function_scope = member_index - .enclosing_function_scope_range(member_id.file_id, member_id.get_position()); - member_index.set_member_function_scope_range(member_id, function_scope); - if guarded { - preserve_guarded_table_assignment_members(db, member_id); - } - } + mark_resolved_member_assignment(db, member_id); 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, @@ -341,6 +399,17 @@ pub fn try_resolve_member( // `Ref` prefix names a declared class, so the member is re-homed onto it // but does not become one of its declared members. if let Some((member_owner, set_owner_only)) = member_owner { + // A prefix that is a local alias of a known global path is a path + // write for provenance purposes, so the member follows the path's + // `---@class` flips. Only computed when the owner resolved to a + // class: path and Element owners already carry their provenance + // through the homing note. + let alias_path = match &member_owner { + LuaMemberOwner::Type(_) => { + alias_target_global_path(db, unresolve_member.file_id, prefix_expr) + } + _ => None, + }; // The Lua pass creates a missing member before it looks at // `set_owner_only`, so this must too: `set_member_owner` cannot // re-home a member that does not exist, and a `Ref` prefix would @@ -352,17 +421,44 @@ pub fn try_resolve_member( &prefix_type, member_owner.clone(), member_id, + alias_path.clone(), ); } if set_owner_only { - db.get_member_index_mut().set_member_owner( - member_owner, - member_id.file_id, - member_id, - ); + match alias_path { + Some(alias_path) => { + db.get_member_index_mut().home_alias_member_with_provenance( + member_owner, + member_id.file_id, + member_id, + alias_path, + true, + ); + } + None => { + db.get_member_index_mut().set_member_owner_only( + member_owner, + member_id.file_id, + member_id, + ); + } + } } else { - add_member(db, member_owner, member_id); + match alias_path { + Some(alias_path) => { + db.get_member_index_mut().home_alias_member_with_provenance( + member_owner, + member_id.file_id, + member_id, + alias_path, + false, + ); + } + None => { + add_member(db, member_owner, member_id); + } + } } } unresolve_member.prefix = None; @@ -400,9 +496,6 @@ pub fn try_resolve_member( member_id.into(), LuaTypeCache::InferType(expr_type.clone()), ); - crate::compilation::analyzer::lua::record_resolved_member_assignment_contribution( - db, member_id, &expr_type, - ); crate::compilation::analyzer::lua::mark_resolved_member_assignment(db, member_id); } @@ -530,6 +623,17 @@ fn merge_table_field_to_def( .set_member_owner(owner.clone(), member_id.file_id, member_id); db.get_member_index_mut() .add_member_to_owner(owner.clone(), member_id); + // A literal that initialises a global path keeps the member following + // the path's `---@class` flips even though it now also lives on the + // def. The homing note above may have dropped that evidence when the + // def is not the path's current canonical owner, so re-stamp here. + if let Some(path) = db + .get_member_index() + .definition_site_path(&InFiled::new(file_id, table_expr.get_range())) + { + db.get_member_index_mut() + .stamp_path_provenance_if_absent([member_id], &path); + } } Some(()) @@ -574,13 +678,14 @@ pub fn try_resolve_return_point( && signature.resolve_return == SignatureReturnStatus::InferResolve { let current_return = signature.get_return_type(); - if !current_return.is_unknown() && !current_return.is_any() { + if !is_unsettled_inferred_return(¤t_return) { return Ok(()); } } - let return_correlations = analyze_return_correlations(db, cache, &return_.return_points); - let return_docs = analyze_return_point(db, cache, &return_.return_points)?; + let (return_correlations, return_docs) = + derive_inferred_return_with_reads(db, cache, return_.signature_id, &return_.return_points); + let return_docs = return_docs?; let inferred_return = return_docs_to_type(&return_docs); let inherited_tail = db @@ -620,13 +725,25 @@ pub fn try_resolve_return_point( return_kind: ReturnTypeKind::default(), })); signature.set_return_correlations(return_correlations); + db.get_signature_index_mut() + .note_return_write(return_.signature_id); return Ok(()); } if should_apply_resolved_return_docs(signature, &return_docs) { + let moved = signature + .return_docs + .iter() + .map(|doc| &doc.type_ref) + .ne(return_docs.iter().map(|doc| &doc.type_ref)); + let correlations_moved = signature.return_correlations() != return_correlations.as_slice(); signature.resolve_return = SignatureReturnStatus::InferResolve; signature.return_docs = return_docs; signature.set_return_correlations(return_correlations); + if moved || correlations_moved { + db.get_signature_index_mut() + .note_return_write(return_.signature_id); + } } Ok(()) @@ -651,8 +768,14 @@ fn should_apply_resolved_return_docs( return true; // Allow upgrading Unknown to Any } - (current_return.is_unknown() || current_return.is_any()) - && !(new_return.is_unknown() || new_return.is_any()) + is_unsettled_inferred_return(¤t_return) && !is_unsettled_inferred_return(&new_return) +} + +/// An inferred return that says nothing yet. `never` belongs here: a function +/// whose body returns an expression cannot return no value, so a `never` +/// return is an operand that had not resolved when the body was read. +pub(crate) fn is_unsettled_inferred_return(return_type: &LuaType) -> bool { + return_type.is_unknown() || return_type.is_any() || return_type.is_never() } fn return_docs_to_type(return_docs: &[LuaDocReturnInfo]) -> LuaType { @@ -671,41 +794,151 @@ fn return_docs_to_type(return_docs: &[LuaDocReturnInfo]) -> LuaType { } } +/// [`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, + file_id: FileId, + iter_exprs: &[LuaExpr], + var_positions: &[TextSize], +) -> Result, InferFailReason> { + compute_iter_var_updates(db, cache, file_id, iter_exprs, var_positions, true) +} + pub fn try_resolve_iter_var( db: &mut DbIndex, cache: &mut LuaInferCache, unresolve_iter_var: &mut UnResolveIterVar, ) -> 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, + false, + ) { + 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() .unwrap_or(LuaType::Unknown); let ret_type = TypeOps::Remove.apply(db, &ret_type, &LuaType::Nil); + // A raw template ref is the placeholder an unbound generic leaves for + // an arm it could not see into, not a type the variable holds, so the + // arms that did bind are the answer. A placeholder alone stays as it + // is: it is what marks the variable as still waiting. + let ret_type = match &ret_type { + LuaType::Union(union) + if ret_type.contain_tpl() && union.types().any(|arm| !arm.contain_tpl()) => + { + LuaType::from_vec( + union + .types() + .filter(|arm| !arm.contain_tpl()) + .cloned() + .collect(), + ) + } + _ => ret_type, + }; + + let ret_type = if settled { + if let Some(Some(proven)) = iter_var_types.settled_overrides.get(idx) { + proven.clone() + } else { + ret_type + } + } else { + ret_type + }; let owner: LuaTypeOwner = decl_id.into(); - let mode = iter_var_write_mode(db.get_type_index().get_type_cache(&owner), &ret_type); - write_type_cache(db, owner, LuaTypeCache::InferType(ret_type), mode); + 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) + }; + updates.push(IterVarTypeUpdate { + owner, + cache: LuaTypeCache::InferType(ret_type), + mode, + }); } - Ok(()) + Ok(updates) +} + +/// 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 } /// The write mode for a settled iterator-variable type. @@ -2361,11 +2594,19 @@ fn try_resolve_constructor_param( if let Some(type_decl) = db.get_type_index().get_type_decl(&root_type_id) && type_decl.is_class() { + let source_range = get_call_arg_expr( + call_expr, + param_info.param_idx, + param_info.is_colon_define, + call_expr.is_colon_call(), + ) + .map(|arg| arg.get_range()) + .unwrap_or_else(|| call_expr.get_range()); let root_type = LuaType::Ref(root_type_id.clone()); db.get_type_index_mut().add_super_type_if_missing( target_id.clone(), file_id, - call_expr.get_range(), + source_range, root_type, ); } @@ -2844,4 +3085,96 @@ mod tests { "string template selection should be independent of union member order" ); } + + fn never_return_doc() -> crate::LuaDocReturnInfo { + crate::LuaDocReturnInfo { + name: None, + type_ref: LuaType::Never, + default_value: None, + description: None, + attributes: None, + return_kind: crate::ReturnTypeKind::default(), + } + } + + fn stale_correlation() -> crate::LuaReturnCorrelation { + crate::LuaReturnCorrelation { + discriminant_slot: 0, + implied_non_nil_slots: vec![1], + } + } + + #[test] + fn return_write_version_advances_on_correlation_only_change() { + use crate::{LuaInferCache, LuaSignatureId, SignatureReturnStatus}; + use rowan::TextSize; + + let file_id = FileId::new(77); + let signature_id = LuaSignatureId::new(file_id, TextSize::new(0)); + let mut db = make_db(); + { + let signature = db.get_signature_index_mut().get_or_create(signature_id); + signature.resolve_return = SignatureReturnStatus::UnResolve; + signature.return_docs = vec![never_return_doc()]; + signature.set_return_correlations(vec![stale_correlation()]); + } + let before = db.get_signature_index().return_write_version(&signature_id); + + let mut cache = LuaInferCache::new(file_id, Default::default()); + let mut unresolve = super::UnResolveReturn { + file_id, + signature_id, + body: None, + return_points: Vec::new(), + }; + super::try_resolve_return_point(&mut db, &mut cache, &mut unresolve) + .expect("empty return points resolve to never"); + + let after = db.get_signature_index().return_write_version(&signature_id); + assert!( + after > before, + "correlation-only change must advance return_write_version" + ); + assert!( + db.get_signature_index() + .get(&signature_id) + .expect("signature") + .return_correlations() + .is_empty(), + "stale correlation must be replaced by the re-derived empty set" + ); + } + + #[test] + fn return_write_version_stays_on_same_correlation_noop() { + use crate::{LuaInferCache, LuaSignatureId, SignatureReturnStatus}; + use rowan::TextSize; + + let file_id = FileId::new(78); + let signature_id = LuaSignatureId::new(file_id, TextSize::new(0)); + let mut db = make_db(); + { + let signature = db.get_signature_index_mut().get_or_create(signature_id); + signature.resolve_return = SignatureReturnStatus::UnResolve; + signature.return_docs = vec![never_return_doc()]; + signature.set_return_correlations(Vec::new()); + } + let before = db.get_signature_index().return_write_version(&signature_id); + + let mut cache = LuaInferCache::new(file_id, Default::default()); + let mut unresolve = super::UnResolveReturn { + file_id, + signature_id, + body: None, + return_points: Vec::new(), + }; + super::try_resolve_return_point(&mut db, &mut cache, &mut unresolve) + .expect("empty return points resolve to never"); + + let after = db.get_signature_index().return_write_version(&signature_id); + assert_eq!( + before, after, + "identical docs and correlations must not advance return_write_version" + ); + } } diff --git a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve_closure.rs b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve_closure.rs index f61f9904f..6ec7cb0c2 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve_closure.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve_closure.rs @@ -3,7 +3,7 @@ use std::{ops::Deref, sync::Arc}; use glua_parser::{ LuaAstNode, LuaCallExpr, LuaExpr, LuaIndexMemberExpr, LuaLiteralToken, LuaTableExpr, LuaVarExpr, }; -use std::collections::HashSet; +use rustc_hash::FxHashSet; use crate::{ DbIndex, GlobalId, GmodHookKind, InferFailReason, InferGuard, InferGuardRef, LuaDocParamInfo, @@ -281,7 +281,7 @@ pub fn resolve_gmod_hook_callback_doc_function( let hook_name = hook_site.hook_name.as_ref()?.clone(); let member_key = LuaMemberKey::Name(hook_name.clone().into()); let mut candidates = Vec::new(); - let mut seen_member_ids = HashSet::new(); + let mut seen_member_ids = FxHashSet::default(); for owner_name in iter_hook_owner_names(db) { for owner in [ LuaMemberOwner::Type(LuaTypeDeclId::global(&owner_name)), @@ -499,6 +499,11 @@ fn resolve_closure_member_type( if !signature.is_resolve_return() { return Err(InferFailReason::UnResolveSignatureReturn(*id)); } + // See `filter_signature_type`: params this pass copied off a + // sibling's receiver are that sibling's answer, not the slot's. + if signature.params_filled_from_slot { + return Ok(()); + } let fake_doc_function = signature.to_doc_func_type(); resolve_doc_function(db, closure_params, &fake_doc_function, self_type) } else { @@ -671,6 +676,7 @@ fn resolve_doc_function( doc_params[0].1 = Some(self_type); } + signature.params_filled_from_slot |= !doc_params.is_empty(); for (index, param) in doc_params.iter().enumerate() { let name = signature.params.get(index).unwrap_or(¶m.0); signature.param_docs.insert( @@ -894,11 +900,16 @@ fn filter_signature_type( // When preserve_returns is true (hook hover path), emit even for return- // only hooks so that `@return`-annotated hooks with no params display // correctly as `function() -> boolean` rather than silently degrading. + // A peer closure filling the same slot has params only + // because this pass copied them off its own receiver. + // Inheriting them would make this closure's receiver a + // question of which sibling the batch resolved first. + let annotated_params = + !sig.param_docs.is_empty() && !sig.params_filled_from_slot; let has_useful_info = if preserve_returns { - !sig.param_docs.is_empty() || !sig.get_return_type().is_nil() + annotated_params || !sig.get_return_type().is_nil() } else { - !sig.param_docs.is_empty() - || (preserve_implicit_receiver && sig.is_colon_define) + annotated_params || (preserve_implicit_receiver && sig.is_colon_define) }; if has_useful_info { let params = sig.get_type_params(); diff --git a/crates/glua_code_analysis/src/compilation/mod.rs b/crates/glua_code_analysis/src/compilation/mod.rs index adeb79a57..2fe7e492f 100644 --- a/crates/glua_code_analysis/src/compilation/mod.rs +++ b/crates/glua_code_analysis/src/compilation/mod.rs @@ -3,6 +3,7 @@ mod test; pub use analyzer::gmod::get_scripted_class_info_for_file; pub(crate) use analyzer::gmod::get_scripted_class_type_decl_id; +pub use analyzer::is_member_assignment_in_conditional_branch; pub use analyzer::unresolve::extract_hook_name; pub use analyzer::unresolve::resolve_gmod_hook_add_callback_doc_function; pub use analyzer::unresolve::resolve_gmod_hook_callback_doc_function; @@ -50,6 +51,7 @@ impl LuaCompilation { /// this list is always empty. pub fn update_index(&mut self, file_ids: Vec) -> Vec { let mut need_analyzed_files = vec![]; + let mut analyzed_file_ids = vec![]; for file_id in file_ids { let tree = match self.db.get_vfs().get_syntax_tree(&file_id) { Some(tree) => tree, @@ -58,12 +60,23 @@ impl LuaCompilation { continue; } }; + analyzed_file_ids.push(file_id); need_analyzed_files.push(InFiled { file_id, value: tree.get_chunk_node(), }); } + // Removing a file unresolved every dependency site that named it. + // Now that it is back, those sites point at it again. Done here rather + // than on the edit path so every caller of `update_index` is symmetric + // with `remove_index`, not just the one that prompted it. + for file_id in &analyzed_file_ids { + self.db + .get_file_dependencies_index_mut() + .relink_unresolved_target(*file_id); + } + analyzer::analyze(&mut self.db, need_analyzed_files); Vec::new() } diff --git a/crates/glua_code_analysis/src/compilation/test/and_or_test.rs b/crates/glua_code_analysis/src/compilation/test/and_or_test.rs index bd95e9c07..62558a7fd 100644 --- a/crates/glua_code_analysis/src/compilation/test/and_or_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/and_or_test.rs @@ -229,4 +229,67 @@ mod test { assert_eq!(ws.humanize_type(folded), "table"); } + + #[gtest] + fn test_resolved_local_or_empty_table_keeps_declared_type() { + // `x = x or {}` over a resolved, non-optional local is a narrowing idiom, + // not a table bootstrap: the declared type must survive the fold. + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" + local x --- @type string + x = x or {} + a = x + "#, + ); + + let a = ws.expr_ty("a"); + assert_eq!(ws.humanize_type(a), "string"); + } + + #[gtest] + fn test_resolved_local_or_empty_table_string_call_no_diagnostic() { + let mut ws = VirtualWorkspace::new(); + assert!(ws.check_code_for( + DiagnosticCode::UndefinedField, + r#" + local x --- @type string + x = x or {} + local y = x:upper() + "#, + )); + } + + #[gtest] + fn test_unresolved_global_or_empty_table_still_bootstraps_table() { + // An undeclared global has nothing to resolve to, so `Foo = Foo or {}` + // remains a bootstrap: the fold yields the fresh table. + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" + Foo = Foo or {} + a = Foo + "#, + ); + + let a = ws.expr_ty("a"); + assert_eq!(ws.humanize_type(a), "table"); + } + + #[gtest] + fn test_string_param_or_empty_table_keeps_string() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" + ---@param x string + function f(x) + x = x or {} + a = x + end + "#, + ); + + let a = ws.expr_ty("a"); + assert_eq!(ws.humanize_type(a), "string"); + } } diff --git a/crates/glua_code_analysis/src/compilation/test/annotation_test.rs b/crates/glua_code_analysis/src/compilation/test/annotation_test.rs index 9b10eded2..3ec04e2a6 100644 --- a/crates/glua_code_analysis/src/compilation/test/annotation_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/annotation_test.rs @@ -1570,4 +1570,92 @@ mod test { "#, )); } + + /// A class documented in two files is one shared property. The merged view takes each + /// scalar field from the lowest-sorting contributor, whichever order the files arrive in, + /// and re-indexing one file leaves the other's contribution standing. + fn shared_doc_description(ws: &mut VirtualWorkspace) -> Option { + ws.get_db_mut() + .get_property_index() + .get_property(&LuaSemanticDeclId::TypeDecl(LuaTypeDeclId::global( + "SharedDoc", + ))) + .and_then(|property| property.description().cloned()) + } + + const SHARED_DOC_A: (&str, &str) = ( + "lua/shared_doc_a.lua", + "--- doc from a\n---@class SharedDoc\n---@field alpha integer\n", + ); + const SHARED_DOC_B: (&str, &str) = ( + "lua/shared_doc_b.lua", + "--- doc from b\n---@class SharedDoc\n---@field beta integer\n", + ); + + #[test] + fn test_class_documented_in_two_files_merges_in_path_order() { + let mut a_first = VirtualWorkspace::new(); + a_first.def_file(SHARED_DOC_A.0, SHARED_DOC_A.1); + a_first.def_file(SHARED_DOC_B.0, SHARED_DOC_B.1); + + let mut b_first = VirtualWorkspace::new(); + b_first.def_file(SHARED_DOC_B.0, SHARED_DOC_B.1); + b_first.def_file(SHARED_DOC_A.0, SHARED_DOC_A.1); + + assert_eq!( + shared_doc_description(&mut a_first).as_deref(), + Some("doc from a") + ); + assert_eq!( + shared_doc_description(&mut b_first).as_deref(), + Some("doc from a") + ); + } + + #[test] + fn test_reindexing_one_documenting_file_keeps_the_other_contribution() { + let mut ws = VirtualWorkspace::new(); + ws.def_file(SHARED_DOC_A.0, SHARED_DOC_A.1); + ws.def_file(SHARED_DOC_B.0, SHARED_DOC_B.1); + + // Re-index the winner; b's contribution has to be what is left. + ws.def_file(SHARED_DOC_A.0, "---@class SharedDoc\n"); + + assert_eq!( + shared_doc_description(&mut ws).as_deref(), + Some("doc from b") + ); + + // And the winner coming back retakes the field. + ws.def_file(SHARED_DOC_A.0, SHARED_DOC_A.1); + + assert_eq!( + shared_doc_description(&mut ws).as_deref(), + Some("doc from a") + ); + } + + #[test] + fn test_reindexing_a_documenting_file_does_not_accumulate_tags() { + let mut ws = VirtualWorkspace::new(); + ws.def_file(SHARED_DOC_A.0, SHARED_DOC_A.1); + ws.def_file( + SHARED_DOC_B.0, + "--- doc from b\n---@class SharedDoc\n---@see Other\n", + ); + ws.def_file( + SHARED_DOC_B.0, + "--- doc from b\n---@class SharedDoc\n---@see Other\n", + ); + + let tags = ws + .get_db_mut() + .get_property_index() + .get_property(&LuaSemanticDeclId::TypeDecl(LuaTypeDeclId::global( + "SharedDoc", + ))) + .and_then(|property| property.tag_content().map(|tags| tags.get_all_tags().len())); + + assert_eq!(tags, Some(1)); + } } 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..2b9081592 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,348 @@ 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_with_init_std_lib(); + 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_with_init_std_lib(); + 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[]")); + } + + #[test] + fn test_breaking_loop_leaves_array_element_type_unchanged() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + 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) + if v == "" then break end + end + local after = arr + "#, + ); + + // A `break` can leave later elements unrewritten. + let after = local_name_type(&mut ws, file_id, "after"); + assert_eq!(after, ws.ty("string[]")); + } + + #[test] + fn test_returning_loop_leaves_array_element_type_unchanged() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + 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) + if v == "" then return end + end + local after = arr + "#, + ); + + // A `return` exits before later elements are rewritten. + let after = local_name_type(&mut ws, file_id, "after"); + assert_eq!(after, ws.ty("string[]")); + } + + #[test] + fn test_goto_loop_leaves_array_element_type_unchanged() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + 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) + goto done + ::done:: + end + local after = arr + "#, + ); + + // A `goto` can jump past the write. + let after = local_name_type(&mut ws, file_id, "after"); + assert_eq!(after, ws.ty("string[]")); + } + + #[test] + fn test_gmod_continue_loop_leaves_array_element_type_unchanged() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + 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 continue end + arr[i] = tonum(v) + end + local after = arr + "#, + ); + + // GMod's `continue` parses as a break-like statement and skips elements. + let after = local_name_type(&mut ws, file_id, "after"); + assert_eq!(after, ws.ty("string[]")); + } + + #[test] + fn test_local_shadowed_ipairs_leaves_array_element_type_unchanged() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let file_id = ws.def( + r#" + ---@return string[] + local function explode() end + ---@param v string + ---@return number? + local function tonum(v) end + + local ipairs = function(t) end + local arr = explode() + for i, v in ipairs(arr) do + arr[i] = tonum(v) + end + local after = arr + "#, + ); + + // The local `ipairs` is not the builtin, so its iteration is unknown. + let after = local_name_type(&mut ws, file_id, "after"); + assert_eq!(after, ws.ty("string[]")); + } + + #[test] + fn test_param_shadowed_ipairs_leaves_array_element_type_unchanged() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let file_id = ws.def( + r#" + ---@return string[] + local function explode() end + ---@param v string + ---@return number? + local function tonum(v) end + + local function shadowed(ipairs) + local arr = explode() + for i, v in ipairs(arr) do + arr[i] = tonum(v) + end + local after = arr + end + "#, + ); + + // A parameter named `ipairs` shadows the builtin for the whole call. + let after = local_name_type(&mut ws, file_id, "after"); + assert_eq!(after, ws.ty("string[]")); + } + + #[test] + fn test_enclosing_scope_shadowed_ipairs_leaves_array_element_type_unchanged() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let file_id = ws.def( + r#" + ---@return string[] + local function explode() end + ---@param v string + ---@return number? + local function tonum(v) end + + local ipairs = function(t) end + do + local arr = explode() + for i, v in ipairs(arr) do + arr[i] = tonum(v) + end + local after = arr + end + "#, + ); + + // The shadow in the enclosing block still applies inside the `do` block. + let after = local_name_type(&mut ws, file_id, "after"); + assert_eq!(after, ws.ty("string[]")); + } + + #[test] + fn test_global_hijacked_ipairs_leaves_array_element_type_unchanged() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let file_id = ws.def( + r#" + ---@return string[] + local function explode() end + ---@param v string + ---@return number? + local function tonum(v) end + + function ipairs(t) + return 1, t[1] + end + local arr = explode() + for i, v in ipairs(arr) do + arr[i] = tonum(v) + end + local after = arr + "#, + ); + + // The user workspace redefines the global `ipairs`. + let after = local_name_type(&mut ws, file_id, "after"); + assert_eq!(after, ws.ty("string[]")); + } + + #[test] + fn test_g_ipairs_leaves_array_element_type_unchanged() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + 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 _G.ipairs(arr) do + arr[i] = tonum(v) + end + local after = arr + "#, + ); + + // `_G.ipairs` is an indexed access, not the bare builtin call. + let after = local_name_type(&mut ws, file_id, "after"); + assert_eq!(after, ws.ty("string[]")); + } + + #[test] + fn test_library_workspace_ipairs_override_still_transforms_array_element_type() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let library_root = ws.virtual_url_generator.base.join("library"); + ws.analysis.add_library_workspace(library_root); + + ws.def_file( + "library/ipairs_override.lua", + r#" + function ipairs(t) + end + "#, + ); + + 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 + "#, + ); + + // A library-workspace redefinition of `ipairs` is still a trusted + // declaration, so the transform applies. + let after = local_name_type(&mut ws, file_id, "after"); + assert_eq!(after, ws.ty("(number?)[]")); + } + + #[test] + fn test_unresolved_ipairs_without_std_lib_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 + arr[i] = tonum(v) + end + local after = arr + "#, + ); + + // With no std or library workspaces, `ipairs` resolves to nothing; an + // unresolved name must not be treated as the builtin. + let after = local_name_type(&mut ws, file_id, "after"); + assert_eq!(after, ws.ty("string[]")); + } } diff --git a/crates/glua_code_analysis/src/compilation/test/closure_param_infer_test.rs b/crates/glua_code_analysis/src/compilation/test/closure_param_infer_test.rs index 7a1675f52..e248f7ce9 100644 --- a/crates/glua_code_analysis/src/compilation/test/closure_param_infer_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/closure_param_infer_test.rs @@ -144,6 +144,7 @@ mod test { }; ws.analysis .update_file_by_uri(&api_uri, Some(api_source("CallbackEntityA"))) + .map(|(id, _)| id) .expect("initial callback API"); let consumer_file_id = ws.def_file( "lua/autorun/shared/callback_consumer.lua", @@ -161,6 +162,7 @@ mod test { ws.analysis .update_file_by_uri(&api_uri, Some(api_source("CallbackEntityB"))) + .map(|(id, _)| id) .expect("edited callback API"); assert_eq!( index_expr_type(&ws, consumer_file_id, "data.proc"), @@ -169,9 +171,11 @@ mod test { ws.analysis .remove_file_by_uri(&api_uri) + .0 .expect("removed callback API"); ws.analysis .update_file_by_uri(&api_uri, Some(api_source("CallbackEntityA"))) + .map(|(id, _)| id) .expect("reopened callback API"); assert_eq!( index_expr_type(&ws, consumer_file_id, "data.proc"), @@ -1074,6 +1078,7 @@ mod test { .to_string(), ), ) + .map(|(id, _)| id) .expect("other workspace file"); let main_file_id = ws.def_file( "base.lua", @@ -1149,16 +1154,18 @@ mod test { &other_workspace.join("lua/weapons/gmod_tool/stools/context_test.lua"), ) .expect("other workspace uri"); - ws.analysis.update_file_by_uri( - &other_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &other_uri, + Some( + r#" ---@class TOOL.context_test : WrongBase TOOL = {} "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); let (base_contract, class_contract) = current_super.map_or_else( || ( @@ -1253,6 +1260,7 @@ mod test { let edge_uri = ws.virtual_url_generator.new_uri("sh_contract.lua"); ws.analysis .update_file_by_uri(&edge_uri, Some(edge_source("CLIENT"))) + .map(|(id, _)| id) .expect("client edge file"); ws.def_file( "sh_override.lua", @@ -1350,16 +1358,18 @@ mod test { let other_uri = lsp_types::Uri::parse_from_file_path(&other_workspace.join("lua/foreign_contract.lua")) .expect("other workspace uri"); - ws.analysis.update_file_by_uri( - &other_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &other_uri, + Some( + r#" ---@class WorkspaceMid : WorkspaceContract local WorkspaceMid = {} "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); ws.def_file( "lua/current_override.lua", r#" @@ -1402,10 +1412,11 @@ mod test { &other_workspace.join("lua/foreign_callback_edges.lua"), ) .expect("other workspace uri"); - ws.analysis.update_file_by_uri( - &other_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &other_uri, + Some( + r#" ---@class CallbackChild : CallbackBase local CallbackChild = {} @@ -1415,9 +1426,10 @@ mod test { ---@class IndexCallbackChild : IndexCallbackBase local IndexCallbackChild = {} "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); ws.def_file( "lua/current_callback_calls.lua", r#" diff --git a/crates/glua_code_analysis/src/compilation/test/closure_return_test.rs b/crates/glua_code_analysis/src/compilation/test/closure_return_test.rs index b6ebf375c..331f6fb2f 100644 --- a/crates/glua_code_analysis/src/compilation/test/closure_return_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/closure_return_test.rs @@ -143,6 +143,7 @@ mod test { assert_eq!(initial, ws.ty("unknown")); ws.analysis .update_file_by_uri(&consumer_uri, Some(format!("{consumer}\n"))) + .map(|(id, _)| id) .expect("edited consumer"); let incremental = local_name_type(&ws, consumer_file_id, "instance"); @@ -201,6 +202,7 @@ mod test { .unwrap_or_default(); ws.analysis .update_file_by_uri(&consumer_uri, Some(format!("{consumer}\n"))) + .map(|(id, _)| id) .expect("edited consumer"); let after = ws .analysis 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/compilation/test/flow.rs b/crates/glua_code_analysis/src/compilation/test/flow.rs index 5c9eb4af0..d68ee8d00 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(); @@ -3200,7 +3257,7 @@ _2 = a[1] .compilation .get_db() .get_signature_index() - .inferred_guard_consumers_for_files(&HashSet::from([file_ids[2]])) + .inferred_guard_consumers_for_files(&HashSet::from_iter([file_ids[2]])) .contains(&file_id) ); } @@ -3223,6 +3280,7 @@ _2 = a[1] .to_string(), ), ) + .map(|(id, _)| id) .expect("predicate file id after update"); let narrowed_type = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); @@ -3300,6 +3358,7 @@ _2 = a[1] &guard_uri, Some(definition.replace("{body}", "return IsValid(ent) and ent:IsPlayer()")), ) + .map(|(id, _)| id) .unwrap_or_else(|| panic!("{case} guard addition")); let narrowed = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); @@ -3308,12 +3367,6 @@ _2 = a[1] ws.analysis.inferred_guard_propagation_stats.reindexed_files, 1 ); - assert_eq!( - ws.analysis - .inferred_guard_propagation_stats - .broad_stabilizations, - 0 - ); } } @@ -3334,6 +3387,7 @@ _2 = a[1] "function IsPlayer(ent) return IsValid(ent) and ent:IsPlayer() end".to_string(), ), ) + .map(|(id, _)| id) .expect("new guard source file id"); let narrowed = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); @@ -3342,12 +3396,6 @@ _2 = a[1] ws.analysis.inferred_guard_propagation_stats.reindexed_files, 1 ); - assert_eq!( - ws.analysis - .inferred_guard_propagation_stats - .broad_stabilizations, - 0 - ); } #[gtest] @@ -3369,12 +3417,6 @@ _2 = a[1] ws.analysis.inferred_guard_propagation_stats.reindexed_files, 1 ); - assert_eq!( - ws.analysis - .inferred_guard_propagation_stats - .broad_stabilizations, - 0 - ); } #[gtest] @@ -3407,17 +3449,11 @@ _2 = a[1] .to_string(), ), ) + .map(|(id, _)| id) .unwrap_or_else(|| panic!("{case} guard source file id")); let narrowed = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); assert_eq!(ws.humanize_type(narrowed), "Player", "{case}"); - assert_eq!( - ws.analysis - .inferred_guard_propagation_stats - .broad_stabilizations, - 0, - "{case}" - ); } } @@ -3441,6 +3477,7 @@ _2 = a[1] "function IsPlayer(ent) return IsValid(ent) and ent:IsPlayer() end".to_string(), ), ) + .map(|(id, _)| id) .expect("deep alias guard source file id"); let narrowed = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); @@ -3466,6 +3503,7 @@ _2 = a[1] "function IsPlayer(ent) return IsValid(ent) and ent:IsPlayer() end".to_string(), ), ) + .map(|(id, _)| id) .expect("cyclic alias guard source file id"); let narrowed = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); @@ -3491,6 +3529,7 @@ _2 = a[1] "function IsPlayer(ent) return IsValid(ent) and ent:IsPlayer() end".to_string(), ), ) + .map(|(id, _)| id) .expect("mutable alias guard source file id"); let narrowed = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); @@ -3591,6 +3630,7 @@ _2 = a[1] &guard_uri, Some(definition.replace("{body}", "return IsValid(ent) and ent:IsPlayer()")), ) + .map(|(id, _)| id) .unwrap_or_else(|| panic!("{case} guard file id after update")); for consumer_uri in consumer_uris { @@ -3690,6 +3730,7 @@ _2 = a[1] &uris[1], Some("function GuardA(ent) return IsValid(ent) and ent:IsPlayer() end".to_string()), ) + .map(|(id, _)| id) .expect("GuardA file id after update"); let narrowed = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); @@ -3776,6 +3817,7 @@ _2 = a[1] ws.analysis .update_file_by_uri(&uris[1], Some(guard_a("-- unrelated edit\n\n", "IsPlayer"))) + .map(|(id, _)| id) .expect("fact-preserving guard source reindex"); assert_eq!( ws.analysis.inferred_guard_propagation_stats.changed_facts, @@ -3784,6 +3826,7 @@ _2 = a[1] ws.analysis .update_file_by_uri(&uris[1], Some(guard_a("-- unrelated edit\n\n", "IsNPC"))) + .map(|(id, _)| id) .expect("guard type change"); let narrowed = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); assert_eq!(ws.humanize_type(narrowed), "NPC"); @@ -3792,18 +3835,13 @@ _2 = a[1] ws.analysis.inferred_guard_propagation_stats.reindexed_files, 5 ); - assert_eq!( - ws.analysis - .inferred_guard_propagation_stats - .broad_stabilizations, - 0 - ); ws.analysis .update_file_by_uri( &uris[1], Some("-- unrelated edit\n\nfunction GuardA(ent) return true end".to_string()), ) + .map(|(id, _)| id) .expect("guard removal"); let narrowed = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); assert_eq!(ws.humanize_type(narrowed), "Entity"); @@ -3812,12 +3850,6 @@ _2 = a[1] ws.analysis.inferred_guard_propagation_stats.reindexed_files, 5 ); - assert_eq!( - ws.analysis - .inferred_guard_propagation_stats - .broad_stabilizations, - 0 - ); } #[gtest] @@ -3912,20 +3944,24 @@ _2 = a[1] let signature_index = ws.analysis.compilation.get_db().get_signature_index(); assert_eq!( - signature_index.inferred_guard_consumers_for_files(&HashSet::from([file_ids[1]])), - HashSet::from([file_ids[2]]) + signature_index + .inferred_guard_consumers_for_files(&HashSet::from_iter([file_ids[1]])), + HashSet::from_iter([file_ids[2]]) ); assert_eq!( - signature_index.inferred_guard_consumers_for_files(&HashSet::from([file_ids[2]])), - HashSet::from([file_ids[3]]) + signature_index + .inferred_guard_consumers_for_files(&HashSet::from_iter([file_ids[2]])), + HashSet::from_iter([file_ids[3]]) ); assert_eq!( - signature_index.inferred_guard_consumers_for_files(&HashSet::from([file_ids[3]])), - HashSet::from([file_ids[4]]) + signature_index + .inferred_guard_consumers_for_files(&HashSet::from_iter([file_ids[3]])), + HashSet::from_iter([file_ids[4]]) ); ws.analysis .update_file_by_uri(&uris[1], Some(guard_a(equivalent_prefix, "IsNPC"))) + .map(|(id, _)| id) .expect("guard type change"); let wrapper_types = { @@ -3934,7 +3970,7 @@ _2 = a[1] .iter() .map(|file_id| { signature_index - .inferred_guard_facts_for_files(&HashSet::from([*file_id])) + .inferred_guard_facts_for_files(&HashSet::from_iter([*file_id])) .into_values() .next() .expect("wrapper inferred guard") @@ -4027,6 +4063,7 @@ _2 = a[1] &guard_uri, Some(source("IsPlayer", "return IsValid(ent) and ent:IsPlayer()")), ) + .map(|(id, _)| id) .expect("dot assignment guard addition"); let narrowed = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); assert_eq!(ws.humanize_type(narrowed), "Player"); @@ -4036,12 +4073,14 @@ _2 = a[1] &guard_uri, Some(source("IsPlayer", "return IsValid(ent) and ent:IsNPC()")), ) + .map(|(id, _)| id) .expect("dot assignment guard type change"); let narrowed = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); assert_eq!(ws.humanize_type(narrowed), "NPC"); ws.analysis .update_file_by_uri(&guard_uri, Some(source("IsPlayer", "return true"))) + .map(|(id, _)| id) .expect("dot assignment guard removal"); let narrowed = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); assert_eq!(ws.humanize_type(narrowed), "Entity"); @@ -4051,6 +4090,7 @@ _2 = a[1] &guard_uri, Some(source("IsPerson", "return IsValid(ent) and ent:IsPlayer()")), ) + .map(|(id, _)| id) .expect("dot assignment guard rename"); let narrowed = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); assert_eq!(ws.humanize_type(narrowed), "Entity"); @@ -4085,7 +4125,7 @@ _2 = a[1] "Predicates = Predicates or {}\nPredicates[\"IsPlayer\"] = function(ent) return true end" .to_string(), ), - ) + ).map(|(id, _)| id) .expect("moved static-string assignment guard removal"); let narrowed = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); assert_eq!(ws.humanize_type(narrowed), "Entity"); @@ -4179,6 +4219,7 @@ _2 = a[1] .to_string(), ), ) + .map(|(id, _)| id) .expect("guard chain file id"); let consumer_file_id = ws @@ -4324,12 +4365,14 @@ _2 = a[1] &guard_uri, Some(source("IsPlayer", "return IsValid(ent) and ent:IsPlayer()")), ) + .map(|(id, _)| id) .expect("guard addition"); let narrowed = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); assert_eq!(ws.humanize_type(narrowed), "Player"); ws.analysis .update_file_by_uri(&guard_uri, Some(source("IsPlayer", "return true"))) + .map(|(id, _)| id) .expect("guard removal"); let narrowed = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); assert_eq!(ws.humanize_type(narrowed), "Entity"); @@ -4339,6 +4382,7 @@ _2 = a[1] &guard_uri, Some(source("IsPerson", "return IsValid(ent) and ent:IsPlayer()")), ) + .map(|(id, _)| id) .expect("guard rename"); let narrowed = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); assert_eq!(ws.humanize_type(narrowed), "Entity"); @@ -4426,7 +4470,7 @@ _2 = a[1] .compilation .get_db() .get_signature_index() - .inferred_guard_consumers_for_files(&HashSet::from([new_file_id])) + .inferred_guard_consumers_for_files(&HashSet::from_iter([new_file_id])) .contains(&consumer_file_id) ); } @@ -4592,6 +4636,7 @@ _2 = a[1] &guards_uri, Some(guard_source("return IsValid(ent) and ent:IsPlayer()")), ) + .map(|(id, _)| id) .expect("realm guard file id after update"); let server_type = nth_name_expr_type_from_end(&mut ws, server_file_id, "narrowed", 0); @@ -4624,6 +4669,7 @@ _2 = a[1] &predicate_uri, Some("\n\nfunction IsPlayer(ent)\n return true\nend".to_string()), ) + .map(|(id, _)| id) .expect("predicate file id after offset shift"); let narrowed_type = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); @@ -4648,6 +4694,7 @@ _2 = a[1] .to_string(), ), ) + .map(|(id, _)| id) .expect("predicate file id after rename"); let narrowed_type = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); @@ -4666,6 +4713,7 @@ _2 = a[1] ws.analysis .remove_file_by_uri(&predicate_uri) + .0 .expect("removed predicate file id"); let narrowed_type = nth_name_expr_type_from_end(&mut ws, consumer_file_id, "narrowed", 0); @@ -4764,6 +4812,7 @@ _2 = a[1] let file_id = ws .analysis .update_file_by_uri(&uri, Some(source("return IsValid(ent) and ent:IsPlayer()"))) + .map(|(id, _)| id) .expect("file id"); let inferred_guard_count = |ws: &VirtualWorkspace| { @@ -4787,6 +4836,7 @@ _2 = a[1] let updated_file_id = ws .analysis .update_file_by_uri(&uri, Some(source("return true"))) + .map(|(id, _)| id) .expect("file id after update"); assert_eq!(updated_file_id, file_id); assert_eq!(inferred_guard_count(&ws), 0); @@ -6069,10 +6119,11 @@ _2 = a[1] ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("isvalid.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@class Entity ---@field GetClass fun(self: Entity): string @@ -6080,9 +6131,10 @@ _2 = a[1] ---@return TypeGuard function _G.IsValid(x) end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); assert!(ws.check_code_for( DiagnosticCode::NeedCheckNil, r#" @@ -6662,10 +6714,11 @@ _2 = a[1] ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("isvalid.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@class Entity ---@field GetClass fun(self: Entity): string @@ -6673,9 +6726,10 @@ _2 = a[1] ---@return TypeGuard function _G.IsValid(x) end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); assert!(ws.check_code_for( DiagnosticCode::NeedCheckNil, r#" @@ -6700,10 +6754,11 @@ _2 = a[1] ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("isvalid.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@class Entity ---@field GetClass fun(self: Entity): string @@ -6711,9 +6766,10 @@ _2 = a[1] ---@return TypeGuard function _G.IsValid(x) end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); assert!(ws.check_code_for( DiagnosticCode::NeedCheckNil, r#" @@ -6789,17 +6845,19 @@ _2 = a[1] ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("isvalid.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@param x any ---@return boolean function _G.IsValid(x) end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); assert!(!ws.check_code_for( DiagnosticCode::NeedCheckNil, r#" @@ -8606,4 +8664,43 @@ _2 = a[1] desc ); } + + /// A truthy guard proves its subject non-nil for a field read, not only for + /// a method call. `x:m()` already read the guard off the source when the + /// value was too unresolved for flow narrowing; `x.f` reported instead. + #[test] + fn truthy_guard_proves_an_unresolved_local_for_a_field_read() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + + assert!(ws.check_code_for( + DiagnosticCode::NeedCheckNil, + r#" + ---@class EntityObject + ---@field origin any + + ---@class BspHolder + local meta = {} + + ---@return EntityObject[] + function meta:GetEntities() end + + local function cachedBrushes(self) + if self.__funcBrush then return self.__funcBrush end + local entities = self:GetEntities() + self.__funcBrush = { [0] = entities[0] } + for _, v in pairs(entities) do + self.__funcBrush[1] = v + end + return self.__funcBrush + end + + function meta:Probe(bNum) + local brush = cachedBrushes(self)[bNum] + if brush then + return brush.origin + end + end + "# + )); + } } diff --git a/crates/glua_code_analysis/src/compilation/test/for_range_var_infer_test.rs b/crates/glua_code_analysis/src/compilation/test/for_range_var_infer_test.rs index df6fe8f9a..221b00f92 100644 --- a/crates/glua_code_analysis/src/compilation/test/for_range_var_infer_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/for_range_var_infer_test.rs @@ -3,7 +3,13 @@ mod test { use std::collections::HashSet; use std::sync::Arc; - use crate::{LuaType, LuaUnionType, VirtualWorkspace}; + use internment::ArcIntern; + use smol_str::SmolStr; + + use crate::{ + GenericTpl, GenericTplId, GlobalId, LuaMemberOwner, LuaType, LuaUnionType, + VirtualWorkspace, compilation::analyzer::settled_assign_write_committable, + }; #[test] fn test_closure_param_infer() { @@ -571,4 +577,606 @@ mod test { assert_eq!(ws.humanize_type(alias_ty.clone()), "{ a: string }"); assert_eq!(def_ty, alias_ty); } + + fn registry_files(reg: &str, source: &str) -> Vec<(String, String)> { + vec![ + ( + format!("lua/autorun/sh_{reg}_registry.lua"), + format!( + r#" + {reg} = {{}} + {reg}.alpha = {{}} + {reg}.beta = {{}} + {reg}.gamma = {{}} + "# + ), + ), + ( + format!("lua/autorun/sh_{reg}_write.lua"), + format!( + r#" + function {reg}_Write(keys) + for _, entry in pairs({source}) do + for _, key in pairs(keys) do + entry[key] = {{ door = 1 }} + end + end + end + "# + ), + ), + ( + format!("lua/autorun/sh_{reg}_read.lua"), + format!( + r#" + function {reg}_Read(name, key) + return {reg}[name][key] + end + + function {reg}_ReadThroughIterVar(key) + for _, entry in pairs({reg}) do + return entry[key] + end + end + "# + ), + ), + ] + } + + fn load_registry(reg: &str, source: &str, reverse: bool) -> VirtualWorkspace { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let mut files = registry_files(reg, source); + if reverse { + files.reverse(); + } + ws.def_files( + files + .iter() + .map(|(path, source)| (path.as_str(), source.as_str())) + .collect(), + ); + ws + } + + fn wildcard_bucket_keys(ws: &VirtualWorkspace, reg: &str) -> Vec { + let owner = LuaMemberOwner::GlobalPath(GlobalId::new(&format!("{reg}.[]"))); + ws.analysis + .compilation + .get_db() + .get_member_index() + .get_members(&owner) + .unwrap_or_default() + .iter() + .map(|member| format!("{:?}", member.get_key())) + .collect() + } + + #[test] + fn pairs_value_var_write_files_onto_the_registry_wildcard_bucket() { + let ws = load_registry("REGWRITE", "REGWRITE", false); + assert_eq!( + wildcard_bucket_keys(&ws, "REGWRITE"), + vec!["ExprType(Unknown)".to_string()] + ); + } + + /// Without the wildcard bucket this read answers `any?`, which is what the + /// `may be nil` reports on such registries come from. + #[test] + fn pairs_value_var_write_is_read_back_through_the_registry_path() { + let mut ws = load_registry("REGREAD", "REGREAD", false); + let ty = ws.expr_ty("REGREAD_Read('alpha', 'door')"); + assert_eq!(ws.humanize_type(ty), "table"); + } + + /// Same member, reached through a second loop's value variable. Nullable + /// here because the write is in another file, where a computed-key write is + /// evidence about the values a registry holds, not that this key is one. + #[test] + fn pairs_value_var_write_is_read_back_through_another_iter_var() { + let mut ws = load_registry("REGITER", "REGITER", false); + let ty = ws.expr_ty("REGITER_ReadThroughIterVar('door')"); + assert_eq!(ws.humanize_type(ty), "table?"); + } + + #[test] + fn registry_wildcard_bucket_does_not_depend_on_file_order() { + let forward = load_registry("REGORDER", "REGORDER", false); + let reverse = load_registry("REGORDER", "REGORDER", true); + assert_eq!( + wildcard_bucket_keys(&forward, "REGORDER"), + wildcard_bucket_keys(&reverse, "REGORDER") + ); + assert_eq!( + wildcard_bucket_keys(&reverse, "REGORDER"), + vec!["ExprType(Unknown)".to_string()] + ); + } + + #[test] + fn pairs_over_a_call_result_files_nothing() { + let ws = load_registry("REGCALL", "GetRegistry()", false); + assert_eq!(wildcard_bucket_keys(&ws, "REGCALL"), Vec::::new()); + assert_eq!( + wildcard_bucket_keys(&ws, "GetRegistry"), + Vec::::new() + ); + } + + #[test] + fn pairs_over_a_local_registry_files_nothing() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.def_file( + "lua/autorun/sh_local_registry.lua", + r#" + local reg = {} + reg.alpha = {} + + function LocalRegistryWrite(keys) + for _, entry in pairs(reg) do + for _, key in pairs(keys) do + entry[key] = { door = 1 } + end + end + end + "#, + ); + assert_eq!(wildcard_bucket_keys(&ws, "reg"), Vec::::new()); + } + + /// A mutable local assigned from a loop variable must settle with it. The + /// loop enumerates a value nothing determines (`keys` is never called), so + /// the loop variable holds a raw template placeholder through the walk and + /// the unresolve waves, and only the settled tail falls it back to `any`. + /// The assignment was bound (then floored) before that, so without a + /// settled replay the local keeps the placeholder-era answer while the + /// loop variable it copies has moved on. + #[test] + fn settled_loop_var_move_rederives_mutable_local_assignment() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + + let file_ids = ws.def_files(vec![( + "lua/autorun/a_settled_assign_use.lua", + r#" + local selected + local function f(keys) + for key in pairs(keys) do + selected = key + end + end + "#, + )]); + + // Read the cold-build decl caches directly: a global copy of `selected` + // can answer from flow facts, which would mask a stale lifetime type + // on the declaration itself. + let loop_file = file_ids[0]; + let db = ws.get_db_mut(); + let decl_tree = db + .get_decl_index() + .get_decl_tree(&loop_file) + .expect("loop file decl tree"); + let decl_id_by_name = |name: &str| { + decl_tree + .get_decls() + .values() + .filter(|decl| decl.get_name() == name) + .map(|decl| decl.get_id()) + .next() + .unwrap_or_else(|| panic!("{name} decl")) + }; + let cached_ty = |db: &crate::DbIndex, decl_id: crate::LuaDeclId| { + db.get_type_index() + .get_type_cache(&decl_id.into()) + .map(|cache| cache.as_type().clone()) + }; + let key_ty = cached_ty(db, decl_id_by_name("key")).expect("key cache"); + let selected_ty = cached_ty(db, decl_id_by_name("selected")); + let _ = db; + + // Guards against passing vacuously: `any` is the deliberate settled + // answer for a loop nothing determines (a loop over an `any` value + // answers the same way), not an unresolved placeholder. + assert_eq!(key_ty, crate::LuaType::Any); + assert_eq!(selected_ty, Some(key_ty)); + } + + /// Reads the cold-build lifetime type cached on a declaration directly. A + /// global copy of the local could answer from flow facts, which would mask + /// a stale lifetime type on the declaration itself. + fn settled_assign_decl_cached_ty( + ws: &mut VirtualWorkspace, + file_id: crate::FileId, + name: &str, + ) -> Option { + let db = ws.get_db_mut(); + let decl_tree = db + .get_decl_index() + .get_decl_tree(&file_id) + .expect("decl tree"); + let decl_id = decl_tree + .get_decls() + .values() + .filter(|decl| decl.get_name() == name) + .map(|decl| decl.get_id()) + .next() + .unwrap_or_else(|| panic!("{name} decl")); + db.get_type_index() + .get_type_cache(&decl_id.into()) + .map(|cache| cache.as_type().clone()) + } + + /// A widening replay must commit: `selected` is seeded with `string` up + /// front, so the settled tail cannot take the loop's grown key union + /// through the placeholder rules — only the structural widening gate (the + /// settled union holding every cached arm) admits it. A supersedes-only + /// gate keeps the stale `string` forever. + #[test] + fn settled_assign_replay_commits_union_widening() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let file_ids = ws.def_files(vec![( + "lua/autorun/a_settled_assign_widen.lua", + r#" + local tb = { s = "b" } + local selected = "init" + local function f() + for kb in pairs(tb) do + selected = kb + end + end + tb[true] = 1 + "#, + )]); + let loop_file = file_ids[0]; + let kb_ty = settled_assign_decl_cached_ty(&mut ws, loop_file, "kb").expect("kb cache"); + let selected_ty = + settled_assign_decl_cached_ty(&mut ws, loop_file, "selected").expect("selected cache"); + assert_eq!(selected_ty, kb_ty); + let LuaType::Union(union) = selected_ty else { + panic!("expected widened key union, got {:?}", kb_ty); + }; + let arms = union.into_set(); + assert!(arms.contains(&LuaType::String), "string arm in {arms:?}"); + assert!( + arms.iter() + .any(|arm| matches!(arm, LuaType::BooleanConst(_))), + "boolean arm in {arms:?}" + ); + } + + /// The claim holder's sideways replace follows a multi-declaration global + /// merge. The reader file walks between the two backing writes (batches + /// walk in URI order), so `m` is bound from the first backing table only; + /// the settled multi-decl pass force-completes it to the merged backing + /// tables, a shape structurally unrelated to the subset. Only the holder + /// — the `selected = m` write that owns the declaration — may replace its + /// own contribution with it; `supersedes` never relates two informative + /// tables. + #[test] + fn settled_assign_replay_holder_sideways_multi_decl_merge() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let file_ids = ws.def_files(vec![ + ("lua/autorun/smd_a_first.lua", "SMD_MERGE = { k = \"s\" }\n"), + ( + "lua/autorun/smd_m_reader.lua", + "local m = SMD_MERGE\nlocal selected\nselected = m\n", + ), + ("lua/autorun/smd_z_second.lua", "SMD_MERGE = { j = 1 }\n"), + ]); + let reader_file = file_ids[1]; + let m_ty = settled_assign_decl_cached_ty(&mut ws, reader_file, "m").expect("m cache"); + let selected_ty = settled_assign_decl_cached_ty(&mut ws, reader_file, "selected") + .expect("selected cache"); + assert_eq!(selected_ty, m_ty); + // Both backing tables are visible in the merged global: a walk-time + // subset could never answer both members, so `m` must have moved in + // the settled tail and `selected` must have tracked it there. + assert!(matches!(ws.expr_ty("SMD_MERGE.k"), LuaType::StringConst(_))); + assert!(matches!( + ws.expr_ty("SMD_MERGE.j"), + LuaType::IntegerConst(_) + )); + } + + /// Source order elects the winner between two moved right-hand sides. The + /// initializer seeds `string` and never moves, so the holder is not even + /// queued; both loop writes widen it, and exactly one — the earliest — + /// may commit per round. A second replace of the same cache within the + /// round would leave the later union behind instead. + #[test] + fn settled_assign_replay_source_order_winner_over_two_moved_rhs() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let file_ids = ws.def_files(vec![( + "lua/autorun/a_settled_assign_order.lua", + r#" + local ta = { s = "a" } + local tb = { s = "b" } + local selected = "init" + local function f() + for ka in pairs(ta) do + selected = ka + end + for kb in pairs(tb) do + selected = kb + end + end + ta[false] = true + tb[true] = 1 + "#, + )]); + let loop_file = file_ids[0]; + let ka_ty = settled_assign_decl_cached_ty(&mut ws, loop_file, "ka").expect("ka cache"); + let kb_ty = settled_assign_decl_cached_ty(&mut ws, loop_file, "kb").expect("kb cache"); + let selected_ty = + settled_assign_decl_cached_ty(&mut ws, loop_file, "selected").expect("selected cache"); + assert_ne!(ka_ty, kb_ty); + assert_eq!(selected_ty, ka_ty); + } + + /// A trailing variadic right-hand side supplies one slot per extra target: + /// `first, second = echo(key)` queues both, and the replay selects each + /// target's own slot out of the settled answer. The generic call fails + /// while the loop variable still holds its template placeholder, so the + /// walk defers both targets and the force wave floors them to `unknown`; + /// only the settled replay — the extra target solely through its + /// `ret_idx = 1` queue entry — can complete them to the settled answer. + /// Without the multi-target queue the second target keeps `unknown`: it + /// reads no cache of its own, so no other pass re-derives it. + #[test] + fn settled_assign_replay_derives_all_multi_targets() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let file_ids = ws.def_files(vec![( + "lua/autorun/a_settled_assign_multi.lua", + r#" + ---@generic T + ---@param x T + ---@return T, T + local function echo(x) return x, x end + + local first + local second + local function f(keys) + for key in pairs(keys) do + first, second = echo(key) + end + end + "#, + )]); + let loop_file = file_ids[0]; + let key_ty = settled_assign_decl_cached_ty(&mut ws, loop_file, "key").expect("key cache"); + let first_ty = + settled_assign_decl_cached_ty(&mut ws, loop_file, "first").expect("first cache"); + let second_ty = + settled_assign_decl_cached_ty(&mut ws, loop_file, "second").expect("second cache"); + // Guards against passing vacuously, as in the single-target repro: + // `any` is the deliberate settled answer for this loop. + assert_eq!(key_ty, crate::LuaType::Any); + assert_eq!(first_ty, key_ty); + assert_eq!(second_ty, key_ty); + } + + /// The force wave and the settled replay share one target and must agree: + /// the call write floors while its callee is still unresolved, the loop + /// write replays once its variable settles, and the tail terminates on + /// the completed value instead of alternating the two contributions. + #[test] + fn settled_assign_replay_converges_with_force_on_shared_target() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let file_ids = ws.def_files(vec![( + "lua/autorun/a_settled_assign_force.lua", + r#" + local selected + local function f(keys, mk) + for key in pairs(keys) do + selected = key + end + selected = mk() + end + "#, + )]); + let loop_file = file_ids[0]; + let key_ty = settled_assign_decl_cached_ty(&mut ws, loop_file, "key").expect("key cache"); + let selected_ty = + settled_assign_decl_cached_ty(&mut ws, loop_file, "selected").expect("selected cache"); + assert_eq!(key_ty, crate::LuaType::Any); + assert_eq!(selected_ty, key_ty); + } + + /// The holder-sideways clause admits only forward moves: the holder may + /// replace its own contribution with an informative settled answer, never + /// trade it for one that says nothing. `any` is authoritative, not an + /// unfinished placeholder, so `any -> nil` (and `any -> unknown`, and an + /// informative answer collapsing to `nil`) must reject even from the + /// claim holder. + #[test] + fn settled_assign_gate_rejects_holder_backward_moves() { + assert!(!settled_assign_write_committable( + true, + &LuaType::Nil, + Some(&LuaType::Any), + )); + assert!(!settled_assign_write_committable( + true, + &LuaType::Unknown, + Some(&LuaType::Any), + )); + assert!(!settled_assign_write_committable( + true, + &LuaType::Nil, + Some(&LuaType::String), + )); + } + + /// A leaked template parameter is not an answer, so any write — holder or + /// not — may replace it with an informative, leak-free settled type. A + /// floored placeholder behaves the same way for a non-holder. + #[test] + fn settled_assign_gate_accepts_non_holder_leak_and_placeholder_to_concrete() { + let leaked = LuaType::TplRef(Arc::new(GenericTpl::new( + GenericTplId::Func(0), + ArcIntern::new(SmolStr::new("T")), + None, + ))); + assert!(settled_assign_write_committable( + false, + &LuaType::String, + Some(&leaked), + )); + assert!(settled_assign_write_committable( + false, + &LuaType::String, + Some(&LuaType::Unknown), + )); + } + + /// The rest of the gate is unchanged: the holder still takes a genuinely + /// sideways informative answer (the multi-declaration merge shape), an + /// informative settled type still displaces `any` from any write, a + /// settled leak or a settled answer that says nothing still commits from + /// nowhere, and a non-holder still cannot move one informative answer to + /// another. + #[test] + fn settled_assign_gate_keeps_sideways_and_any_ranking() { + assert!(settled_assign_write_committable( + true, + &LuaType::Integer, + Some(&LuaType::String), + )); + assert!(settled_assign_write_committable( + false, + &LuaType::String, + Some(&LuaType::Any), + )); + assert!(!settled_assign_write_committable( + false, + &LuaType::Integer, + Some(&LuaType::String), + )); + assert!(!settled_assign_write_committable( + false, + &LuaType::Nil, + Some(&LuaType::String), + )); + assert!(!settled_assign_write_committable( + true, + &LuaType::TplRef(Arc::new(GenericTpl::new( + GenericTplId::Func(1), + ArcIntern::new(SmolStr::new("U")), + None, + ))), + Some(&LuaType::String), + )); + } + + /// The settled tail advances several independent parts of the boundary + /// across successive rounds — a widening union, a multi-declaration merge + /// tracked through the holder-sideways clause, a deferred multi-target, + /// and a member map that must be rekeyed before its loop re-derives. No + /// part may observe a partially settled loop break: every part must + /// converge to its settled answer. + /// + /// The recurrence-vs-advance distinction itself — the same net delta + /// recurring while another part advances must not confirm a cycle — is + /// pinned at the fuse predicate level (`FixpointFuse` tests), where it is + /// directly constructible: with monotone commit gates an end-to-end run + /// cannot move an owner back to a previous answer, so a recurring net + /// delta is unconstructible from Lua. This test pins the other half of + /// the contract: multi-part advancement always runs to convergence. + #[test] + fn settled_tail_multi_part_boundary_converges_without_early_break() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + // `def_files` returns ids in URI-sorted order, so the numeric prefixes + // below are the indexing: the reader still walks between the two + // backing writes, as in the single-purpose merge test. + let file_ids = ws.def_files(vec![ + ( + "lua/autorun/mp_1_widen.lua", + r#" + local tb = { s = "b" } + local keyed = {} + local selected = "init" + local function f() + for kb in pairs(tb) do + selected = kb + keyed[kb] = true + end + for observed in pairs(keyed) do + mp_key_out = observed + end + end + tb[true] = 1 + "#, + ), + ( + "lua/autorun/mp_2_smd_a_first.lua", + "MP_MERGE = { k = \"s\" }\n", + ), + ( + "lua/autorun/mp_3_smd_m_reader.lua", + "local m = MP_MERGE\nlocal selected\nselected = m\n", + ), + ( + "lua/autorun/mp_4_smd_z_second.lua", + "MP_MERGE = { j = 1 }\n", + ), + ( + "lua/autorun/mp_5_multi.lua", + r#" + ---@generic T + ---@param x T + ---@return T, T + local function echo(x) return x, x end + + local first + local second + local function f(keys) + for key in pairs(keys) do + first, second = echo(key) + end + end + "#, + ), + ]); + + let widen_file = file_ids[0]; + let kb_ty = settled_assign_decl_cached_ty(&mut ws, widen_file, "kb").expect("kb cache"); + let widened_ty = + settled_assign_decl_cached_ty(&mut ws, widen_file, "selected").expect("selected cache"); + assert_eq!(widened_ty, kb_ty); + let LuaType::Union(union) = widened_ty else { + panic!("expected widened key union, got {:?}", kb_ty); + }; + let arms = union.into_set(); + assert!(arms.contains(&LuaType::String), "string arm in {arms:?}"); + assert!( + arms.iter() + .any(|arm| matches!(arm, LuaType::BooleanConst(_))), + "boolean arm in {arms:?}" + ); + + let reader_file = file_ids[2]; + let m_ty = settled_assign_decl_cached_ty(&mut ws, reader_file, "m").expect("m cache"); + let tracked_ty = settled_assign_decl_cached_ty(&mut ws, reader_file, "selected") + .expect("selected cache"); + assert_eq!(tracked_ty, m_ty); + assert!(matches!(ws.expr_ty("MP_MERGE.k"), LuaType::StringConst(_))); + assert!(matches!(ws.expr_ty("MP_MERGE.j"), LuaType::IntegerConst(_))); + + let multi_file = file_ids[4]; + let key_ty = settled_assign_decl_cached_ty(&mut ws, multi_file, "key").expect("key cache"); + let first_ty = + settled_assign_decl_cached_ty(&mut ws, multi_file, "first").expect("first cache"); + let second_ty = + settled_assign_decl_cached_ty(&mut ws, multi_file, "second").expect("second cache"); + assert_eq!(key_ty, crate::LuaType::Any); + assert_eq!(first_ty, key_ty); + assert_eq!(second_ty, key_ty); + + // The computed-key target is written with the widening loop key + // before `tb[true]` widens it, then iterated into a global: the + // member key must move after `kb` settles, forcing the unfiltered + // round that rederives the second loop. + assert_eq!(ws.expr_ty("mp_key_out"), kb_ty); + } } diff --git a/crates/glua_code_analysis/src/compilation/test/gmod_realm_hook_test.rs b/crates/glua_code_analysis/src/compilation/test/gmod_realm_hook_test.rs index 7191fe7da..d3f4221df 100644 --- a/crates/glua_code_analysis/src/compilation/test/gmod_realm_hook_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/gmod_realm_hook_test.rs @@ -3643,6 +3643,7 @@ mod test { let old_target_id = ws .analysis .update_file_by_uri(&target_uri, Some("return true".to_string())) + .map(|(id, _)| id) .expect("target file id must be present"); ws.def_file( "lua/autorun/server/sv_loader.lua", @@ -3651,10 +3652,12 @@ mod test { ws.analysis .remove_file_by_uri(&target_uri) + .0 .expect("target should be removed"); let new_target_id = ws .analysis .update_file_by_uri(&target_uri, Some("return true".to_string())) + .map(|(id, _)| id) .expect("recreated target file id must be present"); assert_ne!(old_target_id, new_target_id); @@ -3669,4 +3672,55 @@ mod test { eq(true) ); } + + /// Every load site contributes exactly one incoming edge, carrying the converged state + /// mask, and the edges are ordered by the normalized path of the including file. Before + /// the sites were sorted and the edges recorded after convergence, a site visited ahead of + /// its source's realm also left an edge stamped with the empty mask. + #[gtest] + fn test_load_index_incoming_edges_are_one_per_site_in_source_path_order() { + let mut ws = VirtualWorkspace::new(); + set_gmod_enabled(&mut ws); + + let target_id = ws.def_file("lua/myaddon/target.lua", "return true"); + let b_id = ws.def_file( + "lua/myaddon/b_middle.lua", + r#"include("myaddon/target.lua")"#, + ); + let a_id = ws.def_file( + "lua/myaddon/a_middle.lua", + r#"include("myaddon/target.lua")"#, + ); + ws.def_file( + "lua/autorun/server/sv_boot.lua", + r#" + include("myaddon/b_middle.lua") + include("myaddon/a_middle.lua") + "#, + ); + + let db = ws.get_db_mut(); + let target_info = db + .get_gmod_load_index() + .get_file_info(&target_id) + .expect("target should have load info"); + + // Each `include` registers two dependency sites, one on the call range and one on the + // path-argument range, so two edges per including file is one edge per site. + assert_eq!( + target_info + .incoming_edges + .iter() + .map(|edge| edge.source_file_id) + .collect::>(), + vec![a_id, a_id, b_id, b_id], + ); + assert_that!( + target_info + .incoming_edges + .iter() + .all(|edge| edge.states == GmodStateMask::SERVER), + eq(true) + ); + } } diff --git a/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs b/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs index 1d385b770..fc1bc44cd 100644 --- a/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs @@ -186,16 +186,18 @@ mod test { ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("gm.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@class GM function GM:SetupMove(ply, mv, cmd) end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); } #[gtest] @@ -8075,6 +8077,7 @@ mod test { ws.analysis .update_file_by_uri(&init_uri, Some(format!("{init_code}\n"))) + .map(|(id, _)| id) .expect("expected touched init file id"); let alias_seats_after_touch = index_expr_type(&mut ws, file_id, "selfTbl.seats"); @@ -8257,6 +8260,7 @@ mod test { ws.analysis .update_file_by_uri(&car_uri, Some(format!("{car_code}\n"))) + .map(|(id, _)| id) .expect("expected touched car cl_init file id"); let touched_sounds_type = local_name_type(&mut ws, file_id, "sounds"); @@ -8930,6 +8934,7 @@ mod test { let consumer_uri = ws.virtual_url_generator.new_uri(consumer_path); ws.analysis .update_file_by_uri(&consumer_uri, Some(format!("{consumer_code}\n"))) + .map(|(id, _)| id) .expect("expected touched consumer file id"); let touched_pos_type = local_name_type(&mut ws, consumer_file, "pos"); @@ -9893,12 +9898,13 @@ mod test { crate::LuaMemberIndexItem::Many(ids) => ids.len(), }; assert_eq!( - member_count_before, 1, - "expected latest assignment to replace previous assignment" + member_count_before, 2, + "every writer of the slot stays visible" ); ws.analysis .update_file_by_uri(&shared_uri, Some(format!("\n{shared_text}"))) + .map(|(id, _)| id) .expect("shared file should update"); let member_item_after = ws @@ -9913,8 +9919,8 @@ mod test { crate::LuaMemberIndexItem::Many(ids) => ids.len(), }; assert_eq!( - member_count_after, 1, - "single-file reindex should retain the latest assignment" + member_count_after, 2, + "single-file reindex keeps every writer of the slot" ); } @@ -11978,4 +11984,78 @@ GM.TestValue = 1"#, } assert!(found_call, "should find GetInputBool CallExpr"); } + + /// Two addons ship an `init.lua` for one class. Their `NetworkVar` calls are replayed into + /// a delegating class in the order `build_class_file_map` produced, and `add_call` dedups on + /// a syntax id that carries no file id, so the same byte offsets in both files collide and + /// the later file wins. That order has to come from the paths, not from file ids, which + /// differ between a cold build and an incremental session. + fn delegating_members_for_class_file_order(reversed: bool) -> Vec { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + emmyrc + .gmod + .scripted_class_scopes + .set_include(vec![legacy_scope("entities/**")]); + ws.update_emmyrc(emmyrc); + ws.def_gmod_call_arg_builtins(); + + let first = ( + "lua/entities/target_ent/init.lua", + r#" + function ENT:SetupDataTables() + self:NetworkVar("String", "Alpha") + end + "#, + ); + let second = ( + "addons/z_other/lua/entities/target_ent/init.lua", + r#" + function ENT:SetupDataTables() + self:NetworkVar("String", "Bravo") + end + "#, + ); + + if reversed { + ws.def_file(second.0, second.1); + ws.def_file(first.0, first.1); + } else { + ws.def_file(first.0, first.1); + ws.def_file(second.0, second.1); + } + ws.def_file( + "lua/entities/delegating_ent/init.lua", + r#" + function ENT:SetupDataTables() + scripted_ents.GetMember("target_ent", "SetupDataTables")(self) + end + "#, + ); + + let db = ws.get_db_mut(); + let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("delegating_ent")); + let mut member_names = db + .get_member_index() + .get_members(&owner) + .expect("expected members") + .iter() + .filter_map(|member| member.get_key().get_name().map(|name| name.to_string())) + .collect::>(); + member_names.sort(); + member_names + } + + #[gtest] + fn test_class_file_order_does_not_depend_on_definition_order() { + let forward = delegating_members_for_class_file_order(false); + let reversed = delegating_members_for_class_file_order(true); + + assert!( + forward.iter().any(|name| name.starts_with("Get")), + "fixture produced no synthesized members: {forward:?}" + ); + assert_eq!(forward, reversed); + } } diff --git a/crates/glua_code_analysis/src/compilation/test/infer_str_tpl_test.rs b/crates/glua_code_analysis/src/compilation/test/infer_str_tpl_test.rs index d0a5de101..a63708f7c 100644 --- a/crates/glua_code_analysis/src/compilation/test/infer_str_tpl_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/infer_str_tpl_test.rs @@ -584,7 +584,7 @@ mod test { ); let wheel_ty = ws.expr_ty("ENT:CreateWheel()"); - let expected = ws.ty("glide_wheel|unknown"); + let expected = ws.ty("glide_wheel|any"); assert_eq!(wheel_ty, expected); } @@ -993,7 +993,26 @@ mod test { ws.expr_ty("generated_entity"), ws.ty("sent_realm_generated") ); - ws.def_file( + { + let db = ws.get_db_mut(); + let edge = db + .get_type_index() + .get_super_type_entries(&LuaTypeDeclId::global("sent_realm_generated")) + .and_then(|entries| entries.first()) + .expect("generated superclass edge"); + assert_eq!(edge.file_id, file_id); + assert_eq!( + db.get_gmod_infer_index() + .get_realm_at_offset(&file_id, edge.value.source_range.start()), + GmodRealm::Client + ); + } + + // A real declaration of the same class supersedes the generated one: + // `should_attach_super` only fires while the only declaration is + // auto-generated. Asserted here because it is what a cold build of both + // files produces, and an incremental one has to match it. + let contract_file_id = ws.def_file( "sv_generated_contract.lua", r#" ---@class GeneratedRightBase @@ -1011,16 +1030,16 @@ mod test { assert_eq!(ws.expr_ty("generated_realm_param"), LuaType::Number); let db = ws.get_db_mut(); - let edge = db + let entries = db .get_type_index() .get_super_type_entries(&LuaTypeDeclId::global("sent_realm_generated")) - .and_then(|entries| entries.first()) - .expect("generated superclass edge"); - assert_eq!(edge.file_id, file_id); + .expect("superclass edge"); assert_eq!( - db.get_gmod_infer_index() - .get_realm_at_offset(&file_id, edge.value.source_range.start()), - GmodRealm::Client + entries + .iter() + .map(|entry| entry.file_id) + .collect::>(), + vec![contract_file_id] ); } @@ -2309,7 +2328,47 @@ mod test { let expected = ws.ty("DCategoryList"); assert_eq!(base, expected); let unknown_ty = ws.expr_ty("b"); - assert!(unwrap_instance(&unknown_ty).is_nullable()); + assert!( + unwrap_instance(&unknown_ty).is_nullable(), + "unknown VGUI class must preserve the declared nullable return, got {} ({unknown_ty:?})", + ws.humanize_type_detailed(unknown_ty.clone()) + ); + } + + #[gtest] + fn test_vgui_explicit_class_supersedes_generated_placeholder_for_nullability() { + 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( + r#" + ---@class Panel + ---@class DCategoryList: Panel + known = vgui.Create("DCategoryList") + placeholder = vgui.Create("LatePanel") + "#, + ); + let first_ty = ws.expr_ty("placeholder"); + assert!( + unwrap_instance(&first_ty).is_nullable(), + "generated placeholder must keep the declared nullable return, got {} ({first_ty:?})", + ws.humanize_type_detailed(first_ty.clone()) + ); + + ws.def( + r#" + ---@class LatePanel: Panel + "#, + ); + let declared_ty = ws.expr_ty("vgui.Create('LatePanel')"); + assert!( + !unwrap_instance(&declared_ty).is_nullable(), + "explicit panel declaration must supersede the generated placeholder, got {} ({declared_ty:?})", + ws.humanize_type_detailed(declared_ty.clone()) + ); } #[gtest] @@ -2388,4 +2447,112 @@ mod test { "DColorMixer reassignment should replace the older local panel type, got {diagnostics:?}" ); } + + #[gtest] + fn test_str_tpl_constructor_call_contributes_single_supertype_edge() { + use glua_parser::LuaCallExpr; + + let mut ws = VirtualWorkspace::new(); + + ws.def_file( + "defs.lua", + r#" + ---@attribute constructor(name: string, root_class: string?, strip_self: boolean?, return_self: boolean?) + + ---@class Entity + + ents = {} + + ---@generic T: Entity + ---@[constructor("__init", "Entity")] + ---@param class `T` + ---@return T[] + function ents.FindByClass(class) + end + "#, + ); + + let caller_text = r#"local found = ents.FindByClass("prop_vehicle_jeep")"#; + let caller_path = "caller.lua"; + let caller_id = ws.def_file(caller_path, caller_text); + + let generated_id = LuaTypeDeclId::global("prop_vehicle_jeep"); + let entity_ty = LuaType::Ref(LuaTypeDeclId::global("Entity")); + + let super_types: Vec<_> = ws + .get_db_mut() + .get_type_index() + .get_super_types_iter(&generated_id) + .map(|iter| iter.cloned().collect()) + .unwrap_or_default(); + assert!( + super_types.contains(&entity_ty), + "expected `prop_vehicle_jeep` to inherit `Entity`, got {super_types:?}" + ); + + let collect_caller_entity_edges = |ws: &VirtualWorkspace| { + ws.analysis + .compilation + .get_db() + .get_type_index() + .get_super_type_entries(&generated_id) + .map(|entries| { + entries + .iter() + .filter(|entry| entry.file_id == caller_id && entry.value.typ == entity_ty) + .map(|entry| entry.value.source_range) + .collect::>() + }) + .unwrap_or_default() + }; + + let semantic_model = ws + .analysis + .compilation + .get_semantic_model(caller_id) + .expect("expected semantic model"); + let root = semantic_model.get_root(); + let call_expr = root + .descendants::() + .next() + .expect("expected FindByClass call"); + let call_range = call_expr.get_range(); + let arg_expr = call_expr + .get_args_list() + .expect("expected args") + .get_args() + .next() + .expect("expected class arg"); + let arg_range = arg_expr.get_range(); + + let edges = collect_caller_entity_edges(&ws); + assert_eq!( + edges.len(), + 1, + "expected exactly one raw `prop_vehicle_jeep -> Entity` edge from the caller, got {edges:?} (arg_range={arg_range:?}, call_range={call_range:?})" + ); + assert_eq!( + edges[0], arg_range, + "supertype edge must use the string argument range, got {:?} (arg_range={arg_range:?}, call_range={call_range:?})", + edges[0], + ); + assert_ne!( + edges[0], call_range, + "supertype edge must not use the enclosing call range (arg_range={arg_range:?}, call_range={call_range:?})" + ); + + ws.def_file(caller_path, &format!("{caller_text}\n")); + + let edges_after = collect_caller_entity_edges(&ws); + assert_eq!( + edges_after.len(), + 1, + "reindex must not accumulate duplicate supertype edges, got {edges_after:?} (arg_range={arg_range:?}, call_range={call_range:?})" + ); + assert_eq!( + edges_after[0], arg_range, + "reindexed supertype edge must keep the string argument range, got {:?} (arg_range={arg_range:?}, call_range={call_range:?})", + edges_after[0], + ); + } } diff --git a/crates/glua_code_analysis/src/compilation/test/legacy_module_test.rs b/crates/glua_code_analysis/src/compilation/test/legacy_module_test.rs index ea0a5a4b5..9bbcf22cf 100644 --- a/crates/glua_code_analysis/src/compilation/test/legacy_module_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/legacy_module_test.rs @@ -821,6 +821,7 @@ local _ = tc let module_uri = ws.virtual_url_generator.new_uri("mymod.lua"); ws.analysis .update_file_by_uri(&module_uri, Some(module_source.to_string())) + .map(|(id, _)| id) .expect("initial file update must succeed"); let consumer_file = ws.def_file( @@ -840,6 +841,7 @@ local _ = tc // analysis layer still removes-then-re-indexes the file). ws.analysis .update_file_by_uri(&module_uri, Some(module_source.to_string())) + .map(|(id, _)| id) .expect("reparse must succeed"); // After reparse the alias entry must have been cleaned up and re-created diff --git a/crates/glua_code_analysis/src/compilation/test/library_collision_test.rs b/crates/glua_code_analysis/src/compilation/test/library_collision_test.rs index 76c0a130c..e4e802af5 100644 --- a/crates/glua_code_analysis/src/compilation/test/library_collision_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/library_collision_test.rs @@ -247,6 +247,7 @@ mod tests { workspace .analysis .remove_file_by_uri(&preferred_uri) + .0 .expect("preferred library file should exist"); assert!( workspace @@ -266,6 +267,7 @@ mod tests { workspace .analysis .update_file_by_uri(&preferred_uri, Some(DUPLICATE_ANNOTATIONS.to_string())) + .map(|(id, _)| id) .expect("preferred library file should reopen"); assert_eq!(workspace.analysis.library_definition_collisions().len(), 1); assert_eq!( 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..1d87911f6 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 @@ -710,6 +710,30 @@ mod test { ); } + #[gtest] + fn inferred_return_widens_mutable_string_accumulator() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" + local function build(input) + local result = "" + local index = 1 + while index <= #input do + result = result .. input[index] + index = index + 1 + end + return result + end + + A = build("value") + "#, + ); + + let ty = ws.expr_ty("A"); + assert_that!(ws.check_type(&ty, &LuaType::String), eq(true)); + assert!(!matches!(ty, LuaType::StringConst(_))); + } + #[gtest] fn test_reindex_keeps_later_dynamic_assignment_out_of_earlier_read() { let mut ws = VirtualWorkspace::new(); @@ -1165,7 +1189,107 @@ mod test { ]) .into(), ); - assert_that!(ws.check_type(&ty, &expected), eq(true)); + assert_that!( + ws.check_type(&ty, &expected), + eq(true), + "expected integer collection element union, got {} ({ty:?})", + ws.humanize_type_detailed(ty.clone()) + ); + } + + #[gtest] + fn computed_key_nil_delete_is_not_a_member_before_or_after_reindex() { + let mut ws = VirtualWorkspace::new(); + let clear_path = "lua/a_clear.lua"; + let clear_source = r#" + Store = Store or {} + function Clear(class) + Store.values[class] = nil + end + "#; + ws.def_files(vec![ + (clear_path, clear_source), + ("lua/b_values.lua", "Store.values = {}"), + ]); + + let clear_uri = ws.virtual_url_generator.new_uri(clear_path); + let clear_file = ws + .analysis + .get_file_id(&clear_uri) + .expect("expected clear file"); + let delete_is_indexed = |ws: &VirtualWorkspace| { + let model = ws + .analysis + .compilation + .get_semantic_model(clear_file) + .expect("expected semantic model"); + let index_expr = model + .get_root() + .descendants::() + .find_map(|node| match node { + LuaAst::LuaIndexExpr(index_expr) + if index_expr.syntax().text() == "Store.values[class]" => + { + Some(index_expr) + } + _ => None, + }) + .expect("expected computed-key delete"); + let member_id = LuaMemberId::new(index_expr.get_syntax_id(), clear_file); + ws.analysis + .compilation + .get_db() + .get_member_index() + .get_member(&member_id) + .is_some() + }; + + assert_that!(delete_is_indexed(&ws), eq(false)); + + ws.analysis + .update_file_text_only(&clear_uri, format!("{clear_source}\n")); + ws.analysis.reindex_files(vec![clear_file]); + + assert_that!(delete_is_indexed(&ws), eq(false)); + } + + #[gtest] + fn constant_folded_nil_delete_keeps_the_named_member() { + let mut ws = VirtualWorkspace::new(); + let file_id = ws.def( + r#" + Store = { values = {} } + Store.values[2] = "present" + Store.values[1 + 1] = nil + "#, + ); + let model = ws + .analysis + .compilation + .get_semantic_model(file_id) + .expect("expected semantic model"); + let delete = model + .get_root() + .descendants::() + .find_map(|node| match node { + LuaAst::LuaIndexExpr(index_expr) + if index_expr.syntax().text() == "Store.values[1 + 1]" => + { + Some(index_expr) + } + _ => None, + }) + .expect("expected constant-folded delete"); + let member_id = LuaMemberId::new(delete.get_syntax_id(), file_id); + let member = ws + .analysis + .compilation + .get_db() + .get_member_index() + .get_member(&member_id) + .expect("expected delete member"); + + assert_that!(member.get_key(), eq(&LuaMemberKey::Integer(2))); } #[gtest] @@ -1528,10 +1652,10 @@ mod test { let ty = ws.expr_ty("A"); assert_eq!(ws.humanize_type(ty), "(Player|Ragdoll)?"); - // Direct assignment-target cache probing shows the incoming member write - // type includes both arms before the read-side nil widening is applied. + // The write's own cache carries what it assigned; the `nil` arm comes + // from the sibling writers when the slot is read. let cached_ty = cached_index_expr_type(&ws, file_id, "self.Target"); - assert_eq!(ws.humanize_type(cached_ty), "(Player|Ragdoll)?"); + assert_eq!(ws.humanize_type(cached_ty), "(Player|Ragdoll)"); } #[gtest] @@ -1563,15 +1687,16 @@ mod test { let cached_ty = cached_index_expr_type(&ws, file_id, "self.Target"); let cached_rendered = ws.humanize_type(cached_ty); - assert_eq!(cached_rendered, "(Player|Ragdoll)?"); + assert_eq!(cached_rendered, "(Player|Ragdoll)"); + // Inferring the target reads the slot, which unions every writer, + // the `nil` ones included; that is the type a read of `A` gets too. let inferred_ty = inferred_index_expr_type(&mut ws, file_id, "self.Target"); let inferred_rendered = ws.humanize_type(inferred_ty); assert_eq!(inferred_rendered, "(Player|Ragdoll)?"); - assert_eq!(cached_rendered, inferred_rendered); let read_ty = ws.expr_ty("A"); - assert_eq!(ws.humanize_type(read_ty), "(Player|Ragdoll)?"); + assert_eq!(ws.humanize_type(read_ty), inferred_rendered); } #[gtest] @@ -1644,7 +1769,7 @@ mod test { assert_eq!(ws.humanize_type(post_touch_ty), "(Player|Ragdoll)?"); let cached_ty = cached_index_expr_type(&ws, file_id, "self.Target"); - assert_eq!(ws.humanize_type(cached_ty), "(Player|Ragdoll)?"); + assert_eq!(ws.humanize_type(cached_ty), "(Player|Ragdoll)"); } /// `if c then t.k = v end` does not dominate the other writes of `t.k`, so @@ -2290,6 +2415,7 @@ mod test { "#; ws.analysis .update_file_by_uri(&alias_uri, Some(alias_source.to_string())) + .map(|(id, _)| id) .expect("expected alias file"); ws.def_file("lua/autorun/server/sf_lifecycle_init.lua", "SF = {}\n"); ws.def_file("lua/autorun/client/sf_lifecycle_init.lua", "SF = {}\n"); @@ -2300,6 +2426,7 @@ mod test { let member_file = ws .analysis .update_file_by_uri(&member_uri, Some(member_source.to_string())) + .map(|(id, _)| id) .expect("expected runtime member file"); let assert_runtime_owner = |ws: &VirtualWorkspace, file_id| { @@ -2326,25 +2453,30 @@ mod test { .to_string(), ), ) + .map(|(id, _)| id) .expect("expected alias origin edit"); assert_runtime_owner(&ws, member_file); ws.analysis .remove_file_by_uri(&alias_uri) + .0 .expect("expected alias removal"); assert_runtime_owner(&ws, member_file); ws.analysis .update_file_by_uri(&alias_uri, Some(alias_source.to_string())) + .map(|(id, _)| id) .expect("expected reopened alias file"); assert_runtime_owner(&ws, member_file); ws.analysis .remove_file_by_uri(&member_uri) + .0 .expect("expected runtime member removal"); let reopened_member_file = ws .analysis .update_file_by_uri(&member_uri, Some(member_source.to_string())) + .map(|(id, _)| id) .expect("expected reopened runtime member file"); assert_runtime_owner(&ws, reopened_member_file); } @@ -2405,7 +2537,8 @@ mod test { let fuel_server_uri = ws.virtual_url_generator.new_uri(fuel_server_path); ws.analysis - .update_file_by_uri(&fuel_server_uri, Some(format!("{fuel_server_source}\n"))); + .update_file_by_uri(&fuel_server_uri, Some(format!("{fuel_server_source}\n"))) + .map(|(id, _)| id); assert_that!( file_has_diagnostic(&mut ws, consumer_file, DiagnosticCode::UndefinedField), @@ -2464,7 +2597,8 @@ mod test { let bootstrap_uri = ws.virtual_url_generator.new_uri(bootstrap_path); ws.analysis - .update_file_by_uri(&bootstrap_uri, Some(format!("{bootstrap_source}\n"))); + .update_file_by_uri(&bootstrap_uri, Some(format!("{bootstrap_source}\n"))) + .map(|(id, _)| id); let after_edit_type = local_name_type(&mut ws, consumer_file, "stock"); let after_edit = ws.humanize_type(after_edit_type); @@ -2742,168 +2876,700 @@ mod test { ); } - #[test] - fn test_gmod_string_numeric_index() { - let mut ws = VirtualWorkspace::new_with_init_std_lib(); - let mut emmyrc = Emmyrc::default(); - emmyrc.gmod.enabled = true; - ws.update_emmyrc(emmyrc); + const GLOBAL_CLASS_FLIP_ZEBRA_SOURCE: &str = r#" + Glide = Glide or {} - let file_id = ws.def( - r#" - local str = "XX" - local var = str[2] - "#, - ); + --- @class Zebra + Glide.Repair = Glide.Repair or {} - let index_ty = index_expr_type(&mut ws, file_id, "str[2]"); - assert_eq!( - index_ty, - LuaType::String, - "str[2] index expression should be string, got {:?}", - index_ty - ); + function Glide.Repair.x() end + function Glide.Repair.y() end + "#; - let var_ty = local_name_type(&mut ws, file_id, "var"); - assert_eq!( - var_ty, - LuaType::String, - "local var assigned from str[2] should be string, got {:?}", - var_ty - ); + const GLOBAL_CLASS_FLIP_APPLE_SOURCE: &str = r#" + --- @class Apple + Glide.Repair = Glide.Repair or {} + "#; + + fn global_path_class_member_names( + ws: &VirtualWorkspace, + owner: &LuaMemberOwner, + ) -> Vec { + let mut names = ws + .analysis + .compilation + .get_db() + .get_member_index() + .get_members(owner) + .unwrap_or_default() + .iter() + .filter_map(|member| match member.get_key() { + LuaMemberKey::Name(name) => Some(name.to_string()), + _ => None, + }) + .collect::>(); + names.sort(); + names } - #[test] - fn test_table_expr_key_string() { - let mut ws = VirtualWorkspace::new_with_init_std_lib(); + /// Names of the members currently homed under `owner`, including + /// owner-only writes that are not listed in the owner's item map. + fn current_owner_member_names(ws: &VirtualWorkspace, owner: &LuaMemberOwner) -> Vec { + let index = ws.analysis.compilation.get_db().get_member_index(); + let mut names = index + .get_member_history(owner) + .iter() + .filter(|member| index.get_member_owner(&member.get_id()) == Some(owner)) + .filter_map(|member| match member.get_key() { + LuaMemberKey::Name(name) => Some(name.to_string()), + _ => None, + }) + .collect::>(); + names.sort(); + names + } - ws.def( - r#" - local key = tostring(1) - local t = { [key] = 1 } - value = t[key] - "#, - ); + #[gtest] + fn test_global_class_annotation_flip_rehomes_path_members_to_the_winning_class() { + let zebra_path = "lua/glide/sh_repair_zebra.lua"; + let apple_path = "lua/glide/sh_repair_apple.lua"; + let consumer_path = "lua/entities/glide_repair_tool/init.lua"; + let consumer_source = r#" + local x = Glide.Repair.x + local y = Glide.Repair.y + "#; - let value_ty = ws.expr_ty("value"); - assert!( - matches!(value_ty, LuaType::Integer | LuaType::IntegerConst(_)), - "expected integer type, got {:?}", - value_ty - ); - } + let mut failures = Vec::new(); + for zebra_first in [true, false] { + let label = if zebra_first { + "zebra_then_apple" + } else { + "apple_then_zebra" + }; + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); - #[test] - fn test_table_expr_key_doc_const() { - let mut ws = VirtualWorkspace::new_with_init_std_lib(); + if zebra_first { + ws.def_file(zebra_path, GLOBAL_CLASS_FLIP_ZEBRA_SOURCE); + ws.def_file(apple_path, GLOBAL_CLASS_FLIP_APPLE_SOURCE); + } else { + ws.def_file(apple_path, GLOBAL_CLASS_FLIP_APPLE_SOURCE); + ws.def_file(zebra_path, GLOBAL_CLASS_FLIP_ZEBRA_SOURCE); + } + let consumer_file = ws.def_file(consumer_path, consumer_source); - ws.def( - r#" - ---@type 'field' - local key = "field" - local t = { [key] = 1 } - value = t[key] - "#, - ); + let apple_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("Apple")); + let zebra_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("Zebra")); + let path_owner = LuaMemberOwner::GlobalPath(GlobalId::new("Glide.Repair")); - let value_ty = ws.expr_ty("value"); - assert!( - matches!(value_ty, LuaType::Integer | LuaType::IntegerConst(_)), - "expected integer type, got {:?}", - value_ty - ); + assert_eq!( + global_path_class_member_names(&ws, &path_owner), + vec!["x".to_string(), "y".to_string()], + "{label}: the path canonically resolves to the winning class members" + ); + assert_eq!( + global_path_class_member_names(&ws, &apple_owner), + vec!["x".to_string(), "y".to_string()], + "{label}: the winning class holds the path members" + ); + assert_eq!( + global_path_class_member_names(&ws, &zebra_owner), + Vec::::new(), + "{label}: the losing class must not hold path members" + ); + + assert_that!( + file_has_diagnostic(&mut ws, consumer_file, DiagnosticCode::UndefinedField), + eq(false), + "{label}: consumer should read the path members" + ); + let x_type = index_expr_type(&mut ws, consumer_file, "Glide.Repair.x"); + if x_type.is_unknown() { + failures.push(format!("{label}: Glide.Repair.x resolved as unknown")); + } + } + + assert_that!(failures, is_empty(), "{failures:?}"); } #[gtest] - fn test_bootstrap_table_literal_preserves_methods() { - let mut ws = VirtualWorkspace::new(); - let file_id = ws.def( + fn test_global_class_annotation_winner_removal_flips_to_the_surviving_candidate() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + + let winner_path = "lua/glide/sh_repair_apple.lua"; + ws.def_file(winner_path, GLOBAL_CLASS_FLIP_APPLE_SOURCE); + ws.def_file( + "lua/glide/sh_repair_mid.lua", r#" -local var = Glide.TestVar or {} -function var:TestMethod() end -Glide.TestVar = var + --- @class Mid + Glide.Repair = Glide.Repair or {} + "#, + ); + ws.def_file( + "lua/glide/sv_repair.lua", + "function Glide.Repair.Shared() end\n", + ); + let consumer_file = ws.def_file( + "lua/entities/glide_repair_tool/init.lua", + "local shared = Glide.Repair.Shared\n", + ); -var:TestMethod() -Glide.TestVar:TestMethod() -"#, + let apple_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("Apple")); + assert_eq!( + global_path_class_member_names(&ws, &apple_owner), + vec!["Shared".to_string()], + "precondition: the winner class holds the member before removal" ); - let has_diag = file_has_diagnostic(&mut ws, file_id, DiagnosticCode::UndefinedField); - assert_that!(has_diag, eq(false)); - } + let winner_uri = ws.virtual_url_generator.new_uri(winner_path); + ws.analysis + .update_file_by_uri(&winner_uri, None) + .map(|(id, _)| id); - #[gtest] - fn test_bootstrap_table_cross_file() { - let mut ws = VirtualWorkspace::new(); - ws.def( - r#" -local var = Glide.TestVar or {} -function var:TestMethod() end -Glide.TestVar = var -"#, + let mid_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("Mid")); + let path_owner = LuaMemberOwner::GlobalPath(GlobalId::new("Glide.Repair")); + assert_eq!( + global_path_class_member_names(&ws, &path_owner), + vec!["Shared".to_string()], + "removing the winning proposal must flip the path to the surviving class" ); - let file_id2 = ws.def( - r#" -Glide.TestVar:TestMethod() -"#, + assert_eq!( + global_path_class_member_names(&ws, &mid_owner), + vec!["Shared".to_string()], + "the member must be visible under the surviving class" + ); + assert_eq!( + global_path_class_member_names(&ws, &apple_owner), + Vec::::new(), + "the removed winner's class must not hold the member" + ); + assert_that!( + file_has_diagnostic(&mut ws, consumer_file, DiagnosticCode::UndefinedField), + eq(false), + "the consumer must still resolve the member after the flip" ); - - let has_diag = file_has_diagnostic(&mut ws, file_id2, DiagnosticCode::UndefinedField); - assert_that!(has_diag, eq(false)); } #[gtest] - fn test_guarded_global_field_table_persists_methods_across_files() { + fn test_global_class_annotation_last_proposal_removal_falls_back_to_the_path() { let mut ws = VirtualWorkspace::new_with_init_std_lib(); let mut emmyrc = Emmyrc::default(); emmyrc.gmod.enabled = true; ws.update_emmyrc(emmyrc); - let file_a = ws.def_file( - "file_a.lua", - r#" -Glide = Glide or {} ----@class GlideEditor -local Editor = Glide.VehicleLayoutEditor or {} -function Editor:TestMethod() end -Glide.VehicleLayoutEditor = Editor -"#, - ); - let file_b = ws.def_file( - "file_b.lua", - r#" -local Editor = Glide.VehicleLayoutEditor -Editor:TestMethod() -"#, + let annotator_path = "lua/glide/sh_repair_zebra.lua"; + ws.def_file(annotator_path, GLOBAL_CLASS_FLIP_ZEBRA_SOURCE); + ws.def_file( + "lua/glide/sv_repair.lua", + "function Glide.Repair.Solo() end\n", ); - let file_c = ws.def_file( - "file_c.lua", - r#" -local Editor = Glide.VehicleLayoutEditor -Editor:MissingMethod() -"#, + let consumer_file = ws.def_file( + "lua/entities/glide_repair_tool/init.lua", + "local solo = Glide.Repair.Solo\n", ); - let editor_type = local_name_type(&mut ws, file_b, "Editor"); - assert_that!( - editor_type.is_unknown(), - eq(false), - "Editor should not be unknown. Actually: {:?}", - editor_type + let zebra_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("Zebra")); + assert_eq!( + global_path_class_member_names(&ws, &zebra_owner), + vec!["Solo".to_string(), "x".to_string(), "y".to_string()], + "precondition: the annotated class holds the members before removal" ); - let has_undef = file_has_diagnostic(&mut ws, file_b, DiagnosticCode::UndefinedField); - assert_that!( - has_undef, - eq(false), - "Method defined through guarded local alias should persist across files" - ); + let annotator_uri = ws.virtual_url_generator.new_uri(annotator_path); + ws.analysis + .update_file_by_uri(&annotator_uri, None) + .map(|(id, _)| id); - let has_need_check_nil = file_has_diagnostic(&mut ws, file_b, DiagnosticCode::NeedCheckNil); + let path_owner = LuaMemberOwner::GlobalPath(GlobalId::new("Glide.Repair")); + assert_eq!( + global_path_class_member_names(&ws, &path_owner), + vec!["Solo".to_string()], + "removing the last proposal must fall back to the path owner" + ); + assert_eq!( + global_path_class_member_names(&ws, &zebra_owner), + Vec::::new(), + "no member may be orphaned under the removed class" + ); assert_that!( - has_need_check_nil, + file_has_diagnostic(&mut ws, consumer_file, DiagnosticCode::UndefinedField), eq(false), - "Method call should not require a nil check if correctly inferred" + "the consumer must still resolve the members after the fallback" + ); + } + + #[gtest] + fn test_global_class_annotation_flip_is_stable_across_load_orders() { + let zebra_path = "lua/glide/sh_repair_zebra.lua"; + let apple_path = "lua/glide/sh_repair_apple.lua"; + let consumer_path = "lua/entities/glide_repair_tool/init.lua"; + let consumer_source = r#" + local x = Glide.Repair.x + "#; + + let mut failures = Vec::new(); + for scenario in [ + "sequential_zebra_then_apple", + "sequential_apple_then_zebra", + "batch_startup", + "batch_then_full_reindex", + ] { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + + let consumer_file_id = match scenario { + "sequential_zebra_then_apple" => { + ws.def_file(zebra_path, GLOBAL_CLASS_FLIP_ZEBRA_SOURCE); + ws.def_file(apple_path, GLOBAL_CLASS_FLIP_APPLE_SOURCE); + ws.def_file(consumer_path, consumer_source) + } + "sequential_apple_then_zebra" => { + ws.def_file(apple_path, GLOBAL_CLASS_FLIP_APPLE_SOURCE); + ws.def_file(zebra_path, GLOBAL_CLASS_FLIP_ZEBRA_SOURCE); + ws.def_file(consumer_path, consumer_source) + } + "batch_startup" | "batch_then_full_reindex" => { + let zebra_batch_path = ws.virtual_url_generator.new_path(zebra_path); + let apple_batch_path = ws.virtual_url_generator.new_path(apple_path); + let consumer_batch_path = ws.virtual_url_generator.new_path(consumer_path); + ws.analysis.update_files_by_path(vec![ + ( + zebra_batch_path, + Some(GLOBAL_CLASS_FLIP_ZEBRA_SOURCE.into()), + ), + ( + apple_batch_path, + Some(GLOBAL_CLASS_FLIP_APPLE_SOURCE.into()), + ), + (consumer_batch_path, Some(consumer_source.to_string())), + ]); + let consumer_uri = ws.virtual_url_generator.new_uri(consumer_path); + ws.analysis + .get_file_id(&consumer_uri) + .expect("expected consumer file id") + } + _ => unreachable!(), + }; + + if scenario == "batch_then_full_reindex" { + ws.analysis.reindex(); + } + + let apple_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("Apple")); + let zebra_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("Zebra")); + let path_owner = LuaMemberOwner::GlobalPath(GlobalId::new("Glide.Repair")); + let expected = vec!["x".to_string(), "y".to_string()]; + if global_path_class_member_names(&ws, &path_owner) != expected { + failures.push(format!("{scenario}: canonical path members diverged")); + } + if global_path_class_member_names(&ws, &apple_owner) != expected { + failures.push(format!("{scenario}: winning class members diverged")); + } + if !global_path_class_member_names(&ws, &zebra_owner).is_empty() { + failures.push(format!("{scenario}: losing class kept path members")); + } + if file_has_diagnostic(&mut ws, consumer_file_id, DiagnosticCode::UndefinedField) { + failures.push(format!("{scenario}: consumer has UndefinedField")); + } + } + + assert_that!( + failures, + is_empty(), + "the class flip should be stable across load orders: {failures:?}" + ); + } + + #[gtest] + fn test_global_class_annotation_flip_leaves_genuine_class_members_alone() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + + ws.def_file( + "lua/glide/sh_zebra_class.lua", + r#" + --- @class Zebra + --- @field NativeOnly number + "#, + ); + ws.def_file( + "lua/glide/sh_repair_zebra.lua", + GLOBAL_CLASS_FLIP_ZEBRA_SOURCE, + ); + ws.def_file( + "lua/glide/sh_repair_apple.lua", + GLOBAL_CLASS_FLIP_APPLE_SOURCE, + ); + + let zebra_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("Zebra")); + let apple_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("Apple")); + let path_owner = LuaMemberOwner::GlobalPath(GlobalId::new("Glide.Repair")); + + assert_eq!( + global_path_class_member_names(&ws, &path_owner), + vec!["x".to_string(), "y".to_string()], + "the path members follow the winning class" + ); + assert_eq!( + global_path_class_member_names(&ws, &apple_owner), + vec!["x".to_string(), "y".to_string()], + "the winning class holds only the path members" + ); + assert_eq!( + global_path_class_member_names(&ws, &zebra_owner), + vec!["NativeOnly".to_string()], + "genuine Zebra members must stay under Type(Zebra) across the flip" + ); + } + + const GLOBAL_CLASS_FLIP_MID_SOURCE: &str = r#" + --- @class Mid + Glide.Repair = Glide.Repair or {} + "#; + + const GLOBAL_CLASS_FLIP_ALIAS_SOURCE: &str = r#" + Glide = Glide or {} + local Repair = Glide.Repair + function Repair.FromAlias() end + "#; + + #[gtest] + fn test_global_class_annotation_alias_member_follows_winner_removal() { + let apple_path = "lua/glide/sh_repair_apple.lua"; + let mid_path = "lua/glide/sh_repair_mid.lua"; + let alias_path = "lua/glide/sv_repair_alias.lua"; + let consumer_source = "local from = Glide.Repair.FromAlias\n"; + + let mut failures = Vec::new(); + for apple_first in [true, false] { + let label = if apple_first { + "apple_then_mid" + } else { + "mid_then_apple" + }; + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + + if apple_first { + ws.def_file(apple_path, GLOBAL_CLASS_FLIP_APPLE_SOURCE); + ws.def_file(mid_path, GLOBAL_CLASS_FLIP_MID_SOURCE); + } else { + ws.def_file(mid_path, GLOBAL_CLASS_FLIP_MID_SOURCE); + ws.def_file(apple_path, GLOBAL_CLASS_FLIP_APPLE_SOURCE); + } + ws.def_file(alias_path, GLOBAL_CLASS_FLIP_ALIAS_SOURCE); + let consumer_file = + ws.def_file("lua/entities/glide_repair_tool/init.lua", consumer_source); + + let apple_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("Apple")); + let mid_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("Mid")); + assert_eq!( + global_path_class_member_names(&ws, &apple_owner), + vec!["FromAlias".to_string()], + "{label}: precondition: the alias write homes under the winning class" + ); + + let apple_uri = ws.virtual_url_generator.new_uri(apple_path); + ws.analysis + .update_file_by_uri(&apple_uri, None) + .map(|(id, _)| id); + + assert_eq!( + global_path_class_member_names(&ws, &mid_owner), + vec!["FromAlias".to_string()], + "{label}: removing the winning proposal must take the alias member along" + ); + assert_eq!( + global_path_class_member_names(&ws, &apple_owner), + Vec::::new(), + "{label}: the removed winner's class must not hold the alias member" + ); + assert_that!( + file_has_diagnostic(&mut ws, consumer_file, DiagnosticCode::UndefinedField), + eq(false), + "{label}: the consumer must still resolve the alias member through the path" + ); + let from_type = index_expr_type(&mut ws, consumer_file, "Glide.Repair.FromAlias"); + if from_type.is_unknown() { + failures.push(format!( + "{label}: Glide.Repair.FromAlias resolved as unknown" + )); + } + } + + assert_that!(failures, is_empty(), "{failures:?}"); + } + + #[gtest] + fn test_global_class_annotation_flip_leaves_annotated_instance_writes_alone() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + + ws.def_file( + "lua/glide/sh_repair_zebra.lua", + GLOBAL_CLASS_FLIP_ZEBRA_SOURCE, + ); + ws.def_file( + "lua/glide/sv_zebra_instance.lua", + r#" + --- @type Zebra + local z + z.NativeOnly = 1 + "#, + ); + ws.def_file( + "lua/glide/sh_repair_apple.lua", + GLOBAL_CLASS_FLIP_APPLE_SOURCE, + ); + + let zebra_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("Zebra")); + let apple_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("Apple")); + let path_owner = LuaMemberOwner::GlobalPath(GlobalId::new("Glide.Repair")); + + assert_eq!( + global_path_class_member_names(&ws, &path_owner), + vec!["x".to_string(), "y".to_string()], + "the path members follow the winning class" + ); + assert_eq!( + global_path_class_member_names(&ws, &apple_owner), + vec!["x".to_string(), "y".to_string()], + "the winning class holds only the path members" + ); + assert_eq!( + current_owner_member_names(&ws, &zebra_owner), + vec!["NativeOnly".to_string()], + "an owner-only write through a genuinely Zebra-typed value must stay under Zebra" + ); + } + + #[gtest] + fn test_global_class_annotation_definition_site_fields_follow_winner_removal() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + + ws.def_file( + "lua/glide/sh_repair_apple.lua", + r#" + --- @class Apple + Glide.Repair = Glide.Repair or {} + function Glide.Repair.Direct() end + "#, + ); + ws.def_file("lua/glide/sh_repair_mid.lua", GLOBAL_CLASS_FLIP_MID_SOURCE); + ws.def_file( + "lua/glide/sh_repair_site.lua", + "Glide.Repair.Late = { Wrench = function() end }\n", + ); + let consumer_file = ws.def_file( + "lua/entities/glide_repair_tool/init.lua", + "local wrench = Glide.Repair.Late.Wrench\n", + ); + + let apple_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("Apple")); + assert_eq!( + global_path_class_member_names(&ws, &apple_owner), + vec!["Direct".to_string(), "Late".to_string()], + "precondition: the site's members home under the winning class" + ); + + let apple_uri = ws + .virtual_url_generator + .new_uri("lua/glide/sh_repair_apple.lua"); + ws.analysis + .update_file_by_uri(&apple_uri, None) + .map(|(id, _)| id); + + let mid_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("Mid")); + let late_owner = global_path_class_member_names(&ws, &mid_owner); + assert!( + late_owner.contains(&"Late".to_string()), + "the nested path's slot must move to the surviving class: {late_owner:?}" + ); + assert_that!( + file_has_diagnostic(&mut ws, consumer_file, DiagnosticCode::UndefinedField), + eq(false), + "the consumer must still resolve the nested path member after the flip" + ); + } + + #[test] + fn test_gmod_string_numeric_index() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + + let file_id = ws.def( + r#" + local str = "XX" + local var = str[2] + "#, + ); + + let index_ty = index_expr_type(&mut ws, file_id, "str[2]"); + assert_eq!( + index_ty, + LuaType::String, + "str[2] index expression should be string, got {:?}", + index_ty + ); + + let var_ty = local_name_type(&mut ws, file_id, "var"); + assert_eq!( + var_ty, + LuaType::String, + "local var assigned from str[2] should be string, got {:?}", + var_ty + ); + } + + #[test] + fn test_table_expr_key_string() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + + ws.def( + r#" + local key = tostring(1) + local t = { [key] = 1 } + value = t[key] + "#, + ); + + let value_ty = ws.expr_ty("value"); + assert!( + matches!(value_ty, LuaType::Integer | LuaType::IntegerConst(_)), + "expected integer type, got {:?}", + value_ty + ); + } + + #[test] + fn test_table_expr_key_doc_const() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + + ws.def( + r#" + ---@type 'field' + local key = "field" + local t = { [key] = 1 } + value = t[key] + "#, + ); + + let value_ty = ws.expr_ty("value"); + assert!( + matches!(value_ty, LuaType::Integer | LuaType::IntegerConst(_)), + "expected integer type, got {:?}", + value_ty + ); + } + + #[gtest] + fn test_bootstrap_table_literal_preserves_methods() { + let mut ws = VirtualWorkspace::new(); + let file_id = ws.def( + r#" +local var = Glide.TestVar or {} +function var:TestMethod() end +Glide.TestVar = var + +var:TestMethod() +Glide.TestVar:TestMethod() +"#, + ); + + let has_diag = file_has_diagnostic(&mut ws, file_id, DiagnosticCode::UndefinedField); + assert_that!(has_diag, eq(false)); + } + + #[gtest] + fn test_bootstrap_table_cross_file() { + let mut ws = VirtualWorkspace::new(); + ws.def( + r#" +local var = Glide.TestVar or {} +function var:TestMethod() end +Glide.TestVar = var +"#, + ); + let file_id2 = ws.def( + r#" +Glide.TestVar:TestMethod() +"#, + ); + + let has_diag = file_has_diagnostic(&mut ws, file_id2, DiagnosticCode::UndefinedField); + assert_that!(has_diag, eq(false)); + } + + #[gtest] + fn test_guarded_global_field_table_persists_methods_across_files() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + + let file_a = ws.def_file( + "file_a.lua", + r#" +Glide = Glide or {} +---@class GlideEditor +local Editor = Glide.VehicleLayoutEditor or {} +function Editor:TestMethod() end +Glide.VehicleLayoutEditor = Editor +"#, + ); + let file_b = ws.def_file( + "file_b.lua", + r#" +local Editor = Glide.VehicleLayoutEditor +Editor:TestMethod() +"#, + ); + let file_c = ws.def_file( + "file_c.lua", + r#" +local Editor = Glide.VehicleLayoutEditor +Editor:MissingMethod() +"#, + ); + + let editor_type = local_name_type(&mut ws, file_b, "Editor"); + assert_that!( + editor_type.is_unknown(), + eq(false), + "Editor should not be unknown. Actually: {:?}", + editor_type + ); + + let has_undef = file_has_diagnostic(&mut ws, file_b, DiagnosticCode::UndefinedField); + assert_that!( + has_undef, + eq(false), + "Method defined through guarded local alias should persist across files" + ); + + let has_need_check_nil = file_has_diagnostic(&mut ws, file_b, DiagnosticCode::NeedCheckNil); + assert_that!( + has_need_check_nil, + eq(false), + "Method call should not require a nil check if correctly inferred" ); let has_undef_missing = @@ -2965,16 +3631,18 @@ local util = marauth.util ws.analysis.add_library_workspace(library_root.clone()); let library_uri = Uri::parse_from_file_path(&library_root.join("marauth.lua")) .expect("valid library uri"); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" marauth = {} marauth.util = {} "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); let file_id = ws.def( r#" @@ -3043,19 +3711,21 @@ local testFunction = marauth.util.TestFunction ws.analysis.add_library_workspace(library_root.clone()); let library_uri = Uri::parse_from_file_path(&library_root.join("marauth.lua")).expect("valid uri"); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" marauth = {} marauth.util = {} function marauth.util:TestFunction() end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); let file_id = ws.def( r#" @@ -3084,16 +3754,18 @@ local testFunction = marauth.util.TestFunction ws.analysis.add_library_workspace(library_root.clone()); let library_uri = Uri::parse_from_file_path(&library_root.join("shadowed.lua")) .expect("valid library uri"); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" shadowed = {} shadowed.util = {} "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); let file_id = ws.def( r#" @@ -3123,19 +3795,21 @@ local value = shadowed ws.analysis.add_library_workspace(library_root.clone()); let library_uri = Uri::parse_from_file_path(&library_root.join("marauth.lua")) .expect("valid library uri"); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" marauth = {} marauth.util = {} function marauth.util:TestFunction() end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); let file_id = ws.def( r#" @@ -3217,19 +3891,21 @@ end ws.analysis.add_main_workspace(base_root.clone()); let base_uri = Uri::parse_from_file_path(&base_root.join("gamemode/sh_test1.lua")) .expect("valid base uri"); - ws.analysis.update_file_by_uri( - &base_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &base_uri, + Some( + r#" marauth = marauth or {} marauth.util = marauth.util or {} function marauth.util:TestFunction() end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); let child_file = ws.def_file( "gamemode/sh_test1.lua", @@ -3275,19 +3951,21 @@ local testFunction = marauth.util.TestFunction ws.analysis.add_main_workspace(base_root.clone()); let base_uri = Uri::parse_from_file_path(&base_root.join("gamemode/sh_test1.lua")) .expect("valid base uri"); - ws.analysis.update_file_by_uri( - &base_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &base_uri, + Some( + r#" marauth = marauth or {} marauth.util = marauth.util or {} function marauth.util:BaseFunction() end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); let child_file = ws.def_file( "gamemode/sh_test1.lua", @@ -3421,16 +4099,18 @@ local secondChildFunction = marauth.util.SecondChildFunction ws.analysis.add_main_workspace(base_root.clone()); let base_uri = Uri::parse_from_file_path(&base_root.join("gamemode/sh_test1.lua")) .expect("valid base uri"); - ws.analysis.update_file_by_uri( - &base_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &base_uri, + Some( + r#" marauth = marauth or {} marauth.util = marauth.util or {} "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); let child_file = ws.def_file( "gamemode/sh_test1.lua", @@ -3440,14 +4120,15 @@ marauth.character = marauth.character or {} "#, ); + // Both files bootstrap `marauth` with their own `{}`, and both write + // `marauth.character`. The two literals are definition sites of one + // path, so every writer of the field belongs to that path -- which + // literal a given file wrote is not part of the answer. let owner = first_index_expr_member_owner(&ws, child_file, "marauth.character"); - let LuaMemberOwner::Element(owner_range) = owner else { - panic!("expected marauth.character to be owned by a table element, got {owner:?}"); - }; assert_that!( - owner_range.file_id, - eq(child_file), - "child guarded field assignment should attach to the current file's table owner" + owner == LuaMemberOwner::GlobalPath(crate::GlobalId::new("marauth")), + eq(true), + "a guarded field assignment on a multi-file global belongs to the path" ); } @@ -3487,9 +4168,8 @@ marauth.character = marauth.character or {} } /// Two plain cross-file assignments to the same global field make the - /// member item `Many` with all-file-define members, so - /// `should_widen_file_defines` and `should_widen_table_literals` both hold - /// and each `TableConst` collapses to `table`. + /// member item `Many`; a reader merges the two literals into one + /// `MergedTable` so neither file's fields are lost. #[test] fn test_cross_file_member_merge_widens_table_literals() { let mut ws = VirtualWorkspace::new(); @@ -3502,8 +4182,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:?}" ); } @@ -3540,16 +4220,36 @@ marauth.character = marauth.character or {} ); let consumer = ws.def_file("lua/no_widen_guarded/c.lua", "local cfg = Store.cfg\n"); + // Both writers stay visible and the read merges their literals. What + // must not happen is widening to a bare `table`. let cfg_type = local_name_type(&mut ws, consumer, "cfg"); - let LuaType::MergedTable(merged) = &cfg_type else { - panic!("guarded merge should keep concrete tables, got {cfg_type:?}"); - }; - assert!( - merged + let ranges = match &cfg_type { + LuaType::TableConst(range) => vec![range.clone()], + LuaType::MergedTable(merged) => merged .get_types() .iter() - .all(|typ| matches!(typ, LuaType::TableConst(_))), - "guarded merge must not widen its table literals, got {cfg_type:?}" + .filter_map(|typ| match typ { + LuaType::TableConst(range) => Some(range.clone()), + _ => None, + }) + .collect(), + other => panic!("guarded merge should keep concrete tables, got {other:?}"), + }; + let db = ws.analysis.compilation.get_db(); + let keys = ranges + .iter() + .flat_map(|range| { + db.get_member_index() + .get_members(&LuaMemberOwner::Element(range.clone())) + .unwrap_or_default() + .into_iter() + .map(|member| format!("{:?}", member.get_key())) + .collect::>() + }) + .collect::>(); + assert!( + keys.iter().any(|key| key.contains('a')), + "the plain writer's literal must be read, got {cfg_type:?} with {keys:?}" ); } @@ -3632,6 +4332,75 @@ end) ); } + #[test] + fn unconditional_reset_breaks_guarded_bootstrap_identity() { + let mut ws = VirtualWorkspace::new(); + let file_id = ws.def_file( + "lua/bootstrap_reset.lua", + r#" +holdem = {} +holdem.action = holdem.action or {} +holdem.action.before = 1 +holdem.action = {} +holdem.action.after = 2 +local before = holdem.action.before +local after = holdem.action.after +"#, + ); + + assert_that!( + local_name_type(&mut ws, file_id, "before"), + eq(&LuaType::Nil) + ); + assert_that!( + local_name_type(&mut ws, file_id, "after"), + eq(&LuaType::IntegerConst(2)) + ); + } + + #[gtest] + fn unconditional_reset_drops_earlier_computed_member_in_the_same_function() { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.infer_dynamic_fields = true; + ws.update_emmyrc(emmyrc); + ws.def( + r#" +Store = {} + +---@param key string +function Replace(key) + Store.values = {} + Store.values[key] = 1 + Store.values = {} + Result = Store.values[key] +end +"#, + ); + + assert_that!(ws.expr_ty("Result"), eq(&LuaType::Nil)); + } + + #[test] + fn call_return_propagation_is_not_depth_capped() { + let mut ws = VirtualWorkspace::new(); + let mut source = String::new(); + for index in 0..40 { + source.push_str(&format!( + "function __return_chain_{index}() return __return_chain_{}() end\n", + index + 1 + )); + } + source.push_str("function __return_chain_40() return 1 end\n"); + source.push_str("local result = __return_chain_0()\n"); + let file_id = ws.def(&source); + + assert_that!( + local_name_type(&mut ws, file_id, "result"), + eq(&LuaType::IntegerConst(1)) + ); + } + /// A read that a `t[k] = t[k] or {}` bootstrap dominates is answered by that /// statement, not by whichever sibling file's writer is attached to the /// owner at the moment the read is inferred. @@ -3739,3 +4508,253 @@ 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) + "#; + + /// 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" + ); + } +} + +#[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/metatable_test.rs b/crates/glua_code_analysis/src/compilation/test/metatable_test.rs index bf0801581..301a5a430 100644 --- a/crates/glua_code_analysis/src/compilation/test/metatable_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/metatable_test.rs @@ -1427,27 +1427,32 @@ mod test { let initial_file_id = ws .analysis .update_file_by_uri(&uri, Some(unsupported_content.to_string())) + .map(|(id, _)| id) .expect("literal metatable lifecycle file must be created"); assert!(has_undefined_method(&ws, initial_file_id)); let edited_file_id = ws .analysis .update_file_by_uri(&uri, Some(supported_content.to_string())) + .map(|(id, _)| id) .expect("literal metatable lifecycle file must be updated"); assert!(!has_undefined_method(&ws, edited_file_id)); ws.analysis .remove_file_by_uri(&uri) + .0 .expect("literal metatable lifecycle file must be removed"); let reopened_file_id = ws .analysis .update_file_by_uri(&uri, Some(unsupported_content.to_string())) + .map(|(id, _)| id) .expect("literal metatable lifecycle file must reopen"); assert!(has_undefined_method(&ws, reopened_file_id)); let restored_file_id = ws .analysis .update_file_by_uri(&uri, Some(supported_content.to_string())) + .map(|(id, _)| id) .expect("literal metatable lifecycle file must be restored"); assert!(!has_undefined_method(&ws, restored_file_id)); } @@ -1494,27 +1499,32 @@ mod test { let initial_file_id = ws .analysis .update_file_by_uri(&uri, Some(self_index_content.to_string())) + .map(|(id, _)| id) .expect("metatable lifecycle file must be created"); assert!(!has_undefined_method(&ws, initial_file_id)); let edited_file_id = ws .analysis .update_file_by_uri(&uri, Some(other_index_content.to_string())) + .map(|(id, _)| id) .expect("metatable lifecycle file must be updated"); assert!(has_undefined_method(&ws, edited_file_id)); ws.analysis .remove_file_by_uri(&uri) + .0 .expect("metatable lifecycle file must be removed"); let reopened_file_id = ws .analysis .update_file_by_uri(&uri, Some(other_index_content.to_string())) + .map(|(id, _)| id) .expect("metatable lifecycle file must reopen"); assert!(has_undefined_method(&ws, reopened_file_id)); let restored_file_id = ws .analysis .update_file_by_uri(&uri, Some(self_index_content.to_string())) + .map(|(id, _)| id) .expect("metatable lifecycle file must be restored"); assert!(!has_undefined_method(&ws, restored_file_id)); } @@ -1685,4 +1695,146 @@ mod test { "#, )); } + + #[test] + fn concrete_inferred_returns_follow_late_member_types_to_a_fixpoint() { + let mut ws = VirtualWorkspace::new(); + let player_path = "lua/a_player.lua"; + let player_source = r#" + Player = Player or {} + + function Player:GetGender() + return self._Gender or GENDER_NEUTRAL + end + + local genders = Config.gender + function Player:GetTitle() + return genders[self:GetGender()].title + end + + function Player:GetLabel() + return self:GetTitle() + end + "#; + ws.def_files(vec![ + (player_path, player_source), + ("lua/b_male_write.lua", "Player._Gender = GENDER_MALE"), + ("lua/c_female_write.lua", "Player._Gender = GENDER_FEMALE"), + ( + "lua/d_config.lua", + r#" + Config = { gender = { + [GENDER_NEUTRAL] = { title = "neutral" }, + [GENDER_MALE] = { title = "male" }, + [GENDER_FEMALE] = { title = "female" }, + } } + "#, + ), + ( + "lua/e_enums.lua", + "GENDER_NEUTRAL = \"Neutral\"\nGENDER_MALE = \"Male\"\nGENDER_FEMALE = \"Female\"", + ), + ]); + + let player_uri = ws.virtual_url_generator.new_uri(player_path); + let player_file = ws + .analysis + .get_file_id(&player_uri) + .expect("expected player file"); + let title_return = signature_return_type(&ws, player_file, "GetTitle"); + let label_return = signature_return_type(&ws, player_file, "GetLabel"); + let cold = ( + ws.humanize_type_detailed(title_return), + ws.humanize_type_detailed(label_return), + ); + let expected = ( + "(\"female\"|\"male\"|\"neutral\")".to_string(), + "(\"female\"|\"male\"|\"neutral\")".to_string(), + ); + assert_eq!(cold, expected); + + ws.analysis + .update_file_text_only(&player_uri, format!("{player_source}\n")); + ws.analysis.reindex_files(vec![player_file]); + + let title_return = signature_return_type(&ws, player_file, "GetTitle"); + let label_return = signature_return_type(&ws, player_file, "GetLabel"); + let warm = ( + ws.humanize_type_detailed(title_return), + ws.humanize_type_detailed(label_return), + ); + assert_eq!(warm, expected); + } + + #[test] + fn inferred_returns_follow_late_settled_dynamic_fields_to_a_fixpoint() { + // The writer's `Ranks[rank]` prefix only resolves once the + // `pairs(Ranks)` loop variables settle, so `iconMat` lands after the + // returner's `.iconMat` read was taken. Sorting the returner first + // forces that late publication on a cold build. + let mut ws = VirtualWorkspace::new(); + let returner_path = "lua/a_returner.lua"; + let returner_source = r#" + ---@class IMaterial + ---@return IMaterial + function Material(path) end + + function GetRankData(rank) + return Ranks[rank] + end + + function GetRankIconMaterial(rank) + return GetRankData(rank).iconMat + end + + function GetRankMissing(rank) + return GetRankData(rank).nope + end + "#; + ws.def_files(vec![ + (returner_path, returner_source), + ( + "lua/b_ranks.lua", + r#" + Ranks = { + admin = { title = "Admin" }, + user = { title = "User" }, + } + + for rank, data in pairs(Ranks) do + Ranks[rank].iconMat = Material("icon.vmt") + end + "#, + ), + ]); + + let returner_uri = ws.virtual_url_generator.new_uri(returner_path); + let returner_file = ws + .analysis + .get_file_id(&returner_uri) + .expect("expected returner file"); + let cold_icon = ws.humanize_type_detailed(signature_return_type( + &ws, + returner_file, + "GetRankIconMaterial", + )); + let cold_missing = + ws.humanize_type_detailed(signature_return_type(&ws, returner_file, "GetRankMissing")); + assert_eq!(cold_icon, "IMaterial"); + assert_eq!(cold_missing, "nil"); + + ws.analysis + .update_file_text_only(&returner_uri, format!("{returner_source}\n")); + ws.analysis.reindex_files(vec![returner_file]); + + let warm_icon = ws.humanize_type_detailed(signature_return_type( + &ws, + returner_file, + "GetRankIconMaterial", + )); + let warm_missing = + ws.humanize_type_detailed(signature_return_type(&ws, returner_file, "GetRankMissing")); + assert_eq!(warm_icon, "IMaterial"); + assert_eq!(warm_missing, "nil"); + } } diff --git a/crates/glua_code_analysis/src/compilation/test/mod.rs b/crates/glua_code_analysis/src/compilation/test/mod.rs index 26180c470..cc8fd533d 100644 --- a/crates/glua_code_analysis/src/compilation/test/mod.rs +++ b/crates/glua_code_analysis/src/compilation/test/mod.rs @@ -33,6 +33,7 @@ mod overload_field; mod overload_test; mod pcall_test; mod return_unwrap_test; +mod stack_exhaustion_test; mod static_cal_cmp; mod syntax_error_test; mod tuple_test; 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/compilation/test/stack_exhaustion_test.rs b/crates/glua_code_analysis/src/compilation/test/stack_exhaustion_test.rs new file mode 100644 index 000000000..b55afcd1e --- /dev/null +++ b/crates/glua_code_analysis/src/compilation/test/stack_exhaustion_test.rs @@ -0,0 +1,345 @@ +#[cfg(test)] +mod test { + // Regression tests: deeply nested input must degrade gracefully (parse + // errors + Unknown fallback), never abort the process with a stack + // overflow. Analysis threads range from 2 MB tokio/test workers to the + // 256 MB analysis pool, and source-controlled nesting depth can exceed + // any of them. + // + // The parse-tree types are `!Send`, so a parsed tree cannot cross threads: + // each thread below parses its own copy of the same source. The large- + // stack parse proves the shape is valid (zero errors given room); the + // small-stack runs prove graceful degradation. The `+`-chain and `[]` + // shapes parse iteratively (constant parser stack, so zero parse errors + // on any thread) while inference recurses per level, which isolates the + // `infer_expr` / `infer_doc_type` reserve guards from the parser guards: + // with no parse errors to blame, `Unknown` can only come from inference + // bailing out. + // + // Deep trees drop on the same small-stack threads via iterative rowan + // green-node freeing, so no test leaks to avoid teardown. + use glua_parser::{ + LuaAstNode, LuaAstToken, LuaDocTagParam, LuaExpr, LuaLocalName, LuaParser, LuaUnaryExpr, + ParserConfig, + }; + + use crate::{ + DbIndex, DocTypeInferContext, FileId, LuaInferCache, LuaType, VirtualWorkspace, + infer_doc_type, infer_expr, + }; + + const SMALL_STACK: usize = 2 * 1024 * 1024; + const BIG_STACK: usize = 64 * 1024 * 1024; + + fn deep_calls_source(depth: usize) -> String { + let mut body = String::from("local function f(x) return x end\nlocal v = "); + for _ in 0..depth { + body.push_str("f("); + } + body.push('1'); + for _ in 0..depth { + body.push(')'); + } + body.push('\n'); + body + } + + // A `-` chain parses one frame per level (constant parser stack for these + // depths) while inference recurses per level, and unlike `+` the negation + // of an `Unknown` stays `Unknown` instead of folding away — so `Unknown` + // here can only come from the inference guard. + fn minus_chain_source(depth: usize) -> String { + let mut body = String::from("local v = "); + for _ in 0..depth { + body.push_str("- "); + } + body.push_str("1\n"); + body + } + + fn array_type_source(depth: usize) -> String { + let mut body = String::from("---@param x T"); + for _ in 0..depth { + body.push_str("[]"); + } + body.push_str("\nlocal dummy = 1\n"); + body + } + + /// The classic crash shape parses cleanly given room: 3000-deep calls + /// must produce zero parse errors on a large-stack thread. + #[test] + fn deeply_nested_calls_parse_valid_on_large_stack() { + let body = deep_calls_source(3000); + std::thread::Builder::new() + .stack_size(BIG_STACK) + .spawn(move || { + let tree = LuaParser::parse(&body, ParserConfig::default()); + assert!( + tree.get_errors().is_empty(), + "3000-deep calls must parse cleanly with room, got {:?}", + tree.get_errors() + .iter() + .map(|e| &e.message) + .collect::>() + ); + }) + .expect("worker thread should spawn") + .join() + .expect("large-stack parse must not overflow its stack"); + } + + /// The same shape degrades gracefully end to end on a production-sized + /// (2 MB) worker: the query resolves the real local `v` (not an unrelated + /// global). The parser trips its reserve partway, yet inference still + /// resolves the value instead of aborting the process. + #[test] + fn deeply_nested_calls_infer_gracefully_on_small_stack() { + let body = deep_calls_source(3000); + std::thread::Builder::new() + .stack_size(SMALL_STACK) + .spawn(move || { + let mut ws = VirtualWorkspace::new(); + let file_id = ws.def(&body); + let tree = ws + .analysis + .compilation + .get_db() + .get_vfs() + .get_syntax_tree(&file_id) + .expect("Tree must exist"); + let local_v = tree + .get_chunk_node() + .descendants::() + .find(|name| { + name.get_name_token() + .is_some_and(|token| token.get_name_text() == "v") + }) + .expect("local v must exist"); + let semantic_model = ws + .analysis + .compilation + .get_semantic_model(file_id) + .expect("Model must exist"); + let token = local_v.get_name_token().expect("Name token must exist"); + let info = semantic_model + .get_semantic_info(token.syntax().clone().into()) + .expect("Semantic info must exist"); + let ty = info.display_typ().clone(); + assert!( + matches!(ty, LuaType::IntegerConst(1)), + "3000-deep nesting must still resolve gracefully, got {ty:?}" + ); + }) + .expect("worker thread should spawn") + .join() + .expect("production-sized worker must not overflow its stack"); + } + + // No single depth isolates the inference guard in both profiles: + // release needs >= ~3500 for `infer_expr` to trip (3000 folds to a + // const) while debug's fatter parser frames already error at ~3050, + // which would blame the parser instead of inference. Depths encode + // profile-specific frame sizes, so each profile uses its own depth + // inside the clean-parse + infer-bail window. + #[cfg(debug_assertions)] + const MINUS_CHAIN_DEPTH: usize = 3000; + #[cfg(not(debug_assertions))] + const MINUS_CHAIN_DEPTH: usize = 5000; + + /// `infer_expr`'s own reserve guard, isolated: a `-` chain parses with + /// one frame per level (zero parse errors even on 2 MB) while inference + /// recurses per level, so `Unknown` here can only come from the inference + /// guard. + #[test] + fn infer_expr_bails_via_stack_reserve_not_parse_errors() { + let body = minus_chain_source(MINUS_CHAIN_DEPTH); + let control_body = body.clone(); + std::thread::Builder::new() + .stack_size(SMALL_STACK) + .spawn(move || { + let tree = LuaParser::parse(&body, ParserConfig::default()); + assert!( + tree.get_errors().is_empty(), + "minus chain must parse cleanly, got {:?}", + tree.get_errors() + .iter() + .map(|e| &e.message) + .collect::>() + ); + let outermost = tree + .get_chunk_node() + .descendants::() + .next() + .expect("chain must exist"); + let db = DbIndex::new(); + let mut cache = LuaInferCache::new(FileId::new(0), Default::default()); + let result = infer_expr(&db, &mut cache, LuaExpr::from(outermost)); + assert!( + matches!(result, Ok(LuaType::Unknown)), + "deep inference must bail to Unknown via the reserve guard, got {result:?}" + ); + + let shallow = LuaParser::parse("local v = - - 5\n", ParserConfig::default()); + assert!(shallow.get_errors().is_empty()); + let shallow_expr = shallow + .get_chunk_node() + .descendants::() + .next() + .expect("shallow chain must exist"); + let mut cache = LuaInferCache::new(FileId::new(0), Default::default()); + let shallow_result = infer_expr(&db, &mut cache, LuaExpr::from(shallow_expr)); + assert!( + matches!(&shallow_result, Ok(ty) if !ty.is_unknown()), + "shallow chain must infer normally, got {shallow_result:?}" + ); + }) + .expect("worker thread should spawn") + .join() + .expect("production-sized worker must not overflow its stack"); + + // Control: the same deep chain infers to a real type with room, so + // the small-stack `Unknown` comes from stack pressure, not from the + // shape itself. + std::thread::Builder::new() + .stack_size(BIG_STACK) + .spawn(move || { + let tree = LuaParser::parse(&control_body, ParserConfig::default()); + assert!(tree.get_errors().is_empty()); + let outermost = tree + .get_chunk_node() + .descendants::() + .next() + .expect("chain must exist"); + let db = DbIndex::new(); + let mut cache = LuaInferCache::new(FileId::new(0), Default::default()); + let result = infer_expr(&db, &mut cache, LuaExpr::from(outermost)); + assert!( + matches!(&result, Ok(ty) if !ty.is_unknown()), + "deep chain must infer normally with room, got {result:?}" + ); + }) + .expect("worker thread should spawn") + .join() + .expect("large-stack inference must not overflow its stack"); + } + + /// `infer_doc_type`'s own reserve guard, isolated: suffixed `[]` types + /// parse in a loop (zero parse errors even on 2 MB) while doc inference + /// recurses per level, so `Unknown` here can only come from the doc + /// inference guard. + /// + /// Depth 40000 sits above the ~28.5k first-trip point of the pre-fix tree + /// build (28000 parsed cleanly, 29000 aborted a 2 MB release worker + /// inside the vendored rowan `NodeCache` growth rehash, whose subtree + /// hash recursed once per level past every parser/inference reserve + /// guard): with the stored-hash rehash key the same shape parses in ~25 + /// ms release / ~0.1 s debug, so the `Unknown` below still isolates the + /// `infer_doc_type` guard rather than the parser. + #[test] + fn infer_doc_type_bails_via_stack_reserve_not_parse_errors() { + let body = array_type_source(40000); + let control_body = body.clone(); + std::thread::Builder::new() + .stack_size(SMALL_STACK) + .spawn(move || { + let tree = LuaParser::parse(&body, ParserConfig::default()); + assert!( + tree.get_errors().is_empty(), + "array nesting must parse cleanly, got {:?}", + tree.get_errors() + .iter() + .map(|e| &e.message) + .collect::>() + ); + let doc_type = tree + .get_chunk_node() + .descendants::() + .next() + .expect("param tag must exist") + .get_type() + .expect("param type must exist"); + let db = DbIndex::new(); + let ctx = DocTypeInferContext::new(&db, FileId::new(1)); + let result = infer_doc_type(ctx, &doc_type); + assert!( + result.is_unknown(), + "deep doc nesting must bail to Unknown, got {result:?}" + ); + + let shallow = LuaParser::parse( + "---@param x T[]\nlocal dummy = 1\n", + ParserConfig::default(), + ); + assert!(shallow.get_errors().is_empty()); + let shallow_type = shallow + .get_chunk_node() + .descendants::() + .next() + .expect("param tag must exist") + .get_type() + .expect("param type must exist"); + let shallow_result = infer_doc_type(ctx, &shallow_type); + assert!( + !shallow_result.is_unknown(), + "shallow doc nesting must infer normally, got {shallow_result:?}" + ); + }) + .expect("worker thread should spawn") + .join() + .expect("production-sized worker must not overflow its stack"); + + // Control: the same deep nesting resolves to a real type with room. + std::thread::Builder::new() + .stack_size(BIG_STACK) + .spawn(move || { + let tree = LuaParser::parse(&control_body, ParserConfig::default()); + assert!(tree.get_errors().is_empty()); + let doc_type = tree + .get_chunk_node() + .descendants::() + .next() + .expect("param tag must exist") + .get_type() + .expect("param type must exist"); + let db = DbIndex::new(); + let ctx = DocTypeInferContext::new(&db, FileId::new(1)); + let result = infer_doc_type(ctx, &doc_type); + assert!( + !result.is_unknown(), + "deep doc nesting must infer normally with room, got {result:?}" + ); + }) + .expect("worker thread should spawn") + .join() + .expect("large-stack inference must not overflow its stack"); + } + + /// Production fidelity: analysis also runs on default-stack tokio workers + /// (~2 MB), not just test threads. The original crash shape (500 + /// sequential guarded writes) runs here on an explicit 2 MB thread: any + /// stack overflow fails this test via the join instead of aborting the + /// suite runner. + #[test] + fn guarded_slots_index_gracefully_on_production_sized_stacks() { + let mut body = String::from("ns = ns or {}\n"); + for _ in 0..500 { + body.push_str("ns.slot = ns.slot or {}\n"); + } + body.push_str("return ns\n"); + + std::thread::Builder::new() + .stack_size(2 * 1024 * 1024) + .spawn(move || { + let mut ws = crate::VirtualWorkspace::new_with_init_std_lib(); + let file_id = ws.def(&body); + ws.analysis + .compilation + .get_semantic_model(file_id) + .expect("semantic model"); + }) + .expect("worker thread should spawn") + .join() + .expect("production-sized worker must not overflow its stack"); + } +} 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/compilation/test/unguarded_child_inference_test.rs b/crates/glua_code_analysis/src/compilation/test/unguarded_child_inference_test.rs index 5a59b7971..44185d985 100644 --- a/crates/glua_code_analysis/src/compilation/test/unguarded_child_inference_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/unguarded_child_inference_test.rs @@ -483,10 +483,12 @@ mod test { .new_uri("lua/autorun/client/consumer.lua"); ws.analysis .update_file_by_uri(&producer_uri, Some(STRUCTURAL_PRODUCER.to_string())) + .map(|(id, _)| id) .expect("initial producer"); let mut consumer_file_id = ws .analysis .update_file_by_uri(&consumer_uri, Some(CONSUMER.to_string())) + .map(|(id, _)| id) .expect("initial consumer"); assert_eq!( nested_callback_state(&mut ws, consumer_file_id), @@ -495,6 +497,7 @@ mod test { ws.analysis .update_file_by_uri(&producer_uri, Some(NON_STRUCTURAL_PRODUCER.to_string())) + .map(|(id, _)| id) .expect("producer without structural callback data"); assert_eq!( nested_callback_state(&mut ws, consumer_file_id), @@ -503,6 +506,7 @@ mod test { ws.analysis .update_file_by_uri(&producer_uri, Some(STRUCTURAL_PRODUCER.to_string())) + .map(|(id, _)| id) .expect("restored structural producer"); assert_eq!( nested_callback_state(&mut ws, consumer_file_id), @@ -512,6 +516,7 @@ mod test { consumer_file_id = ws .analysis .update_file_by_uri(&consumer_uri, Some(EDITED_CONSUMER.to_string())) + .map(|(id, _)| id) .expect("edited consumer"); assert_eq!( nested_callback_state(&mut ws, consumer_file_id), @@ -520,6 +525,7 @@ mod test { ws.analysis .remove_file_by_uri(&producer_uri) + .0 .expect("removed producer"); assert_eq!( nested_callback_state(&mut ws, consumer_file_id), @@ -528,6 +534,7 @@ mod test { ws.analysis .update_file_by_uri(&producer_uri, Some(STRUCTURAL_PRODUCER.to_string())) + .map(|(id, _)| id) .expect("reopened producer"); assert_eq!( nested_callback_state(&mut ws, consumer_file_id), @@ -536,10 +543,12 @@ mod test { ws.analysis .remove_file_by_uri(&consumer_uri) + .0 .expect("removed consumer"); consumer_file_id = ws .analysis .update_file_by_uri(&consumer_uri, Some(CONSUMER.to_string())) + .map(|(id, _)| id) .expect("reopened consumer"); assert_eq!( nested_callback_state(&mut ws, consumer_file_id), @@ -1819,6 +1828,7 @@ mod test { let file_id = ws .analysis .update_file_by_uri(&uri, Some(IMMUTABLE_SOURCE.to_string())) + .map(|(id, _)| id) .expect("initial immutable file"); assert_eq!( diagnostic_count(&mut ws, file_id, DiagnosticCode::InferUnguardedChild), @@ -1828,6 +1838,7 @@ mod test { let updated_file_id = ws .analysis .update_file_by_uri(&uri, Some(MUTABLE_SOURCE.to_string())) + .map(|(id, _)| id) .expect("mutable update"); assert_eq!(updated_file_id, file_id); assert_eq!( @@ -1842,6 +1853,7 @@ mod test { let restored_file_id = ws .analysis .update_file_by_uri(&uri, Some(IMMUTABLE_SOURCE.to_string())) + .map(|(id, _)| id) .expect("restored immutable update"); assert_eq!( diagnostic_count( @@ -1854,10 +1866,12 @@ mod test { ws.analysis .remove_file_by_uri(&uri) + .0 .expect("removed incremental file"); let reopened_file_id = ws .analysis .update_file_by_uri(&uri, Some(IMMUTABLE_SOURCE.to_string())) + .map(|(id, _)| id) .expect("reopened immutable file"); assert_eq!( diagnostic_count( diff --git a/crates/glua_code_analysis/src/config/configs/diagnostics.rs b/crates/glua_code_analysis/src/config/configs/diagnostics.rs index 7d173348d..240687cc2 100644 --- a/crates/glua_code_analysis/src/config/configs/diagnostics.rs +++ b/crates/glua_code_analysis/src/config/configs/diagnostics.rs @@ -40,7 +40,7 @@ impl Default for EmmyrcDiagnostic { enable: default_true(), globals: Vec::new(), globals_regex: Vec::new(), - severity: HashMap::new(), + severity: HashMap::default(), enables: Vec::new(), diagnostic_interval: Some(500), } diff --git a/crates/glua_code_analysis/src/config/configs/gmod.rs b/crates/glua_code_analysis/src/config/configs/gmod.rs index ca4456b1b..5e8228caf 100644 --- a/crates/glua_code_analysis/src/config/configs/gmod.rs +++ b/crates/glua_code_analysis/src/config/configs/gmod.rs @@ -1,4 +1,6 @@ use std::collections::{HashMap, HashSet}; + +use rustc_hash::{FxHashMap, FxHashSet}; use std::hash::Hash; use std::path::Path; use std::sync::Arc; @@ -992,7 +994,7 @@ fn merge_scripted_class_definitions( .iter() .enumerate() .map(|(idx, definition)| (definition.id.clone(), idx)) - .collect::>(); + .collect::>(); let mut synthetic_legacy_id = 0usize; for entry in entries { @@ -1067,7 +1069,7 @@ fn merge_scripted_class_definitions( } } - let mut seen = HashSet::new(); + let mut seen = FxHashSet::default(); resolved.retain(|definition| seen.insert(definition.id.clone())); if !legacy_include.is_empty() && !has_definition_entries { @@ -1440,7 +1442,7 @@ impl EmmyrcGmodScriptedClassScopes { } pub fn hook_owner_globals(&self) -> Vec { - let mut seen = HashSet::new(); + let mut seen = FxHashSet::default(); let mut globals = Vec::new(); for definition in self @@ -1627,12 +1629,12 @@ impl EmmyrcGmodScriptedClassScopes { { let definitions = self.resolved_definitions_slice(); if definitions.is_empty() { - return (HashSet::new(), HashMap::new()); + return (HashSet::default(), HashMap::default()); } let compiled_definitions = compile_scope_definitions(definitions); - let mut scope_files = HashSet::new(); - let mut matches = HashMap::new(); + let mut scope_files = HashSet::default(); + let mut matches = HashMap::default(); for (file_id, file_path) in files { let candidate_paths = build_scope_candidate_paths(file_path); if !compiled_definitions.iter().any(|definition| { diff --git a/crates/glua_code_analysis/src/config/configs/runtime.rs b/crates/glua_code_analysis/src/config/configs/runtime.rs index 6e424db82..211c33bf4 100644 --- a/crates/glua_code_analysis/src/config/configs/runtime.rs +++ b/crates/glua_code_analysis/src/config/configs/runtime.rs @@ -36,7 +36,7 @@ impl Default for EmmyrcRuntime { extensions: Vec::new(), require_pattern: Vec::new(), nonstandard_symbol: default_nonstandard_symbols(), - special: HashMap::new(), + special: HashMap::default(), } } } diff --git a/crates/glua_code_analysis/src/config/flatten_config/mod.rs b/crates/glua_code_analysis/src/config/flatten_config/mod.rs index 267a519ac..9ab581a8f 100644 --- a/crates/glua_code_analysis/src/config/flatten_config/mod.rs +++ b/crates/glua_code_analysis/src/config/flatten_config/mod.rs @@ -1,17 +1,17 @@ mod test; -use std::collections::HashMap; +use rustc_hash::FxHashMap; use serde_json::Value; #[derive(Debug, Clone)] pub struct FlattenConfigObject { - config: HashMap, + config: FxHashMap, } impl FlattenConfigObject { pub fn parse(luals_json: Value) -> Self { - let mut config = HashMap::new(); + let mut config = FxHashMap::default(); flatten_object("", &luals_json, &mut config); Self { config } } @@ -21,7 +21,7 @@ impl FlattenConfigObject { } } -fn flatten_object(prefix: &str, val: &Value, config: &mut HashMap) { +fn flatten_object(prefix: &str, val: &Value, config: &mut FxHashMap) { match val { Value::Object(map) => { for (k, v) in map.iter() { diff --git a/crates/glua_code_analysis/src/config/mod.rs b/crates/glua_code_analysis/src/config/mod.rs index 0724a4f94..d2f67c217 100644 --- a/crates/glua_code_analysis/src/config/mod.rs +++ b/crates/glua_code_analysis/src/config/mod.rs @@ -6,7 +6,8 @@ mod pre_process; #[cfg(test)] mod test; -use std::{collections::HashMap, path::Path}; +use std::collections::HashMap; +use std::path::Path; pub use config_loader::{ConfigLoadError, load_configs, load_configs_raw, try_load_configs}; pub use configs::{ @@ -94,7 +95,7 @@ impl Emmyrc { node_cache: &'cache mut NodeCache, ) -> ParserConfig<'cache> { let lua_language_level = self.get_language_level(); - let mut special_like = HashMap::new(); + let mut special_like = HashMap::default(); for (name, func) in self.runtime.special.iter() { if let Some(func) = (*func).into() { special_like.insert(name.clone(), func); 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..ffc0385c5 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 @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use rustc_hash::FxHashMap; use glua_parser::LuaSyntaxId; use smol_str::SmolStr; @@ -14,8 +14,8 @@ pub struct AccessorFuncAnnotation { #[derive(Debug, Default)] pub struct AccessorFuncAnnotationIndex { - by_name: HashMap>, - by_file: HashMap>, + by_name: FxHashMap>, + by_file: FxHashMap>, } impl AccessorFuncAnnotationIndex { @@ -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) } @@ -71,7 +95,7 @@ pub struct AccessorFuncCallMetadata { #[derive(Debug, Default)] pub struct AccessorFuncCallIndex { - calls: HashMap>, + calls: FxHashMap>, } impl AccessorFuncCallIndex { 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/mod.rs similarity index 56% rename from crates/glua_code_analysis/src/db_index/call_site_param.rs rename to crates/glua_code_analysis/src/db_index/call_site_param/mod.rs index 863d7fb1f..cdccc3fad 100644 --- a/crates/glua_code_analysis/src/db_index/call_site_param.rs +++ b/crates/glua_code_analysis/src/db_index/call_site_param/mod.rs @@ -1,15 +1,36 @@ -use std::collections::{HashMap, HashSet}; +mod test; + +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use std::path::PathBuf; 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 +111,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 +130,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 +152,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 +168,255 @@ 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); + } + } + + /// Every signature of `file_id` this index still holds a reference to. + /// + /// `remove_files` drops what the file itself contributed, never what a + /// caller outside the re-index recorded about it, so these are the ids + /// that survive an edit on the old position. + pub fn stored_signature_ids_for_file(&self, file_id: FileId) -> HashSet { + let mut ids: HashSet = HashSet::default(); + if let Some(signatures) = self.contributor_signatures_by_file.get(&file_id) { + ids.extend(signatures.iter().copied()); + } + for consumer in self + .return_consumers_by_signature_file + .get(&file_id) + .into_iter() + .flatten() + { + ids.insert(consumer.signature_id); + } + for (_, contribution) in &self.deferred_contributions { + if contribution.signature_id.get_file_id() == file_id { + ids.insert(contribution.signature_id); + } + } + // Parked by `remove_files` before the new index existed, so nothing + // else will re-home it. + for (signature_id, _) in self.pending_previous_params.keys() { + if signature_id.get_file_id() == file_id { + ids.insert(*signature_id); + } + } + ids + } + + /// Re-keys every stored reference to a signature the edit moved inside + /// `remap.file_id`, and drops the ones it destroyed. + /// + /// Returns the contributor files whose evidence named a destroyed + /// signature: their entry is gone and only re-analysing them can rebuild + /// it. The candidate signatures are looked up through the reverse indexes + /// rather than scanned - `remove_files` never touches a contribution filed + /// under a *caller* that is not itself being re-indexed, which is exactly + /// the set this has to fix. + pub fn remap_file_signatures(&mut self, remap: &crate::FileRemap) -> HashSet { + let file_id = remap.file_id; + let candidates = self.stored_signature_ids_for_file(file_id); + + let mut moved = HashMap::default(); + let mut lost: HashSet = HashSet::default(); + for signature_id in candidates { + match remap.signature_id(signature_id) { + crate::Remap::Moved(new_id) => { + if new_id != signature_id { + moved.insert(signature_id, new_id); + } + } + crate::Remap::Unrelated => {} + crate::Remap::Lost => { + lost.insert(signature_id); + } + } + } + + let mut dirty = HashSet::default(); + if !lost.is_empty() { + for signature_id in &lost { + if let Some(files) = self.contributor_files_by_signature.remove(signature_id) { + dirty.extend(files); + } + if let Some(signatures) = self + .contributor_signatures_by_file + .get_mut(&signature_id.get_file_id()) + { + signatures.remove(signature_id); + } + } + for (contributor, contributions) in &mut self.file_contributions { + let before = contributions.len(); + contributions.retain(|c| !lost.contains(&c.signature_id)); + if contributions.len() != before { + dirty.insert(*contributor); + } + } + self.deferred_contributions.retain(|(contributor, c)| { + let keep = !lost.contains(&c.signature_id); + if !keep { + dirty.insert(*contributor); + } + keep + }); + for (consumer_file, consumers) in &mut self.file_return_consumers { + let before = consumers.len(); + consumers.retain(|c| !lost.contains(&c.signature_id)); + if consumers.len() != before { + dirty.insert(*consumer_file); + } + } + self.pending_previous_params + .retain(|(signature_id, _), _| !lost.contains(signature_id)); + } + + if !moved.is_empty() { + self.remap_signatures(&moved); + } + if !moved.is_empty() || !lost.is_empty() { + self.rebuild_derived_state(); + self.rebuild_return_consumers(); + self.rebuild_source_dependents(); + } + dirty.remove(&file_id); + dirty + } + + /// 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::default(); + 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::default(); + for (path, signature_id) in previous { + by_path + .entry(path.as_str()) + .or_default() + .push(*signature_id); + } + let mut current: HashMap<&str, Vec> = HashMap::default(); + 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_and_hasher(parked.len(), Default::default()); + 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( @@ -183,7 +467,7 @@ impl CallSiteParamIndex { &mut self, updates: Vec<(FileId, Vec<(LuaSignatureId, usize, LuaTypeFact)>)>, ) -> HashSet { - let mut affected_params = HashSet::new(); + let mut affected_params = HashSet::default(); for (file_id, _) in &updates { if let Some(contributions) = self.file_contributions.get(file_id) { affected_params.extend( @@ -278,14 +562,14 @@ impl CallSiteParamIndex { /// caller can requeue the returns and consumers derived from them. pub(crate) fn flush_deferred_contributions(&mut self) -> HashSet { if self.deferred_contributions.is_empty() { - return HashSet::new(); + return HashSet::default(); } let queued = std::mem::take(&mut self.deferred_contributions); let affected = queued .iter() .map(|(_, contribution)| (contribution.signature_id, contribution.param_idx)) .collect(); - let previous = self.snapshot_param_facts(affected, HashMap::new()); + let previous = self.snapshot_param_facts(affected, HashMap::default()); for (file_id, contribution) in queued { self.file_contributions .entry(file_id) @@ -302,7 +586,7 @@ impl CallSiteParamIndex { &self, file_ids: &HashSet, ) -> HashMap<(LuaSignatureId, usize), LuaType> { - let mut out = HashMap::new(); + let mut out = HashMap::default(); for file_id in file_ids { let Some(contributions) = self.file_contributions.get(file_id) else { continue; @@ -321,7 +605,6 @@ impl CallSiteParamIndex { } out } - /// Every call-site-inferred parameter type currently indexed. pub fn iter_inferred_params( &self, @@ -406,6 +689,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, @@ -441,10 +760,13 @@ impl CallSiteParamIndex { .unwrap_or_default() } - pub fn collect_source_dependents(&self, source_files: &HashSet) -> Vec { + pub fn collect_source_dependents( + &self, + source_files: &std::collections::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::>(); @@ -461,6 +783,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, @@ -481,6 +816,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() { @@ -497,7 +864,7 @@ impl CallSiteParamIndex { self.inference_events_by_file.clear(); let mut accumulators = - HashMap::>::new(); + HashMap::>::default(); for file_id in sorted_file_ids(&self.file_contributions) { let Some(contributions) = self.file_contributions.get(&file_id) else { @@ -510,9 +877,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() @@ -559,36 +934,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); - if let Some(path) = self.source_paths.get(source_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) { self.source_path_dependents .entry(path.clone()) .or_default() @@ -637,7 +1018,7 @@ impl CallSiteParamIndex { self.file_source_dependencies .entry(consumer.file_id) .or_default() - .insert(signature_file_id); + .insert(CallSiteSourceId::Signature(consumer.signature_id)); } } } @@ -671,7 +1052,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); } @@ -683,6 +1076,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(); @@ -695,6 +1091,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(); @@ -708,192 +1105,3 @@ fn is_concrete_structural_callback_fact(fact: &LuaTypeFact) -> bool { .iter() .any(|step| step.event.kind == LuaInferenceProvenanceKind::ConcreteValue) } - -#[cfg(test)] -mod tests { - use super::*; - - fn signature_id(file_id: FileId, position: u32) -> LuaSignatureId { - serde_json::from_str(&format!("\"{}|{}\"", file_id.id, position)).unwrap() - } - - fn inferred_union_members( - index: &CallSiteParamIndex, - signature_id: &LuaSignatureId, - ) -> Vec { - match index.get_inferred_param(signature_id, 0) { - Some(LuaType::Union(union)) => union.as_ref().into_vec(), - Some(other) => vec![other.clone()], - None => panic!("expected inferred param for signature {signature_id:?}"), - } - } - - #[test] - fn inferred_param_union_order_is_stable_across_file_insertion_order() { - let lower_file_id = FileId::new(1); - let higher_file_id = FileId::new(2); - let signature_id = signature_id(FileId::new(10), 0); - - let lower_file_contribution = (signature_id, 0, LuaType::String); - let higher_file_contribution = (signature_id, 0, LuaType::Boolean); - - let mut forward_index = CallSiteParamIndex::new(); - forward_index.set_files_contributions(vec![ - (higher_file_id, vec![higher_file_contribution.clone()]), - (lower_file_id, vec![lower_file_contribution.clone()]), - ]); - - let mut reverse_index = CallSiteParamIndex::new(); - reverse_index.set_files_contributions(vec![ - (lower_file_id, vec![lower_file_contribution]), - (higher_file_id, vec![higher_file_contribution]), - ]); - - // The union is canonicalised by type rather than by which file - // contributed first, so the order no longer depends on the contributing - // set being complete — an incremental reindex that replaces only some - // files' contributions still produces this exact union. - let expected = vec![LuaType::Boolean, LuaType::String]; - assert_eq!( - inferred_union_members(&forward_index, &signature_id), - expected - ); - assert_eq!( - inferred_union_members(&reverse_index, &signature_id), - expected - ); - } - - #[test] - fn batch_removal_matches_rebuilding_with_surviving_file_inputs() { - let removed_first = FileId::new(1); - let surviving = FileId::new(2); - let removed_last = FileId::new(3); - let removed_first_signature = signature_id(removed_first, 10); - let surviving_signature = signature_id(surviving, 20); - let removed_last_signature = signature_id(removed_last, 30); - - let mut index = CallSiteParamIndex::new(); - index.set_files_source_signatures(vec![ - ( - removed_first, - vec![( - "removed-first".to_string(), - removed_first_signature, - vec![0], - )], - ), - ( - surviving, - vec![("surviving".to_string(), surviving_signature, vec![1])], - ), - ( - removed_last, - vec![("removed-last".to_string(), removed_last_signature, vec![2])], - ), - ]); - index.set_files_contributions(vec![ - ( - removed_first, - vec![(surviving_signature, 0, LuaType::String)], - ), - (surviving, vec![(surviving_signature, 0, LuaType::Boolean)]), - ( - removed_last, - vec![(surviving_signature, 0, LuaType::Integer)], - ), - ]); - - index.remove_files(&[removed_last, removed_first]); - - let mut expected = CallSiteParamIndex::new(); - expected.set_files_source_signatures(vec![( - surviving, - vec![("surviving".to_string(), surviving_signature, vec![1])], - )]); - expected.set_files_contributions(vec![( - surviving, - vec![(surviving_signature, 0, LuaType::Boolean)], - )]); - - assert_eq!( - index.source_signatures_by_path, - expected.source_signatures_by_path - ); - assert_eq!(index.mutated_params, expected.mutated_params); - assert_eq!(index.inferred_params, expected.inferred_params); - assert_eq!( - index.inference_events_by_file, - expected.inference_events_by_file - ); - } - - #[test] - fn removed_contribution_reports_its_signature_as_changed() { - let source_file = FileId::new(1); - let signature_id = signature_id(FileId::new(2), 10); - let mut index = CallSiteParamIndex::new(); - index.set_files_contributions(vec![( - source_file, - vec![(signature_id, 0, LuaType::String)], - )]); - - index.remove(source_file); - let changed = index.set_files_fact_contributions(vec![(source_file, Vec::new())]); - - assert_eq!(changed, HashSet::from([signature_id])); - } - - #[test] - fn flushing_a_deferred_contribution_reports_its_signature_as_changed() { - let source_file = FileId::new(1); - let signature_id = signature_id(FileId::new(2), 10); - let mut index = CallSiteParamIndex::new(); - index.set_files_contributions(vec![( - source_file, - vec![(signature_id, 0, LuaType::String)], - )]); - - index.queue_deferred_contribution( - source_file, - signature_id, - 0, - LuaTypeFact::certain(LuaType::Boolean), - ); - - assert_eq!( - index.flush_deferred_contributions(), - HashSet::from([signature_id]) - ); - assert_eq!( - inferred_union_members(&index, &signature_id), - vec![LuaType::Boolean, LuaType::String] - ); - } - - #[test] - fn flushing_an_empty_queue_reports_no_changed_signatures() { - let mut index = CallSiteParamIndex::new(); - - assert!(index.flush_deferred_contributions().is_empty()); - } - - #[test] - fn unchanged_reindexed_contribution_does_not_report_a_change() { - let source_file = FileId::new(1); - let signature_id = signature_id(FileId::new(2), 10); - let mut index = CallSiteParamIndex::new(); - index.set_files_contributions(vec![( - source_file, - vec![(signature_id, 0, LuaType::String)], - )]); - - index.remove(source_file); - let changed = index.set_files_fact_contributions(vec![( - source_file, - vec![(signature_id, 0, LuaTypeFact::certain(LuaType::String))], - )]); - - assert!(changed.is_empty()); - } -} diff --git a/crates/glua_code_analysis/src/db_index/call_site_param/test.rs b/crates/glua_code_analysis/src/db_index/call_site_param/test.rs new file mode 100644 index 000000000..48080c296 --- /dev/null +++ b/crates/glua_code_analysis/src/db_index/call_site_param/test.rs @@ -0,0 +1,373 @@ +#[cfg(test)] +mod tests { + use crate::db_index::call_site_param::*; + + fn signature_id(file_id: FileId, position: u32) -> LuaSignatureId { + serde_json::from_str(&format!("\"{}|{}\"", file_id.id, position)).unwrap() + } + + fn inferred_union_members( + index: &CallSiteParamIndex, + signature_id: &LuaSignatureId, + ) -> Vec { + match index.get_inferred_param(signature_id, 0) { + Some(LuaType::Union(union)) => union.as_ref().into_vec(), + Some(other) => vec![other.clone()], + None => panic!("expected inferred param for signature {signature_id:?}"), + } + } + + #[test] + fn inferred_param_union_order_is_stable_across_file_insertion_order() { + let lower_file_id = FileId::new(1); + let higher_file_id = FileId::new(2); + let signature_id = signature_id(FileId::new(10), 0); + + let lower_file_contribution = (signature_id, 0, LuaType::String); + let higher_file_contribution = (signature_id, 0, LuaType::Boolean); + + let mut forward_index = CallSiteParamIndex::new(); + forward_index.set_files_contributions(vec![ + (higher_file_id, vec![higher_file_contribution.clone()]), + (lower_file_id, vec![lower_file_contribution.clone()]), + ]); + + let mut reverse_index = CallSiteParamIndex::new(); + reverse_index.set_files_contributions(vec![ + (lower_file_id, vec![lower_file_contribution]), + (higher_file_id, vec![higher_file_contribution]), + ]); + + // The union is canonicalised by type rather than by which file + // contributed first, so the order no longer depends on the contributing + // set being complete — an incremental reindex that replaces only some + // files' contributions still produces this exact union. + let expected = vec![LuaType::Boolean, LuaType::String]; + assert_eq!( + inferred_union_members(&forward_index, &signature_id), + expected + ); + assert_eq!( + inferred_union_members(&reverse_index, &signature_id), + expected + ); + } + + #[test] + fn batch_removal_matches_rebuilding_with_surviving_file_inputs() { + let removed_first = FileId::new(1); + let surviving = FileId::new(2); + let removed_last = FileId::new(3); + let removed_first_signature = signature_id(removed_first, 10); + let surviving_signature = signature_id(surviving, 20); + let removed_last_signature = signature_id(removed_last, 30); + + let mut index = CallSiteParamIndex::new(); + index.set_files_source_signatures(vec![ + ( + removed_first, + vec![( + "removed-first".to_string(), + removed_first_signature, + vec![0], + )], + ), + ( + surviving, + vec![("surviving".to_string(), surviving_signature, vec![1])], + ), + ( + removed_last, + vec![("removed-last".to_string(), removed_last_signature, vec![2])], + ), + ]); + index.set_files_contributions(vec![ + ( + removed_first, + vec![(surviving_signature, 0, LuaType::String)], + ), + (surviving, vec![(surviving_signature, 0, LuaType::Boolean)]), + ( + removed_last, + vec![(surviving_signature, 0, LuaType::Integer)], + ), + ]); + + index.remove_files(&[removed_last, removed_first]); + + let mut expected = CallSiteParamIndex::new(); + expected.set_files_source_signatures(vec![( + surviving, + vec![("surviving".to_string(), surviving_signature, vec![1])], + )]); + expected.set_files_contributions(vec![( + surviving, + vec![(surviving_signature, 0, LuaType::Boolean)], + )]); + + assert_eq!( + index.source_signatures_by_path, + expected.source_signatures_by_path + ); + assert_eq!(index.mutated_params, expected.mutated_params); + assert_eq!(index.inferred_params, expected.inferred_params); + assert_eq!( + index.inference_events_by_file, + expected.inference_events_by_file + ); + } + + #[test] + fn removed_contribution_reports_its_signature_as_changed() { + let source_file = FileId::new(1); + let signature_id = signature_id(FileId::new(2), 10); + let mut index = CallSiteParamIndex::new(); + index.set_files_contributions(vec![( + source_file, + vec![(signature_id, 0, LuaType::String)], + )]); + + index.remove(source_file); + let changed = index.set_files_fact_contributions(vec![(source_file, Vec::new())]); + + assert_eq!(changed, HashSet::from_iter([signature_id])); + } + + #[test] + fn flushing_a_deferred_contribution_reports_its_signature_as_changed() { + let source_file = FileId::new(1); + let signature_id = signature_id(FileId::new(2), 10); + let mut index = CallSiteParamIndex::new(); + index.set_files_contributions(vec![( + source_file, + vec![(signature_id, 0, LuaType::String)], + )]); + + index.queue_deferred_contribution( + source_file, + signature_id, + 0, + LuaTypeFact::certain(LuaType::Boolean), + ); + + assert_eq!( + index.flush_deferred_contributions(), + HashSet::from_iter([signature_id]) + ); + assert_eq!( + inferred_union_members(&index, &signature_id), + vec![LuaType::Boolean, LuaType::String] + ); + } + + #[test] + fn flushing_an_empty_queue_reports_no_changed_signatures() { + let mut index = CallSiteParamIndex::new(); + + assert!(index.flush_deferred_contributions().is_empty()); + } + + #[test] + fn unchanged_reindexed_contribution_does_not_report_a_change() { + let source_file = FileId::new(1); + let signature_id = signature_id(FileId::new(2), 10); + let mut index = CallSiteParamIndex::new(); + index.set_files_contributions(vec![( + source_file, + vec![(signature_id, 0, LuaType::String)], + )]); + + index.remove(source_file); + let changed = index.set_files_fact_contributions(vec![( + source_file, + vec![(signature_id, 0, LuaTypeFact::certain(LuaType::String))], + )]); + + 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_iter([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_iter([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_iter([callee])); + + assert!( + index + .collect_signature_contributor_files(&[signature_id]) + .is_empty() + ); + assert!( + index + .collect_contributor_files(&HashSet::from_iter([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_iter([caller])); + + assert!( + index + .collect_signature_contributor_files(&[signature_id]) + .is_empty() + ); + assert!( + index + .collect_contributor_files(&HashSet::from_iter([callee])) + .is_empty() + ); + } +} diff --git a/crates/glua_code_analysis/src/db_index/declaration/decl_tree.rs b/crates/glua_code_analysis/src/db_index/declaration/decl_tree.rs index 9d5d64e0b..c3130770d 100644 --- a/crates/glua_code_analysis/src/db_index/declaration/decl_tree.rs +++ b/crates/glua_code_analysis/src/db_index/declaration/decl_tree.rs @@ -13,6 +13,15 @@ pub struct LuaDeclarationTree { decls: FxHashMap, module_decls_by_name: FxHashMap>, scopes: Vec, + /// Each scope's index in its parent's `children`, parallel to `scopes`. + scope_child_index: Vec, + /// Per scope, the children `base_walk_up` would visit that can match: + /// direct declarations, plus child scopes with direct declarations of + /// their own. Sorted by `(position, child index)` — the same order they + /// hold in `children` — so the walk iterates newest-first by + /// binary-searching the `position < start` boundary instead of scanning + /// every statement scope. Only scopes with any hold an entry. + interesting_children: FxHashMap>, } impl LuaDeclarationTree { @@ -22,6 +31,8 @@ impl LuaDeclarationTree { decls: FxHashMap::default(), module_decls_by_name: FxHashMap::default(), scopes: Vec::new(), + scope_child_index: Vec::new(), + interesting_children: FxHashMap::default(), } } @@ -95,14 +106,7 @@ impl LuaDeclarationTree { let mut scope = self.scopes.first()?; loop { - let child_scope = scope - .get_children() - .iter() - .filter_map(|child| match child { - ScopeOrDeclId::Scope(child_id) => self.scopes.get(child_id.id as usize), - ScopeOrDeclId::Decl(_) => None, - }) - .find(|child_scope| child_scope.get_range().contains(position)); + let child_scope = self.find_child_scope(scope, position); if child_scope.is_none() { break; } @@ -112,40 +116,114 @@ impl LuaDeclarationTree { Some(scope) } + /// Innermost direct child scope of `scope` containing `position`, if any. + /// + /// Children are appended in walk (source) order and sibling scopes cover + /// disjoint syntax ranges, so child starts are ordered and at most one + /// sibling contains the position: binary search finds it instead of + /// scanning every statement scope of the file on each lookup. An + /// unresolvable key falls back to the linear scan; a candidate that does + /// not contain the position means no sibling does (earlier ones end no + /// later than its start), so that answers `None` directly. Debug builds + /// check every answer against the linear scan. + fn find_child_scope(&self, scope: &LuaScope, position: TextSize) -> Option<&LuaScope> { + let children = scope.get_children(); + if children.is_empty() { + return None; + } + let child_start = |child: &ScopeOrDeclId| -> Option { + match child { + ScopeOrDeclId::Scope(child_id) => self + .scopes + .get(child_id.id as usize) + .map(|child_scope| child_scope.get_position()), + ScopeOrDeclId::Decl(decl_id) => { + self.get_decl(decl_id).map(|decl| decl.get_position()) + } + } + }; + + // First index whose start lies past `position`. Children arrive in + // walk (source) order, so starts are ordered and this is a binary + // search; any unresolvable key falls back to the linear scan. + let mut lo = 0usize; + let mut hi = children.len(); + while lo < hi { + let mid = lo + (hi - lo) / 2; + match child_start(&children[mid]) { + None => return self.linear_find_child_scope(scope, position), + Some(start) if start <= position => lo = mid + 1, + Some(_) => hi = mid, + } + } + // The containing scope, if any, is the nearest preceding scope entry: + // entries between it and `lo` are declarations, which never contain. + // Scopes past `lo` start beyond `position` and cannot contain it, and + // under the ordering invariant earlier scopes end no later than this + // one's start, so a miss here means no sibling contains the position. + let mut found: Option<&LuaScope> = None; + let mut back = lo; + while back > 0 { + back -= 1; + match &children[back] { + ScopeOrDeclId::Decl(_) => continue, + ScopeOrDeclId::Scope(child_id) => { + found = self.scopes.get(child_id.id as usize); + break; + } + } + } + let hit = found.filter(|candidate| candidate.get_range().contains(position)); + #[cfg(debug_assertions)] + debug_assert_eq!( + hit.map(|candidate| candidate.get_id()), + self.linear_find_child_scope(scope, position) + .map(|found| found.get_id()), + "find_child_scope fast path diverged from the linear scan", + ); + hit + } + + /// Reference implementation of [`Self::find_child_scope`]'s inner step, + /// kept to pin the fast path in debug builds and as the fallback for + /// unresolvable keys in all builds. + fn linear_find_child_scope(&self, scope: &LuaScope, position: TextSize) -> Option<&LuaScope> { + scope + .get_children() + .iter() + .filter_map(|child| match child { + ScopeOrDeclId::Scope(child_id) => self.scopes.get(child_id.id as usize), + ScopeOrDeclId::Decl(_) => None, + }) + .find(|child_scope| child_scope.get_range().contains(position)) + } + + /// Walks up the scope tree, newest declaration first. + /// + /// Sibling scopes without direct declarations are elided: their only visit + /// would be `f(Scope)`, which carries no declaration. Both current callers + /// ignore `Scope` visits, so the elision is unobservable to them; a future + /// closure that depends on visiting every scope must not use this walk. fn base_walk_up(&self, scope: &LuaScope, start_pos: TextSize, level: usize, f: &mut F) where F: FnMut(ScopeOrDeclId) -> bool, { - let cur_index = scope.get_children().iter().rposition(|child| match child { - ScopeOrDeclId::Decl(decl_id) => decl_id.position < start_pos, - ScopeOrDeclId::Scope(scope_id) => { - let child_scope = match self.scopes.get(scope_id.id as usize) { - Some(scope) => scope, - None => return false, - }; - child_scope.get_position() < start_pos - } - }); - - if let Some(cur_index) = cur_index { - for i in (0..=cur_index).rev() { - let scope_or_id = match scope.get_children().get(i) { - Some(scope_or_id) => scope_or_id, - None => continue, - }; - - match scope_or_id { - ScopeOrDeclId::Decl(decl_id) => { - if f(decl_id.into()) { + if let Some(interesting) = self.interesting_children.get(&scope.get_id()) { + // Sorted by `(position, child index)`, the order entries hold in + // `children`, so this visits exactly what the old reverse scan + // over `children` would, minus the elided scopes. + let end = interesting.partition_point(|(key, _)| key.0 < start_pos); + for (_, child) in interesting[..end].iter().rev() { + match child { + InterestingChild::Decl(decl_id) => { + if f((*decl_id).into()) { return; } } - ScopeOrDeclId::Scope(scope_id) => { - let child_scope = match self.scopes.get(scope_id.id as usize) { - Some(scope) => scope, - None => continue, + InterestingChild::Scope(scope_id) => { + let Some(child_scope) = self.scopes.get(scope_id.id as usize) else { + continue; }; - if self.walk_over_scope(child_scope, f) { return; } @@ -263,22 +341,97 @@ impl LuaDeclarationTree { let scope = LuaScope::new(range, kind, scope_id); self.scopes.push(scope); + self.scope_child_index.push(u32::MAX); scope_id } pub fn add_decl_to_scope(&mut self, scope_id: LuaScopeId, decl_id: LuaDeclId) { - if let Some(scope) = self.scopes.get_mut(scope_id.id as usize) { - scope.add_decl(decl_id); + let Some(scope) = self.scopes.get_mut(scope_id.id as usize) else { + return; + }; + let vec_index = scope.get_children().len() as u32; + scope.add_decl(decl_id); + let (kind, parent, position) = (scope.get_kind(), scope.get_parent(), scope.get_position()); + insert_interesting_child( + &mut self.interesting_children, + scope_id, + (decl_id.position, vec_index), + InterestingChild::Decl(decl_id), + ); + // A child scope with direct declarations is visited for them when the + // walk passes its parent; record it there. The key makes repeat + // inserts idempotent, so every declaration can file unconditionally. + if matches!( + kind, + LuaScopeKind::LocalOrAssignStat | LuaScopeKind::FuncStat | LuaScopeKind::MethodStat + ) && let Some(parent_id) = parent + { + let scope_key = ( + position, + self.scope_child_index + .get(scope_id.id as usize) + .copied() + .unwrap_or(u32::MAX), + ); + insert_interesting_child( + &mut self.interesting_children, + parent_id, + scope_key, + InterestingChild::Scope(scope_id), + ); } } pub fn add_child_scope(&mut self, parent_id: LuaScopeId, child_id: LuaScopeId) { if let Some(parent) = self.scopes.get_mut(parent_id.id as usize) { + let vec_index = parent.get_children().len() as u32; parent.add_child(child_id); + if let Some(slot) = self.scope_child_index.get_mut(child_id.id as usize) { + *slot = vec_index; + } } if let Some(child) = self.scopes.get_mut(child_id.id as usize) { child.set_parent(Some(parent_id)); } + // Declarations normally land after the link above, which files the + // scope into its parent then. If any landed first, the parent never + // saw them: file the scope now so the walk finds them either way. + self.file_child_scope_with_decls(parent_id, child_id); + } + + /// Records `child_id` under `parent_id`'s visit set when the child + /// already holds direct declarations (see `add_decl_to_scope` for the + /// usual link-then-declare order this covers for). + fn file_child_scope_with_decls(&mut self, parent_id: LuaScopeId, child_id: LuaScopeId) { + let (kind, position, vec_index, has_decls) = match self.scopes.get(child_id.id as usize) { + Some(child) => ( + child.get_kind(), + child.get_position(), + self.scope_child_index + .get(child_id.id as usize) + .copied() + .unwrap_or(u32::MAX), + child + .get_children() + .iter() + .any(|entry| matches!(entry, ScopeOrDeclId::Decl(_))), + ), + None => return, + }; + if !has_decls + || !matches!( + kind, + LuaScopeKind::LocalOrAssignStat | LuaScopeKind::FuncStat | LuaScopeKind::MethodStat + ) + { + return; + } + insert_interesting_child( + &mut self.interesting_children, + parent_id, + (position, vec_index), + InterestingChild::Scope(child_id), + ); } pub fn get_root_scope(&self) -> Option<&LuaScope> { @@ -294,6 +447,32 @@ impl LuaDeclarationTree { } } +/// A child `base_walk_up` visits that can match: a direct declaration, or a +/// child scope visited for its own direct declarations via `walk_over_scope`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InterestingChild { + Decl(LuaDeclId), + Scope(LuaScopeId), +} + +/// Files `child` under `scope_id`'s ordered visit set, keeping `(position, +/// child index)` order. Scope entries deduplicate by key, so a scope files +/// once however many declarations it gains. +fn insert_interesting_child( + interesting: &mut FxHashMap>, + scope_id: LuaScopeId, + key: (TextSize, u32), + child: InterestingChild, +) { + let entries = interesting.entry(scope_id).or_default(); + match entries.binary_search_by_key(&key, |(key, _)| *key) { + // Same key twice is the same scope filing again; declarations never + // share a key (their child index is unique per push). + Ok(_) if matches!(child, InterestingChild::Scope(_)) => {} + Ok(at) | Err(at) => entries.insert(at, (key, child)), + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum LuaDeclOrMemberId { Decl(LuaDeclId), diff --git a/crates/glua_code_analysis/src/db_index/declaration/mod.rs b/crates/glua_code_analysis/src/db_index/declaration/mod.rs index 7c53ba6f2..cbd7ee575 100644 --- a/crates/glua_code_analysis/src/db_index/declaration/mod.rs +++ b/crates/glua_code_analysis/src/db_index/declaration/mod.rs @@ -7,23 +7,16 @@ pub use decl::LuaDeclExtra; pub use decl::{LocalAttribute, LuaDecl, LuaDeclInitializer}; pub use decl_id::LuaDeclId; pub use decl_tree::{LuaDeclOrMemberId, LuaDeclarationTree}; -use rowan::TextRange; use rustc_hash::FxHashMap; pub use scope::{LuaScope, LuaScopeId, LuaScopeKind, ScopeOrDeclId}; -use crate::{FileId, LuaMemberId}; +use crate::FileId; use super::traits::LuaIndex; #[derive(Debug)] pub struct LuaDeclIndex { decl_trees: FxHashMap, - /// The table literal a global declaration is written with — the `{}` of - /// `X = {}` or of the GLua-idiomatic `X = X or {}`. - global_initializer_tables: FxHashMap, - /// The same fact for a *nested* global path: the `{}` of `X.k = {}` or - /// of `X.k = X.k or {}`, keyed by the member that declares it. - global_member_initializer_tables: FxHashMap, } impl Default for LuaDeclIndex { @@ -36,37 +29,9 @@ impl LuaDeclIndex { pub fn new() -> Self { Self { decl_trees: FxHashMap::default(), - global_initializer_tables: FxHashMap::default(), - global_member_initializer_tables: FxHashMap::default(), } } - pub fn set_global_initializer_table(&mut self, decl_id: LuaDeclId, range: TextRange) { - self.global_initializer_tables.insert(decl_id, range); - } - - pub fn get_global_initializer_table(&self, decl_id: &LuaDeclId) -> Option { - self.global_initializer_tables.get(decl_id).copied() - } - - pub fn set_global_member_initializer_table( - &mut self, - member_id: LuaMemberId, - range: TextRange, - ) { - self.global_member_initializer_tables - .insert(member_id, range); - } - - pub fn get_global_member_initializer_table( - &self, - member_id: &LuaMemberId, - ) -> Option { - self.global_member_initializer_tables - .get(member_id) - .copied() - } - pub fn add_decl_tree(&mut self, tree: LuaDeclarationTree) { self.decl_trees.insert(tree.file_id(), tree); } @@ -93,15 +58,9 @@ impl LuaDeclIndex { impl LuaIndex for LuaDeclIndex { fn remove(&mut self, file_id: FileId) { self.decl_trees.remove(&file_id); - self.global_initializer_tables - .retain(|decl_id, _| decl_id.file_id != file_id); - self.global_member_initializer_tables - .retain(|member_id, _| member_id.file_id != file_id); } fn clear(&mut self) { self.decl_trees.clear(); - self.global_initializer_tables.clear(); - self.global_member_initializer_tables.clear(); } } diff --git a/crates/glua_code_analysis/src/db_index/dependency/file_dependency_relation.rs b/crates/glua_code_analysis/src/db_index/dependency/file_dependency_relation.rs index a5a998fca..310748c9a 100644 --- a/crates/glua_code_analysis/src/db_index/dependency/file_dependency_relation.rs +++ b/crates/glua_code_analysis/src/db_index/dependency/file_dependency_relation.rs @@ -1,5 +1,6 @@ use crate::FileId; -use std::collections::{HashMap, HashSet, VecDeque}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; +use std::collections::VecDeque; #[derive(Debug)] pub struct FileDependencyRelation<'a> { @@ -124,13 +125,13 @@ impl<'a> FileDependencyRelation<'a> { /// Get all direct and indirect dependencies for the file list pub fn collect_file_dependents(&self, file_ids: Vec) -> Vec { - let mut reverse_map: HashMap> = HashMap::new(); + let mut reverse_map: HashMap> = HashMap::default(); for (&fid, deps) in self.dependencies.iter() { for &dep in deps { reverse_map.entry(dep).or_default().push(fid); } } - let mut result = HashSet::new(); + let mut result = HashSet::default(); let mut queue = VecDeque::new(); for file_id in file_ids { queue.push_back(file_id); @@ -154,15 +155,15 @@ mod tests { #[test] fn test_best_analysis_order() { - let mut map = HashMap::new(); + let mut map = HashMap::default(); // 文件1依赖文件2 map.insert(FileId::new(1), { - let mut s = HashSet::new(); + let mut s = HashSet::default(); s.insert(FileId::new(2)); s }); // 文件2没有依赖 - map.insert(FileId::new(2), HashSet::new()); + map.insert(FileId::new(2), HashSet::default()); let rel = FileDependencyRelation::new(&map); let result = rel.get_best_analysis_order(&[FileId::new(1), FileId::new(2)], &HashSet::default()); @@ -172,22 +173,22 @@ mod tests { #[test] fn test_best_analysis_order2() { - let mut map = HashMap::new(); + let mut map = HashMap::default(); // 文件1依赖文件2和文件3 map.insert(1.into(), { - let mut s = HashSet::new(); + let mut s = HashSet::default(); s.insert(2.into()); s.insert(3.into()); s }); // 文件2依赖文件3 map.insert(2.into(), { - let mut s = HashSet::new(); + let mut s = HashSet::default(); s.insert(3.into()); s }); // 文件3没有依赖 - map.insert(3.into(), HashSet::new()); + map.insert(3.into(), HashSet::default()); let rel = FileDependencyRelation::new(&map); let result = rel.get_best_analysis_order(&[1.into(), 2.into(), 3.into()], &HashSet::default()); @@ -197,23 +198,23 @@ mod tests { #[test] fn test_no_deps_files_first() { - let mut map = HashMap::new(); + let mut map = HashMap::default(); // 文件1依赖文件2 map.insert(FileId::new(1), { - let mut s = HashSet::new(); + let mut s = HashSet::default(); s.insert(FileId::new(2)); s }); // 文件2依赖文件1(循环依赖) map.insert(FileId::new(2), { - let mut s = HashSet::new(); + let mut s = HashSet::default(); s.insert(FileId::new(1)); s }); // 文件3没有依赖 - map.insert(FileId::new(3), HashSet::new()); + map.insert(FileId::new(3), HashSet::default()); // 文件4没有依赖 - map.insert(FileId::new(4), HashSet::new()); + map.insert(FileId::new(4), HashSet::default()); let rel = FileDependencyRelation::new(&map); let result = rel.get_best_analysis_order( @@ -236,12 +237,12 @@ mod tests { #[test] fn the_analysis_order_places_every_dependency_before_its_dependents() { - let mut map = HashMap::new(); + let mut map = HashMap::default(); map.insert(1.into(), [2.into(), 3.into()].into_iter().collect()); map.insert(2.into(), [3.into()].into_iter().collect()); - map.insert(3.into(), HashSet::new()); + map.insert(3.into(), HashSet::default()); map.insert(4.into(), [1.into()].into_iter().collect()); - map.insert(5.into(), HashSet::new()); + map.insert(5.into(), HashSet::default()); let rel = FileDependencyRelation::new(&map); let files: Vec = (1..=5).map(FileId::new).collect(); let metas = HashSet::from_iter([FileId::new(5)]); @@ -266,12 +267,12 @@ mod tests { #[test] fn a_level_never_contains_a_file_depending_on_a_sibling() { - let mut map = HashMap::new(); + let mut map = HashMap::default(); map.insert(1.into(), [2.into(), 3.into()].into_iter().collect()); map.insert(2.into(), [3.into()].into_iter().collect()); - map.insert(3.into(), HashSet::new()); + map.insert(3.into(), HashSet::default()); map.insert(4.into(), [1.into()].into_iter().collect()); - map.insert(5.into(), HashSet::new()); + map.insert(5.into(), HashSet::default()); let rel = FileDependencyRelation::new(&map); let files: Vec = (1..=5).map(FileId::new).collect(); @@ -290,10 +291,10 @@ mod tests { #[test] fn cyclic_files_each_get_their_own_level() { - let mut map = HashMap::new(); + let mut map = HashMap::default(); map.insert(1.into(), [2.into()].into_iter().collect()); map.insert(2.into(), [1.into()].into_iter().collect()); - map.insert(3.into(), HashSet::new()); + map.insert(3.into(), HashSet::default()); let rel = FileDependencyRelation::new(&map); let files: Vec = (1..=3).map(FileId::new).collect(); @@ -306,13 +307,13 @@ mod tests { #[test] fn test_collect_file_dependents() { - let mut deps = HashMap::new(); + let mut deps = HashMap::default(); deps.insert( FileId::new(1), [FileId::new(2), FileId::new(3)].iter().cloned().collect(), ); deps.insert(FileId::new(2), [FileId::new(3)].iter().cloned().collect()); - deps.insert(FileId::new(3), HashSet::new()); + deps.insert(FileId::new(3), HashSet::default()); deps.insert(FileId::new(4), [FileId::new(3)].iter().cloned().collect()); let rel = FileDependencyRelation::new(&deps); @@ -323,17 +324,17 @@ mod tests { #[test] fn test_meta_files_first() { - let mut map = HashMap::new(); + let mut map = HashMap::default(); // 所有文件都没有依赖 - map.insert(FileId::new(1), HashSet::new()); - map.insert(FileId::new(2), HashSet::new()); - map.insert(FileId::new(3), HashSet::new()); - map.insert(FileId::new(4), HashSet::new()); + map.insert(FileId::new(1), HashSet::default()); + map.insert(FileId::new(2), HashSet::default()); + map.insert(FileId::new(3), HashSet::default()); + map.insert(FileId::new(4), HashSet::default()); let rel = FileDependencyRelation::new(&map); // 文件2和4是meta文件 - let mut metas = HashSet::new(); + let mut metas = HashSet::default(); metas.insert(FileId::new(2)); metas.insert(FileId::new(4)); @@ -356,21 +357,21 @@ mod tests { #[test] fn test_meta_with_dependencies() { - let mut map = HashMap::new(); + let mut map = HashMap::default(); // File 1 depends on file 2 (meta) map.insert(FileId::new(1), { - let mut s = HashSet::new(); + let mut s = HashSet::default(); s.insert(FileId::new(2)); s }); // File 2 (meta) has no dependencies - map.insert(FileId::new(2), HashSet::new()); + map.insert(FileId::new(2), HashSet::default()); // File 3 has no dependencies - map.insert(FileId::new(3), HashSet::new()); + map.insert(FileId::new(3), HashSet::default()); let rel = FileDependencyRelation::new(&map); - let mut metas = HashSet::new(); + let mut metas = HashSet::default(); metas.insert(FileId::new(2)); let result = diff --git a/crates/glua_code_analysis/src/db_index/dependency/mod.rs b/crates/glua_code_analysis/src/db_index/dependency/mod.rs index 40c0a872e..eec5f13b9 100644 --- a/crates/glua_code_analysis/src/db_index/dependency/mod.rs +++ b/crates/glua_code_analysis/src/db_index/dependency/mod.rs @@ -1,6 +1,6 @@ mod file_dependency_relation; -use std::collections::{HashMap, HashSet}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use file_dependency_relation::FileDependencyRelation; use rowan::TextRange; @@ -49,6 +49,10 @@ pub struct LuaDependencyIndex { resolved_sites_by_target: HashMap>, /// Normalized target path key -> unresolved callers. unresolved_dependents_by_path_key: HashMap>, + /// The sites a file's removal unresolved, so re-indexing it can put them + /// back. Keyed by the removed target; each entry is the site's source file + /// and the path it named. + unresolved_by_removal: HashMap)>>, #[cfg(test)] target_transition_site_visits: usize, } @@ -62,12 +66,13 @@ impl Default for LuaDependencyIndex { impl LuaDependencyIndex { pub fn new() -> Self { Self { - dependencies: HashMap::new(), - dependency_kinds: HashMap::new(), - dependency_callers_by_target: HashMap::new(), - dependency_sites: HashMap::new(), - resolved_sites_by_target: HashMap::new(), - unresolved_dependents_by_path_key: HashMap::new(), + dependencies: HashMap::default(), + dependency_kinds: HashMap::default(), + dependency_callers_by_target: HashMap::default(), + dependency_sites: HashMap::default(), + resolved_sites_by_target: HashMap::default(), + unresolved_dependents_by_path_key: HashMap::default(), + unresolved_by_removal: HashMap::default(), #[cfg(test)] target_transition_site_visits: 0, } @@ -161,7 +166,7 @@ impl LuaDependencyIndex { &self, path_keys: impl IntoIterator, ) -> Vec { - let mut dependents = HashSet::new(); + let mut dependents = HashSet::default(); for path_key in path_keys { if let Some(path_dependents) = self.unresolved_dependents_by_path_key.get(&path_key) { dependents.extend(path_dependents.iter().copied()); @@ -274,6 +279,57 @@ impl LuaDependencyIndex { } } + /// The exact inverse of [`transition_target_sites_to_unresolved`]: a file + /// that is indexed again re-resolves the sites its removal unresolved. + /// + /// Removing a file unresolves its callers' sites in place, and only the + /// caller's own re-analysis used to put the target back. A path that + /// re-indexes the target alone therefore left every includer pointing at + /// nothing - invisible in diagnostics, wrong in the index. Only the sites + /// the removal actually flipped are restored, so this cannot resolve a + /// site a build would have left unresolved on its own. + /// + /// [`transition_target_sites_to_unresolved`]: Self::transition_target_sites_to_unresolved + pub fn relink_unresolved_target(&mut self, target_file_id: FileId) { + let Some(unresolved_by_removal) = self.unresolved_by_removal.remove(&target_file_id) else { + return; + }; + + let mut restored: Vec<(FileId, usize, LuaDependencySite)> = Vec::new(); + for (source_file_id, path) in unresolved_by_removal { + let Some(sites) = self.dependency_sites.get_mut(&source_file_id) else { + continue; + }; + for (site_index, site) in sites.iter_mut().enumerate() { + if site.target_file_id.is_some() || site.path != path { + continue; + } + let unresolved = site.clone(); + site.target_file_id = Some(target_file_id); + restored.push((source_file_id, site_index, unresolved)); + } + } + if restored.is_empty() { + return; + } + + let unresolved = restored + .iter() + .map(|(_, _, site)| site.clone()) + .collect::>(); + self.unindex_unresolved_sites(&unresolved); + for (source_file_id, site_index, site) in restored { + self.add_dependency_file(source_file_id, target_file_id, site.kind); + self.resolved_sites_by_target + .entry(target_file_id) + .or_default() + .push(DependencySiteLocation { + source_file_id, + site_index, + }); + } + } + fn transition_target_sites_to_unresolved(&mut self, target_file_id: FileId) { let Some(mut locations) = self.resolved_sites_by_target.remove(&target_file_id) else { return; @@ -283,6 +339,7 @@ impl LuaDependencyIndex { let mut site_visits = 0; let dependency_sites = &mut self.dependency_sites; let unresolved_dependents_by_path_key = &mut self.unresolved_dependents_by_path_key; + let mut flipped = Vec::new(); for location in locations { #[cfg(test)] { @@ -297,6 +354,10 @@ impl LuaDependencyIndex { }; site.target_file_id = None; Self::index_unresolved_site(unresolved_dependents_by_path_key, site); + flipped.push((location.source_file_id, site.path.clone())); + } + if !flipped.is_empty() { + self.unresolved_by_removal.insert(target_file_id, flipped); } #[cfg(test)] { @@ -340,6 +401,7 @@ impl LuaIndex for LuaDependencyIndex { self.dependency_sites.clear(); self.resolved_sites_by_target.clear(); self.unresolved_dependents_by_path_key.clear(); + self.unresolved_by_removal.clear(); #[cfg(test)] { self.target_transition_site_visits = 0; diff --git a/crates/glua_code_analysis/src/db_index/diagnostic/mod.rs b/crates/glua_code_analysis/src/db_index/diagnostic/mod.rs index 106ba0ce8..99cde5514 100644 --- a/crates/glua_code_analysis/src/db_index/diagnostic/mod.rs +++ b/crates/glua_code_analysis/src/db_index/diagnostic/mod.rs @@ -1,7 +1,7 @@ mod analyze_error; mod diagnostic_action; -use std::collections::{HashMap, HashSet}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; pub use analyze_error::AnalyzeError; pub use diagnostic_action::{DiagnosticAction, DiagnosticActionKind}; @@ -28,10 +28,10 @@ impl Default for DiagnosticIndex { impl DiagnosticIndex { pub fn new() -> Self { Self { - diagnostic_actions: HashMap::new(), - diagnostics: HashMap::new(), - file_diagnostic_disabled: HashMap::new(), - file_diagnostic_enabled: HashMap::new(), + diagnostic_actions: HashMap::default(), + diagnostics: HashMap::default(), + file_diagnostic_disabled: HashMap::default(), + file_diagnostic_enabled: HashMap::default(), } } 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..dc58f74c4 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 @@ -1,15 +1,52 @@ -use std::collections::{HashMap, HashSet}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use rowan::TextRange; use smol_str::SmolStr; use super::traits::LuaIndex; -use crate::{DbIndex, FileId, InFiled, LuaMemberId, LuaMemberKey, LuaMemberOwner, LuaTypeDeclId}; +use crate::{ + DbIndex, FileId, GlobalId, InFiled, LuaMemberId, LuaMemberKey, LuaMemberOwner, LuaTypeDeclId, +}; #[derive(Debug, Clone, Hash, PartialEq, Eq)] pub enum DynamicFieldOwner { Type(LuaTypeDeclId), Table(InFiled), + /// A global path whose table several files bootstrap. Keyed by the path + /// rather than by one of its literals, so `X.k[v] = w` written against any + /// of them lands in the bucket every reader of `X.k` consults. + GlobalPath(GlobalId), +} + +/// The one owner a dynamic field written on `owner` belongs to. +/// +/// Mirrors [`crate::LuaMemberIndex::canonical_owner`]: a table literal that +/// initialises a global path is not an owner of its own, so a field registered +/// on it belongs to the path (or, once the path's declaration carries a +/// `---@class`, to that class). Literals that initialise nothing keep their +/// range, exactly as before. +pub fn canonical_dynamic_field_owner(db: &DbIndex, owner: DynamicFieldOwner) -> DynamicFieldOwner { + let DynamicFieldOwner::Table(range) = &owner else { + return owner; + }; + match db + .get_member_index() + .canonical_owner(LuaMemberOwner::Element(range.clone())) + { + LuaMemberOwner::GlobalPath(path) => DynamicFieldOwner::GlobalPath(path), + LuaMemberOwner::Type(type_id) => DynamicFieldOwner::Type(type_id), + _ => owner, + } +} + +/// The canonical dynamic-field owner a member owner names. +pub fn dynamic_field_owner_of(db: &DbIndex, owner: &LuaMemberOwner) -> Option { + match db.get_member_index().canonical_owner(owner.clone()) { + LuaMemberOwner::Type(type_id) => Some(DynamicFieldOwner::Type(type_id)), + LuaMemberOwner::GlobalPath(path) => Some(DynamicFieldOwner::GlobalPath(path)), + LuaMemberOwner::Element(range) => Some(DynamicFieldOwner::Table(range)), + LuaMemberOwner::LocalUnresolve => None, + } } /// True when a wildcard (computed-key) assignment is the *only* thing known @@ -30,7 +67,9 @@ pub fn is_pure_wildcard_registry(db: &DbIndex, owner: &DynamicFieldOwner) -> boo let member_owner = match owner { DynamicFieldOwner::Type(id) => LuaMemberOwner::Type(id.clone()), DynamicFieldOwner::Table(range) => LuaMemberOwner::Element(range.clone()), + DynamicFieldOwner::GlobalPath(path) => LuaMemberOwner::GlobalPath(path.clone()), }; + let member_owner = db.get_member_index().canonical_owner(member_owner); db.get_member_index() .get_members(&member_owner) .is_none_or(|members| { @@ -82,18 +121,48 @@ 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() } + /// Returns whether the definition site was new to the index. pub fn add_field( &mut self, owner: DynamicFieldOwner, field_name: SmolStr, file_id: FileId, range: TextRange, - ) { + ) -> bool { let definition = InFiled::new(file_id, range); let direct_definitions = self .direct_field_definitions @@ -104,7 +173,7 @@ impl DynamicFieldIndex { if !direct_definitions.contains(&definition) { direct_definitions.push(definition); } - self.add_field_inner(owner, field_name, file_id, range); + self.add_field_inner(owner, field_name, file_id, range) } pub fn add_propagated_field( @@ -123,7 +192,7 @@ impl DynamicFieldIndex { field_name: SmolStr, file_id: FileId, range: TextRange, - ) { + ) -> bool { self.owner_fields .entry(owner.clone()) .or_default() @@ -138,7 +207,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| { @@ -155,14 +224,16 @@ impl DynamicFieldIndex { .or_default() .push((owner, field_name, range)); } + is_new_definition } + /// Returns whether the definition site was new to the index. pub fn add_wildcard_definition( &mut self, owner: DynamicFieldOwner, file_id: FileId, range: TextRange, - ) { + ) -> bool { let definitions = self.wildcard_definitions.entry(owner.clone()).or_default(); let definition = InFiled::new(file_id, range); let is_new_definition = !definitions.contains(&definition); @@ -173,6 +244,7 @@ impl DynamicFieldIndex { .or_default() .push((owner, range)); } + is_new_definition } pub fn add_unattributed_field(&mut self, field_name: SmolStr, file_id: FileId) { @@ -189,6 +261,207 @@ impl DynamicFieldIndex { } } + /// Takes back an unattributed record once a settled retry has attributed + /// the write to a real owner. + /// Returns whether a record was actually removed. + pub fn remove_unattributed_field(&mut self, field_name: &str, file_id: FileId) -> bool { + let mut removed = false; + if let Some(files) = self.unattributed_fields.get_mut(field_name) { + removed = files.remove(&file_id); + if removed && files.is_empty() { + self.unattributed_fields.remove(field_name); + } + } + if let Some(names) = self.unattributed_file_contributions.get_mut(&file_id) { + names.retain(|name| name != field_name); + if names.is_empty() { + self.unattributed_file_contributions.remove(&file_id); + } + } + removed + } + + /// Re-keys the table-literal owners in the edited file and drops the + /// groups whose literal the edit destroyed. + /// + /// Returns the files that contributed a field to a destroyed group: 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 + /// nothing else rebuilds it. + pub fn remap_file_table_owners(&mut self, remap: &crate::FileRemap) -> HashSet { + let owners: HashSet = 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()) + .filter(|owner| { + matches!(owner, DynamicFieldOwner::Table(range) if range.file_id == remap.file_id) + }) + .cloned() + .collect(); + + let mut moved = rustc_hash::FxHashMap::default(); + let mut lost: HashSet = HashSet::default(); + for owner in owners { + let DynamicFieldOwner::Table(range) = &owner else { + continue; + }; + match remap.table_range(range) { + crate::Remap::Moved(new) => { + if &new != range { + moved.insert(range.clone(), new); + } + } + crate::Remap::Unrelated => {} + crate::Remap::Lost => { + lost.insert(owner); + } + } + } + + self.remap_table_ranges(&moved); + + let mut dirty = HashSet::default(); + if !lost.is_empty() { + for owner in &lost { + self.owner_fields.remove(owner); + self.field_definitions.remove(owner); + self.direct_field_definitions.remove(owner); + self.finite_named_members.remove(owner); + self.wildcard_definitions.remove(owner); + } + for (file_id, entries) in &mut self.file_contributions { + let before = entries.len(); + entries.retain(|(owner, _, _)| !lost.contains(owner)); + if entries.len() != before { + dirty.insert(*file_id); + } + } + for (file_id, entries) in &mut self.wildcard_file_contributions { + let before = entries.len(); + entries.retain(|(owner, _)| !lost.contains(owner)); + if entries.len() != before { + dirty.insert(*file_id); + } + } + } + dirty.remove(&remap.file_id); + dirty + } + + /// 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(_) | DynamicFieldOwner::GlobalPath(_) => None, + } + } + + // 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, |$slot:ident, $group:ident| $merge:block) => {{ + 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, group) in detached { + let $slot = $field.entry(new).or_default(); + let $group = group; + $merge + } + }}; + } + + 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() { + 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(_) | DynamicFieldOwner::GlobalPath(_) => None, + } + } + 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()) + .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. @@ -285,16 +558,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> { @@ -310,6 +583,39 @@ impl DynamicFieldIndex { .is_some_and(|definitions| !definitions.is_empty()) } + /// Every owner's assigning-file records, for whole-index snapshots. + pub fn iter_all_owner_fields( + &self, + ) -> impl Iterator>)> { + self.owner_fields.iter() + } + + /// Every owner's field-definition sites, for whole-index snapshots. + pub fn iter_all_field_definitions( + &self, + ) -> impl Iterator< + Item = ( + &DynamicFieldOwner, + &HashMap>>, + ), + > { + self.field_definitions.iter() + } + + /// Every unattributed-field record, for whole-index snapshots. + pub fn iter_all_unattributed_fields( + &self, + ) -> impl Iterator)> { + self.unattributed_fields.iter() + } + + /// Every finite-named-member record, for whole-index snapshots. + pub fn iter_all_finite_named_members( + &self, + ) -> impl Iterator)> { + self.finite_named_members.iter() + } + pub fn get_all_wildcard_definitions(&self) -> Vec> { let mut definitions = self .wildcard_definitions @@ -398,7 +704,7 @@ fn normalize_file_contributions( .iter() .map(|(file_id, entries)| { let entry_counts = entries.iter().cloned().fold( - HashMap::<(DynamicFieldOwner, SmolStr, TextRange), usize>::new(), + HashMap::<(DynamicFieldOwner, SmolStr, TextRange), usize>::default(), |mut counts, entry| { *counts.entry(entry).or_default() += 1; counts @@ -417,7 +723,7 @@ fn normalize_wildcard_file_contributions( .iter() .map(|(file_id, entries)| { let entry_counts = entries.iter().cloned().fold( - HashMap::<(DynamicFieldOwner, TextRange), usize>::new(), + HashMap::<(DynamicFieldOwner, TextRange), usize>::default(), |mut counts, entry| { *counts.entry(entry).or_default() += 1; counts @@ -439,7 +745,7 @@ fn normalize_field_definitions( .iter() .map(|(field_name, definitions)| { let definition_counts = definitions.iter().cloned().fold( - HashMap::, usize>::new(), + HashMap::, usize>::default(), |mut counts, definition| { *counts.entry(definition).or_default() += 1; counts @@ -462,7 +768,7 @@ impl LuaIndex for DynamicFieldIndex { let removed: HashSet = file_ids.iter().copied().collect(); // The definition maps are swept once for the whole batch: retaining // per file made removal cost O(files × index) on high fan-in edits. - let mut files_with_removed_fields: HashSet = HashSet::new(); + let mut files_with_removed_fields: HashSet = HashSet::default(); self.field_definitions.retain(|_, fields| { fields.retain(|_, definitions| { definitions.retain(|definition| { @@ -489,7 +795,7 @@ impl LuaIndex for DynamicFieldIndex { !members.is_empty() }); - let mut files_with_removed_wildcards: HashSet = HashSet::new(); + let mut files_with_removed_wildcards: HashSet = HashSet::default(); self.wildcard_definitions.retain(|_, definitions| { definitions.retain(|definition| { if removed.contains(&definition.file_id) { @@ -560,6 +866,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); @@ -589,9 +958,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); @@ -645,7 +1014,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] @@ -668,7 +1037,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)), @@ -676,8 +1045,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/db_index/edit/export_map.rs b/crates/glua_code_analysis/src/db_index/edit/export_map.rs new file mode 100644 index 000000000..c35021a43 --- /dev/null +++ b/crates/glua_code_analysis/src/db_index/edit/export_map.rs @@ -0,0 +1,1168 @@ +use std::collections::HashSet; +use std::hash::{Hash, Hasher}; + +use rowan::TextRange; +use rustc_hash::{FxHashMap, FxHashSet, FxHasher}; +use smol_str::SmolStr; + +use super::position_map::{FileRemap, Remap}; +use crate::{ + DbIndex, FileId, GenericParam, InFiled, LuaDeclId, LuaInferenceNodeId, LuaInferredGuardOwner, + LuaMemberId, LuaMemberKey, LuaMemberOwner, LuaOperatorOwner, LuaSemanticDeclId, LuaSignatureId, + LuaType, LuaTypeDeclId, LuaTypeOwner, VariadicType, +}; + +/// Which half of a network message a flow describes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum NetSide { + Send, + Receive, +} + +/// The symbol a realm is recorded for. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum RealmSubject { + Decl(LuaDeclId), + Member(LuaMemberId), +} + +/// One cross-file-visible fact a file contributes. +/// +/// Every variant is either an identity another file can name or a whole-file +/// section. Nothing local-only gets a key: a `local` declaration, its inferred +/// type and its flow facts are invisible outside the file, so an edit that +/// touches only those produces no key and dirties nothing. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ExportKey { + Member(LuaMemberId), + TypeDecl(LuaTypeDeclId), + TypeCache(LuaTypeOwner), + Signature(LuaSignatureId), + ContributedParam(LuaSignatureId, u16), + InferredGuard(LuaInferredGuardOwner), + Property(LuaSemanticDeclId), + Operator(LuaOperatorOwner, SmolStr), + NetFlow(SmolStr, NetSide), + Metatable(InFiled), + Realm(RealmSubject), + FileRealmMetadata, + ModuleExport, + LoadEdges, + Namespace, +} + +pub type ExportMap = FxHashMap; + +/// What changed between two export maps of the same file. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct ChangedExports { + pub changed: Vec, + pub added: Vec, + pub removed: Vec, +} + +impl ChangedExports { + pub fn is_empty(&self) -> bool { + self.changed.is_empty() && self.added.is_empty() && self.removed.is_empty() + } + + /// Every key in the diff, whatever way it changed. + pub fn keys(&self) -> impl Iterator { + self.changed.iter().chain(&self.added).chain(&self.removed) + } +} + +/// `old` must already be expressed in `new`'s coordinates - build it with a +/// [`FileRemap`] carrying the edit's `PositionMap`, and `new` with +/// [`FileRemap::identity`]. +pub fn diff_exports(old: &ExportMap, new: &ExportMap) -> ChangedExports { + let mut diff = ChangedExports::default(); + for (key, hash) in old { + match new.get(key) { + Some(new_hash) if new_hash == hash => {} + Some(_) => diff.changed.push(key.clone()), + None => diff.removed.push(key.clone()), + } + } + for key in new.keys() { + if !old.contains_key(key) { + diff.added.push(key.clone()); + } + } + diff +} + +/// The cross-file-visible exports of one file, keyed so a diff can name +/// exactly which of them moved. +/// +/// Reads no syntax tree: every key and every hashed value comes from the +/// index, and position-carrying identities are rewritten through `remap` +/// rather than normalised against the current tree. A `Lost` identity yields +/// no key, so it reads as a removed export and dirties its dependents. +pub fn export_map(db: &DbIndex, file_id: FileId, remap: &FileRemap) -> ExportMap { + let mut map = ExportMap::default(); + let files = FxHashSet::from_iter([file_id]); + + members(db, file_id, remap, &mut map); + type_decls(db, file_id, remap, &mut map); + type_caches(db, file_id, remap, &mut map); + signatures(db, file_id, remap, &mut map); + contributed_params(db, &files, remap, &mut map); + inferred_guards(db, &files, remap, &mut map); + properties(db, file_id, remap, &mut map); + operators(db, file_id, remap, &mut map); + net_flows(db, file_id, &mut map); + metatables(db, file_id, remap, &mut map); + realms(db, file_id, remap, &mut map); + module_export(db, file_id, remap, &mut map); + load_edges(db, file_id, &mut map); + namespaces(db, file_id, &mut map); + + map +} + +fn hash_with(build: impl FnOnce(&mut FxHasher)) -> u64 { + let mut hasher = FxHasher::default(); + build(&mut hasher); + hasher.finish() +} + +// --- 1. Members declared in this file --- +fn members(db: &DbIndex, file_id: FileId, remap: &FileRemap, map: &mut ExportMap) { + let member_index = db.get_member_index(); + for member in member_index.get_file_members(file_id) { + let member_id = remapped(remap.member_id(member.get_id()), member.get_id()); + let owner = member_index.get_member_owner(&member.get_id()); + map.insert( + ExportKey::Member(member_id), + hash_with(|h| { + hash_member_key(remap, member.get_key(), h); + match owner { + Some(owner) => hash_member_owner(remap, owner, h), + None => "NoOwner".hash(h), + } + member.get_feature().hash(h); + member_index + .is_non_overwriting_assignment_member(member.get_id()) + .hash(h); + }), + ); + } +} + +// --- 3. Type decls defined in this file --- +fn type_decls(db: &DbIndex, file_id: FileId, remap: &FileRemap, map: &mut ExportMap) { + let type_index = db.get_type_index(); + let Some(decl_ids) = type_index.get_file_type_decl_ids(file_id) else { + return; + }; + for decl_id in decl_ids { + map.insert( + ExportKey::TypeDecl(decl_id.clone()), + hash_with(|h| { + if let Some(type_decl) = type_index.get_type_decl(decl_id) { + match type_decl.get_alias_ref() { + Some(alias_ref) => hash_type(remap, alias_ref, h), + None => "NoAlias".hash(h), + } + let (kind, flags) = type_decl.kind_and_flags(); + format!("{kind:?}").hash(h); + flags.hash(h); + let (extra_type, flat) = type_decl.extra_type(); + flat.hash(h); + match extra_type { + Some(extra_type) => hash_type(remap, extra_type, h), + None => "NoExtra".hash(h), + } + } + if let Some(supers) = type_index.get_super_type_entries(decl_id) { + for sup in supers.iter().filter(|s| s.file_id == file_id) { + hash_type(remap, &sup.value.typ, h); + } + } + if let Some(params) = type_index.get_generic_params(decl_id) { + for param in params { + hash_generic_param(remap, param, h); + } + } + }), + ); + } +} + +// --- 4. Exported type caches --- +fn type_caches(db: &DbIndex, file_id: FileId, remap: &FileRemap, map: &mut ExportMap) { + let type_index = db.get_type_index(); + let Some(owners) = type_index.file_type_owners(file_id) else { + return; + }; + for owner in owners { + match owner { + // A local's cached type is not observable from another file, and + // the cached type of a bare expression is local memoisation. + LuaTypeOwner::Decl(decl_id) => { + if db + .get_decl_index() + .get_decl(decl_id) + .is_none_or(|decl| decl.is_local()) + { + continue; + } + } + LuaTypeOwner::Member(_) => {} + LuaTypeOwner::SyntaxId(_) => continue, + } + let Some(cache) = type_index.get_type_cache(owner) else { + continue; + }; + let key_owner = remapped(remap.type_owner(owner), owner.clone()); + map.insert( + ExportKey::TypeCache(key_owner), + hash_with(|h| hash_type(remap, cache.as_type(), h)), + ); + } +} + +// --- 5. Signatures defined in this file --- +fn signatures(db: &DbIndex, file_id: FileId, remap: &FileRemap, map: &mut ExportMap) { + let signature_index = db.get_signature_index(); + let Some(signature_ids) = signature_index.get_file_signature_ids(file_id) else { + return; + }; + for signature_id in signature_ids { + let key_id = remapped(remap.signature_id(*signature_id), *signature_id); + map.insert( + ExportKey::Signature(key_id), + hash_with(|h| { + if let Some(sig) = signature_index.get(signature_id) { + sig.is_vararg.hash(h); + sig.is_colon_define.hash(h); + sig.async_state.hash(h); + sig.resolve_return.hash(h); + format!("{:?}", sig.nodiscard).hash(h); + sig.params.hash(h); + for param in &sig.generic_params { + param.name.hash(h); + match ¶m.constraint { + Some(constraint) => hash_type(remap, constraint, h), + None => "NoConstraint".hash(h), + } + } + let mut param_indices: Vec<&usize> = sig.param_docs.keys().collect(); + param_indices.sort_unstable(); + for idx in param_indices { + idx.hash(h); + let doc = &sig.param_docs[idx]; + doc.name.hash(h); + doc.nullable.hash(h); + doc.description.hash(h); + format!("{:?}", doc.default_value).hash(h); + format!("{:?}", doc.attributes).hash(h); + hash_type(remap, &doc.type_ref, h); + } + for ret in &sig.return_docs { + ret.name.hash(h); + ret.description.hash(h); + format!("{:?}", ret.default_value).hash(h); + format!("{:?}", ret.attributes).hash(h); + format!("{:?}", ret.return_kind).hash(h); + hash_type(remap, &ret.type_ref, h); + } + for overload in &sig.overloads { + hash_type(remap, &LuaType::DocFunction(overload.clone()), h); + } + format!("{:?}", sig.require_guard_param()).hash(h); + sig.nil_return_guard_params().hash(h); + format!("{:?}", sig.return_correlations()).hash(h); + format!("{:?}", sig.direct_param_return_alias()).hash(h); + format!("{:?}", sig.class_name_param_return_alias()).hash(h); + format!("{:?}", sig.falsy_param_nil_free_return_slots()).hash(h); + format!("{:?}", sig.falsy_param_return_aliases()).hash(h); + for out_param in &sig.out_params { + format!("{:?}", out_param.root).hash(h); + out_param.field_path.hash(h); + hash_type(remap, &out_param.type_ref, h); + } + } + match signature_index.inferred_positive_guard(signature_id) { + Some(guard) => { + guard.param_idx.hash(h); + hash_type(remap, &guard.narrowed_type, h); + } + None => "NoInferredGuard".hash(h), + } + }), + ); + } +} + +// --- 6. Parameter types this file's call sites are evidence for --- +fn contributed_params( + db: &DbIndex, + files: &FxHashSet, + remap: &FileRemap, + map: &mut ExportMap, +) { + for ((signature_id, param_idx), typ) in db + .get_call_site_param_index() + .inferred_params_for_contributor_files(files) + { + let key_id = remapped(remap.signature_id(signature_id), signature_id); + map.insert( + ExportKey::ContributedParam(key_id, param_idx as u16), + hash_with(|h| hash_type(remap, &typ, h)), + ); + } +} + +// --- 7. Inferred guard facts produced by this file --- +fn inferred_guards( + db: &DbIndex, + files: &FxHashSet, + remap: &FileRemap, + map: &mut ExportMap, +) { + for (owner, guard) in db + .get_signature_index() + .inferred_guard_facts_for_files(files) + { + let key_owner = remapped(remap.inferred_guard_owner(&owner), owner.clone()); + map.insert( + ExportKey::InferredGuard(key_owner), + hash_with(|h| { + guard.param_idx.hash(h); + hash_type(remap, &guard.narrowed_type, h); + }), + ); + } +} + +// --- 8. Annotations on this file's symbols that other files act on --- +fn properties(db: &DbIndex, file_id: FileId, remap: &FileRemap, map: &mut ExportMap) { + let default = crate::LuaCommonProperty::new(); + let default_acted_on = acted_on(&default); + for (owner, property) in db.get_property_index().properties_in_file(file_id) { + // A local declaration is not resolvable from another file, so an + // annotation on one changes nothing outside it. + if let LuaSemanticDeclId::LuaDecl(decl_id) = owner + && db + .get_decl_index() + .get_decl(decl_id) + .is_none_or(|decl| decl.is_local()) + { + continue; + } + // 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. + let acted_on = acted_on(property); + if acted_on == default_acted_on { + continue; + } + let key_owner = remapped(remap.semantic_decl_id(owner), owner.clone()); + map.insert( + ExportKey::Property(key_owner), + hash_with(|h| acted_on.hash(h)), + ); + } +} + +/// The property fields another file's diagnostics read. The free-text +/// description and source are excluded: a hover reads them from the index when +/// the request arrives, so no dependent holds a copy that can go stale. +fn acted_on(property: &crate::LuaCommonProperty) -> String { + format!( + "{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}", + property.visibility, + property.deprecated, + property.export, + property.decl_features, + property.version_conds, + property.attribute_uses, + property.default_value, + property.tag_content, + ) +} + +// --- 9. Metamethods this file declares --- +fn operators(db: &DbIndex, file_id: FileId, remap: &FileRemap, map: &mut ExportMap) { + for operator in db.get_operator_index().operators_in_file(file_id) { + let owner = operator.get_owner(); + let key_owner = remapped(remap.operator_owner(owner), owner.clone()); + map.insert( + ExportKey::Operator(key_owner, format!("{:?}", operator.get_op()).into()), + hash_with(|h| hash_type(remap, &operator.get_operator_func(db), h)), + ); + } +} + +// --- 10. Network flows this file declares --- +fn net_flows(db: &DbIndex, file_id: FileId, map: &mut ExportMap) { + fn hash_ops(ops: &[crate::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 Some(network) = db.get_gmod_network_index().get_file_data(file_id) else { + return; + }; + // One file can declare several flows for the same message; their hashes + // are folded in sorted order so the key stays the message name, which is + // what the peer-file lookup is keyed by. + let mut per_message: FxHashMap<(SmolStr, NetSide), Vec> = FxHashMap::default(); + for flow in &network.send_flows { + per_message + .entry((flow.message_name.as_str().into(), NetSide::Send)) + .or_default() + .push(hash_with(|h| { + format!("{:?}", flow.send_kind).hash(h); + flow.send_display_name.hash(h); + flow.send_target.hash(h); + flow.is_wrapped.hash(h); + hash_ops(&flow.writes, h); + })); + } + for flow in &network.receive_flows { + per_message + .entry((flow.message_name.as_str().into(), NetSide::Receive)) + .or_default() + .push(hash_with(|h| { + flow.reads_opaque.hash(h); + hash_ops(&flow.reads, h); + })); + } + for ((name, side), mut hashes) in per_message { + hashes.sort_unstable(); + map.insert( + ExportKey::NetFlow(name, side), + hash_with(|h| hashes.hash(h)), + ); + } +} + +// --- 11. Metatable bindings this file declares --- +fn metatables(db: &DbIndex, file_id: FileId, remap: &FileRemap, map: &mut ExportMap) { + for (table, binding) in db.get_metatable_index().iter_bindings() { + if binding.writer_file_id != file_id { + continue; + } + let key_range = remapped(remap.table_range(table), table.clone()); + let value_range = remapped( + remap.table_range(&binding.metatable), + binding.metatable.clone(), + ); + map.insert( + ExportKey::Metatable(key_range), + hash_with(|h| { + value_range.file_id.hash(h); + u32::from(value_range.value.start()).hash(h); + u32::from(value_range.value.end()).hash(h); + }), + ); + } +} + +// --- 12. Realm per exported symbol, and the file's realm metadata --- +fn realms(db: &DbIndex, file_id: FileId, remap: &FileRemap, map: &mut ExportMap) { + let gmod_infer = db.get_gmod_infer_index(); + 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 key_id = remapped(remap.decl_id(*decl_id), *decl_id); + let realm = gmod_infer.get_realm_at_offset(&file_id, decl_id.position); + map.insert( + ExportKey::Realm(RealmSubject::Decl(key_id)), + hash_with(|h| format!("{realm:?}").hash(h)), + ); + } + } + for member in db.get_member_index().get_file_members(file_id) { + let member_id = member.get_id(); + let key_id = remapped(remap.member_id(member_id), member_id); + let realm = gmod_infer.get_realm_at_offset(&file_id, member_id.get_position()); + map.insert( + ExportKey::Realm(RealmSubject::Member(key_id)), + hash_with(|h| format!("{realm:?}").hash(h)), + ); + } + + if let Some(metadata) = gmod_infer.get_realm_file_metadata(&file_id) { + map.insert( + ExportKey::FileRealmMetadata, + hash_with(|h| { + // Field by field: `branch_realm_ranges` carries the source + // ranges of the `if CLIENT`/`if SERVER` blocks, and which + // realms the file narrows to is the observable part, not where + // the blocks sit. + format!( + "{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}", + metadata.inferred_realm, + metadata.load_realm, + metadata.load_status, + metadata.load_state_mask, + metadata.filename_hint, + metadata.dependency_hints, + metadata.annotation_realm, + ) + .hash(h); + let branch_realms: Vec = metadata + .branch_realm_ranges + .iter() + .map(|range| format!("{:?}", range.realm)) + .collect(); + branch_realms.hash(h); + }), + ); + } +} + +// --- 13. What this file exports as a module --- +fn module_export(db: &DbIndex, file_id: FileId, remap: &FileRemap, map: &mut ExportMap) { + let Some(module) = db.get_module_index().get_module(file_id) else { + return; + }; + map.insert( + ExportKey::ModuleExport, + hash_with(|h| { + module.full_module_name.hash(h); + module.visible.hash(h); + module.is_meta.hash(h); + format!("{:?}", module.workspace_id).hash(h); + format!("{:?}", module.version_conds).hash(h); + match &module.export_type { + Some(export_type) => hash_type(remap, export_type, h), + None => "NoExport".hash(h), + } + match &module.semantic_id { + Some(id) => match remap.semantic_decl_id(id) { + Remap::Moved(mapped) => format!("{mapped:?}").hash(h), + Remap::Unrelated => format!("{id:?}").hash(h), + Remap::Lost => "LOST".hash(h), + }, + None => "NoSemanticId".hash(h), + } + }), + ); +} + +// --- 14. Load edges this file declares --- +fn load_edges(db: &DbIndex, file_id: FileId, map: &mut ExportMap) { + let dependency_index = db.get_file_dependencies_index(); + let mut sites: Vec = dependency_index + .get_dependency_sites(&file_id) + .unwrap_or_default() + .iter() + // The call's range is left out: it moves on any edit above it, and the + // edge is identified by its target and kind. + .map(|site| { + format!( + "{:?}|{:?}|{:?}|{}", + site.kind, site.target_file_id, site.path, site.original_expr + ) + }) + .collect(); + sites.sort_unstable(); + 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(); + if sites.is_empty() && required.is_empty() { + return; + } + map.insert( + ExportKey::LoadEdges, + hash_with(|h| { + sites.hash(h); + required.hash(h); + }), + ); +} + +// --- 15. Namespace / using --- +fn namespaces(db: &DbIndex, file_id: FileId, map: &mut ExportMap) { + let type_index = db.get_type_index(); + let namespace = type_index.get_file_namespace(&file_id); + let using = type_index.get_file_using_namespace(&file_id); + if namespace.is_none() && using.is_none() { + return; + } + map.insert( + ExportKey::Namespace, + hash_with(|h| { + namespace.hash(h); + using.hash(h); + }), + ); +} + +/// The identity to key an entry by: its new form when it moved, and its old +/// form otherwise. +/// +/// A lost identity deliberately keeps its old form. The new map is built in +/// the new text's coordinates and cannot contain it, so the key lands in +/// `removed` - and the old form is the one every dependent recorded, so it is +/// also the one the reverse indexes answer to. Dropping the key instead would +/// leave a deleted export with no key on either side and nothing to dirty its +/// readers with. +fn remapped(mapped: Remap, current: T) -> T { + match mapped { + Remap::Moved(value) => value, + Remap::Unrelated | Remap::Lost => current, + } +} + +/// Hashes an identity the edit may have moved. +/// +/// A `Lost` identity folds in a sentinel, which forces the enclosing key to +/// compare as changed - the value genuinely names something that is gone. +fn hash_remapped_identity( + tag: &str, + mapped: Remap, + current: &T, + hasher: &mut impl Hasher, +) { + tag.hash(hasher); + match mapped { + Remap::Moved(value) => format!("{value:?}").hash(hasher), + Remap::Unrelated => format!("{current:?}").hash(hasher), + Remap::Lost => "LOST".hash(hasher), + } +} + +fn hash_member_owner(remap: &FileRemap, 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(range) => { + hash_remapped_identity("Element", remap.table_range(range), range, hasher); + } + LuaMemberOwner::LocalUnresolve => { + "LocalUnresolve".hash(hasher); + } + } +} + +fn hash_member_key(remap: &FileRemap, 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(typ) => { + "ExprType".hash(hasher); + hash_type(remap, typ, hasher); + } + } +} + +fn hash_generic_param(remap: &FileRemap, param: &GenericParam, hasher: &mut impl Hasher) { + param.name.hash(hasher); + format!("{:?}", param.attributes).hash(hasher); + match ¶m.type_constraint { + Some(constraint) => hash_type(remap, constraint, hasher), + None => "NoConstraint".hash(hasher), + } +} + +/// Hashes everything about a type that another file can observe. +/// +/// The three position-carrying identities - a table literal's range, an +/// instance's range and a signature id - go through `remap`, so a value that +/// merely shifted hashes the same before and after the edit while a value +/// repointed at a different literal or function does not. +pub(crate) fn hash_type(remap: &FileRemap, 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(remap: &FileRemap, tag: &str, arms: &[LuaType], hasher: &mut impl Hasher) { + tag.hash(hasher); + let mut arm_hashes: Vec = arms + .iter() + .map(|arm| hash_with(|h| hash_type(remap, arm, h))) + .collect(); + arm_hashes.sort_unstable(); + arm_hashes.hash(hasher); + } + + match typ { + LuaType::StringConst(s) | LuaType::DocStringConst(s) => { + "StringConst".hash(hasher); + s.as_str().hash(hasher); + } + LuaType::IntegerConst(i) | LuaType::DocIntegerConst(i) => { + "IntegerConst".hash(hasher); + i.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); + } + LuaType::TableConst(range) => { + hash_remapped_identity("TableConst", remap.table_range(range), range, hasher); + } + LuaType::Instance(inst) => { + let range = inst.get_range(); + hash_remapped_identity("Instance", remap.table_range(range), range, hasher); + hash_type(remap, inst.get_base(), hasher); + } + LuaType::Signature(id) => { + hash_remapped_identity("Signature", remap.signature_id(*id), id, hasher); + } + 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(remap, "Union", &union.into_vec(), hasher), + LuaType::Intersection(inter) => { + hash_unordered(remap, "Intersection", inter.get_types(), hasher) + } + LuaType::MergedTable(merged) => { + hash_unordered(remap, "MergedTable", merged.get_types(), hasher) + } + LuaType::Tuple(tuple) => { + "Tuple".hash(hasher); + tuple.status.hash(hasher); + for sub in tuple.get_types() { + hash_type(remap, sub, hasher); + } + } + LuaType::Array(arr) => { + "Array".hash(hasher); + format!("{:?}", arr.get_len()).hash(hasher); + hash_type(remap, arr.get_base(), hasher); + } + LuaType::Object(obj) => { + "Object".hash(hasher); + for (key, value) in obj.get_fields() { + hash_member_key(remap, key, hasher); + hash_type(remap, value, hasher); + } + for (key, value) in obj.get_index_access() { + hash_type(remap, key, hasher); + hash_type(remap, value, hasher); + } + } + LuaType::TableGeneric(params) => { + "TableGeneric".hash(hasher); + for param in params.iter() { + hash_type(remap, param, hasher); + } + } + LuaType::TableOf(inner) => { + "TableOf".hash(hasher); + hash_type(remap, inner, hasher); + } + LuaType::TypeGuard(inner) => { + "TypeGuard".hash(hasher); + hash_type(remap, inner, hasher); + } + LuaType::Generic(generic) => { + "Generic".hash(hasher); + generic.get_base_type_id().get_name().hash(hasher); + for param in generic.get_params() { + hash_type(remap, 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_type(remap, param_type, hasher), + None => "NoParamType".hash(hasher), + } + } + hash_type(remap, func.get_ret(), hasher); + } + LuaType::ModuleRef(file_id) => { + "ModuleRef".hash(hasher); + file_id.hash(hasher); + } + LuaType::Variadic(variadic) => { + "Variadic".hash(hasher); + match variadic.as_ref() { + VariadicType::Base(base) => { + "Base".hash(hasher); + hash_type(remap, base, hasher); + } + VariadicType::Multi(types) => { + "Multi".hash(hasher); + for sub in types { + hash_type(remap, sub, hasher); + } + } + } + } + LuaType::Call(call) => { + "Call".hash(hasher); + format!("{:?}", call.get_call_kind()).hash(hasher); + for operand in call.get_operands() { + hash_type(remap, operand, hasher); + } + } + LuaType::MultiLineUnion(union) => { + "MultiLineUnion".hash(hasher); + for (arm, description) in union.get_unions() { + description.hash(hasher); + hash_type(remap, arm, hasher); + } + } + LuaType::Conditional(cond) => { + "Conditional".hash(hasher); + cond.has_new.hash(hasher); + for param in cond.get_infer_params() { + hash_generic_param(remap, param, hasher); + } + hash_type(remap, cond.get_condition(), hasher); + hash_type(remap, cond.get_true_type(), hasher); + hash_type(remap, cond.get_false_type(), hasher); + } + LuaType::Mapped(mapped) => { + "Mapped".hash(hasher); + format!("{:?}", mapped.param.0).hash(hasher); + hash_generic_param(remap, &mapped.param.1, hasher); + mapped.is_readonly.hash(hasher); + mapped.is_optional.hash(hasher); + hash_type(remap, &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_type(remap, 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); + tpl.get_tpl_id().hash(hasher); + if let Some(constraint) = tpl.get_constraint() { + hash_type(remap, constraint, hasher); + } + } + LuaType::TplRef(tpl) => hash_tpl_ref("TplRef", remap, tpl, hasher), + LuaType::ConstTplRef(tpl) => hash_tpl_ref("ConstTplRef", remap, tpl, hasher), + // An interned name, with no nested type and no source position: the + // name is the whole identity. + LuaType::Namespace(name) => { + "Namespace".hash(hasher); + name.hash(hasher); + } + LuaType::Language(name) => { + "Language".hash(hasher); + name.hash(hasher); + } + LuaType::ConditionalInfer(name) => { + "ConditionalInfer".hash(hasher); + name.hash(hasher); + } + // Payload-free variants: the discriminant is the whole identity. Listed + // rather than matched with a wildcard so that a new `LuaType` variant + // fails to compile here instead of being hashed as if it carried + // nothing — a variant holding a range or a nested type would then hash + // equal across an edit that moved it, and its dependents would never be + // re-analysed. + LuaType::Unknown + | LuaType::Any + | LuaType::Nil + | LuaType::Table + | LuaType::Userdata + | LuaType::Function + | LuaType::Thread + | LuaType::Boolean + | LuaType::String + | LuaType::Integer + | LuaType::Number + | LuaType::Io + | LuaType::SelfInfer + | LuaType::Global + | LuaType::Never => std::mem::discriminant(typ).hash(hasher), + } +} + +fn hash_tpl_ref(tag: &str, remap: &FileRemap, tpl: &crate::GenericTpl, hasher: &mut impl Hasher) { + tag.hash(hasher); + tpl.get_tpl_id().hash(hasher); + tpl.get_name().hash(hasher); + match tpl.get_constraint() { + Some(constraint) => hash_type(remap, constraint, hasher), + None => "NoConstraint".hash(hasher), + } +} + +/// The identities a symbol's cached type names, as reverse-index keys. +/// +/// A file that read this symbol cached the *value*, so its own cache names +/// those identities: the table literal, the signature, the class. Looking them +/// up finds exactly the readers, without dirtying everyone who merely holds a +/// member on the same owner. +fn push_cached_type_refs(db: &DbIndex, owner: &LuaTypeOwner, out: &mut Vec) { + let Some(cache) = db.get_type_index().get_type_cache(owner) else { + return; + }; + crate::TypeVisitTrait::visit_type(cache.as_type(), &mut |inner| match inner { + LuaType::TableConst(range) => out.push(crate::TypeCacheRef::Table(range.clone())), + LuaType::Instance(instance) => { + out.push(crate::TypeCacheRef::Instance(instance.get_range().clone())) + } + LuaType::Signature(signature_id) => out.push(crate::TypeCacheRef::Signature(*signature_id)), + LuaType::Ref(type_id) | LuaType::Def(type_id) => { + out.push(crate::TypeCacheRef::Decl(type_id.clone())) + } + _ => {} + }); +} + +/// The files that read any of the facts in `diff`. +/// +/// Queries are batched per reverse index rather than issued per key: the +/// lookups are set unions, and one call per key would turn a diff of a few +/// hundred keys into a few hundred index walks. +pub fn dependents_of(db: &DbIndex, file_id: FileId, diff: &ChangedExports) -> HashSet { + // A `---@module` reader caches `LuaType::ModuleRef(file_id)`, filed in the + // reverse index under the providing file rather than under any symbol it + // exports. No export key names that identity, so seeding the file itself is + // the only thing that reaches those readers. It cannot be narrowed to the + // keys that moved: a module can hand back the same table identity and still + // change a member's type, leaving every key the reader named untouched. + let mut type_refs: Vec = vec![crate::TypeCacheRef::Module(file_id)]; + let mut nodes: Vec = Vec::new(); + let mut signatures: Vec = Vec::new(); + // Each owner, and whether any key naming it appeared or vanished. One + // entry per owner: a file with a thousand writes to one slot names that + // owner a thousand times, and the member enumeration below is per owner, + // so a vector here makes the whole pass quadratic in the slot's writers. + let mut owners: FxHashMap = FxHashMap::default(); + let mut net_messages: Vec = Vec::new(); + let mut file_level = false; + + // A key that appeared or vanished also reaches files that read through its + // owner and found nothing: a failed lookup records no dependency edge, and + // it may now hit. A key whose hash merely changed has no such readers - the + // ones it has are named by the edges below. + let structural: FxHashSet<&ExportKey> = diff.added.iter().chain(&diff.removed).collect(); + let mut missed_readers: Vec = Vec::new(); + + for key in diff.keys() { + match key { + ExportKey::Member(member_id) => { + let owner = LuaTypeOwner::Member(*member_id); + push_cached_type_refs(db, &owner, &mut type_refs); + nodes.push(LuaInferenceNodeId::TypeOwner(owner)); + let member_index = db.get_member_index(); + if let Some(owner) = member_index.get_member_owner(member_id) { + record_owner_dependency(&mut owners, owner, structural.contains(key)); + // A reader whose lookup of this key found nothing cached + // no type, so no reverse index below names it. + if structural.contains(key) + && let Some(member) = member_index.get_member(member_id) + { + missed_readers + .extend(member_index.missed_member_readers(owner, member.get_key())); + } + } + } + ExportKey::TypeDecl(type_decl_id) => { + type_refs.push(crate::TypeCacheRef::Decl(type_decl_id.clone())); + } + ExportKey::TypeCache(owner) => { + push_cached_type_refs(db, owner, &mut type_refs); + nodes.push(LuaInferenceNodeId::TypeOwner(owner.clone())); + if let LuaTypeOwner::Member(member_id) = owner + && let Some(owner) = db.get_member_index().get_member_owner(member_id) + { + record_owner_dependency(&mut owners, owner, structural.contains(key)); + } + } + ExportKey::Signature(signature_id) => { + type_refs.push(crate::TypeCacheRef::Signature(*signature_id)); + signatures.push(*signature_id); + } + ExportKey::ContributedParam(signature_id, param_idx) => { + nodes.push(LuaInferenceNodeId::SignatureParam { + signature_id: *signature_id, + param_idx: *param_idx, + }); + } + ExportKey::InferredGuard(owner) => { + signatures.push(owner.signature_id()); + } + ExportKey::Property(semantic_id) => match semantic_id { + LuaSemanticDeclId::TypeDecl(type_decl_id) => { + type_refs.push(crate::TypeCacheRef::Decl(type_decl_id.clone())); + } + LuaSemanticDeclId::Member(member_id) => { + nodes.push(LuaInferenceNodeId::TypeOwner(LuaTypeOwner::Member( + *member_id, + ))); + } + LuaSemanticDeclId::LuaDecl(decl_id) => { + nodes.push(LuaInferenceNodeId::TypeOwner(LuaTypeOwner::Decl(*decl_id))); + } + LuaSemanticDeclId::Signature(signature_id) => { + type_refs.push(crate::TypeCacheRef::Signature(*signature_id)); + signatures.push(*signature_id); + } + }, + ExportKey::Operator(owner, _) => match owner { + LuaOperatorOwner::Table(range) => { + type_refs.push(crate::TypeCacheRef::Table(range.clone())); + type_refs.push(crate::TypeCacheRef::Instance(range.clone())); + } + LuaOperatorOwner::Type(type_decl_id) => { + type_refs.push(crate::TypeCacheRef::Decl(type_decl_id.clone())); + } + }, + ExportKey::NetFlow(name, _) => net_messages.push(name.clone()), + ExportKey::Metatable(range) => { + type_refs.push(crate::TypeCacheRef::Table(range.clone())); + type_refs.push(crate::TypeCacheRef::Instance(range.clone())); + record_owner_dependency(&mut owners, &LuaMemberOwner::Element(range.clone()), true); + } + ExportKey::Realm(RealmSubject::Decl(decl_id)) => { + nodes.push(LuaInferenceNodeId::TypeOwner(LuaTypeOwner::Decl(*decl_id))); + } + ExportKey::Realm(RealmSubject::Member(member_id)) => { + nodes.push(LuaInferenceNodeId::TypeOwner(LuaTypeOwner::Member( + *member_id, + ))); + } + ExportKey::FileRealmMetadata + | ExportKey::ModuleExport + | ExportKey::LoadEdges + | ExportKey::Namespace => file_level = true, + } + } + + let member_index = db.get_member_index(); + let mut dependents: HashSet = missed_readers.into_iter().collect(); + for (owner, structural) in &owners { + // A reader that resolved a member through this owner cached the owner + // itself - the class it named, the literal it indexed - so the reverse + // index finds it whether the member appeared or merely changed. + match owner { + LuaMemberOwner::Element(range) => { + type_refs.push(crate::TypeCacheRef::Table(range.clone())); + type_refs.push(crate::TypeCacheRef::Instance(range.clone())); + } + LuaMemberOwner::Type(type_decl_id) => { + type_refs.push(crate::TypeCacheRef::Decl(type_decl_id.clone())); + } + // No reverse index names a global path. Every file that already + // resolved some member of the path is the population that can see + // a sibling appear or vanish; a file whose only read of the path + // failed is not reachable, which is the same gap as today. + LuaMemberOwner::GlobalPath(_) => {} + LuaMemberOwner::LocalUnresolve => continue, + } + // Walking every member of the owner is only for a key that appeared or + // vanished: a sibling read that found nothing recorded no edge at all. + // A member whose value merely changed is reached through the ref above, + // and enumerating a hub owner's members here dirties hundreds of files + // that read none of it. + if !structural { + continue; + } + for member in member_index.get_members(owner).unwrap_or_default() { + let member_id = member.get_id(); + dependents.insert(member_id.file_id); + nodes.push(LuaInferenceNodeId::TypeOwner(LuaTypeOwner::Member( + member_id, + ))); + } + } + + let type_index = db.get_type_index(); + dependents.extend(type_index.files_with_type_caches_referencing(&type_refs)); + dependents.extend(type_index.files_depending_on_inference_nodes(&nodes)); + + let call_site_index = db.get_call_site_param_index(); + dependents.extend(call_site_index.collect_signature_contributor_files(&signatures)); + dependents.extend( + db.get_signature_index() + .settled_read_dependents_for_signatures(&signatures), + ); + let sources: Vec = signatures + .iter() + .map(|id| crate::CallSiteSourceId::Signature(*id)) + .collect(); + dependents.extend(call_site_index.collect_source_node_dependents(&sources)); + // A parameter's declared type is read by the callee itself. + dependents.extend(signatures.iter().map(|id| id.get_file_id())); + + let network_index = db.get_gmod_network_index(); + for name in &net_messages { + dependents.extend( + network_index + .get_send_flows_for_message(name) + .into_iter() + .map(|(file_id, _)| file_id), + ); + dependents.extend( + network_index + .get_receive_flows_for_message(name) + .into_iter() + .map(|(file_id, _)| file_id), + ); + } + + if file_level { + dependents.extend( + db.get_file_dependencies_index() + .get_file_dependencies() + .collect_file_dependents(vec![file_id]), + ); + } + + dependents.remove(&file_id); + dependents +} + +/// Files `owner` as the home of a changed export, OR-ing whether any key +/// naming it appeared or vanished: one enumeration in `dependents_of` covers +/// every key of the owner either way. +fn record_owner_dependency( + owners: &mut FxHashMap, + owner: &LuaMemberOwner, + structural: bool, +) { + owners + .entry(owner.clone()) + .and_modify(|known| *known |= structural) + .or_insert(structural); +} diff --git a/crates/glua_code_analysis/src/db_index/edit/mod.rs b/crates/glua_code_analysis/src/db_index/edit/mod.rs new file mode 100644 index 000000000..26a5f158e --- /dev/null +++ b/crates/glua_code_analysis/src/db_index/edit/mod.rs @@ -0,0 +1,59 @@ +//! Incremental-edit machinery: where an edit moved things, what a file +//! exports, and who reads it. + +mod export_map; +mod position_map; +#[cfg(test)] +mod test; + +pub use export_map::{ + ChangedExports, ExportKey, ExportMap, NetSide, RealmSubject, dependents_of, diff_exports, + export_map, +}; +pub use position_map::{FileFacts, FileRemap, PositionMap, Remap}; + +use std::collections::HashSet; + +use super::{DbIndex, FileId}; + +/// Rewrites every reference into `remap.file_id` that another file holds. +/// +/// Runs after the edited file has been re-indexed: its own entries are rebuilt +/// by that, and what survives on an old position is what some *other* file +/// recorded about it. Returns the files whose reference could not be +/// rewritten - they have to be re-analysed instead. +pub fn remap_into_file(db: &mut DbIndex, remap: &FileRemap) -> HashSet { + let mut dirty = HashSet::default(); + if remap.map.is_identity() { + return dirty; + } + + // The member index drives the assignment-contribution store, so its + // signature half is threaded through it. + let (forgotten, member_dirty) = db.get_member_index_mut().remap_file_element_owners(remap); + dirty.extend(member_dirty); + if !forgotten.is_empty() { + db.get_type_index_mut() + .remove_member_type_caches(&forgotten); + } + + dirty.extend( + db.get_call_site_param_index_mut() + .remap_file_signatures(remap), + ); + dirty.extend(db.get_signature_index_mut().remap_settled_reads(remap)); + dirty.extend(db.get_signature_index_mut().remap_payload_types(remap)); + dirty.extend(db.get_type_index_mut().remap_file_identities(remap)); + dirty.extend( + db.get_dynamic_field_index_mut() + .remap_file_table_owners(remap), + ); + dirty.extend(db.get_metatable_index_mut().remap_file_ranges(remap)); + dirty.extend( + db.get_flow_index_mut() + .remap_special_call_effect_types(remap), + ); + + dirty.remove(&remap.file_id); + dirty +} diff --git a/crates/glua_code_analysis/src/db_index/edit/position_map.rs b/crates/glua_code_analysis/src/db_index/edit/position_map.rs new file mode 100644 index 000000000..c4a38ab90 --- /dev/null +++ b/crates/glua_code_analysis/src/db_index/edit/position_map.rs @@ -0,0 +1,559 @@ +use glua_parser::{LuaAstNode, LuaKind, LuaSyntaxId, LuaTableExpr}; +use rowan::{TextRange, TextSize}; +use rustc_hash::FxHashSet; + +use crate::{ + DbIndex, FileId, InFiled, LuaDeclId, LuaDefinitionId, LuaInferenceNodeId, + LuaInferredGuardOwner, LuaMemberId, LuaOperatorOwner, LuaSemanticDeclId, LuaSignatureId, + LuaTypeOwner, +}; + +/// The single hunk an edit changed, as byte offsets. +/// +/// Offsets before `hunk_start` are unmoved; offsets at or after `old_hunk_end` +/// shift by `new_hunk_end - old_hunk_end`; offsets inside the hunk have no +/// image. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PositionMap { + hunk_start: u32, + old_hunk_end: u32, + new_hunk_end: u32, +} + +impl PositionMap { + /// The map for "nothing moved": every offset is its own image. + pub fn identity() -> Self { + Self { + hunk_start: u32::MAX, + old_hunk_end: u32::MAX, + new_hunk_end: u32::MAX, + } + } + + /// The single hunk between two versions of a file's text. + pub fn new(old_text: &str, new_text: &str) -> Self { + let old = old_text.as_bytes(); + let new = new_text.as_bytes(); + let max_prefix = old.len().min(new.len()); + let mut prefix = 0; + while prefix < max_prefix && old[prefix] == new[prefix] { + prefix += 1; + } + // A prefix that ends mid-character would put a hunk boundary inside a + // code point, and every offset the index holds is a character + // boundary. + while prefix > 0 + && !(old_text.is_char_boundary(prefix) && new_text.is_char_boundary(prefix)) + { + prefix -= 1; + } + + let max_suffix = (old.len() - prefix).min(new.len() - prefix); + let mut suffix = 0; + while suffix < max_suffix && old[old.len() - 1 - suffix] == new[new.len() - 1 - suffix] { + suffix += 1; + } + while suffix > 0 + && !(old_text.is_char_boundary(old.len() - suffix) + && new_text.is_char_boundary(new.len() - suffix)) + { + suffix -= 1; + } + + let map = Self { + hunk_start: prefix as u32, + old_hunk_end: (old.len() - suffix) as u32, + new_hunk_end: (new.len() - suffix) as u32, + }; + debug_assert!(map.hunk_start <= map.old_hunk_end); + debug_assert!(map.hunk_start <= map.new_hunk_end); + map + } + + /// The map for a file whose whole text was replaced, or that was deleted + /// (`new_len == 0`). Nothing inside it survives. + pub fn whole_file(old_len: usize, new_len: usize) -> Self { + Self { + hunk_start: 0, + old_hunk_end: old_len as u32, + new_hunk_end: new_len as u32, + } + } + + /// Whether every offset maps to itself, so the remap pass can be skipped. + pub fn is_identity(&self) -> bool { + self.hunk_start == self.old_hunk_end && self.old_hunk_end == self.new_hunk_end + } + + /// The hunk as `(start, old_end, new_end)`. + pub fn hunk(&self) -> (u32, u32, u32) { + (self.hunk_start, self.old_hunk_end, self.new_hunk_end) + } + + pub fn map(&self, pos: TextSize) -> Option { + let p = u32::from(pos); + if p < self.hunk_start { + Some(pos) + } else if p >= self.old_hunk_end { + Some(TextSize::new(p - self.old_hunk_end + self.new_hunk_end)) + } else { + None + } + } + + /// The image of a range, or `None` when an endpoint fell inside the hunk. + /// + /// A range whose endpoints straddle the hunk keeps its start and takes the + /// shift on its end: the edit happened *within* it. Reporting it lost + /// instead would invalidate every dependent of a hub table literal or a + /// long function on any edit inside it, while what actually changed is + /// visible as a per-member or per-signature export change. + pub fn map_range(&self, range: TextRange) -> Option { + let start = u32::from(range.start()); + let end = u32::from(range.end()); + if end <= self.hunk_start { + return Some(range); + } + if start >= self.old_hunk_end { + return Some(TextRange::new( + self.map(range.start())?, + self.map(range.end())?, + )); + } + if start <= self.hunk_start && end >= self.old_hunk_end { + return Some(TextRange::new( + range.start(), + TextSize::new(end - self.old_hunk_end + self.new_hunk_end), + )); + } + None + } +} + +/// The three answers a remap can give about one stored identity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Remap { + /// The identity does not belong to the edited file. Leave the entry alone. + Unrelated, + /// The identity's new form. May equal the old one when the edit was below + /// it. + Moved(T), + /// The identity did not survive the edit. The entry cannot be rewritten + /// and its owning file is dirty. + Lost, +} + +impl Remap { + pub fn is_lost(&self) -> bool { + matches!(self, Self::Lost) + } + + pub fn moved(self) -> Option { + match self { + Self::Moved(value) => Some(value), + _ => None, + } + } +} + +/// What the edited file holds *after* it was re-indexed. +/// +/// A [`PositionMap`] says where an offset would land; it cannot say whether +/// anything is still there. An edit that replaces one function with another of +/// the same length maps every identity onto a position that exists but names a +/// different thing, so every mapped identity is confirmed against these before +/// it is written back. +#[derive(Debug, Default)] +pub struct FileFacts { + signature_positions: FxHashSet, + decl_positions: FxHashSet, + member_ids: FxHashSet, + syntax_ids: FxHashSet, + /// The ranges something can still own members on: a table literal in the + /// new tree, or a range the re-indexed file registers an `Element` owner + /// at. An owner is keyed by whatever expression the write was made on - a + /// literal, but also a `vgui.Create(..)` call - so "is there still an + /// owner here" is not a question the tree alone answers. Answers "did the + /// mapped range land on something that still holds members". + owner_ranges: FxHashSet, +} + +impl FileFacts { + pub fn collect(db: &DbIndex, file_id: FileId) -> Self { + let signature_positions = db + .get_signature_index() + .get_file_signature_ids(file_id) + .map(|ids| ids.iter().map(|id| id.get_position()).collect()) + .unwrap_or_default(); + let decl_positions = db + .get_decl_index() + .get_decl_tree(&file_id) + .map(|tree| tree.get_decls().keys().map(|id| id.position).collect()) + .unwrap_or_default(); + let member_ids = db + .get_member_index() + .get_file_members(file_id) + .into_iter() + .map(|member| *member.get_id().get_syntax_id()) + .collect(); + + let mut owner_ranges = db + .get_member_index() + .element_owner_ranges_in_file(file_id) + .into_iter() + .map(|range| range.value) + .collect::>(); + let mut syntax_ids = FxHashSet::default(); + if let Some(tree) = db.get_vfs().get_syntax_tree(&file_id) { + for element in tree.get_red_root().descendants_with_tokens() { + match element { + rowan::NodeOrToken::Node(node) => { + syntax_ids.insert(LuaSyntaxId::from_node(&node)); + } + rowan::NodeOrToken::Token(token) => { + syntax_ids.insert(LuaSyntaxId::from_token(&token)); + } + } + } + owner_ranges.extend( + tree.get_chunk_node() + .descendants::() + .map(|table| table.get_range()), + ); + } + + Self { + signature_positions, + decl_positions, + member_ids, + syntax_ids, + owner_ranges, + } + } +} + +/// A [`PositionMap`] bound to the file it describes, so a caller cannot apply +/// it to an identity from another file. +#[derive(Debug)] +pub struct FileRemap { + pub file_id: FileId, + pub map: PositionMap, + /// Absent while the new index does not exist yet - the pre-edit export map + /// is built before the file is re-indexed, and an identity that maps onto + /// nothing simply reads as a removed export there. + facts: Option, +} + +impl FileRemap { + /// A remap that moves nothing, for reading a file that was not edited. + pub fn identity(file_id: FileId) -> Self { + Self { + file_id, + map: PositionMap::identity(), + facts: None, + } + } + + /// A remap whose results are taken on trust, for use before the file has + /// been re-indexed. + pub fn unvalidated(file_id: FileId, map: PositionMap) -> Self { + Self { + file_id, + map, + facts: None, + } + } + + /// A remap that confirms every mapped identity against the file's current + /// index and tree. Use after `update_index(file_id)`. + pub fn validated(db: &DbIndex, file_id: FileId, map: PositionMap) -> Self { + Self { + file_id, + map, + facts: (!map.is_identity()).then(|| FileFacts::collect(db, file_id)), + } + } + + fn owns(&self, file_id: FileId) -> bool { + file_id == self.file_id + } + + fn pos(&self, file_id: FileId, position: TextSize) -> Remap { + if !self.owns(file_id) { + return Remap::Unrelated; + } + match self.map.map(position) { + Some(mapped) => Remap::Moved(mapped), + None => Remap::Lost, + } + } + + fn range(&self, file_id: FileId, range: TextRange) -> Remap { + if !self.owns(file_id) { + return Remap::Unrelated; + } + match self.map.map_range(range) { + Some(mapped) => Remap::Moved(mapped), + None => Remap::Lost, + } + } + + /// Whether the position sits entirely in the edit's unchanged prefix. + /// + /// `map` returns an offset unchanged exactly when it lies before the + /// hunk, and those bytes are identical in both texts, so the code the + /// identity names is still there whatever the index happens to enumerate. + /// Checking existence for it would reject live identities the facts + /// cannot see - a `DocTagClass` node, say, which the syntax tree's + /// `descendants_with_tokens` walk does not reach. Judged by geometry, not + /// by whether the map happens to return the original value: a replacement + /// that swaps one function for another at the same offset also maps the + /// offset to itself, and that identity is *not* in the unchanged prefix. + fn pos_is_before_the_hunk(&self, position: TextSize) -> bool { + u32::from(position) < self.map.hunk_start + } + + /// The range counterpart: `map_range` keeps a range unchanged exactly + /// when its end lies at or before the hunk start. + fn range_is_before_the_hunk(&self, range: TextRange) -> bool { + u32::from(range.end()) <= self.map.hunk_start + } + + /// Resolves a mapped identity against the re-indexed file. + /// + /// An identity entirely in the unchanged prefix was untouched by the edit, + /// so its stored form is already correct whatever the index enumerates. + /// Otherwise the identity moves onto its mapped position: if that position + /// is occupied in the new index the reference is rewritten to it (a + /// collision - what used to sit at the old offset now sits at its image, + /// even when the old offset is occupied again by something else), and if + /// it is not, the identity is lost. + /// + /// The one exception is an identity the file's own index still holds at + /// its original position while the image is empty. Such an entry has not + /// been rebuilt by the re-index yet, so it and the reference to it must + /// stay in step rather than one being rewritten and the other not. + fn resolve( + &self, + mapped: Remap, + before_the_hunk: bool, + original_occupied: bool, + exists: impl Fn(&FileFacts, &T) -> bool, + ) -> Remap { + let Some(facts) = &self.facts else { + return mapped; + }; + // `Unrelated` here means the identity is not in this file at all, so + // the file's own facts say nothing about it. + if matches!(mapped, Remap::Unrelated) || before_the_hunk { + return mapped; + } + match &mapped { + Remap::Moved(value) if exists(facts, value) => mapped, + Remap::Moved(_) | Remap::Lost if original_occupied => Remap::Unrelated, + Remap::Moved(_) | Remap::Lost => Remap::Lost, + Remap::Unrelated => Remap::Unrelated, + } + } + + pub fn signature_id(&self, id: LuaSignatureId) -> Remap { + let mapped = match self.pos(id.get_file_id(), id.get_position()) { + Remap::Moved(position) => Remap::Moved(LuaSignatureId::new(id.get_file_id(), position)), + Remap::Unrelated => Remap::Unrelated, + Remap::Lost => Remap::Lost, + }; + self.resolve( + mapped, + self.pos_is_before_the_hunk(id.get_position()), + self.facts + .as_ref() + .is_some_and(|facts| facts.signature_positions.contains(&id.get_position())), + |facts, id| facts.signature_positions.contains(&id.get_position()), + ) + } + + pub fn decl_id(&self, id: LuaDeclId) -> Remap { + let mapped = match self.pos(id.file_id, id.position) { + Remap::Moved(position) => Remap::Moved(LuaDeclId::new(id.file_id, position)), + Remap::Unrelated => Remap::Unrelated, + Remap::Lost => Remap::Lost, + }; + self.resolve( + mapped, + self.pos_is_before_the_hunk(id.position), + self.facts + .as_ref() + .is_some_and(|facts| facts.decl_positions.contains(&id.position)), + |facts, id| facts.decl_positions.contains(&id.position), + ) + } + + pub fn syntax_id(&self, file_id: FileId, id: LuaSyntaxId) -> Remap { + let mapped = match self.range(file_id, id.get_range()) { + Remap::Moved(range) => Remap::Moved(with_range(id, range)), + Remap::Unrelated => Remap::Unrelated, + Remap::Lost => Remap::Lost, + }; + self.resolve( + mapped, + self.range_is_before_the_hunk(id.get_range()), + self.facts + .as_ref() + .is_some_and(|facts| facts.syntax_ids.contains(&id)), + |facts, id| facts.syntax_ids.contains(id), + ) + } + + pub fn member_id(&self, id: LuaMemberId) -> Remap { + let mapped = match self.range(id.file_id, id.get_syntax_id().get_range()) { + Remap::Moved(range) => Remap::Moved(LuaMemberId::new( + with_range(*id.get_syntax_id(), range), + id.file_id, + )), + Remap::Unrelated => Remap::Unrelated, + Remap::Lost => Remap::Lost, + }; + self.resolve( + mapped, + self.range_is_before_the_hunk(id.get_syntax_id().get_range()), + self.facts + .as_ref() + .is_some_and(|facts| facts.member_ids.contains(id.get_syntax_id())), + |facts, id| facts.member_ids.contains(id.get_syntax_id()), + ) + } + + /// A table literal's range, the identity behind `TableConst`, `Instance` + /// and `LuaMemberOwner::Element`. + pub fn table_range(&self, range: &InFiled) -> Remap> { + let mapped = match self.range(range.file_id, range.value) { + Remap::Moved(value) => Remap::Moved(InFiled::new(range.file_id, value)), + Remap::Unrelated => Remap::Unrelated, + Remap::Lost => Remap::Lost, + }; + self.resolve( + mapped, + self.range_is_before_the_hunk(range.value), + self.facts + .as_ref() + .is_some_and(|facts| facts.owner_ranges.contains(&range.value)), + |facts, range| facts.owner_ranges.contains(&range.value), + ) + } + + pub fn type_owner(&self, owner: &LuaTypeOwner) -> Remap { + match owner { + LuaTypeOwner::Decl(id) => map_into(self.decl_id(*id), LuaTypeOwner::Decl), + LuaTypeOwner::Member(id) => map_into(self.member_id(*id), LuaTypeOwner::Member), + LuaTypeOwner::SyntaxId(id) => map_into(self.syntax_id(id.file_id, id.value), |value| { + LuaTypeOwner::SyntaxId(InFiled::new(id.file_id, value)) + }), + } + } + + pub fn definition_id(&self, id: &LuaDefinitionId) -> Remap { + match id { + LuaDefinitionId::Declaration(decl_id) => { + map_into(self.decl_id(*decl_id), LuaDefinitionId::Declaration) + } + LuaDefinitionId::Assignment { + file_id, + assignment, + target_idx, + } => map_into(self.syntax_id(*file_id, *assignment), |assignment| { + LuaDefinitionId::Assignment { + file_id: *file_id, + assignment, + target_idx: *target_idx, + } + }), + } + } + + pub fn inference_node(&self, node: &LuaInferenceNodeId) -> Remap { + match node { + LuaInferenceNodeId::TypeOwner(owner) => { + map_into(self.type_owner(owner), LuaInferenceNodeId::TypeOwner) + } + LuaInferenceNodeId::Definition(definition) => map_into( + self.definition_id(definition), + LuaInferenceNodeId::Definition, + ), + LuaInferenceNodeId::SignatureParam { + signature_id, + param_idx, + } => map_into(self.signature_id(*signature_id), |signature_id| { + LuaInferenceNodeId::SignatureParam { + signature_id, + param_idx: *param_idx, + } + }), + } + } + + pub fn semantic_decl_id(&self, id: &LuaSemanticDeclId) -> Remap { + match id { + // A type decl is named, not positioned. + LuaSemanticDeclId::TypeDecl(_) => Remap::Unrelated, + LuaSemanticDeclId::Member(member_id) => { + map_into(self.member_id(*member_id), LuaSemanticDeclId::Member) + } + LuaSemanticDeclId::LuaDecl(decl_id) => { + map_into(self.decl_id(*decl_id), LuaSemanticDeclId::LuaDecl) + } + LuaSemanticDeclId::Signature(signature_id) => map_into( + self.signature_id(*signature_id), + LuaSemanticDeclId::Signature, + ), + } + } + + pub fn operator_owner(&self, owner: &LuaOperatorOwner) -> Remap { + match owner { + LuaOperatorOwner::Table(range) => { + map_into(self.table_range(range), LuaOperatorOwner::Table) + } + LuaOperatorOwner::Type(_) => Remap::Unrelated, + } + } + + pub fn inferred_guard_owner( + &self, + owner: &LuaInferredGuardOwner, + ) -> Remap { + match owner { + LuaInferredGuardOwner::GlobalPath { + signature_id, + state_mask, + path, + } => map_into(self.signature_id(*signature_id), |signature_id| { + LuaInferredGuardOwner::GlobalPath { + signature_id, + state_mask: *state_mask, + path: path.clone(), + } + }), + } + } +} + +fn map_into(remap: Remap, build: impl FnOnce(T) -> U) -> Remap { + match remap { + Remap::Moved(value) => Remap::Moved(build(value)), + Remap::Unrelated => Remap::Unrelated, + Remap::Lost => Remap::Lost, + } +} + +/// The same node kind at a different range. +/// +/// `LuaSyntaxId`'s kind field is private and its two accessors each read half +/// of it, so the round trip goes through whichever half `is_token` selects. +fn with_range(id: LuaSyntaxId, range: TextRange) -> LuaSyntaxId { + let kind: LuaKind = if id.is_token() { + id.get_token_kind().into() + } else { + id.get_kind().into() + }; + LuaSyntaxId::new(kind, range) +} diff --git a/crates/glua_code_analysis/src/db_index/edit/test.rs b/crates/glua_code_analysis/src/db_index/edit/test.rs new file mode 100644 index 000000000..2ad71ada7 --- /dev/null +++ b/crates/glua_code_analysis/src/db_index/edit/test.rs @@ -0,0 +1,912 @@ +use googletest::prelude::*; +use lsp_types::Uri; +use rowan::{TextRange, TextSize}; + +use super::{ExportKey, FileRemap, PositionMap, export_map, remap_into_file}; +use crate::{ + DbIndex, Emmyrc, FileId, InFiled, LuaSignatureId, LuaType, LuaTypeOwner, SignatureReturnStatus, + TypeVisitTrait, VirtualWorkspace, diff_exports, +}; + +fn range(start: u32, end: u32) -> TextRange { + TextRange::new(TextSize::new(start), TextSize::new(end)) +} + +// --- PositionMap arithmetic --- + +#[gtest] +fn an_insertion_shifts_everything_after_it_and_loses_nothing() { + let map = PositionMap::new("abcdef", "abcXXdef"); + expect_that!(map.hunk(), eq((3, 3, 5))); + expect_that!(map.map(TextSize::new(2)), some(eq(TextSize::new(2)))); + // The insertion point itself belongs to what follows it. + expect_that!(map.map(TextSize::new(3)), some(eq(TextSize::new(5)))); + expect_that!(map.map(TextSize::new(6)), some(eq(TextSize::new(8)))); + // Nothing is strictly inside an empty hunk. + expect_that!(map.map_range(range(1, 5)), some(eq(range(1, 7)))); + expect_that!(map.map_range(range(0, 3)), some(eq(range(0, 3)))); + expect_that!(map.map_range(range(3, 6)), some(eq(range(5, 8)))); +} + +#[gtest] +fn a_deletion_loses_only_what_was_inside_it() { + let map = PositionMap::new("abcXXdef", "abcdef"); + expect_that!(map.hunk(), eq((3, 5, 3))); + expect_that!(map.map(TextSize::new(2)), some(eq(TextSize::new(2)))); + expect_that!(map.map(TextSize::new(4)), none()); + expect_that!(map.map(TextSize::new(5)), some(eq(TextSize::new(3)))); + expect_that!(map.map_range(range(4, 7)), none()); + expect_that!(map.map_range(range(0, 2)), some(eq(range(0, 2)))); + expect_that!(map.map_range(range(6, 8)), some(eq(range(4, 6)))); +} + +#[gtest] +fn a_replacement_maps_the_text_on_either_side() { + let map = PositionMap::new("abcXYZdef", "abcQdef"); + expect_that!(map.hunk(), eq((3, 6, 4))); + expect_that!(map.map(TextSize::new(1)), some(eq(TextSize::new(1)))); + expect_that!(map.map(TextSize::new(4)), none()); + expect_that!(map.map(TextSize::new(6)), some(eq(TextSize::new(4)))); +} + +#[gtest] +fn identical_texts_are_the_identity_map() { + let map = PositionMap::new("abcdef", "abcdef"); + expect_that!(map.is_identity(), is_true()); + expect_that!(PositionMap::identity().is_identity(), is_true()); + expect_that!(map.map(TextSize::new(3)), some(eq(TextSize::new(3)))); + expect_that!(map.map_range(range(0, 6)), some(eq(range(0, 6)))); + expect_that!(PositionMap::new("abc", "abd").is_identity(), is_false()); +} + +#[gtest] +fn a_whole_file_replacement_keeps_only_a_range_that_brackets_it() { + let map = PositionMap::whole_file(10, 4); + expect_that!(map.map(TextSize::new(0)), none()); + expect_that!(map.map(TextSize::new(10)), some(eq(TextSize::new(4)))); + expect_that!(map.map_range(range(2, 8)), none()); + // The bracketing range still maps: the edit happened inside it. + expect_that!(map.map_range(range(0, 10)), some(eq(range(0, 4)))); + + let deleted = PositionMap::whole_file(10, 0); + expect_that!(deleted.map_range(range(1, 3)), none()); +} + +/// A hub table literal or a long function brackets almost every edit made +/// inside it. Calling those ranges lost would invalidate every dependent on +/// every keystroke; what actually changed shows up as a per-member or +/// per-signature key instead. +#[gtest] +fn a_range_that_spans_the_hunk_moves_with_its_end() { + let map = PositionMap::new("aaXXbb", "aaYYYbb"); + expect_that!(map.hunk(), eq((2, 4, 5))); + expect_that!(map.map_range(range(0, 6)), some(eq(range(0, 7)))); + expect_that!(map.map_range(range(2, 4)), some(eq(range(2, 5)))); + // One endpoint strictly inside is still lost. + expect_that!(map.map_range(range(3, 6)), none()); + expect_that!(map.map_range(range(0, 3)), none()); +} + +#[gtest] +fn a_hunk_boundary_never_splits_a_code_point() { + // The two strings share the first byte of `é` (0xC3) but differ in the + // second, so the raw byte prefix would end mid-character. + let map = PositionMap::new("aé", "aè"); + let (start, old_end, new_end) = map.hunk(); + expect_that!(start, eq(1)); + expect_that!(old_end, eq(3)); + expect_that!(new_end, eq(3)); + expect_that!("aé".is_char_boundary(start as usize), is_true()); + expect_that!("aè".is_char_boundary(start as usize), is_true()); + + // A trailing multibyte character shared by both sides must stay whole in + // the suffix too. + let map = PositionMap::new("aXé", "aYYé"); + let (start, old_end, new_end) = map.hunk(); + expect_that!(start, eq(1)); + expect_that!("aXé".is_char_boundary(old_end as usize), is_true()); + expect_that!("aYYé".is_char_boundary(new_end as usize), is_true()); + expect_that!( + map.map(TextSize::new(old_end)), + some(eq(TextSize::new(new_end))) + ); +} + +// --- Export map --- + +fn workspace() -> VirtualWorkspace { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.update_emmyrc(Emmyrc::default()); + ws +} + +fn write(ws: &mut VirtualWorkspace, uri: &Uri, text: &str) -> FileId { + ws.analysis + .update_file_by_uri(uri, Some(text.to_string())) + .map(|(id, _)| id) + .expect("file id") +} + +/// The diff an edit to one file produces, with the old map expressed in the +/// new text's coordinates - which is what makes the two comparable. +fn diff_after_edit(first: &str, second: &str) -> super::ChangedExports { + let mut ws = workspace(); + let uri = ws.virtual_url_generator.new_uri("lua/subject.lua"); + let file_id = write(&mut ws, &uri, first); + let map = PositionMap::new(first, second); + let old = export_map( + ws.analysis.compilation.get_db(), + file_id, + &FileRemap::unvalidated(file_id, map), + ); + write(&mut ws, &uri, second); + let new = export_map( + ws.analysis.compilation.get_db(), + file_id, + &FileRemap::identity(file_id), + ); + diff_exports(&old, &new) +} + +/// The case the whole redesign exists for: a statement inside a function body +/// is not observable from any other file, so it must produce no diff at all. +#[gtest] +fn a_body_only_edit_changes_no_export() { + let diff = diff_after_edit( + r#" + config = config or {} + config.Mode = "server" + function config.Match(a, b) + if a == b then return true end + return false + end + "#, + r#" + config = config or {} + config.Mode = "server" + function config.Match(a, b) + local scratch = 1 + _ = scratch + if a == b then return true end + return false + end + "#, + ); + expect_that!( + diff.is_empty(), + is_true(), + "unexpected export diff: {diff:?}" + ); +} + +#[gtest] +fn adding_a_parameter_changes_that_signature_only() { + let diff = diff_after_edit( + r#" + config = config or {} + function config.Match(a) end + "#, + r#" + config = config or {} + function config.Match(a, b) end + "#, + ); + expect_that!(diff.is_empty(), is_false()); + expect_that!( + diff.keys() + .any(|key| matches!(key, ExportKey::Signature(_))), + is_true() + ); +} + +#[gtest] +fn adding_an_exported_function_adds_a_member_key() { + let diff = diff_after_edit( + r#" + config = config or {} + function config.First() end + "#, + r#" + config = config or {} + function config.Second() end + function config.First() end + "#, + ); + expect_that!( + diff.added + .iter() + .any(|key| matches!(key, ExportKey::Member(_))), + is_true() + ); +} + +#[gtest] +fn removing_an_exported_function_removes_its_keys() { + let diff = diff_after_edit( + r#" + config = config or {} + function config.Second() end + function config.First() end + "#, + r#" + config = config or {} + function config.First() end + "#, + ); + expect_that!( + diff.removed + .iter() + .any(|key| matches!(key, ExportKey::Member(_) | ExportKey::Signature(_))), + is_true() + ); +} + +#[gtest] +fn adding_a_field_to_a_table_literal_adds_a_member_key() { + let diff = diff_after_edit( + r#" + registry = { first = 1 } + "#, + r#" + registry = { first = 1, second = 2 } + "#, + ); + expect_that!( + diff.added + .iter() + .any(|key| matches!(key, ExportKey::Member(_))), + is_true() + ); +} + +// --- Remap --- + +/// Writes `text` to `uri` without re-indexing, then re-indexes that file alone +/// and applies the remap. Returns the files the remap could not rewrite. +fn edit_and_remap( + ws: &mut VirtualWorkspace, + uri: &Uri, + file_id: FileId, + old_text: &str, + new_text: &str, +) -> Vec { + ws.analysis.update_file_text_only(uri, new_text.to_string()); + ws.analysis.compilation.remove_index(vec![file_id]); + ws.analysis.compilation.update_index(vec![file_id]); + let map = PositionMap::new(old_text, new_text); + let remap = FileRemap::validated(ws.analysis.compilation.get_db(), file_id, map); + let mut dirty: Vec = remap_into_file(ws.analysis.compilation.get_db_mut(), &remap) + .into_iter() + .collect(); + dirty.sort_by_key(|file_id| file_id.id); + dirty +} + +/// Every position-carrying identity a file's cached types name, as text. +fn cached_identities(db: &DbIndex, file_id: FileId) -> Vec { + let mut found = Vec::new(); + let Some(owners) = db.get_type_index().file_type_owners(file_id) else { + return found; + }; + for owner in owners { + let Some(cache) = db.get_type_index().get_type_cache(owner) else { + continue; + }; + TypeVisitTrait::visit_type(cache.as_type(), &mut |inner| match inner { + LuaType::Signature(id) => found.push(format!("{id:?}")), + LuaType::TableConst(range) => found.push(format!("{range:?}")), + _ => {} + }); + } + found.sort(); + found +} + +/// A dependent's cache names the callee's signature by byte position, so an +/// edit above the function renames the identity without changing anything the +/// dependent reads. The remap is what keeps the two in step without +/// re-analysing the dependent. +#[gtest] +fn a_cross_file_signature_cache_follows_an_offset_shift() { + let mut ws = workspace(); + let provider_uri = ws.virtual_url_generator.new_uri("lua/provider.lua"); + let first = "provider = provider or {}\nfunction provider.Describe() end\n"; + let second = "provider = provider or {}\nlocal padding = 1\n_ = padding\nfunction provider.Describe() end\n"; + let provider_id = write(&mut ws, &provider_uri, first); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/consumer.lua"); + let consumer_id = write( + &mut ws, + &consumer_uri, + "consumer_handle = provider.Describe\n", + ); + + let before = cached_identities(ws.analysis.compilation.get_db(), consumer_id); + expect_that!( + before.iter().any(|id| id.contains("LuaSignatureId")), + is_true() + ); + + edit_and_remap(&mut ws, &provider_uri, provider_id, first, second); + + let after = cached_identities(ws.analysis.compilation.get_db(), consumer_id); + expect_that!(after, not(eq(&before))); + // The rewritten id must be one the re-indexed provider actually holds. + let live: Vec = ws + .analysis + .compilation + .get_db() + .get_signature_index() + .get_file_signature_ids(provider_id) + .map(|ids| ids.iter().map(|id| format!("{id:?}")).collect()) + .unwrap_or_default(); + for id in after.iter().filter(|id| id.contains("LuaSignatureId")) { + expect_that!(live.iter().any(|live_id| live_id == id), is_true()); + } +} + +/// A collision at the old offset must not read as "unrelated": the consumer +/// names what *used* to sit there, and the re-indexed file holds a different +/// signature at exactly that offset, so the reference has to follow the +/// function to its image instead of being written off as someone else's +/// entry. +#[gtest] +fn a_cross_file_signature_cache_survives_a_collision_at_its_old_offset() { + let mut ws = workspace(); + let provider_uri = ws.virtual_url_generator.new_uri("lua/provider.lua"); + let first = "A = 1\nfunction fa() end\nfb = function() end\n"; + // Replaces `fa` with a longer line whose own closure starts exactly where + // `fb`'s closure used to, so after the edit a different signature + // occupies the offset the consumer names. + let second = "A = 1\nlocal xxxxxxxxxxxxxx = function() end\nfb = function() end\n"; + let provider_id = write(&mut ws, &provider_uri, first); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/consumer.lua"); + let consumer_id = write(&mut ws, &consumer_uri, "handle = fb\n"); + + let before = cached_signature_ids(ws.analysis.compilation.get_db(), consumer_id); + let old_id = *before + .iter() + .find(|id| id.get_file_id() == provider_id) + .expect("the consumer caches the provider's signature"); + let old_pos = u32::from(old_id.get_position()); + // Fixture guard: the old offset names `fb`'s closure, and the edited text + // puts a different closure at exactly that offset. + expect_that!( + first[old_pos as usize..].starts_with("function()"), + is_true() + ); + expect_that!( + second[old_pos as usize..].starts_with("function()"), + is_true() + ); + + edit_and_remap(&mut ws, &provider_uri, provider_id, first, second); + + let mapped_pos = PositionMap::new(first, second) + .map(old_id.get_position()) + .expect("the edit maps the signature position"); + expect_that!(u32::from(mapped_pos), not(eq(old_pos))); + + let after = cached_signature_ids(ws.analysis.compilation.get_db(), consumer_id); + expect_that!( + after.contains(&LuaSignatureId::new(provider_id, mapped_pos)), + is_true() + ); + // The collision was real: the provider holds signatures at both offsets. + let live = live_signature_positions(ws.analysis.compilation.get_db(), provider_id); + expect_that!(live.contains(&old_pos), is_true()); + expect_that!(live.contains(&u32::from(mapped_pos)), is_true()); +} + +/// A same-length edit still destroys what it replaced: an identity mapping +/// onto its own offset is not evidence it survived, so the dependent must be +/// re-analysed rather than left pointing at the replacement bytes. +#[gtest] +fn a_same_length_replacement_that_destroys_its_target_dirties_the_dependent() { + let mut ws = workspace(); + let provider_uri = ws.virtual_url_generator.new_uri("lua/provider.lua"); + let first = "A = 1\nfunction fa() end\nfb = function() end\n"; + // The comment line is exactly as long as the function it replaces. + let second = "A = 1\n-----------------\nfb = function() end\n"; + let provider_id = write(&mut ws, &provider_uri, first); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/consumer.lua"); + let consumer_id = write(&mut ws, &consumer_uri, "handle = fa\n"); + expect_that!( + cached_signature_ids(ws.analysis.compilation.get_db(), consumer_id) + .iter() + .any(|id| id.get_file_id() == provider_id), + is_true() + ); + + let dirty = edit_and_remap(&mut ws, &provider_uri, provider_id, first, &second); + + let db = ws.analysis.compilation.get_db(); + // The signature is gone ... + let old_pos = cached_signature_ids(db, consumer_id) + .iter() + .find(|id| id.get_file_id() == provider_id) + .map(|id| u32::from(id.get_position())) + .expect("the consumer still names the stale signature"); + expect_that!( + live_signature_positions(db, provider_id).contains(&old_pos), + is_false() + ); + // ... and the dependent is dirty rather than silently kept. + expect_that!(dirty, contains(eq(&consumer_id))); +} + +/// The table's range maps onto itself under a same-length edit, which must +/// not read as "before the hunk": what the range names is gone, and value +/// equality between a range and its image proves nothing about the bytes. +#[gtest] +fn a_same_length_edit_mapping_a_table_onto_itself_still_loses_it() { + let mut ws = workspace(); + let provider_uri = ws.virtual_url_generator.new_uri("lua/tables.lua"); + let first = "registry = { first = 1 }\n"; + // Padded to the same length, so the table literal's range is its own + // image while the literal itself is destroyed. + let second = "registry = nil \n"; + let provider_id = write(&mut ws, &provider_uri, first); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/reads_tables.lua"); + let consumer_id = write(&mut ws, &consumer_uri, "handle = registry\n"); + expect_that!( + cached_identities(ws.analysis.compilation.get_db(), consumer_id) + .iter() + .any(|id| id.contains("InFiled")), + is_true() + ); + + let dirty = edit_and_remap(&mut ws, &provider_uri, provider_id, first, second); + + let db = ws.analysis.compilation.get_db(); + expect_that!(live_table_ranges(db, provider_id), is_empty()); + expect_that!(dirty, contains(eq(&consumer_id))); +} + +#[gtest] +fn a_cross_file_table_cache_follows_an_offset_shift() { + let mut ws = workspace(); + let provider_uri = ws.virtual_url_generator.new_uri("lua/tables.lua"); + let first = "registry = { first = 1 }\n"; + let second = "local padding = 1\n_ = padding\nregistry = { first = 1 }\n"; + let provider_id = write(&mut ws, &provider_uri, first); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/reads_tables.lua"); + let consumer_id = write(&mut ws, &consumer_uri, "handle = registry\n"); + + let before = cached_identities(ws.analysis.compilation.get_db(), consumer_id); + expect_that!(before.iter().any(|id| id.contains("InFiled")), is_true()); + + edit_and_remap(&mut ws, &provider_uri, provider_id, first, second); + + let after = cached_identities(ws.analysis.compilation.get_db(), consumer_id); + expect_that!(after, not(eq(&before))); + let live = live_table_ranges(ws.analysis.compilation.get_db(), provider_id); + for id in after.iter().filter(|id| id.contains("InFiled")) { + expect_that!(live.iter().any(|live_id| id.contains(live_id)), is_true()); + } +} + +fn live_table_ranges(db: &DbIndex, file_id: FileId) -> Vec { + use glua_parser::{LuaAstNode, LuaTableExpr}; + let Some(tree) = db.get_vfs().get_syntax_tree(&file_id) else { + return Vec::new(); + }; + tree.get_chunk_node() + .descendants::() + .map(|table| format!("{:?}", table.get_range())) + .collect() +} + +/// The `LuaSignatureId`s a file's cached types name, as values. +fn cached_signature_ids(db: &DbIndex, file_id: FileId) -> Vec { + let mut found = Vec::new(); + let Some(owners) = db.get_type_index().file_type_owners(file_id) else { + return found; + }; + for owner in owners { + let Some(cache) = db.get_type_index().get_type_cache(owner) else { + continue; + }; + TypeVisitTrait::visit_type(cache.as_type(), &mut |inner| { + if let LuaType::Signature(id) = inner { + found.push(*id); + } + }); + } + found +} + +fn live_signature_positions(db: &DbIndex, file_id: FileId) -> Vec { + let mut found: Vec = db + .get_signature_index() + .get_file_signature_ids(file_id) + .map(|ids| ids.iter().map(|id| u32::from(id.get_position())).collect()) + .unwrap_or_default(); + found.sort(); + found +} + +/// An edit that destroys the literal a dependent's cache names cannot be +/// rewritten, so the dependent has to be re-analysed instead. +#[gtest] +fn a_destroyed_table_reports_its_dependent_as_dirty() { + let mut ws = workspace(); + let provider_uri = ws.virtual_url_generator.new_uri("lua/tables.lua"); + let first = "registry = { first = 1 }\n"; + let second = "registry = nil\n"; + let provider_id = write(&mut ws, &provider_uri, first); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/reads_tables.lua"); + let consumer_id = write(&mut ws, &consumer_uri, "handle = registry\n"); + expect_that!( + cached_identities(ws.analysis.compilation.get_db(), consumer_id) + .iter() + .any(|id| id.contains("InFiled")), + is_true() + ); + + let dirty = edit_and_remap(&mut ws, &provider_uri, provider_id, first, second); + expect_that!(dirty, contains(eq(&consumer_id))); +} + +/// A foreign inferred return names the provider's table literal by range. An +/// insertion above it shifts that range; the stored return must follow without +/// re-analysing the consumer. +#[gtest] +fn a_foreign_inferred_return_table_follows_an_offset_shift() { + let mut ws = workspace(); + let provider_uri = ws.virtual_url_generator.new_uri("lua/tables.lua"); + let first = "registry = { first = 1 }\n"; + let second = "local padding = 1\n_ = padding\nregistry = { first = 1 }\n"; + let provider_id = write(&mut ws, &provider_uri, first); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/fetch.lua"); + let consumer_id = write( + &mut ws, + &consumer_uri, + "function fetchRegistry() return registry end\n", + ); + + // Fixture guard: the consumer owns one inferred return embedding the + // provider's table, and no type cache names it, so only the signature + // payload can carry the shift. + let (consumer_sig, old_range) = { + let db = ws.analysis.compilation.get_db(); + let consumer_sig = *db + .get_signature_index() + .get_file_signature_ids(consumer_id) + .expect("consumer signatures") + .iter() + .next() + .expect("consumer signature"); + let sig = db + .get_signature_index() + .get(&consumer_sig) + .expect("consumer signature"); + expect_that!(sig.resolve_return, eq(SignatureReturnStatus::InferResolve)); + expect_that!(sig.return_docs.len(), eq(1)); + let LuaType::TableConst(old_range) = sig.return_docs[0].type_ref.clone() else { + panic!( + "consumer inferred return should embed the provider table, got {:?}", + sig.return_docs[0].type_ref + ); + }; + expect_that!(old_range.file_id, eq(provider_id)); + expect_that!(old_range.value, eq(range(11, 24))); + expect_that!( + cached_identities(db, consumer_id) + .iter() + .any(|id| id.contains("InFiled")), + is_false() + ); + (consumer_sig, old_range) + }; + + edit_and_remap(&mut ws, &provider_uri, provider_id, first, second); + + let db = ws.analysis.compilation.get_db(); + let after = db + .get_signature_index() + .get(&consumer_sig) + .expect("consumer signature") + .return_docs[0] + .type_ref + .clone(); + let expected_range = PositionMap::new(first, second) + .map_range(old_range.value) + .expect("the shift maps the table range"); + let expected = LuaType::TableConst(InFiled::new(provider_id, expected_range)); + // The remapped payload must name the live literal ... + expect_that!( + live_table_ranges(db, provider_id) + .iter() + .any(|s| s == &format!("{expected_range:?}")), + is_true() + ); + // ... and be exactly the remapped old identity. + expect_that!(after, eq(&expected)); +} + +/// The same for a `Signature` identity: returning the provider's function +/// stores its id in the consumer's inferred return, which must follow the +/// provider's offset shift. +#[gtest] +fn a_foreign_inferred_return_signature_follows_an_offset_shift() { + let mut ws = workspace(); + let provider_uri = ws.virtual_url_generator.new_uri("lua/provider.lua"); + let first = "provider = provider or {}\nfunction provider.Describe() end\n"; + let second = "provider = provider or {}\nlocal padding = 1\n_ = padding\nfunction provider.Describe() end\n"; + let provider_id = write(&mut ws, &provider_uri, first); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/fetch.lua"); + let consumer_id = write( + &mut ws, + &consumer_uri, + "function fetchDescribe() return provider.Describe end\n", + ); + + let (consumer_sig, old_id) = { + let db = ws.analysis.compilation.get_db(); + let consumer_sig = *db + .get_signature_index() + .get_file_signature_ids(consumer_id) + .expect("consumer signatures") + .iter() + .next() + .expect("consumer signature"); + let sig = db + .get_signature_index() + .get(&consumer_sig) + .expect("consumer signature"); + expect_that!(sig.resolve_return, eq(SignatureReturnStatus::InferResolve)); + expect_that!(sig.return_docs.len(), eq(1)); + let LuaType::Signature(old_id) = sig.return_docs[0].type_ref.clone() else { + panic!( + "consumer inferred return should embed the provider signature, got {:?}", + sig.return_docs[0].type_ref + ); + }; + expect_that!(old_id.get_file_id(), eq(provider_id)); + expect_that!( + db.get_signature_index() + .get_file_signature_ids(provider_id) + .is_some_and(|ids| ids.contains(&old_id)), + is_true() + ); + let old_debug = format!("{old_id:?}"); + expect_that!( + cached_identities(db, consumer_id) + .iter() + .any(|id| id == &old_debug), + is_false() + ); + (consumer_sig, old_id) + }; + + edit_and_remap(&mut ws, &provider_uri, provider_id, first, second); + + let db = ws.analysis.compilation.get_db(); + let after = db + .get_signature_index() + .get(&consumer_sig) + .expect("consumer signature") + .return_docs[0] + .type_ref + .clone(); + let expected_pos = PositionMap::new(first, second) + .map(old_id.get_position()) + .expect("the shift maps the signature position"); + let expected_id = LuaSignatureId::new(provider_id, expected_pos); + let expected = LuaType::Signature(expected_id); + expect_that!( + db.get_signature_index() + .get_file_signature_ids(provider_id) + .is_some_and(|ids| ids.contains(&expected_id)), + is_true() + ); + expect_that!(after, eq(&expected)); +} + +/// Destroying the table a foreign inferred return names cannot be rewritten, +/// so the signature owner has to be re-analysed instead. The consumer holds +/// no type-cache reference to the table, so only signature-payload loss can +/// dirty it. +#[gtest] +fn a_destroyed_table_in_a_foreign_inferred_return_dirties_its_owner() { + let mut ws = workspace(); + let provider_uri = ws.virtual_url_generator.new_uri("lua/tables.lua"); + let first = "registry = { first = 1 }\n"; + let second = "registry = nil\n"; + let provider_id = write(&mut ws, &provider_uri, first); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/fetch.lua"); + let consumer_id = write( + &mut ws, + &consumer_uri, + "function fetchRegistry() return registry end\n", + ); + + let (consumer_sig, old_range) = { + let db = ws.analysis.compilation.get_db(); + let consumer_sig = *db + .get_signature_index() + .get_file_signature_ids(consumer_id) + .expect("consumer signatures") + .iter() + .next() + .expect("consumer signature"); + let sig = db + .get_signature_index() + .get(&consumer_sig) + .expect("consumer signature"); + expect_that!(sig.resolve_return, eq(SignatureReturnStatus::InferResolve)); + let LuaType::TableConst(old_range) = sig.return_docs[0].type_ref.clone() else { + panic!( + "consumer inferred return should embed the provider table, got {:?}", + sig.return_docs[0].type_ref + ); + }; + expect_that!(old_range.file_id, eq(provider_id)); + expect_that!( + cached_identities(db, consumer_id) + .iter() + .any(|id| id.contains("InFiled")), + is_false() + ); + let deps = db + .get_type_index() + .files_with_type_caches_referencing_files(&std::collections::HashSet::from([ + provider_id, + ])); + expect_that!(deps.contains(&consumer_id), is_false()); + (consumer_sig, old_range) + }; + + let dirty = edit_and_remap(&mut ws, &provider_uri, provider_id, first, second); + + let db = ws.analysis.compilation.get_db(); + // The literal is gone ... + expect_that!(live_table_ranges(db, provider_id), is_empty()); + // ... and the owner is dirty rather than left dangling on the old range. + expect_that!( + db.get_signature_index() + .get(&consumer_sig) + .expect("consumer signature") + .return_docs[0] + .type_ref + .clone(), + eq(&LuaType::TableConst(old_range)) + ); + expect_that!(dirty, contains(eq(&consumer_id))); +} + +/// Destroying the signature a foreign inferred return names must dirty the +/// signature owner. The consumer holds no type-cache reference to it, so only +/// signature-payload loss can dirty it. +#[gtest] +fn a_destroyed_signature_in_a_foreign_inferred_return_dirties_its_owner() { + let mut ws = workspace(); + let provider_uri = ws.virtual_url_generator.new_uri("lua/provider.lua"); + let first = "provider = provider or {}\nfunction provider.Describe() end\n"; + let second = "provider = provider or {}\nprovider.Describe = 1\n"; + let provider_id = write(&mut ws, &provider_uri, first); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/fetch.lua"); + let consumer_id = write( + &mut ws, + &consumer_uri, + "function fetchDescribe() return provider.Describe end\n", + ); + + let (consumer_sig, old_id) = { + let db = ws.analysis.compilation.get_db(); + let consumer_sig = *db + .get_signature_index() + .get_file_signature_ids(consumer_id) + .expect("consumer signatures") + .iter() + .next() + .expect("consumer signature"); + let sig = db + .get_signature_index() + .get(&consumer_sig) + .expect("consumer signature"); + expect_that!(sig.resolve_return, eq(SignatureReturnStatus::InferResolve)); + let LuaType::Signature(old_id) = sig.return_docs[0].type_ref.clone() else { + panic!( + "consumer inferred return should embed the provider signature, got {:?}", + sig.return_docs[0].type_ref + ); + }; + expect_that!(old_id.get_file_id(), eq(provider_id)); + let old_debug = format!("{old_id:?}"); + expect_that!( + cached_identities(db, consumer_id) + .iter() + .any(|id| id == &old_debug), + is_false() + ); + let deps = db + .get_type_index() + .files_with_type_caches_referencing_files(&std::collections::HashSet::from([ + provider_id, + ])); + expect_that!(deps.contains(&consumer_id), is_false()); + (consumer_sig, old_id) + }; + + let dirty = edit_and_remap(&mut ws, &provider_uri, provider_id, first, second); + + let db = ws.analysis.compilation.get_db(); + // The provider signature is gone ... + expect_that!(db.get_signature_index().get(&old_id).is_none(), is_true()); + // ... and the owner is dirty rather than left dangling on it. + expect_that!( + db.get_signature_index() + .get(&consumer_sig) + .expect("consumer signature") + .return_docs[0] + .type_ref + .clone(), + eq(&LuaType::Signature(old_id)) + ); + expect_that!(dirty, contains(eq(&consumer_id))); +} + +/// A `LuaTypeOwner` that no longer exists after the edit must not be written +/// back onto a position that happens to be occupied by something else. +#[gtest] +fn a_validated_remap_rejects_an_identity_the_new_index_does_not_hold() { + let mut ws = workspace(); + let uri = ws.virtual_url_generator.new_uri("lua/subject.lua"); + let first = "provider = provider or {}\nfunction provider.Describe() end\n"; + let file_id = write(&mut ws, &uri, first); + let signature_id = *ws + .analysis + .compilation + .get_db() + .get_signature_index() + .get_file_signature_ids(file_id) + .and_then(|ids| ids.iter().next()) + .expect("a signature"); + + let second = "provider = provider or {}\nprovider.Describe = 1\n"; + ws.analysis.update_file_text_only(&uri, second.to_string()); + ws.analysis.compilation.remove_index(vec![file_id]); + ws.analysis.compilation.update_index(vec![file_id]); + + let remap = FileRemap::validated( + ws.analysis.compilation.get_db(), + file_id, + PositionMap::new(first, second), + ); + expect_that!(remap.signature_id(signature_id).is_lost(), is_true()); +} + +#[gtest] +fn an_identity_remap_leaves_every_owner_alone() { + let mut ws = workspace(); + let uri = ws.virtual_url_generator.new_uri("lua/subject.lua"); + let file_id = write(&mut ws, &uri, "registry = { first = 1 }\n"); + let db = ws.analysis.compilation.get_db(); + let owners: Vec = db + .get_type_index() + .file_type_owners(file_id) + .map(|owners| owners.iter().cloned().collect()) + .unwrap_or_default(); + let remap = FileRemap::identity(file_id); + for owner in owners { + expect_that!( + matches!(remap.type_owner(&owner), super::Remap::Moved(_)), + is_true() + ); + } + expect_that!( + remap_into_file(ws.analysis.compilation.get_db_mut(), &remap), + is_empty() + ); +} diff --git a/crates/glua_code_analysis/src/db_index/flow/flow_tree.rs b/crates/glua_code_analysis/src/db_index/flow/flow_tree.rs index c0cd3ffa8..ec55dd008 100644 --- a/crates/glua_code_analysis/src/db_index/flow/flow_tree.rs +++ b/crates/glua_code_analysis/src/db_index/flow/flow_tree.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use glua_parser::{LuaAstPtr, LuaExpr, LuaSyntaxId}; use internment::ArcIntern; @@ -30,8 +30,6 @@ pub struct FileNarrowingCapability { /// respectively to stay sound. pub has_opaque_name_target: bool, pub has_opaque_index_target: bool, - /// Condition flow nodes keyed by stable index path. - pub condition_flows_by_path: HashMap, HashSet>, } impl FileNarrowingCapability { @@ -45,10 +43,6 @@ impl FileNarrowingCapability { pub fn index_path_can_be_narrowed(&self, path: &ArcIntern) -> bool { self.has_opaque_index_target || self.referenced_index_paths.contains(path) } - - fn condition_flows(&self, path: &ArcIntern) -> Option<&HashSet> { - self.condition_flows_by_path.get(path) - } } /// Metadata for BranchLabel nodes that enables the merge-skip optimisation. @@ -119,11 +113,11 @@ mod tests { infos: Vec, ) -> FlowTree { FlowTree::new( - HashMap::new(), + HashMap::default(), nodes, branches, - HashMap::new(), - HashMap::new(), + HashMap::default(), + HashMap::default(), infos, FileNarrowingCapability::default(), ) @@ -383,44 +377,8 @@ impl FlowTree { bindings: HashMap, branch_label_info: HashMap, assignment_flow_info: Vec, - mut narrowing_capability: FileNarrowingCapability, + narrowing_capability: FileNarrowingCapability, ) -> Self { - let mut successors = vec![Vec::new(); flow_nodes.len()]; - for node in &flow_nodes { - let Some(antecedent) = &node.antecedent else { - continue; - }; - match antecedent { - crate::FlowAntecedent::Single(antecedent) => { - if let Some(flow_successors) = successors.get_mut(antecedent.0 as usize) { - flow_successors.push(node.id); - } - } - crate::FlowAntecedent::Multiple(id) => { - if let Some(antecedents) = multiple_antecedents.get(*id as usize) { - for antecedent in antecedents { - if let Some(flow_successors) = successors.get_mut(antecedent.0 as usize) - { - flow_successors.push(node.id); - } - } - } - } - } - } - for reachable in narrowing_capability.condition_flows_by_path.values_mut() { - let mut pending = reachable.iter().copied().collect::>(); - while let Some(flow_id) = pending.pop() { - let Some(flow_successors) = successors.get(flow_id.0 as usize) else { - continue; - }; - for successor in flow_successors { - if reachable.insert(*successor) { - pending.push(*successor); - } - } - } - } Self { decl_bind_expr_ref, flow_nodes, @@ -444,16 +402,6 @@ impl FlowTree { self.flow_nodes.get(flow_id.0 as usize) } - pub fn has_condition_path_antecedent( - &self, - flow_id: FlowId, - path: &ArcIntern, - ) -> bool { - self.narrowing_capability - .condition_flows(path) - .is_some_and(|flows| flows.contains(&flow_id)) - } - pub fn get_multi_antecedents(&self, id: u32) -> Option<&[FlowId]> { self.multiple_antecedents .get(id as usize) @@ -480,7 +428,7 @@ impl FlowTree { ) -> std::sync::Arc<[LuaDefinitionId]> { let mut definitions = Vec::new(); let mut pending = vec![flow_id]; - let mut visited = HashSet::new(); + let mut visited = HashSet::default(); while let Some(current) = pending.pop() { if !visited.insert(current) { diff --git a/crates/glua_code_analysis/src/db_index/flow/mod.rs b/crates/glua_code_analysis/src/db_index/flow/mod.rs index 46fe128a5..68833e4ef 100644 --- a/crates/glua_code_analysis/src/db_index/flow/mod.rs +++ b/crates/glua_code_analysis/src/db_index/flow/mod.rs @@ -87,6 +87,35 @@ impl LuaFlowIndex { .push(LuaSpecialCallEffect { target, type_ref }); } + /// Rewrites the types stored on special-call effects. + /// + /// `type_ref` is the callee's declared out-param type, so it can name a + /// table literal or signature in a file other than the caller this effect + /// is filed under. A holder whose type names something the edit destroyed + /// keeps its stored value and is reported dirty for re-analysis instead. + pub fn remap_special_call_effect_types( + &mut self, + remap: &crate::FileRemap, + ) -> rustc_hash::FxHashSet { + if remap.map.is_identity() { + return rustc_hash::FxHashSet::default(); + } + let mut dirty = rustc_hash::FxHashSet::default(); + for (holder, effects) in self.special_call_effects.iter_mut() { + for effects in effects.values_mut() { + for effect in effects.iter_mut() { + let result = crate::db_index::remap_identities_in_type(&effect.type_ref, remap); + if result.lost { + dirty.insert(*holder); + } else if let Some(typ) = result.typ { + effect.type_ref = typ; + } + } + } + } + dirty + } + pub fn get_special_call_effects( &self, file_id: &FileId, diff --git a/crates/glua_code_analysis/src/db_index/global/mod.rs b/crates/glua_code_analysis/src/db_index/global/mod.rs index 9376e1395..ee9d4eded 100644 --- a/crates/glua_code_analysis/src/db_index/global/mod.rs +++ b/crates/glua_code_analysis/src/db_index/global/mod.rs @@ -1,6 +1,7 @@ mod global_id; -use std::collections::{BTreeMap, HashMap}; +use rustc_hash::FxHashMap; +use std::collections::BTreeMap; pub use global_id::GlobalId; @@ -10,7 +11,7 @@ use super::{LuaDeclId, LuaIndex, LuaModuleIndex, WorkspaceId}; #[derive(Debug)] pub struct LuaGlobalIndex { - global_decl: HashMap>, + global_decl: FxHashMap>, } /// Canonical order for a global's declarations: source position, with the file @@ -29,7 +30,7 @@ impl Default for LuaGlobalIndex { impl LuaGlobalIndex { pub fn new() -> Self { Self { - global_decl: HashMap::new(), + global_decl: FxHashMap::default(), } } @@ -46,7 +47,7 @@ impl LuaGlobalIndex { } pub fn get_all_global_decl_ids(&self) -> Vec { - // `global_decl` is a `HashMap`, so its iteration order is not stable + // `global_decl` is a `FxHashMap`, so its iteration order is not stable // across index states; sort so callers see the same sequence whatever // order the globals were discovered in. let mut decls = self @@ -64,19 +65,6 @@ impl LuaGlobalIndex { self.global_decl.get(&id) } - /// Every global name that more than one declaration writes, sorted by - /// name so parents settle before the nested paths derived from them. - pub fn sorted_multi_declaration_globals(&self) -> Vec { - let mut global_ids = self - .global_decl - .iter() - .filter(|(_, decl_ids)| decl_ids.len() > 1) - .map(|(global_id, _)| global_id.clone()) - .collect::>(); - global_ids.sort_unstable_by(|left, right| left.get_name().cmp(right.get_name())); - global_ids - } - pub fn get_global_decl_ids_in_workspace( &self, name: &str, @@ -157,7 +145,7 @@ impl LuaIndex for LuaGlobalIndex { let removed_file_ids = file_ids .iter() .copied() - .collect::>(); + .collect::>(); self.global_decl.retain(|_, decl_ids| { decl_ids.retain(|decl_id| !removed_file_ids.contains(&decl_id.file_id)); !decl_ids.is_empty() 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..570850520 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 @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use glua_parser::LuaSyntaxId; use rowan::TextSize; @@ -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)] @@ -345,12 +355,13 @@ struct DermaSkinDefinition { impl GmodClassMetadataIndex { pub fn new() -> Self { Self { - file_metadata: HashMap::new(), - vgui_panels: HashMap::new(), - derma_skins: HashMap::new(), - vgui_forwarding_parents: HashMap::new(), - vgui_panel_parent_chains: HashMap::new(), - incomplete_vgui_panel_parent_chains: HashSet::new(), + file_metadata: HashMap::default(), + vgui_panels: HashMap::default(), + derma_skins: HashMap::default(), + vgui_forwarding_parents: HashMap::default(), + vgui_panel_parent_chains: HashMap::default(), + incomplete_vgui_panel_parent_chains: HashSet::default(), + pending_vgui_parent_relation_files: HashMap::default(), } } @@ -533,8 +544,8 @@ impl GmodClassMetadataIndex { } fn recompute_derived_caches(&mut self) { - let mut vgui_panels = HashMap::new(); - let mut derma_skins = HashMap::new(); + let mut vgui_panels = HashMap::default(); + let mut derma_skins = HashMap::default(); for (file_id, file_metadata) in &self.file_metadata { for call in &file_metadata.vgui_register_calls { @@ -611,7 +622,19 @@ impl GmodClassMetadataIndex { { *existing = call; } else { - calls.push(call); + // Kept in source order: calls arrive in whatever order the scan + // and the forwarding passes produce them, which differs between a + // cold build and a re-index of the same file. + let insert_at = calls.partition_point(|existing| { + ( + existing.syntax_id.get_range().start(), + existing.syntax_id.get_range().end(), + ) < ( + call.syntax_id.get_range().start(), + call.syntax_id.get_range().end(), + ) + }); + calls.insert(insert_at, call); } } @@ -718,8 +741,8 @@ impl GmodClassMetadataIndex { // Order-free despite the hash-ordered walk: a chain survives only if // every relation agrees on it — any disagreement marks the child // incomplete and the entry is dropped below, whichever one landed first. - let mut parent_chains = HashMap::>::new(); - let mut incomplete = HashSet::new(); + let mut parent_chains = HashMap::>::default(); + let mut incomplete = HashSet::default(); for metadata in self.file_metadata.values() { for call in &metadata.vgui_parent_calls { for relation in &call.relations { @@ -742,6 +765,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 +779,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 +892,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 +918,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 +1098,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/gmod_infer/mod.rs b/crates/glua_code_analysis/src/db_index/gmod_infer/mod.rs index 5a9c999f8..710545383 100644 --- a/crates/glua_code_analysis/src/db_index/gmod_infer/mod.rs +++ b/crates/glua_code_analysis/src/db_index/gmod_infer/mod.rs @@ -611,12 +611,16 @@ impl GmodInferIndex { self.gm_method_realm_annotations.iter() } - /// Set per-file member realm ranges (sorted). Empty clears. + /// Records this file's `---@realm` decl ranges, sorted. + /// + /// An empty vector is recorded rather than dropped: the entry is what says + /// the file was scanned, and a file with no annotation is the common case, + /// not an absent answer. Dropping it made + /// [`has_member_realm_ranges`](Self::has_member_realm_ranges) report the + /// scanned-and-empty file as never scanned, which is the one thing it exists + /// to tell apart. Removal of the file's entries is + /// [`LuaIndex::remove`](crate::LuaIndex::remove)'s job. pub fn set_member_realm_ranges(&mut self, file_id: FileId, mut ranges: Vec) { - if ranges.is_empty() { - self.member_realm_ranges.remove(&file_id); - return; - } ranges.sort_by_key(|r| r.range.start()); self.member_realm_ranges.insert(file_id, ranges); } diff --git a/crates/glua_code_analysis/src/db_index/gmod_load/mod.rs b/crates/glua_code_analysis/src/db_index/gmod_load/mod.rs index 3aaff0651..e8b7aef87 100644 --- a/crates/glua_code_analysis/src/db_index/gmod_load/mod.rs +++ b/crates/glua_code_analysis/src/db_index/gmod_load/mod.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use rowan::TextRange; @@ -300,12 +300,12 @@ pub struct GmodLoadIndex { impl GmodLoadIndex { pub fn new() -> Self { Self { - file_infos: HashMap::new(), + file_infos: HashMap::default(), unresolved_edges: Vec::new(), - execution_environments: HashMap::new(), - execution_environment_sites: HashMap::new(), - execution_environment_file_flows: HashMap::new(), - execution_environment_role_sources: HashSet::new(), + execution_environments: HashMap::default(), + execution_environment_sites: HashMap::default(), + execution_environment_file_flows: HashMap::default(), + execution_environment_role_sources: HashSet::default(), execution_environment_roles_dirty: false, } } @@ -688,9 +688,9 @@ mod tests { let source = FileId::new(1); let target = FileId::new(2); let mut index = GmodLoadIndex::new(); - index.set_execution_environment_sites(HashMap::from([( + index.set_execution_environment_sites(HashMap::from_iter([( source, - HashMap::from([(target, HashSet::from(["simple".to_string()]))]), + HashMap::from_iter([(target, HashSet::from_iter(["simple".to_string()]))]), )])); index.remove(source); @@ -703,9 +703,9 @@ mod tests { let source = FileId::new(1); let target = FileId::new(2); let mut index = GmodLoadIndex::new(); - index.set_execution_environment_sites(HashMap::from([( + index.set_execution_environment_sites(HashMap::from_iter([( source, - HashMap::from([(target, HashSet::from(["simple".to_string()]))]), + HashMap::from_iter([(target, HashSet::from_iter(["simple".to_string()]))]), )])); index.clear(); diff --git a/crates/glua_code_analysis/src/db_index/gmod_network/mod.rs b/crates/glua_code_analysis/src/db_index/gmod_network/mod.rs index 5d26f797c..92737fa5f 100644 --- a/crates/glua_code_analysis/src/db_index/gmod_network/mod.rs +++ b/crates/glua_code_analysis/src/db_index/gmod_network/mod.rs @@ -1,9 +1,10 @@ +use rustc_hash::FxHashMap; use std::collections::HashMap; use rowan::TextRange; use smol_str::SmolStr; -use super::LuaIndex; +use super::{LuaDeclId, LuaIndex}; use crate::{FileId, GmodRealm}; mod pair; @@ -224,36 +225,139 @@ pub struct FileNetworkData { pub receive_flows: Vec, } +/// What one reference site in one file contributes to the net-helper name +/// closure: the written names of the functions enclosing the sites, and the +/// local declarations those functions are bound to (whose own references carry +/// the chain onward). +#[derive(Debug, Clone, Default)] +pub struct NetNameExpansion { + pub names: Vec, + pub locals: Vec, +} + +/// Memo of the per-file step of the net-helper name closure. +/// +/// The step is a pure function of one file's syntax tree and one file's own +/// references, so an entry stays valid until that file's reference revision +/// moves — which covers a re-index, a removal, and a late cross-file reference +/// written by a retained unresolve alike. +#[derive(Debug, Default)] +pub struct NetHelperNameMemo { + files: FxHashMap, +} + +#[derive(Debug, Default)] +struct NetHelperFileMemo { + revision: u64, + names: FxHashMap, + decls: FxHashMap, +} + +impl NetHelperNameMemo { + fn valid(&self, file_id: FileId, revision: u64) -> Option<&NetHelperFileMemo> { + self.files + .get(&file_id) + .filter(|memo| memo.revision == revision) + } + + fn entry(&mut self, file_id: FileId, revision: u64) -> &mut NetHelperFileMemo { + let memo = self.files.entry(file_id).or_default(); + if memo.revision != revision { + *memo = NetHelperFileMemo { + revision, + ..Default::default() + }; + } + memo + } + + pub fn name( + &self, + file_id: FileId, + revision: u64, + name: &SmolStr, + ) -> Option<&NetNameExpansion> { + self.valid(file_id, revision)?.names.get(name) + } + + pub fn set_name( + &mut self, + file_id: FileId, + revision: u64, + name: SmolStr, + expansion: NetNameExpansion, + ) { + self.entry(file_id, revision).names.insert(name, expansion); + } + + pub fn decl( + &self, + file_id: FileId, + revision: u64, + decl_id: &LuaDeclId, + ) -> Option<&NetNameExpansion> { + self.valid(file_id, revision)?.decls.get(decl_id) + } + + pub fn set_decl( + &mut self, + file_id: FileId, + revision: u64, + decl_id: LuaDeclId, + expansion: NetNameExpansion, + ) { + self.entry(file_id, revision) + .decls + .insert(decl_id, expansion); + } +} + #[derive(Debug, Default)] pub struct GmodNetworkIndex { - file_data: HashMap, - send_flows_by_message: HashMap>, - receive_flows_by_message: HashMap>, - materialized_definition_counts: HashMap<(FileId, TextRange), usize>, + file_data: FxHashMap, + send_flows_by_message: FxHashMap>, + receive_flows_by_message: FxHashMap>, + materialized_definition_counts: FxHashMap<(FileId, TextRange), usize>, /// Canonical function metadata per `(wire_format, direction)`, derived from /// annotated signatures during analysis. Features that must *emit* a net call /// — read completions, "expected `x`, got `y`" messages when one side has no /// call to name — resolve it here instead of from a hardcoded table. /// Workspace-global and rebuilt per analyze pass, so it is not per-file state. - canonical_ops: HashMap<(SmolStr, NetOpDirection), CanonicalNetOp>, + canonical_ops: FxHashMap<(SmolStr, NetOpDirection), CanonicalNetOp>, + /// See [`NetHelperNameMemo`]. Kept here so it survives across analyze + /// passes; its own revision guard, not this index's lifecycle, decides + /// whether an entry is usable. + helper_name_memo: NetHelperNameMemo, } impl GmodNetworkIndex { pub fn new() -> Self { Self { - file_data: HashMap::new(), - send_flows_by_message: HashMap::new(), - receive_flows_by_message: HashMap::new(), - materialized_definition_counts: HashMap::new(), - canonical_ops: HashMap::new(), + file_data: FxHashMap::default(), + send_flows_by_message: FxHashMap::default(), + receive_flows_by_message: FxHashMap::default(), + materialized_definition_counts: FxHashMap::default(), + canonical_ops: FxHashMap::default(), + helper_name_memo: NetHelperNameMemo::default(), } } + /// Lends the net-helper name memo out for a pass. The index holds it again + /// through [`Self::restore_helper_name_memo`]; leaving it out only costs the + /// pass its hits. + pub fn take_helper_name_memo(&mut self) -> NetHelperNameMemo { + std::mem::take(&mut self.helper_name_memo) + } + + pub fn restore_helper_name_memo(&mut self, memo: NetHelperNameMemo) { + self.helper_name_memo = memo; + } + /// Replaces the canonical op table. Called once per analyze pass with /// metadata collected from annotated signatures. pub fn set_canonical_ops( &mut self, - canonical_ops: HashMap<(SmolStr, NetOpDirection), CanonicalNetOp>, + canonical_ops: FxHashMap<(SmolStr, NetOpDirection), CanonicalNetOp>, ) { self.canonical_ops = canonical_ops; } @@ -281,7 +385,7 @@ impl GmodNetworkIndex { /// for it. Used by the coverage test that guards against a typo silently /// breaking pairing for one op. pub fn wire_format_coverage(&self) -> HashMap { - let mut coverage: HashMap = HashMap::new(); + let mut coverage: HashMap = HashMap::default(); for (wire_format, direction) in self.canonical_ops.keys() { let entry = coverage .entry(wire_format.clone()) @@ -406,6 +510,7 @@ impl GmodNetworkIndex { self.receive_flows_by_message.clear(); self.materialized_definition_counts.clear(); self.canonical_ops.clear(); + self.helper_name_memo.files.clear(); } fn index_file_data(&mut self, file_id: FileId, data: &FileNetworkData) { @@ -479,6 +584,9 @@ impl GmodNetworkIndex { impl LuaIndex for GmodNetworkIndex { fn remove(&mut self, file_id: FileId) { self.remove_file(file_id); + // Memory hygiene only: the memo's revision guard already rejects a stale + // entry, this stops a removed file's entry from outliving the file. + self.helper_name_memo.files.remove(&file_id); } fn clear(&mut self) { @@ -522,6 +630,32 @@ mod tests { } } + #[test] + fn helper_name_memo_entries_only_answer_for_the_revision_they_were_stored_at() { + let file_id = FileId::new(1); + let name: SmolStr = "Send".into(); + let expansion = NetNameExpansion { + names: vec![SmolStr::new("SendThing")], + locals: Vec::new(), + }; + let mut memo = NetHelperNameMemo::default(); + + memo.set_name(file_id, 7, name.clone(), expansion); + assert!(memo.name(file_id, 7, &name).is_some()); + assert!(memo.name(file_id, 8, &name).is_none()); + assert!(memo.name(FileId::new(2), 7, &name).is_none()); + + // Storing at a newer revision drops everything the old one held. + memo.set_name( + file_id, + 8, + SmolStr::new("Other"), + NetNameExpansion::default(), + ); + assert!(memo.name(file_id, 8, &name).is_none()); + assert!(memo.name(file_id, 8, &SmolStr::new("Other")).is_some()); + } + #[test] fn add_file_data_replaces_previous_message_indexes_for_same_file() { let file_id = FileId::new(1); 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 deleted file mode 100644 index 58e9db156..000000000 --- a/crates/glua_code_analysis/src/db_index/member/assignment_contribution.rs +++ /dev/null @@ -1,134 +0,0 @@ -use rustc_hash::FxHashMap; -use std::collections::HashSet; - -use super::{LuaMemberId, LuaMemberKey, LuaMemberOwner}; -use crate::{FileId, LuaType}; - -/// The group a member assignment contributes its evidence to. -pub type MemberAssignmentContributionKey = (LuaMemberOwner, LuaMemberKey); - -/// What one writer of `owner.key = value` knows on its own. -#[derive(Debug, Clone)] -pub struct MemberAssignmentContribution { - /// What this write bound — what a sibling reads out of the type cache. - pub bound_type: LuaType, - /// What this write carried before it was merged with any sibling. - pub source_type: LuaType, - pub doc_type: Option, - /// Taken from syntax at the write, so it does not change with batch phase. - pub guarded_bootstrap: bool, - /// Whether the write asked the merge to keep table literals unwidened. - pub preserve_table_literals: bool, -} - -#[derive(Debug, Default)] -pub struct MemberAssignmentContributionStore { - by_owner_key: FxHashMap< - MemberAssignmentContributionKey, - FxHashMap, - >, - /// Reverse index used to sweep a file's entries without scanning the store. - by_file: FxHashMap>, -} - -impl MemberAssignmentContributionStore { - pub fn record( - &mut self, - owner: LuaMemberOwner, - key: LuaMemberKey, - member_id: LuaMemberId, - contribution: MemberAssignmentContribution, - ) { - let store_key = (owner, key); - let previous = self - .by_file - .entry(member_id.file_id) - .or_default() - .insert(member_id, store_key.clone()); - if let Some(previous) = previous - && previous != store_key - { - self.detach(&previous, member_id); - } - self.by_owner_key - .entry(store_key) - .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) { - for file_id in removed { - let Some(entries) = self.by_file.remove(file_id) else { - continue; - }; - for (member_id, store_key) in entries { - self.detach(&store_key, member_id); - } - } - } - - pub fn contributions( - &self, - store_key: &MemberAssignmentContributionKey, - ) -> Option<&FxHashMap> { - self.by_owner_key.get(store_key) - } - - /// The contribution this member recorded, wherever its writer group - /// currently sits. - pub fn contribution_of( - &self, - member_id: &LuaMemberId, - ) -> Option<&MemberAssignmentContribution> { - let store_key = self.by_file.get(&member_id.file_id)?.get(member_id)?; - self.by_owner_key.get(store_key)?.get(member_id) - } - - /// The `(owner, key)` group this member's write currently contributes to. - pub fn contribution_group_of( - &self, - member_id: &LuaMemberId, - ) -> Option<(LuaMemberOwner, LuaMemberKey)> { - self.by_file - .get(&member_id.file_id)? - .get(member_id) - .cloned() - } - - /// The distinct groups the given files wrote to. - pub fn keys_for_files( - &self, - files: &HashSet, - ) -> HashSet { - let mut keys = HashSet::new(); - for file_id in files { - let Some(entries) = self.by_file.get(file_id) else { - continue; - }; - keys.extend(entries.values().cloned()); - } - keys - } - - /// Number of stored writer entries, for the profile report. - pub fn entry_count(&self) -> usize { - self.by_owner_key.values().map(FxHashMap::len).sum() - } - - pub fn clear(&mut self) { - self.by_owner_key.clear(); - self.by_file.clear(); - } - - fn detach(&mut self, store_key: &MemberAssignmentContributionKey, member_id: LuaMemberId) { - let Some(group) = self.by_owner_key.get_mut(store_key) else { - return; - }; - group.remove(&member_id); - if group.is_empty() { - self.by_owner_key.remove(store_key); - } - } -} 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..4b1bd211a 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 @@ -35,6 +35,13 @@ impl LuaMember { &self.key } + /// Only [`crate::LuaMemberIndex::rekey_member`] may call this: every key + /// map has to move with the member, or a later removal reads the new key + /// back and leaves the old slot behind. + pub(crate) fn set_key(&mut self, key: LuaMemberKey) { + self.key = key; + } + pub fn get_file_id(&self) -> FileId { self.member_id.file_id } @@ -68,6 +75,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/db_index/member/lua_member_item.rs b/crates/glua_code_analysis/src/db_index/member/lua_member_item.rs index 98fd038b7..cd6abc956 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 @@ -6,12 +6,12 @@ use std::{ use crate::{ DbIndex, FileId, InferFailReason, LuaFunctionType, LuaSemanticDeclId, LuaType, TypeOps, db_index::{WorkspaceKind, WorkspaceResolutionKey, gmod_infer::GmodRealm}, - is_table_assignment_merge_type, widen_file_define_member_type, + is_table_assignment_merge_type, widen_literal_type_for_assignment, }; use glua_parser::{BinaryOperator, LuaAssignStat, LuaAstNode, LuaExpr, PathTrait}; use rowan::TextSize; -use super::LuaMemberId; +use super::{LuaMemberId, LuaMemberOwner}; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum LuaMemberIndexItem { @@ -254,15 +254,56 @@ fn visible_member_ids_at_offset( caller_file_id: &FileId, caller_position: TextSize, ) -> Vec { - member_ids + let visible = member_ids .iter() .copied() .filter(|member_id| { member_visible_at_offset(db, *member_id, caller_file_id, caller_position) }) + .collect(); + drop_overwritten_writers(db, visible) +} + +/// A plain top-level write, `x.y = v` outside any function and any branch, +/// replaces whatever the same file wrote to the slot before it, so a reader +/// who sees that write does not see those earlier writers. +fn drop_overwritten_writers(db: &DbIndex, visible: Vec) -> Vec { + let overwriters = visible + .iter() + .copied() + .filter(|member_id| is_top_level_overwriting_assignment(db, *member_id)) + .collect::>(); + if overwriters.is_empty() { + return visible; + } + visible + .into_iter() + .filter(|member_id| { + !db.get_member_index() + .get_member(member_id) + .is_some_and(|member| member.get_feature().is_file_define()) + || !overwriters.iter().any(|overwriter| { + overwriter.file_id == member_id.file_id + && member_id.get_position() < overwriter.get_position() + }) + }) .collect() } +fn is_top_level_overwriting_assignment(db: &DbIndex, member_id: LuaMemberId) -> bool { + let member_index = db.get_member_index(); + let Some(member) = member_index.get_member(&member_id) else { + return false; + }; + member.get_feature().is_file_define() + && member.get_syntax_id().get_kind() == glua_parser::LuaSyntaxKind::IndexExpr + && !matches!(member.get_key(), super::LuaMemberKey::ExprType(_)) + && member_index + .member_function_scope_range(member_id) + .is_none() + && !member_index.is_non_overwriting_assignment_member(member_id) +} + fn expand_member_ids_with_owner_key_history( db: &DbIndex, member_ids: Vec, @@ -433,6 +474,50 @@ fn expr_access_path(expr: &LuaExpr) -> Option { } } +/// A table literal that is the table a class-annotated path was declared with +/// reads as that class: `X.k = X.k or {}` in a second file hands back the very +/// table the first file annotated, so the two are one type, not `C|table`. +fn class_table_as_class(db: &DbIndex, typ: &LuaType) -> LuaType { + let LuaType::TableConst(range) = typ else { + return typ.clone(); + }; + match db + .get_member_index() + .canonical_owner(LuaMemberOwner::Element(range.clone())) + .get_type_id() + { + Some(class) => LuaType::Ref(class.clone()), + None => typ.clone(), + } +} + +/// Whether this writer spells the table it assigns, `x.y = {...}`, rather +/// than naming one that already exists. Spelled literals are one runtime table +/// however many files spell it, so they merge; a reference is a candidate of +/// its own, and two writers naming different tables are a conflict to keep. +fn writer_spells_table(db: &DbIndex, member_id: LuaMemberId, typ: &LuaType) -> bool { + match typ { + LuaType::TableConst(range) => { + (range.file_id == member_id.file_id && range.value.start() >= member_id.get_position()) + || db + .get_member_index() + .is_non_overwriting_assignment_member(member_id) + } + LuaType::MergedTable(merged) => merged + .get_types() + .iter() + .all(|typ| writer_spells_table(db, member_id, typ)), + LuaType::Union(union) => union + .types() + .all(|typ| typ.is_nil() || writer_spells_table(db, member_id, typ)), + LuaType::MultiLineUnion(multi) => multi + .get_unions() + .iter() + .all(|(typ, _)| typ.is_nil() || writer_spells_table(db, member_id, typ)), + _ => true, + } +} + fn resolve_member_type( db: &DbIndex, member_item: &LuaMemberIndexItem, @@ -476,15 +561,6 @@ fn resolve_member_type( db.get_member_index() .is_non_overwriting_assignment_member(member.get_id()) }); - let should_widen_table_literals = should_widen_file_defines - && !all_non_overwriting_assignment_file_defines - && members.iter().all(|member| { - db.get_type_index() - .get_type_cache(&member.get_id().into()) - .is_some_and(|cache| { - cache.is_doc() || is_table_assignment_merge_type(cache.as_type()) - }) - }); if db.get_emmyrc().strict.meta_override_file_define { for member in &members { let feature = member.get_feature(); @@ -499,7 +575,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() @@ -509,14 +586,35 @@ fn resolve_member_type( continue; } - let member_type = member_type_cache.as_type(); + let member_type = class_table_as_class(db, member_type_cache.as_type()); + if !is_table_assignment_merge_type(&member_type) + || !writer_spells_table(db, member.get_id(), &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) + widen_literal_type_for_assignment(&member_type) } else { - member_type.clone() + member_type }; - 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) { @@ -1318,24 +1416,6 @@ mod tests { )) } - #[test] - fn file_define_member_widening_preserves_nested_table_consts_inside_unions() { - let typ = LuaType::from_vec(vec![table_const(1, 2), LuaType::String]); - - let widened = super::widen_file_define_member_type(&typ, true); - - let LuaType::Union(union) = widened else { - panic!("expected file-define widened union"); - }; - assert!( - union - .types() - .any(|typ| matches!(typ, LuaType::TableConst(_))) - ); - assert!(union.types().any(|typ| matches!(typ, LuaType::String))); - assert!(!union.types().any(|typ| matches!(typ, LuaType::Table))); - } - #[test] fn table_assignment_merge_type_includes_open_table_shapes_only() { assert!(super::is_table_assignment_merge_type(&LuaType::Table)); @@ -2042,8 +2122,11 @@ mod tests { let collapsed = db .get_member_index() .get_member_item(&owner, &key) - .expect("runtime assignments collapse to the latest member"); - assert_eq!(collapsed, &LuaMemberIndexItem::One(later_member)); + .expect("both runtime assignments stay in the slot"); + assert_eq!( + collapsed, + &LuaMemberIndexItem::Many(vec![earlier_member, later_member]) + ); let collapsed_visible = collapsed.visible_member_ids_with_realm_at_offset(&db, &caller_file, TextSize::new(20)); assert_eq!(collapsed_visible, vec![earlier_member, later_member]); @@ -2155,8 +2238,11 @@ mod tests { let item = db .get_member_index() .get_member_item(&owner, &key) - .expect("runtime assignments collapse to the latest member"); - assert_eq!(item, &LuaMemberIndexItem::One(later_member)); + .expect("both runtime assignments stay in the slot"); + assert_eq!( + item, + &LuaMemberIndexItem::Many(vec![earlier_member, later_member]) + ); let visible = item.visible_member_ids_with_realm_at_offset(&db, &caller_file, TextSize::new(20)); @@ -2195,7 +2281,7 @@ mod tests { let latest_item = db .get_member_index() .get_member_item(&owner, &key) - .expect("runtime assignments collapse to the latest member"); + .expect("both runtime assignments stay in the slot"); let pre_expanded_history = LuaMemberIndexItem::Many(vec![earlier_member, later_member]); let expanded_visible = latest_item.visible_member_ids_with_realm_at_offset( @@ -2245,7 +2331,7 @@ mod tests { let item = db .get_member_index() .get_member_item(&owner, &key) - .expect("runtime assignments collapse to the latest member"); + .expect("both runtime assignments stay in the slot"); let visible = item.visible_member_ids_with_realm_at_offset(&db, &caller_file, TextSize::new(40)); 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..386df4b61 100644 --- a/crates/glua_code_analysis/src/db_index/member/mod.rs +++ b/crates/glua_code_analysis/src/db_index/member/mod.rs @@ -1,43 +1,121 @@ -mod assignment_contribution; mod lua_member; mod lua_member_feature; mod lua_member_item; mod lua_member_owner; mod lua_owner_members; +mod test; -use glua_parser::LuaSyntaxKind; use rowan::{TextRange, TextSize}; use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; -use std::collections::BTreeMap; +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; use super::traits::LuaIndex; use crate::{FileId, GlobalId, db_index::member::lua_owner_members::LuaOwnerMembers}; -pub use assignment_contribution::{ - MemberAssignmentContribution, MemberAssignmentContributionKey, - MemberAssignmentContributionStore, -}; pub use lua_member::{LuaMember, LuaMemberId, LuaMemberKey}; pub use lua_member_feature::LuaMemberFeature; pub use lua_member_item::LuaMemberIndexItem; pub use lua_member_owner::LuaMemberOwner; +/// Members filed by owner and then by key. +/// +/// Two of these are kept. The visible one answers lookups; the history one also +/// keeps members a later write displaced, so a removal can still find where they +/// were filed. +type OwnerKeyMap = HashMap>>; + +/// Appends `id` to its slot unless the slot already holds it. +fn push_owner_key_id_unique( + target: &mut OwnerKeyMap, + owner: LuaMemberOwner, + key: LuaMemberKey, + id: LuaMemberId, +) { + let member_ids = target.entry(owner).or_default().entry(key).or_default(); + if !member_ids.contains(&id) { + member_ids.push(id); + } +} + +/// Appends `id` to its slot, which the caller knows does not hold it yet. +fn push_owner_key_id( + target: &mut OwnerKeyMap, + owner: LuaMemberOwner, + key: LuaMemberKey, + id: LuaMemberId, +) { + target + .entry(owner) + .or_default() + .entry(key) + .or_default() + .push(id); +} + +/// Drops `id` from its slot, and the slot and owner once they are empty. +fn remove_owner_key_id( + target: &mut OwnerKeyMap, + owner: &LuaMemberOwner, + key: &LuaMemberKey, + id: LuaMemberId, +) { + let Some(owner_items) = target.get_mut(owner) else { + return; + }; + if let Some(member_ids) = owner_items.get_mut(key) { + member_ids.retain(|member_id| *member_id != id); + if member_ids.is_empty() { + owner_items.remove(key); + } + } + if owner_items.is_empty() { + target.remove(owner); + } +} + #[derive(Debug)] pub struct LuaMemberIndex { members: HashMap, in_filed: HashMap>, owner_members: HashMap, member_current_owner: HashMap, - member_owner_key_index: HashMap>>, - member_owner_key_history_index: - HashMap>>, + member_owner_key_index: OwnerKeyMap, + member_owner_key_history_index: OwnerKeyMap, + /// The owners under which a file's members were filed in the two + /// owner-key indexes. A file sweep only has to visit these owners, rather + /// than every owner in the workspace. Entries may outlive the members that + /// caused them; a sweep of an owner that holds none is a no-op. + owner_key_index_owners_by_file: HashMap>, current_owner_member_history: HashMap>, current_members_by_key: HashMap>, non_overwriting_assignment_members: HashSet, - /// Assignment members written inside a conditional construct (`if c - /// then t.k = v end`). - conditional_branch_assignment_members: HashSet, + /// `(owner, key)` slots whose last co-writer readmission merged nothing. + /// + /// See [`Self::readmit_preserved_assignment_co_writers`]: every later + /// scan of such a slot is a proven no-op, so marks skip the walk. Only + /// `clear` drops entries, with the index they describe — every other + /// mutation preserves the coverage they record, and any future history + /// write without a paired item merge must clear its slot here. + preserved_co_writer_reconciled: HashSet<(LuaMemberOwner, LuaMemberKey)>, + /// Monotonic homing generation for per-`(owner, key)` memos. + /// + /// The history index retains ids a later owner move left behind, so a raw + /// length plus a still-homed decider cannot prove the filtered set is + /// unchanged: moving a non-deciding writer away, or moving a historical + /// writer back, leaves both checks green while the truth changed. Every + /// mutation that can move an id between canonical slots, change what a + /// canonical slot names, or otherwise edit history membership bumps this + /// counter; pure appends (`add_member` first filings, + /// `add_member_to_owner`, `add_member_alias_to_owner`) do not. Memo + /// holders record the revision they walked and suffix-fold only on a + /// match, rebuilding from scratch otherwise. + homing_revision: u64, + /// Members whose owner was recorded without adding them to the owner's + /// member list: a write through an instance of a class, not the class. + /// See [`Self::set_member_owner_only`]. + owner_only_members: HashSet, /// Members whose owner was decided by scripted-class synthesis rather /// than by name resolution. synthesized_owner_members: HashSet, @@ -45,13 +123,35 @@ pub struct LuaMemberIndex { /// type read mid-fixpoint. deferred_index_expr_members: HashSet, function_scope_ranges: HashMap>, - /// 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>, member_function_scope_ranges: HashMap, - /// Per-writer evidence for the member assignment widening merge. See - /// [`MemberAssignmentContribution`]. - assignment_contributions: MemberAssignmentContributionStore, + /// The owner a table literal that initialises a global path resolves to: + /// the `{}` of `X = {}` or of the GLua-idiomatic `X = X or {}`, at any + /// depth. Held as the owner rather than the path so the normaliser can + /// hand back a reference. See [`LuaMemberIndex::canonical_owner`]. + definition_site_owner: HashMap, LuaMemberOwner>, + /// The reverse of [`Self::definition_site_owner`], each path's sites sorted + /// by `(file id, range)`. + path_definition_sites: HashMap>>, + definition_sites_by_file: HashMap>>, + /// The class a global path's declaration carries (`---@class oslib` on + /// `os = {}`). Such a path's members belong to the class, which is where + /// every reader of the annotated type looks for them. + global_path_class_owner: HashMap, + /// Every `---@class` proposal made for a global path's declaration, by + /// path. The winner is the set minimum, so two files annotating one + /// global settle on the same class regardless of analysis order, and a + /// removed file's proposal can be dropped without losing the others. + global_path_class_candidates: HashMap>, + /// The global path a member's homing went through: its write named the + /// path directly, or a table literal that initialises one. Such members + /// follow the path when its winning `---@class` changes; members homed + /// onto a class by any other route stay where they are. + member_path_provenance: HashMap, + /// Member reads that found nothing, by the file that read. A file whose + /// read failed cached no type and so left no dependency edge; when another + /// file later defines that key, this is how the reader is found. + missed_member_reads_by_file: HashMap>, + missed_member_readers: HashMap<(LuaMemberOwner, LuaMemberKey), HashSet>, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -60,15 +160,33 @@ enum MemberOrOwner { Owner(LuaMemberOwner), } -#[derive(Debug)] -enum MemberInsertAction { - Noop, - Store(LuaMemberIndexItem), - StoreRemovingVisibleOldIds { - item: LuaMemberIndexItem, - old_ids: Vec, - }, - PushPreservedAssignment, +/// One file's `---@class` proposal for a global path's declaration. +/// +/// Ordered by class name first — the winner must be name-lexicographic-min so +/// two files annotating one global do not depend on analysis order — then by +/// file id and class identity, which breaks ties between same-named classes +/// deterministically. Deriving [`Eq`] over all three fields makes re-proposing +/// the same class from the same file a no-op. +#[derive(Debug, Clone, PartialEq, Eq)] +struct GlobalPathClassCandidate { + class_name: Box, + file_id: FileId, + class: crate::LuaTypeDeclId, +} + +impl Ord for GlobalPathClassCandidate { + fn cmp(&self, other: &Self) -> Ordering { + self.class_name + .cmp(&other.class_name) + .then_with(|| self.file_id.cmp(&other.file_id)) + .then_with(|| self.class.stable_cmp(&other.class)) + } +} + +impl PartialOrd for GlobalPathClassCandidate { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } } impl Default for LuaMemberIndex { @@ -86,46 +204,55 @@ impl LuaMemberIndex { member_current_owner: HashMap::default(), member_owner_key_index: HashMap::default(), member_owner_key_history_index: HashMap::default(), + owner_key_index_owners_by_file: HashMap::default(), current_owner_member_history: HashMap::default(), current_members_by_key: HashMap::default(), non_overwriting_assignment_members: HashSet::default(), - conditional_branch_assignment_members: HashSet::default(), + preserved_co_writer_reconciled: HashSet::default(), + homing_revision: 0, + owner_only_members: HashSet::default(), synthesized_owner_members: HashSet::default(), deferred_index_expr_members: HashSet::default(), function_scope_ranges: HashMap::default(), - conditional_branch_ranges: HashMap::default(), member_function_scope_ranges: HashMap::default(), - assignment_contributions: MemberAssignmentContributionStore::default(), + definition_site_owner: HashMap::default(), + path_definition_sites: HashMap::default(), + definition_sites_by_file: HashMap::default(), + global_path_class_owner: HashMap::default(), + global_path_class_candidates: HashMap::default(), + member_path_provenance: HashMap::default(), + missed_member_reads_by_file: HashMap::default(), + missed_member_readers: HashMap::default(), } } - /// Records this write's own evidence for the settled widening re-derivation. - pub fn record_member_assignment_contribution( - &mut self, - member_id: LuaMemberId, - contribution: MemberAssignmentContribution, - ) -> Option<()> { - let owner = self.member_current_owner.get(&member_id)?.clone(); - let key = self.get_member(&member_id)?.get_key().clone(); - self.assignment_contributions - .record(owner, key, member_id, contribution); - Some(()) - } - - pub fn member_assignment_contributions(&self) -> &MemberAssignmentContributionStore { - &self.assignment_contributions + /// The current homing generation. Per-`(owner, key)` memos record this + /// alongside what they walked; a mismatch proves an owner, key, or + /// canonical mapping moved under them, so only a full rebuild answers. + pub fn homing_revision(&self) -> u64 { + self.homing_revision } - pub fn member_assignment_contributions_mut( - &mut self, - ) -> &mut MemberAssignmentContributionStore { - &mut self.assignment_contributions + fn bump_homing_revision(&mut self) { + self.homing_revision = self.homing_revision.wrapping_add(1); } pub fn add_member(&mut self, owner: LuaMemberOwner, member: LuaMember) -> LuaMemberId { let id = member.get_id(); + let owner = self + .effective_path_member_owner(id, &owner) + .unwrap_or(owner); + self.note_member_path_provenance(id, &owner); + let owner = self.canonical(&owner).clone(); let file_id = member.get_file_id(); let function_scope = self.assignment_file_define_scope_for_member(&member); + // Re-filing an existing id re-homes whatever the old filing named: + // the member keeps its identity while its owner and key maps move, so + // any memo holding the old slot must rebuild. Pure first filings are + // append-only and leave the revision alone. + if self.members.contains_key(&id) { + self.bump_homing_revision(); + } self.members.insert(id, member); self.set_member_function_scope_range(id, function_scope); self.add_in_file_object(file_id, MemberOrOwner::Member(id)); @@ -137,380 +264,137 @@ impl LuaMemberIndex { .or_default() .insert(member_id_sort_key(id), id); self.add_in_file_object(file_id, MemberOrOwner::Owner(owner.clone())); - self.add_new_member_to_owner_key_index(owner.clone(), id); - self.add_new_member_to_owner_key_history_index(owner.clone(), id); + self.add_new_member_to_owner_key_indexes(owner.clone(), id); self.add_member_to_owner(owner.clone(), id); } id } - fn add_in_file_object(&mut self, file_id: FileId, member_or_owner: MemberOrOwner) { - self.in_filed - .entry(file_id) - .or_default() - .insert(member_or_owner); - } - - pub fn add_member_to_owner(&mut self, owner: LuaMemberOwner, id: LuaMemberId) -> Option<()> { - let member = self.get_member(&id)?; - let key = member.get_key().clone(); - let is_decl = member.get_feature().is_decl(); - if self.member_current_owner.get(&id) != Some(&owner) { - self.add_member_to_owner_key_index(owner.clone(), id); - self.add_member_to_owner_key_history_index(owner.clone(), id); - } - - self.owner_members - .entry(owner.clone()) - .or_insert_with(LuaOwnerMembers::new); - - let current_item = self - .owner_members - .get(&owner) - .and_then(|owner_members| owner_members.get_member(&key)); - let action = self.classify_member_insert(&owner, &key, id, is_decl, current_item); - self.apply_member_insert_action(owner, key, id, action); - - Some(()) - } - - fn classify_member_insert( - &self, - owner: &LuaMemberOwner, - key: &LuaMemberKey, - id: LuaMemberId, - is_decl: bool, - current_item: Option<&LuaMemberIndexItem>, - ) -> MemberInsertAction { - let Some(item) = current_item else { - return MemberInsertAction::Store(LuaMemberIndexItem::One(id)); - }; - - if is_decl { - return match item { - LuaMemberIndexItem::One(old_id) if *old_id == id => MemberInsertAction::Noop, - LuaMemberIndexItem::One(old_id) => { - MemberInsertAction::Store(LuaMemberIndexItem::Many(vec![*old_id, id])) - } - LuaMemberIndexItem::Many(ids) if ids.contains(&id) => MemberInsertAction::Noop, - LuaMemberIndexItem::Many(ids) => { - let mut ids = ids.clone(); - ids.push(id); - MemberInsertAction::Store(LuaMemberIndexItem::Many(ids)) - } - }; - } - - if let Some(action) = self.classify_conditional_branch_insert(id, item) { - return action; - } - - if self.should_preserve_assignment_file_define_member(owner, key, id) { - return MemberInsertAction::PushPreservedAssignment; - } - - if self.is_item_only_meta(item) { - return match item { - LuaMemberIndexItem::One(old_id) if *old_id == id => MemberInsertAction::Noop, - LuaMemberIndexItem::One(old_id) => { - MemberInsertAction::Store(LuaMemberIndexItem::Many(vec![id, *old_id])) - } - LuaMemberIndexItem::Many(ids) if ids.contains(&id) => MemberInsertAction::Noop, - LuaMemberIndexItem::Many(ids) => { - let mut ids = ids.clone(); - ids.push(id); - MemberInsertAction::Store(LuaMemberIndexItem::Many(ids)) - } - }; - } - - if !self.is_item_only_file_define(item) { - return MemberInsertAction::Noop; - } - - let old_member_ids = member_ids_from_item(item); - let all_assignment_file_defines = self.is_assignment_file_define_member(id) - && old_member_ids - .iter() - .all(|old_id| self.is_assignment_file_define_member(*old_id)); - - if all_assignment_file_defines { - let should_preserve_members = self.non_overwriting_assignment_members.contains(&id) - && old_member_ids - .iter() - .all(|old_id| self.non_overwriting_assignment_members.contains(old_id)); - if should_preserve_members { - let mut ids = old_member_ids; - if !ids.contains(&id) { - ids.push(id); - } - let item = match ids.as_slice() { - [id] => LuaMemberIndexItem::One(*id), - _ => LuaMemberIndexItem::Many(ids), - }; - return MemberInsertAction::Store(item); - } - - // A guarded self-assignment (`t.k = t.k or {}`) is a - // placeholder: its `{}` carries no member information of its - // own, so it must not take the visible slot from a real writer - // in another file -- which one survived would then depend on - // load order. Only across files: statements in one file run in - // source order, so a later write there genuinely supersedes. - if self.non_overwriting_assignment_members.contains(&id) - && old_member_ids.iter().any(|old_id| { - old_id.file_id != id.file_id - && !self.non_overwriting_assignment_members.contains(old_id) - }) - { - return MemberInsertAction::Noop; - } - - // The surviving write is the *latest defined* one, not the one - // that arrived last -- the rule the mixed-feature fall-through - // below already applies. Within a file the two agree, because - // the sort key leads with source position. - let candidates = || old_member_ids.iter().copied().chain(std::iter::once(id)); - let winner = candidates() - .filter(|candidate| { - !self.non_overwriting_assignment_members.contains(candidate) - || !candidates().any(|other| { - other.file_id != candidate.file_id - && !self.non_overwriting_assignment_members.contains(&other) - }) - }) - .max_by_key(|candidate| member_id_sort_key(*candidate)) - .unwrap_or_else(|| latest_defined_member(&old_member_ids, id)); - - return match item { - LuaMemberIndexItem::One(old_id) if *old_id == id => MemberInsertAction::Noop, - LuaMemberIndexItem::Many(ids) if ids.contains(&id) => MemberInsertAction::Noop, - _ => MemberInsertAction::Store(LuaMemberIndexItem::One(winner)), - }; + /// Files an existing member under a different key. + /// + /// A dynamically keyed write is filed under the type its key expression + /// inferred to, and that type is a function of how far the walk had got. + /// Once the settled passes have taken the key again against the complete + /// map, the member has to move to the slot the settled key names — the + /// member's own id is its identity, so moving it keeps the writers of a + /// slot together instead of orphaning the old key. + /// + /// Every removal path reads the key back off the member, so the member and + /// all key-indexed maps move together; a half-moved member would leave the + /// old slot behind on the next `remove_files`. + pub fn rekey_member(&mut self, member_id: LuaMemberId, new_key: LuaMemberKey) -> Option<()> { + let old_key = self.get_member(&member_id)?.get_key().clone(); + if old_key == new_key { + return Some(()); } - match item { - LuaMemberIndexItem::One(old_id) if *old_id == id => MemberInsertAction::Noop, - _ => { - // Ids this owner only *aliases* belong to another owner, and - // `add_member_alias_to_owner` never displaces what it finds. Letting - // 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)); - let winner = latest_defined_member(&owned, id); - let mut visible = aliased; - visible.push(winner); - visible.sort_by_key(|visible_id| member_id_sort_key(*visible_id)); - let new_item = match visible.as_slice() { - [only] => LuaMemberIndexItem::One(*only), - _ => LuaMemberIndexItem::Many(visible), + // The key names the canonical slot, so the old slot's history keeps a + // left-behind entry at the same raw length while the filtered set + // changed: memos must rebuild rather than suffix-fold. + self.bump_homing_revision(); + let owner = self.member_current_owner.get(&member_id).cloned(); + if let Some(owner) = &owner { + self.remove_member_from_all_owner_key_indexes(owner, member_id); + self.remove_current_member_key(member_id); + if let Some(owner_members) = self.owner_members.get_mut(owner) { + let slot_empty = match owner_members.get_member_mut(&old_key) { + Some(LuaMemberIndexItem::One(id)) => *id == member_id, + Some(LuaMemberIndexItem::Many(ids)) => { + ids.retain(|id| *id != member_id); + ids.is_empty() + } + None => false, }; - if item == &new_item { - return MemberInsertAction::Noop; - } - MemberInsertAction::StoreRemovingVisibleOldIds { - item: new_item, - old_ids: owned - .into_iter() - .chain(std::iter::once(id)) - .filter(|candidate| { - *candidate != winner - && !self.is_assignment_file_define_member(*candidate) - }) - .collect(), + if slot_empty { + owner_members.remove_member(&old_key); } } } - } - /// Resolves an owner/key slot that any conditional-branch write - /// contributes to, as a function of the members involved rather than of - /// their arrival order. - fn classify_conditional_branch_insert( - &self, - id: LuaMemberId, - item: &LuaMemberIndexItem, - ) -> Option { - if !self.is_item_only_file_define(item) - || !self.is_item_only_file_define(&LuaMemberIndexItem::One(id)) - { - return None; - } + self.members.get_mut(&member_id)?.set_key(new_key.clone()); - let mut candidates = member_ids_from_item(item); - if !candidates.contains(&id) { - candidates.push(id); + if let Some(owner) = owner { + self.add_current_member_key(member_id); + self.add_member_to_owner_key_indexes(owner.clone(), member_id); + self.add_member_to_owner(owner, member_id); } - let new_item = self.conditional_branch_item(&candidates)?; - Some(if &new_item == item { - MemberInsertAction::Noop - } else { - MemberInsertAction::Store(new_item) - }) + Some(()) } - /// The visible item for a slot at least one conditional write reaches. - /// - /// Only writes that run in the same flow can overwrite each other, so the - /// candidates are bucketed by function scope: two writes in different scopes - /// are parallel -- each runs on its own object, which is how a class collects - /// a callback field from every instance that sets one -- and both stay - /// visible. Within one scope the writes are successive, so only the last one - /// survives, unless they sit in different branches of the same `if`, where - /// exactly one of them runs and all of them survive. - /// - /// Ordered by [`member_id_sort_key`], so it is a pure function of the - /// candidate set. - fn conditional_branch_item(&self, candidates: &[LuaMemberId]) -> Option { - if !candidates.iter().any(|candidate| { - self.conditional_branch_assignment_members - .contains(candidate) - }) { - return None; - } - - let mut scopes: Vec<(Option, Vec)> = Vec::new(); - for candidate in candidates.iter().copied() { - let scope = self.member_function_scope_range(candidate); - match scopes.iter_mut().find(|(seen, _)| *seen == scope) { - Some((_, members)) => members.push(candidate), - None => scopes.push((scope, vec![candidate])), - } - } - - let mut kept = Vec::new(); - for (_, members) in &scopes { - kept.extend(self.live_writes_in_one_scope(members)); - } - if kept.is_empty() { - return None; - } - kept.sort_by_key(|id| member_id_sort_key(*id)); - - Some(match kept.as_slice() { - [only] => LuaMemberIndexItem::One(*only), - _ => LuaMemberIndexItem::Many(kept), - }) + fn add_in_file_object(&mut self, file_id: FileId, member_or_owner: MemberOrOwner) { + self.in_filed + .entry(file_id) + .or_default() + .insert(member_or_owner); } - /// The writes among `members` that can still be live at the end of the one - /// function scope they share: the branches of an `if` they write to from more - /// than one side, because exactly one of those runs, and otherwise just the - /// latest write, because successive writes in one flow overwrite each other. - fn live_writes_in_one_scope(&self, members: &[LuaMemberId]) -> Vec { - let chains = members - .iter() - .map(|member_id| self.enclosing_conditional_branches(*member_id)) - .collect::>(); - - // A write survives its scope only if some other write sits in a different - // branch of an `if` that encloses them both. Two survivors in the same - // branch still overwrite each other, so a branch contributes its latest. - let mut latest_per_branch: Vec<(Option<(TextRange, TextRange)>, LuaMemberId)> = Vec::new(); - for (index, member_id) in members.iter().copied().enumerate() { - let has_alternative = chains.iter().enumerate().any(|(other, other_chain)| { - other != index - && chains[index].iter().any(|(branch, if_range)| { - other_chain - .iter() - .any(|(seen, seen_if)| seen_if == if_range && seen != branch) - }) - }); - if !has_alternative { - continue; - } - let branch = chains[index].first().copied(); - match latest_per_branch - .iter_mut() - .find(|(seen, _)| *seen == branch) - { - Some(entry) => { - if member_id_sort_key(member_id) > member_id_sort_key(entry.1) { - entry.1 = member_id; - } - } - None => latest_per_branch.push((branch, member_id)), - } - } - if !latest_per_branch.is_empty() { - return latest_per_branch - .into_iter() - .map(|(_, member_id)| member_id) - .collect(); + /// Files `id` under `owner`. A slot holds every member written or declared + /// for its key, ordered by [`member_id_sort_key`]; readers merge them. + pub fn add_member_to_owner(&mut self, owner: LuaMemberOwner, id: LuaMemberId) -> Option<()> { + let owner = self + .effective_path_member_owner(id, &owner) + .unwrap_or(owner); + let owner = self.canonical(&owner).clone(); + let key = self.get_member(&id)?.get_key().clone(); + if self.member_current_owner.get(&id) != Some(&owner) { + self.add_member_to_owner_key_indexes(owner.clone(), id); } - - members - .iter() - .copied() - .max_by_key(|member_id| member_id_sort_key(*member_id)) - .into_iter() - .collect() + self.owner_only_members.remove(&id); + self.merge_member_into_owner_item(owner, key, id); + Some(()) } - /// 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<()> { - 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(); - let candidates = self - .get_current_owner_members_for_key(&owner, &key) - .into_iter() - .map(|member| member.get_id()) - .collect::>(); - if !candidates - .iter() - .all(|candidate| self.is_assignment_file_define_member(*candidate)) - { - return None; - } - - let item = self.conditional_branch_item(&candidates)?; - let owner_members = self.owner_members.get_mut(&owner)?; - if owner_members.get_member(&key) != Some(&item) { - owner_members.add_member(key, item); - } + /// Records `owner` as `id`'s owner without listing `id` among the owner's + /// members: the write went through an instance of a class and does not + /// define a member on it. The mark keeps later co-writer readmission from + /// listing it either, which otherwise depended on which writer the batch + /// walked first. + pub fn set_member_owner_only( + &mut self, + owner: LuaMemberOwner, + file_id: FileId, + id: LuaMemberId, + ) -> Option<()> { + self.set_member_owner(owner, file_id, id)?; + self.owner_only_members.insert(id); Some(()) } - fn apply_member_insert_action( + /// Homes a member whose write went through an alias of the global path + /// `alias_path`, stamping that provenance first. + /// + /// `local Repair = Glide.Repair` names the path's table, so the member + /// must follow the path when its winning `---@class` changes — exactly + /// what a write through the path itself would do. The provenance is + /// recorded before the effective-owner redirect so a write that names a + /// losing candidate class re-homes onto the winner, and the note's drop + /// branch is skipped so a homing that lands elsewhere keeps the alias's + /// evidence. `owner_only` files the member without listing it among the + /// owner's members, matching [`Self::set_member_owner_only`]. + pub fn home_alias_member_with_provenance( &mut self, owner: LuaMemberOwner, - key: LuaMemberKey, + file_id: FileId, id: LuaMemberId, - action: MemberInsertAction, - ) { - match action { - MemberInsertAction::Noop => {} - MemberInsertAction::Store(item) => { - self.owner_members - .entry(owner) - .or_insert_with(LuaOwnerMembers::new) - .add_member(key, item); - } - MemberInsertAction::StoreRemovingVisibleOldIds { item, old_ids } => { - for old_id in old_ids { - self.remove_member_from_visible_owner_key_index(&owner, old_id); - } - self.owner_members - .entry(owner) - .or_insert_with(LuaOwnerMembers::new) - .add_member(key, item); - } - MemberInsertAction::PushPreservedAssignment => { - self.merge_member_into_owner_item(owner, key, id); - } + alias_path: GlobalId, + owner_only: bool, + ) -> Option<()> { + self.member_path_provenance.insert(id, alias_path); + self.record_member_owner(owner, file_id, id)?; + if owner_only { + self.owner_only_members.insert(id); + return Some(()); } + let key = self.get_member(&id)?.get_key().clone(); + self.owner_only_members.remove(&id); + let owner = self.member_current_owner.get(&id)?.clone(); + self.merge_member_into_owner_item(owner, key, id); + Some(()) } - /// Records that `id`'s owner was decided by scripted-class synthesis, so the - /// global-member migration must leave it alone. + /// Records that `id`'s owner was decided by scripted-class synthesis, so + /// [`Self::set_global_path_class`] leaves it where it is instead of moving + /// it onto the annotated class. pub fn pin_synthesized_owner(&mut self, id: LuaMemberId) { self.synthesized_owner_members.insert(id); } @@ -533,9 +417,12 @@ impl LuaMemberIndex { /// Removes `id` from `owner` entirely, including the item /// `set_member_owner` leaves behind. pub fn detach_member_from_owner(&mut self, owner: &LuaMemberOwner, id: LuaMemberId) { + let owner = &self.canonical(owner).clone(); let Some(key) = self.get_member(&id).map(|member| member.get_key().clone()) else { return; }; + self.bump_homing_revision(); + self.drop_member_path_provenance_on_detach(owner, id); self.remove_member_from_all_owner_key_indexes(owner, id); self.remove_current_owner_member(owner, id); @@ -565,104 +452,20 @@ impl LuaMemberIndex { owner: LuaMemberOwner, id: LuaMemberId, ) -> Option<()> { + let owner = self.canonical(&owner).clone(); let member = self.get_member(&id)?; let file_id = member.get_file_id(); let key = member.get_key().clone(); if self.member_current_owner.get(&id) != Some(&owner) { - self.add_member_to_owner_key_index(owner.clone(), id); - self.add_member_to_owner_key_history_index(owner.clone(), id); - } - - let owner_members = self - .owner_members - .entry(owner.clone()) - .or_insert_with(LuaOwnerMembers::new); - if owner_members.contains_member(&key) { - self.merge_member_into_owner_item(owner.clone(), key, id); - } else { - owner_members.add_member(key, LuaMemberIndexItem::One(id)); + self.add_member_to_owner_key_indexes(owner.clone(), id); } + self.merge_member_into_owner_item(owner.clone(), key, id); self.add_in_file_object(file_id, MemberOrOwner::Owner(owner)); Some(()) } - /// Whether every write - /// [`add_member_alias_to_owner`](Self::add_member_alias_to_owner) would - /// perform for `(owner, id)` is already in the index, so calling it would - /// leave the index unchanged. - pub(crate) fn alias_to_owner_is_recorded( - &self, - owner: &LuaMemberOwner, - id: LuaMemberId, - ) -> bool { - let Some(member) = self.get_member(&id) else { - return false; - }; - 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)) - }; - if self.member_current_owner.get(&id) != Some(owner) - && !(is_indexed(&self.member_owner_key_index) - && is_indexed(&self.member_owner_key_history_index)) - { - return false; - } - - let item_holds_member = self - .owner_members - .get(owner) - .and_then(|owner_members| owner_members.get_member(key)) - .is_some_and(|item| match item { - LuaMemberIndexItem::One(existing_id) => *existing_id == id, - LuaMemberIndexItem::Many(ids) => ids.contains(&id), - }); - if !item_holds_member { - return false; - } - - self.in_filed - .get(&member.get_file_id()) - .is_some_and(|objects| objects.contains(&MemberOrOwner::Owner(owner.clone()))) - } - - fn should_preserve_assignment_file_define_member( - &self, - owner: &LuaMemberOwner, - key: &LuaMemberKey, - id: LuaMemberId, - ) -> bool { - if !self.non_overwriting_assignment_members.contains(&id) - || !self.is_assignment_file_define_member(id) - { - return false; - } - - self.owner_members - .get(owner) - .and_then(|owner_members| owner_members.get_member(key)) - .is_some_and(|item| self.item_can_append_preserved_assignment_member(item)) - } - - fn item_can_append_preserved_assignment_member(&self, item: &LuaMemberIndexItem) -> bool { - match item { - LuaMemberIndexItem::One(id) => { - self.is_assignment_file_define_member(*id) - && self.non_overwriting_assignment_members.contains(id) - } - LuaMemberIndexItem::Many(ids) => ids.last().is_some_and(|id| { - self.is_assignment_file_define_member(*id) - && self.non_overwriting_assignment_members.contains(id) - }), - } - } - /// Adds `id` to the item already stored at `owner`/`key`, keeping the item a /// set ordered by `member_id_sort_key`. Never removes an existing id, and is /// a no-op when `id` is already present. @@ -672,12 +475,12 @@ impl LuaMemberIndex { key: LuaMemberKey, id: LuaMemberId, ) { - let Some(item) = self + let owner_members = self .owner_members .entry(owner) - .or_insert_with(LuaOwnerMembers::new) - .get_member_mut(&key) - else { + .or_insert_with(LuaOwnerMembers::new); + let Some(item) = owner_members.get_member_mut(&key) else { + owner_members.add_member(key, LuaMemberIndexItem::One(id)); return; }; @@ -688,13 +491,11 @@ impl LuaMemberIndex { } } LuaMemberIndexItem::Many(ids) => { - // `Many` is not guaranteed sorted — `classify_member_insert` - // appends in arrival order — so the ordered fast paths below - // cannot themselves rule out a duplicate. Enumerating a member - // twice is worse than the linear scan; these lists are short. - if ids.contains(&id) { - return; - } + // `Many` is always sorted by `member_id_sort_key`: `One` + // promotes through `sorted_member_pair`, every later insert + // lands through the paths below, and removals only filter — + // so the binary search doubles as the duplicate check and no + // linear scan is needed. if ids .last() .is_none_or(|last_id| member_id_sort_key(*last_id) < member_id_sort_key(id)) @@ -713,203 +514,191 @@ impl LuaMemberIndex { } } - fn add_member_to_owner_key_index(&mut self, owner: LuaMemberOwner, id: LuaMemberId) { - self.add_member_id_to_owner_key_map(owner, id, false); - } + /// Files `id` under its owner and key in both owner-key indexes, ignoring a + /// slot that already holds it. + fn add_member_to_owner_key_indexes(&mut self, owner: LuaMemberOwner, id: LuaMemberId) { + let Some(key) = self.get_member(&id).map(|member| member.get_key().clone()) else { + return; + }; - fn add_member_to_owner_key_history_index(&mut self, owner: LuaMemberOwner, id: LuaMemberId) { - self.add_member_id_to_owner_key_map(owner, id, true); + self.record_owner_key_index_owner(id.file_id, &owner); + push_owner_key_id_unique( + &mut self.member_owner_key_index, + owner.clone(), + key.clone(), + id, + ); + push_owner_key_id_unique(&mut self.member_owner_key_history_index, owner, key, id); } - fn add_new_member_to_owner_key_index(&mut self, owner: LuaMemberOwner, id: LuaMemberId) { - self.add_new_member_id_to_owner_key_map(owner, id, false); + /// [`add_member_to_owner_key_indexes`](Self::add_member_to_owner_key_indexes) + /// for a member the caller knows neither index holds yet. + fn add_new_member_to_owner_key_indexes(&mut self, owner: LuaMemberOwner, id: LuaMemberId) { + let Some(key) = self.get_member(&id).map(|member| member.get_key().clone()) else { + return; + }; + + self.record_owner_key_index_owner(id.file_id, &owner); + push_owner_key_id( + &mut self.member_owner_key_index, + owner.clone(), + key.clone(), + id, + ); + push_owner_key_id(&mut self.member_owner_key_history_index, owner, key, id); } - fn add_new_member_to_owner_key_history_index( - &mut self, - owner: LuaMemberOwner, - id: LuaMemberId, - ) { - self.add_new_member_id_to_owner_key_map(owner, id, true); + fn record_owner_key_index_owner(&mut self, file_id: FileId, owner: &LuaMemberOwner) { + let owners = self + .owner_key_index_owners_by_file + .entry(file_id) + .or_default(); + if !owners.contains(owner) { + owners.insert(owner.clone()); + } } - fn add_member_id_to_owner_key_map( + /// Drops `id` from the visible index only, leaving the history index to + /// remember that this owner once held it. + fn remove_member_from_visible_owner_key_index( &mut self, - owner: LuaMemberOwner, + owner: &LuaMemberOwner, id: LuaMemberId, - history: bool, ) { let Some(key) = self.get_member(&id).map(|member| member.get_key().clone()) else { return; }; - - { - let target_index = if history { - &mut self.member_owner_key_history_index - } else { - &mut self.member_owner_key_index - }; - let member_ids = target_index - .entry(owner) - .or_default() - .entry(key.clone()) - .or_default(); - if !member_ids.contains(&id) { - member_ids.push(id); - } - } + remove_owner_key_id(&mut self.member_owner_key_index, owner, &key, id); } - fn add_new_member_id_to_owner_key_map( + fn remove_member_from_all_owner_key_indexes( &mut self, - owner: LuaMemberOwner, + owner: &LuaMemberOwner, id: LuaMemberId, - history: bool, ) { let Some(key) = self.get_member(&id).map(|member| member.get_key().clone()) else { return; }; - - let target_index = if history { - &mut self.member_owner_key_history_index - } else { - &mut self.member_owner_key_index - }; - target_index - .entry(owner) - .or_default() - .entry(key) - .or_default() - .push(id); - } - - fn remove_member_from_visible_owner_key_index( - &mut self, - owner: &LuaMemberOwner, - id: LuaMemberId, - ) { - self.remove_member_from_owner_key_map(owner, id, false); - } - - fn remove_member_from_all_owner_key_indexes( - &mut self, - owner: &LuaMemberOwner, - id: LuaMemberId, - ) { - self.remove_member_from_visible_owner_key_index(owner, id); - self.remove_member_from_owner_key_map(owner, id, true); - } - - fn remove_member_from_owner_key_map( - &mut self, - owner: &LuaMemberOwner, - id: LuaMemberId, - history: bool, - ) { - let Some(key) = self.get_member(&id).map(|member| member.get_key().clone()) else { - return; - }; - - let mut remove_owner_entry = false; - let target_index = if history { - &mut self.member_owner_key_history_index - } else { - &mut self.member_owner_key_index - }; - if let Some(owner_items) = target_index.get_mut(owner) { - if let Some(member_ids) = owner_items.get_mut(&key) { - member_ids.retain(|member_id| *member_id != id); - if member_ids.is_empty() { - owner_items.remove(&key); - } - } - remove_owner_entry = owner_items.is_empty(); - } - - if remove_owner_entry { - target_index.remove(owner); - } - } + remove_owner_key_id(&mut self.member_owner_key_index, owner, &key, id); + remove_owner_key_id(&mut self.member_owner_key_history_index, owner, &key, id); + } fn remove_files_members_from_owner_key_indexes(&mut self, removed: &HashSet) { - Self::remove_files_members_from_owner_key_map(&mut self.member_owner_key_index, removed); - Self::remove_files_members_from_owner_key_map( + let owners = removed + .iter() + .filter_map(|file_id| self.owner_key_index_owners_by_file.remove(file_id)) + .flatten() + .collect::>(); + let visible_removed = Self::remove_files_members_from_owner_key_map( + &mut self.member_owner_key_index, + removed, + &owners, + ); + let history_removed = Self::remove_files_members_from_owner_key_map( &mut self.member_owner_key_history_index, removed, + &owners, ); + // The sweep edits history membership behind every memo's back: a slot + // that lost an entry shrinks, but one that lost nothing keeps its raw + // length, so only bump when an entry actually left. + if visible_removed || history_removed { + self.bump_homing_revision(); + } } fn remove_files_members_from_owner_key_map( owner_key_index: &mut HashMap>>, removed: &HashSet, - ) { - owner_key_index.retain(|_, key_members| { + owners: &HashSet, + ) -> bool { + let mut removed_any = false; + for owner in owners { + let Some(key_members) = owner_key_index.get_mut(owner) else { + continue; + }; key_members.retain(|_, member_ids| { + let before = member_ids.len(); member_ids.retain(|member_id| !removed.contains(&member_id.file_id)); + if member_ids.len() != before { + removed_any = true; + } !member_ids.is_empty() }); - !key_members.is_empty() - }); - } - - fn is_item_only_meta(&self, item: &LuaMemberIndexItem) -> bool { - match item { - LuaMemberIndexItem::One(id) => { - if let Some(member) = self.get_member(id) { - return member.get_feature().is_meta_decl(); - } - } - LuaMemberIndexItem::Many(ids) => { - for id in ids { - if let Some(member) = self.get_member(id) - && !member.get_feature().is_meta_decl() - { - return false; - } - } - return true; + if key_members.is_empty() { + owner_key_index.remove(owner); } } - - false - } - - fn is_item_only_file_define(&self, item: &LuaMemberIndexItem) -> bool { - match item { - LuaMemberIndexItem::One(id) => self - .get_member(id) - .is_some_and(|member| member.get_feature().is_file_define()), - LuaMemberIndexItem::Many(ids) => ids.iter().all(|id| { - self.get_member(id) - .is_some_and(|member| member.get_feature().is_file_define()) - }), - } + removed_any } fn is_assignment_file_define_member(&self, id: LuaMemberId) -> bool { - self.get_member(&id).is_some_and(|member| { - member.get_feature().is_file_define() - && member.get_syntax_id().get_kind() == LuaSyntaxKind::IndexExpr - }) + self.get_member(&id) + .is_some_and(LuaMember::is_assignment_define) } fn assignment_file_define_scope_for_member(&self, member: &LuaMember) -> Option { - if !member.get_feature().is_file_define() - || member.get_syntax_id().get_kind() != LuaSyntaxKind::IndexExpr - { + if !member.is_assignment_define() { return None; } self.enclosing_function_scope_range(member.get_file_id(), member.get_id().get_position()) } + /// Records `owner` as `id`'s current owner, filing its history entry. + /// + /// Production callers always follow this with an item merge (see + /// [`Self::add_member_to_owner`]); a bare call without one would leave a + /// history entry the item does not list, which must clear that slot in + /// [`Self::preserved_co_writer_reconciled`]. + /// + /// Homing invariant: a real owner move leaves the old slot's history + /// holding a left-behind entry, so its raw length is unchanged while its + /// filtered set shrank; a deferred first homing (no previous owner, but + /// the id is already indexed) grows a slot whose history already listed + /// the id without changing its raw length either. Both cases bump + /// [`Self::homing_revision`] so per-`(owner, key)` memos rebuild instead + /// of trusting a length check. Re-homing onto the same owner, or homing + /// a brand-new id for the first time, is append-only and leaves the + /// revision alone. pub fn set_member_owner( &mut self, owner: LuaMemberOwner, file_id: FileId, id: LuaMemberId, ) -> Option<()> { + let owner = self + .effective_path_member_owner(id, &owner) + .unwrap_or(owner); + self.note_member_path_provenance(id, &owner); + self.record_member_owner(owner, file_id, id) + } + + /// The filing half of [`Self::set_member_owner`], without the provenance + /// note. Callers that record provenance themselves route through here so + /// the note's drop branch cannot undo what they just recorded. + fn record_member_owner( + &mut self, + owner: LuaMemberOwner, + file_id: FileId, + id: LuaMemberId, + ) -> Option<()> { + let owner = self + .effective_path_member_owner(id, &owner) + .unwrap_or(owner); + let owner = self.canonical(&owner).clone(); + // Read before the insert decides the bump: a real move, or a first + // homing for an id the index already holds (deferred resolution filed + // the member under an unknown owner and only now learned better). + // A pure first filing (unknown id) and a same-owner re-home are + // append-only and must not invalidate memos. + let already_indexed = self.members.contains_key(&id); let previous_owner = self.member_current_owner.insert(id, owner.clone()); + match &previous_owner { + None if already_indexed => self.bump_homing_revision(), + Some(previous) if previous != &owner => self.bump_homing_revision(), + _ => {} + } if previous_owner.is_none() { self.add_current_member_key(id); } @@ -926,8 +715,7 @@ impl LuaMemberIndex { .or_default() .insert(member_id_sort_key(id), id); - self.add_member_to_owner_key_index(owner.clone(), id); - self.add_member_to_owner_key_history_index(owner.clone(), id); + self.add_member_to_owner_key_indexes(owner.clone(), id); if self.member_function_scope_range(id).is_none() && let Some(member) = self.get_member(&id) { @@ -944,11 +732,391 @@ impl LuaMemberIndex { } pub fn get_member_mut(&mut self, id: &LuaMemberId) -> Option<&mut LuaMember> { + // Defensive: the key lives on the member, so any mutable access could + // re-home it behind every memo's back. No caller does so today except + // through `rekey_member` (which bumps itself), but a future direct + // `set_key` would otherwise read stale suffix-folded answers. + if self.members.contains_key(id) { + self.bump_homing_revision(); + } self.members.get_mut(id) } /// Every global path that currently has members parked on it, in a /// stable order. + /// Records `site` as a table literal that initialises the global `path`. + /// + /// A path's members all live on [`LuaMemberOwner::GlobalPath`]; its literals + /// are definition sites, not owners. The site is decided by the file that + /// owns the range, and that file's declaration walk always precedes any + /// member insertion naming the range, so a member never has to be re-homed: + /// the decl walk is pre-order, so `X = {}` registers before the literal's + /// own fields are added, and every later phase runs after the whole batch's + /// decl phase. A file cannot name another file's literal range + /// syntactically, so a foreign write is always `a.b.c = v`, which the decl + /// analyzer already routes to the path. + pub fn set_definition_site(&mut self, path: GlobalId, site: crate::InFiled) { + let owner = LuaMemberOwner::GlobalPath(path.clone()); + if self.definition_site_owner.get(&site) == Some(&owner) { + return; + } + // Remapping what a canonical slot names re-homes every member filed + // under the affected literal: memos keyed by the old canonical slot + // must rebuild. `forget_definition_site` bumps for the unmap; bump + // again for the remap itself (a fresh site has no unmap to bump on). + self.forget_definition_site(&site); + self.bump_homing_revision(); + self.definition_site_owner.insert(site.clone(), owner); + let sites = self.path_definition_sites.entry(path.clone()).or_default(); + if let Err(at) = sites.binary_search_by_key(&site_sort_key(&site), site_sort_key) { + sites.insert(at, site.clone()); + } + self.definition_sites_by_file + .entry(site.file_id) + .or_default() + .push(site.clone()); + // A member filed under the literal before the site was registered was + // homed onto the Element owner with no path provenance. Stamp it so a + // later `---@class` flip still takes it along. Idempotent, and rare: + // the decl walk registers each site before the writes it owns. + let member_ids: Vec = self + .owner_members + .get(&LuaMemberOwner::Element(site)) + .into_iter() + .flat_map(LuaOwnerMembers::get_member_items) + .flat_map(member_ids_from_item) + .collect(); + self.stamp_path_provenance_if_absent(member_ids, &path); + } + + /// The global path the table literal `site` initialises, if one is + /// registered for it. + pub fn definition_site_path(&self, site: &crate::InFiled) -> Option { + self.definition_site_owner.get(site)?.get_path().cloned() + } + + /// Whether `path` is a global path the index knows about: it has members + /// filed under it, a registered definition site, or a `---@class` + /// proposal. Read-only and cheap, so producers may consult it per write. + pub fn is_known_global_path(&self, path: &GlobalId) -> bool { + self.owner_members + .contains_key(&LuaMemberOwner::GlobalPath(path.clone())) + || self.global_path_class_candidates.contains_key(path) + || self.path_definition_sites.contains_key(path) + } + + /// Records `path` as the provenance of members that do not have one yet. + /// + /// Members already carrying provenance keep it: the earliest evidence of + /// how a member reached its owner is the one its later homings honor, and + /// re-stamping the same path stays a no-op. + pub fn stamp_path_provenance_if_absent( + &mut self, + member_ids: impl IntoIterator, + path: &GlobalId, + ) { + for member_id in member_ids { + self.member_path_provenance + .entry(member_id) + .or_insert_with(|| path.clone()); + } + } + + /// The literals initialising `path`, sorted by `(file id, range)`. + pub fn definition_sites(&self, path: &GlobalId) -> &[crate::InFiled] { + self.path_definition_sites + .get(path) + .map_or(&[], |sites| sites.as_slice()) + } + + /// The one owner a table literal's members belong to. + /// + /// A literal that initialises a global path is not an owner: its members + /// belong to the path, alongside every other literal's and every + /// `path.k = v` write's. Applied wherever an owner enters or is looked up + /// in this index, so producers keep building [`LuaMemberOwner::Element`] + /// and never see the difference. + pub fn canonical_owner(&self, owner: LuaMemberOwner) -> LuaMemberOwner { + match self.canonical(&owner) { + canonical if canonical == &owner => owner, + canonical => canonical.clone(), + } + } + + fn canonical<'a>(&'a self, owner: &'a LuaMemberOwner) -> &'a LuaMemberOwner { + let owner = match owner { + LuaMemberOwner::Element(range) => { + self.definition_site_owner.get(range).unwrap_or(owner) + } + _ => owner, + }; + match owner { + LuaMemberOwner::GlobalPath(path) => { + self.global_path_class_owner.get(path).unwrap_or(owner) + } + _ => owner, + } + } + + /// Records or drops `id`'s global-path provenance for a homing decision. + /// + /// A write that named global path `path` directly, or that filed the member + /// under a table literal initialising one, makes the member a path member: + /// it must follow the path when its winning `---@class` changes. A member + /// re-homed onto the path's current canonical owner keeps its provenance; + /// one re-homed anywhere else is no longer a path member and loses it. + fn note_member_path_provenance(&mut self, id: LuaMemberId, raw_owner: &LuaMemberOwner) { + if let Some(path) = raw_owner.get_path() { + self.member_path_provenance.insert(id, path.clone()); + return; + } + let canonical = self.canonical(raw_owner).clone(); + if let Some(path) = canonical.get_path() { + self.member_path_provenance.insert(id, path.clone()); + return; + } + let Some(recorded) = self.member_path_provenance.get(&id).cloned() else { + return; + }; + let recorded_canonical = self + .canonical(&LuaMemberOwner::GlobalPath(recorded)) + .clone(); + if recorded_canonical != canonical { + self.member_path_provenance.remove(&id); + } + } + + /// Drops `id`'s provenance when it is detached from the owner the + /// provenance names, so a final detachment cannot leave a member the next + /// class flip would resurrect onto a path it left. + fn drop_member_path_provenance_on_detach( + &mut self, + detached_from: &LuaMemberOwner, + id: LuaMemberId, + ) { + let Some(recorded) = self.member_path_provenance.get(&id).cloned() else { + return; + }; + let provenance_canonical = self + .canonical(&LuaMemberOwner::GlobalPath(recorded)) + .clone(); + if provenance_canonical == *detached_from { + self.member_path_provenance.remove(&id); + } + } + + /// The owner a path member's homing actually lands on. + /// + /// A path and its annotated classes are one table. A write that homes a + /// path-provenance member onto a class the path has already outranked + /// names the same table through a losing name — a re-indexed file still + /// binding its own (losing) class does this on every ripple — so the + /// winner decides and the member homes under it instead. Without this the + /// members surface under both classes. Homing to any other owner is a + /// genuine move and is left alone. + fn effective_path_member_owner( + &self, + id: LuaMemberId, + owner: &LuaMemberOwner, + ) -> Option { + let class = owner.get_type_id()?; + let provenance = self.member_path_provenance.get(&id)?; + let candidates = self.global_path_class_candidates.get(provenance)?; + let winner = candidates.iter().next()?.class.clone(); + if *class == winner { + return None; + } + candidates + .iter() + .any(|candidate| candidate.class == *class) + .then(|| LuaMemberOwner::Type(winner)) + } + + /// Records that `path`'s declaration is annotated `---@class`, and moves + /// the path's members onto the winning class. + /// + /// A global with a class annotation has two names for one table, and every + /// reader of the annotated type looks the members up on the class. Every + /// proposal is recorded, and the winner is the name-lexicographic-min + /// candidate (ties broken by file id and class identity), so two files + /// annotating one global do not depend on which was analysed first. When + /// the winner changes, the members already on the losing class move to the + /// winner; members still parked on the path itself move too. + pub fn set_global_path_class( + &mut self, + path: GlobalId, + class: crate::LuaTypeDeclId, + file_id: FileId, + ) { + let winner = { + let candidates = self + .global_path_class_candidates + .entry(path.clone()) + .or_default(); + candidates.insert(GlobalPathClassCandidate { + class_name: class.get_name().into(), + file_id, + class: class.clone(), + }); + candidates + .iter() + .next() + .map(|candidate| candidate.class.clone()) + }; + let Some(winner) = winner else { + return; + }; + let old = self + .global_path_class_owner + .get(&path) + .and_then(LuaMemberOwner::get_type_id) + .cloned(); + if old.as_ref() == Some(&winner) { + return; + } + self.global_path_class_owner + .insert(path.clone(), LuaMemberOwner::Type(winner.clone())); + // The canonical mapping itself changed what the path slot names, on + // top of the per-member moves in `rehome_global_path_class` (each + // bumps on its own). + self.bump_homing_revision(); + self.rehome_global_path_class(&path, old.as_ref(), &LuaMemberOwner::Type(winner)); + } + + /// Moves the members of `path` between its two homes: from the path slot + /// and, when one is losing it, from the losing class, onto `to`. + /// + /// Only members that reached their current home through `path` move: + /// members homed onto the losing class by any other route (a write through + /// a genuinely class-typed value) stay put, as do members whose owner was + /// decided by scripted-class synthesis. Owner-only members move only when + /// their provenance names `path` — a write through the path or an alias of + /// it — since an owner-only write through a genuinely class-typed value is + /// not a path member. The moved member's item is pruned from the owner it leaves — + /// `set_member_owner` rewrites the key indexes but leaves owner items + /// behind — so the losing class stops listing path members. + fn rehome_global_path_class( + &mut self, + path: &GlobalId, + from: Option<&crate::LuaTypeDeclId>, + to: &LuaMemberOwner, + ) { + let path_owner = LuaMemberOwner::GlobalPath(path.clone()); + let mut member_ids = self + .owner_members + .get(&path_owner) + .into_iter() + .flat_map(|members| members.get_member_items()) + .flat_map(member_ids_from_item) + .filter(|id| { + !self.synthesized_owner_members.contains(id) + && self.member_current_owner.get(id) == Some(&path_owner) + && (!self.owner_only_members.contains(id) + || self.member_path_provenance.get(id) == Some(path)) + }) + .collect::>(); + + if let Some(old) = from { + let old_owner = LuaMemberOwner::Type(old.clone()); + member_ids.extend( + self.owner_members + .get(&old_owner) + .into_iter() + .flat_map(|members| members.get_member_items()) + .flat_map(member_ids_from_item) + .filter(|id| { + !self.synthesized_owner_members.contains(id) + && self.member_current_owner.get(id) == Some(&old_owner) + && self.member_path_provenance.get(id) == Some(path) + }), + ); + } + + member_ids.sort_unstable_by_key(|id| member_id_sort_key(*id)); + member_ids.dedup(); + for member_id in member_ids { + self.remove_member_from_owner_item(&path_owner, member_id); + if let Some(old) = from { + self.remove_member_from_owner_item(&LuaMemberOwner::Type(old.clone()), member_id); + } + self.set_member_owner(to.clone(), member_id.file_id, member_id); + self.add_member_to_owner(to.clone(), member_id); + } + } + + /// Drops `id` from the member item it occupies under `owner`, and the + /// owner once its map is empty, without touching the owner-key indexes or + /// the member itself. + fn remove_member_from_owner_item(&mut self, owner: &LuaMemberOwner, id: LuaMemberId) { + let Some(key) = self.get_member(&id).map(|member| member.get_key().clone()) else { + return; + }; + let Some(owner_members) = self.owner_members.get_mut(owner) else { + return; + }; + let drop_key = match owner_members.get_member_mut(&key) { + Some(LuaMemberIndexItem::One(existing)) => *existing == id, + Some(LuaMemberIndexItem::Many(ids)) => { + ids.retain(|held| *held != id); + ids.is_empty() + } + None => false, + }; + if drop_key { + owner_members.remove_member(&key); + } + if owner_members.is_empty() { + self.owner_members.remove(owner); + } + } + + fn remove_file_definition_sites(&mut self, file_id: FileId) { + let Some(sites) = self.definition_sites_by_file.remove(&file_id) else { + return; + }; + for site in sites { + self.forget_definition_site(&site); + } + } + + fn forget_definition_site(&mut self, site: &crate::InFiled) { + let Some(path) = self + .definition_site_owner + .remove(site) + .and_then(|owner| owner.get_path().cloned()) + else { + return; + }; + // Unmapping what a canonical slot names re-homes the literal's + // members back onto the literal itself. + self.bump_homing_revision(); + if let Some(sites) = self.path_definition_sites.get_mut(&path) { + sites.retain(|held| held != site); + if sites.is_empty() { + self.path_definition_sites.remove(&path); + } + } + if let Some(sites) = self.definition_sites_by_file.get_mut(&site.file_id) { + sites.retain(|held| held != site); + if sites.is_empty() { + self.definition_sites_by_file.remove(&site.file_id); + } + } + } + + /// Every path with at least one definition site, and its sites, in a stable + /// order. Used by the determinism harness to observe that ownership does + /// not depend on index order. + pub fn sorted_definition_sites(&self) -> Vec<(&GlobalId, &[crate::InFiled])> { + let mut entries: Vec<(&GlobalId, &[crate::InFiled])> = self + .path_definition_sites + .iter() + .map(|(path, sites)| (path, sites.as_slice())) + .collect(); + entries.sort_unstable_by(|left, right| left.0.get_name().cmp(right.0.get_name())); + entries + } + pub fn sorted_global_path_owners(&self) -> Vec { let mut global_ids = self .owner_members @@ -962,7 +1130,43 @@ 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 = self.canonical(owner); let owner_members = self.owner_members.get(owner)?; if owner_members.get_member_len() == 0 { return Some(Vec::new()); @@ -989,6 +1193,7 @@ impl LuaMemberIndex { /// The owner's members whose key is an expression rather than a name, in /// the same order [`Self::get_members`] would yield them. pub fn get_expr_key_members(&self, owner: &LuaMemberOwner) -> Option> { + let owner = self.canonical(owner); let owner_members = self.owner_members.get(owner)?; let mut member_ids = Vec::new(); for key in owner_members.expr_keys() { @@ -1017,6 +1222,7 @@ impl LuaMemberIndex { owner: &LuaMemberOwner, key: &LuaMemberKey, ) -> Option> { + let owner = self.canonical(owner); let owner_members = self.owner_members.get(owner)?; let mut member_ids = match owner_members.get_member(key) { Some(LuaMemberIndexItem::One(id)) => vec![*id], @@ -1037,7 +1243,7 @@ impl LuaMemberIndex { owner: &LuaMemberOwner, ) -> impl Iterator + 'a { self.owner_members - .get(owner) + .get(self.canonical(owner)) .into_iter() .flat_map(LuaOwnerMembers::get_member_keys) } @@ -1062,12 +1268,14 @@ impl LuaMemberIndex { owner: &LuaMemberOwner, key: &LuaMemberKey, ) -> Option<&LuaMemberIndexItem> { + let owner = self.canonical(owner); self.owner_members .get(owner) .and_then(|map| map.get_member(key)) } pub fn get_member_len(&self, owner: &LuaMemberOwner) -> usize { + let owner = self.canonical(owner); self.owner_members .get(owner) .map_or(0, |map| map.get_member_len()) @@ -1080,6 +1288,7 @@ impl LuaMemberIndex { /// key can outlive the member it points at. Stops at the first live id /// rather than materialising the owner's whole member list. pub fn has_live_member(&self, owner: &LuaMemberOwner) -> bool { + let owner = self.canonical(owner); let Some(owner_members) = self.owner_members.get(owner) else { return false; }; @@ -1102,6 +1311,7 @@ impl LuaMemberIndex { owner: &LuaMemberOwner, key: &LuaMemberKey, ) -> usize { + let owner = self.canonical(owner); self.member_owner_key_index .get(owner) .and_then(|owner_items| owner_items.get(key)) @@ -1115,6 +1325,7 @@ impl LuaMemberIndex { key: &LuaMemberKey, excluded_member_id: LuaMemberId, ) -> bool { + let owner = self.canonical(owner); let Some(member_ids) = self .member_owner_key_index .get(owner) @@ -1135,6 +1346,7 @@ impl LuaMemberIndex { owner: &LuaMemberOwner, key: &LuaMemberKey, ) -> Vec<&LuaMember> { + let owner = self.canonical(owner); let Some(owner_items) = self.member_owner_key_index.get(owner) else { return Vec::new(); }; @@ -1157,6 +1369,7 @@ impl LuaMemberIndex { /// Every member ever keyed under `owner`, including ones hidden by the /// latest-assignment view and ones since re-homed to a concrete owner. pub fn get_member_history(&self, owner: &LuaMemberOwner) -> Vec<&LuaMember> { + let owner = self.canonical(owner); let Some(owner_items) = self.member_owner_key_history_index.get(owner) else { return Vec::new(); }; @@ -1177,6 +1390,7 @@ impl LuaMemberIndex { owner: &LuaMemberOwner, global_id: &GlobalId, ) -> Vec { + let owner = self.canonical(owner); // The member key is the part of the path below the owner, so the members // declaring it are one bucket of the owner's history rather than all of // it. Reading the whole history to filter it built and sorted every @@ -1233,41 +1447,6 @@ impl LuaMemberIndex { } } - pub fn add_conditional_branch_range( - &mut self, - file_id: FileId, - branch: TextRange, - if_range: TextRange, - ) { - let ranges = self.conditional_branch_ranges.entry(file_id).or_default(); - match ranges.binary_search_by_key(&branch.start(), |(branch, _)| branch.start()) { - Ok(index) | Err(index) => ranges.insert(index, (branch, if_range)), - } - } - - /// Every `if` branch containing `member_id`, each paired with the `if` it - /// belongs to, innermost first. Empty when the write is not inside any - /// branch. - fn enclosing_conditional_branches( - &self, - member_id: LuaMemberId, - ) -> Vec<(TextRange, TextRange)> { - let Some(ranges) = self.conditional_branch_ranges.get(&member_id.file_id) else { - return Vec::new(); - }; - let position = member_id.get_position(); - let mut enclosing = Vec::new(); - let mut index = ranges.partition_point(|(branch, _)| branch.start() <= position); - while index > 0 { - index -= 1; - let entry = ranges[index]; - if entry.0.contains(position) { - enclosing.push(entry); - } - } - enclosing - } - pub fn enclosing_function_scope_range( &self, file_id: FileId, @@ -1303,16 +1482,86 @@ impl LuaMemberIndex { pub fn mark_non_overwriting_assignment_member(&mut self, member_id: LuaMemberId) { self.non_overwriting_assignment_members.insert(member_id); + self.readmit_preserved_assignment_co_writers(member_id); } - pub fn is_non_overwriting_assignment_member(&self, member_id: LuaMemberId) -> bool { + /// Re-admits the co-writers this member's slot lost before the member was + /// known to be a guarded self-assignment (`t.k = t.k or {}`). + /// + /// A write is marked *after* it is inserted, so a writer that arrives + /// before its mark can leave a slot its co-writers no longer cover: on a + /// cold build the co-writer is in the same batch and its own mark merges + /// it back, while re-indexing one file can drop a co-writer the batch + /// does not re-analyse, which nothing then restores. Re-applying the + /// accumulate rule here is the answer a different arrival order would + /// have produced, so both paths settle on it. + /// + /// Once a full scan of a slot merges nothing, later scans merge nothing + /// either, so the slot is remembered in + /// [`Self::preserved_co_writer_reconciled`] to skip them: every + /// production history write pairs with an item merge (`add_member`, + /// `add_member_to_owner`, `add_member_alias_to_owner`, `rekey_member`, + /// and `set_member_owner` only through those callers), every removal + /// touches both sides for the same id, and marking only ever covers an + /// already-listed member. Any future history write without a paired item + /// merge must clear that slot's entry. + /// + /// Exception: [`Self::set_member_owner_only`] calls `set_member_owner` + /// without an item merge (see the `set_member_owner_only` body), filing a + /// history entry the item never lists. That bare write is safe only + /// because readmission skips [`Self::owner_only_members`] when merging + /// co-writers back, so the unlisted id can never be merged in later. + fn readmit_preserved_assignment_co_writers(&mut self, member_id: LuaMemberId) -> Option<()> { + if !self.is_preserved_assignment_member(member_id) { + return None; + } + let owner = self.member_current_owner.get(&member_id)?.clone(); + let key = self.get_member(&member_id)?.get_key().clone(); + if self + .preserved_co_writer_reconciled + .contains(&(owner.clone(), key.clone())) + { + return Some(()); + } + let visible = self + .owner_members + .get(&owner) + .and_then(|owner_members| owner_members.get_member(&key)) + .map(member_ids_from_item)?; + // Only a slot whose every writer is a guarded assignment accumulates + // them all; one holding a real writer keeps deciding by the rules that + // put it there. + if !visible + .iter() + .all(|id| self.is_preserved_assignment_member(*id)) + { + return None; + } + let visible_set: HashSet = visible.iter().copied().collect(); + let mut merged_any = false; + for co_writer in self.get_current_owner_member_ids_for_key_unsorted(&owner, &key) { + if co_writer != member_id + && !visible_set.contains(&co_writer) + && !self.owner_only_members.contains(&co_writer) + && self.is_preserved_assignment_member(co_writer) + { + self.merge_member_into_owner_item(owner.clone(), key.clone(), co_writer); + merged_any = true; + } + } + if !merged_any { + self.preserved_co_writer_reconciled.insert((owner, key)); + } + Some(()) + } + + fn is_preserved_assignment_member(&self, member_id: LuaMemberId) -> bool { self.non_overwriting_assignment_members.contains(&member_id) + && self.is_assignment_file_define_member(member_id) } - pub fn mark_conditional_branch_assignment_member(&mut self, member_id: LuaMemberId) { - 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); + pub fn is_non_overwriting_assignment_member(&self, member_id: LuaMemberId) -> bool { + self.non_overwriting_assignment_members.contains(&member_id) } pub fn get_current_owner_members_for_key( @@ -1320,7 +1569,48 @@ impl LuaMemberIndex { owner: &LuaMemberOwner, key: &LuaMemberKey, ) -> Vec<&LuaMember> { - let Some(member_ids) = self + let mut members = self + .get_current_owner_member_ids_for_key_unsorted(owner, key) + .into_iter() + .filter_map(|member_id| self.get_member(&member_id)) + .collect::>(); + members.sort_by_key(|member| member_id_sort_key(member.get_id())); + members + } + + /// History-slot ids still homed under `owner`/`key`, in insertion order. + /// + /// The unsorted form of + /// [`get_current_owner_members_for_key`](Self::get_current_owner_members_for_key): + /// per-write callers that only need membership or a minimum must not pay + /// that sort on every write. + pub fn get_current_owner_member_ids_for_key_unsorted( + &self, + owner: &LuaMemberOwner, + key: &LuaMemberKey, + ) -> Vec { + self.get_current_owner_member_ids_for_key_unsorted_from(owner, key, 0) + } + + /// The tail of the history slot + /// [`get_current_owner_member_ids_for_key_unsorted`](Self::get_current_owner_member_ids_for_key_unsorted) + /// would return: same insertion order and same homed-under-`owner`/`key` + /// filter, skipping the first `skip` raw history entries. + /// + /// Per-write callers that memoize the slot walk the new suffix only, so N + /// appends cost O(N) total instead of O(N^2). The caller must key its memo + /// off the canonical `(owner, key)` and the raw length from + /// [`owner_key_history_len`](Self::owner_key_history_len): any owner or key + /// mutation files the member under a different canonical slot (a miss), and + /// removals and rekeys never run while a per-pass memo is alive. + pub fn get_current_owner_member_ids_for_key_unsorted_from( + &self, + owner: &LuaMemberOwner, + key: &LuaMemberKey, + skip: usize, + ) -> Vec { + let owner = self.canonical(owner); + let Some(member_ids) = self .member_owner_key_history_index .get(owner) .and_then(|owner_items| owner_items.get(key)) @@ -1328,19 +1618,35 @@ impl LuaMemberIndex { return Vec::new(); }; - let mut members = member_ids + member_ids .iter() .copied() - .filter_map(|member_id| { + .skip(skip) + .filter(|member_id| { self.member_current_owner - .get(&member_id) - .filter(|current_owner| *current_owner == owner)?; - self.get_member(&member_id) - .filter(|member| member.get_key() == key) + .get(member_id) + .is_some_and(|current_owner| current_owner == owner) + && self + .get_member(member_id) + .is_some_and(|member| member.get_key() == key) }) - .collect::>(); - members.sort_by_key(|member| stable_member_sort_key(member)); - members + .collect() + } + + /// Raw history-slot length for `owner`/`key`: O(1), no filtering or allocation. + /// + /// Counts every id ever filed under the canonical slot, including entries a + /// later owner move left behind (those fail the homed filter above). A memo + /// hit on this length plus the homed checks its caller performs means the + /// filtered set is unchanged: within one analysis pass history slots only + /// grow by append. + pub fn owner_key_history_len(&self, owner: &LuaMemberOwner, key: &LuaMemberKey) -> usize { + let owner = self.canonical(owner); + self.member_owner_key_history_index + .get(owner) + .and_then(|owner_items| owner_items.get(key)) + .map(|member_ids| member_ids.len()) + .unwrap_or(0) } /// Returns every still-current member ever indexed under `owner`, including @@ -1350,6 +1656,7 @@ impl LuaMemberIndex { /// owner region wholesale. Ordinary semantic lookup should continue using /// `get_members`, which applies runtime overwrite visibility. pub fn get_current_owner_member_history(&self, owner: &LuaMemberOwner) -> Vec<&LuaMember> { + let owner = self.canonical(owner); let Some(member_ids) = self.current_owner_member_history.get(owner) else { return Vec::new(); }; @@ -1422,69 +1729,14 @@ impl LuaMemberIndex { }) .collect() } - - pub fn retain_only_member_for_owner_key(&mut self, member_id: LuaMemberId) -> Option<()> { - let owner = self.member_current_owner.get(&member_id)?.clone(); - let key = self.get_member(&member_id)?.get_key().clone(); - let member_ids = self.member_owner_key_index.get(&owner)?.get(&key)?; - if !member_ids - .iter() - .copied() - .all(|id| self.is_assignment_file_define_member(id)) - { - return Some(()); - } - - let member_ids = self.member_owner_key_index.get_mut(&owner)?.get_mut(&key)?; - member_ids.retain(|id| *id == member_id); - Some(()) - } - - pub fn preserve_members_for_owner_key( - &mut self, - member_id: LuaMemberId, - member_ids: Vec, - ) -> Option<()> { - let owner = self.member_current_owner.get(&member_id)?.clone(); - let key = self.get_member(&member_id)?.get_key().clone(); - let mut preserved_member_ids = Vec::with_capacity(member_ids.len()); - - for id in member_ids { - if self.member_current_owner.get(&id) != Some(&owner) { - continue; - } - let Some(member) = self.get_member(&id) else { - continue; - }; - if member.get_key() != &key || preserved_member_ids.contains(&id) { - continue; - } - - preserved_member_ids.push(id); - } - - let item = match preserved_member_ids.as_slice() { - [] => return Some(()), - [id] => LuaMemberIndexItem::One(*id), - _ => LuaMemberIndexItem::Many(preserved_member_ids.clone()), - }; - - self.member_owner_key_index - .entry(owner.clone()) - .or_default() - .insert(key.clone(), preserved_member_ids); - self.owner_members - .entry(owner) - .or_insert_with(LuaOwnerMembers::new) - .add_member(key, item); - - Some(()) - } } -fn stable_member_sort_key(member: &LuaMember) -> (u32, u32, u32, u16) { - let member_id = member.get_id(); - member_id_sort_key(member_id) +fn site_sort_key(site: &crate::InFiled) -> (u32, u32, u32) { + ( + site.file_id.id, + site.value.start().into(), + site.value.end().into(), + ) } // The owner-level sorted member-id cache depends on these file id, position, @@ -1508,6 +1760,41 @@ 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); } @@ -1573,7 +1846,374 @@ impl LuaMemberIndex { } } self.function_scope_ranges.remove(&file_id); - self.conditional_branch_ranges.remove(&file_id); + self.remove_file_definition_sites(file_id); + self.recompute_global_path_class_winners(file_id); + } + + /// Recomputes the class winner of every global path this file proposed. + /// + /// A proposal is a vote: removing the file drops its vote and the winner + /// is recomputed from the survivors. A lost winner moves the path's + /// members onto the surviving class; a lost last proposal removes the + /// mapping and moves the members back onto the path itself, so nothing is + /// left stranded under a class no file declares. Per-file rehomes run + /// before the batch owner-key sweep in [`Self::remove_files`], so the + /// surviving members' re-written key indexes are never swept away. + fn recompute_global_path_class_winners(&mut self, file_id: FileId) { + let mut affected: Vec = Vec::new(); + for (path, candidates) in self.global_path_class_candidates.iter_mut() { + let before = candidates.len(); + candidates.retain(|candidate| candidate.file_id != file_id); + if candidates.len() != before { + affected.push(path.clone()); + } + } + if affected.is_empty() { + return; + } + affected.sort_unstable_by(|left, right| left.get_name().cmp(right.get_name())); + + for path in affected { + if self + .global_path_class_candidates + .get(&path) + .is_some_and(BTreeSet::is_empty) + { + self.global_path_class_candidates.remove(&path); + } + let winner = self + .global_path_class_candidates + .get(&path) + .and_then(|candidates| candidates.iter().next()) + .map(|candidate| candidate.class.clone()); + let old = self + .global_path_class_owner + .get(&path) + .and_then(LuaMemberOwner::get_type_id) + .cloned(); + match winner { + Some(winner) => { + if old.as_ref() != Some(&winner) { + self.global_path_class_owner + .insert(path.clone(), LuaMemberOwner::Type(winner.clone())); + self.bump_homing_revision(); + self.rehome_global_path_class( + &path, + old.as_ref(), + &LuaMemberOwner::Type(winner), + ); + } + } + None => { + if let Some(old) = old { + self.global_path_class_owner.remove(&path); + self.bump_homing_revision(); + self.rehome_global_path_class( + &path, + Some(&old), + &LuaMemberOwner::GlobalPath(path.clone()), + ); + } + } + } + } + } + + /// 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() + }); + } + + /// Re-homes every table-literal owner in the edited file onto its new + /// range, and purges the ones the edit destroyed. + /// + /// Returns the members forgotten with a destroyed owner - their cached + /// types have to come off with them - and the files that had registered a + /// member on one, which nothing else will rebuild. + pub fn remap_file_element_owners( + &mut self, + remap: &crate::FileRemap, + ) -> (Vec, HashSet) { + let mut moved: rustc_hash::FxHashMap, crate::InFiled> = + rustc_hash::FxHashMap::default(); + let mut deleted: Vec> = Vec::new(); + for range in self.element_owner_ranges_in_file(remap.file_id) { + match remap.table_range(&range) { + crate::Remap::Moved(new) => { + if new != range { + moved.insert(range, new); + } + } + crate::Remap::Unrelated => {} + crate::Remap::Lost => deleted.push(range), + } + } + + let mut dirty = HashSet::default(); + dirty.extend(self.remap_missed_member_reads(remap)); + for range in &deleted { + let owner = LuaMemberOwner::Element(range.clone()); + for member in self.get_members(&owner).unwrap_or_default() { + dirty.insert(member.get_id().file_id); + } + } + + // A definition site is a literal range in the edited file too, and the + // edit moves it exactly as it moves an element owner's. It owns no + // members of its own, so a site the edit destroyed dirties nothing: + // the path keeps every other file's members, and this file re-registers + // its own sites when it is re-indexed. + let mut deleted_sites: Vec> = Vec::new(); + let sites = self + .definition_sites_by_file + .get(&remap.file_id) + .cloned() + .unwrap_or_default(); + for site in sites { + match remap.table_range(&site) { + crate::Remap::Moved(new) => { + if new != site { + moved.insert(site, new); + } + } + crate::Remap::Unrelated => {} + crate::Remap::Lost => deleted_sites.push(site), + } + } + for site in &deleted_sites { + self.forget_definition_site(site); + } + + self.remap_elements(&moved); + let forgotten = self.remove_deleted_element_owners(&deleted); + dirty.remove(&remap.file_id); + (forgotten, dirty) + } + + fn remap_missed_member_reads(&mut self, remap: &crate::FileRemap) -> HashSet { + let updates = self + .missed_member_readers + .keys() + .filter_map(|(owner, key)| { + let LuaMemberOwner::Element(range) = owner else { + return None; + }; + match remap.table_range(range) { + crate::Remap::Moved(new) if new != *range => Some(( + (owner.clone(), key.clone()), + Some((LuaMemberOwner::Element(new), key.clone())), + )), + crate::Remap::Lost => Some(((owner.clone(), key.clone()), None)), + crate::Remap::Moved(_) | crate::Remap::Unrelated => None, + } + }) + .collect::>(); + let mut dirty = HashSet::default(); + let mut affected_readers = HashSet::default(); + + for (old, new) in updates { + let readers = self.missed_member_readers.remove(&old).unwrap_or_default(); + affected_readers.extend(readers.iter().copied()); + if let Some(new) = &new { + self.missed_member_readers + .entry(new.clone()) + .or_default() + .extend(readers.iter().copied()); + } else { + dirty.extend(readers.iter().copied()); + } + for reader in readers { + let Some(reads) = self.missed_member_reads_by_file.get_mut(&reader) else { + continue; + }; + reads.retain(|read| read != &old); + if let Some(new) = &new { + reads.push(new.clone()); + } + } + } + + for reader in affected_readers { + if let Some(reads) = self.missed_member_reads_by_file.get_mut(&reader) { + reads.sort_unstable_by_key(|read| format!("{read:?}")); + reads.dedup(); + } + } + dirty + } + + pub fn remap_elements( + &mut self, + map: &rustc_hash::FxHashMap, crate::InFiled>, + ) { + if map.is_empty() { + return; + } + // 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. + // 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()); + 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.remap_definition_sites(map); + for (member_id, old_owner, new_owner) in to_move { + self.detach_member_from_owner(&old_owner, member_id); + self.set_member_owner(new_owner.clone(), member_id.file_id, member_id); + self.add_member_to_owner(new_owner, member_id); + 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); + // `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); + } + } + } + + /// Re-keys the definition sites an edit moved. + fn remap_definition_sites( + &mut self, + map: &rustc_hash::FxHashMap, crate::InFiled>, + ) { + let mut moves: Vec<(crate::InFiled, GlobalId)> = map + .keys() + .filter_map(|old| { + let path = self.definition_site_owner.get(old)?.get_path()?.clone(); + Some((old.clone(), path)) + }) + .collect(); + moves.sort_unstable_by_key(|(old, _)| site_sort_key(old)); + for (old, _) in &moves { + self.forget_definition_site(old); + } + for (old, path) in moves { + self.set_definition_site(path, map[&old].clone()); + } + } + + /// 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 { + // A file removal hands its literals here wholesale; a definition + // site among them leaves the store with the file, not with the + // path's members. + self.forget_definition_site(range); + let owner = LuaMemberOwner::Element(range.clone()); + if let Some(member_items) = self.owner_members.remove(&owner) { + 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.forget_member(id); + forgotten.push(id); + } + } + self.member_owner_key_index.remove(&owner); + self.member_owner_key_history_index.remove(&owner); + self.current_owner_member_history.remove(&owner); + self.remove_owner_from_all_files(&owner); + } + forgotten + } +} + +impl LuaMemberIndex { + /// Replaces the member reads of `file_id` that found nothing. + pub fn set_missed_member_reads( + &mut self, + file_id: FileId, + reads: impl IntoIterator, + ) { + self.clear_missed_member_reads(file_id); + let mut reads: Vec<_> = reads + .into_iter() + .map(|(owner, key)| (self.canonical_owner(owner), key)) + .collect(); + if reads.is_empty() { + return; + } + reads.sort_unstable_by_key(|read| format!("{read:?}")); + reads.dedup(); + for read in &reads { + self.missed_member_readers + .entry(read.clone()) + .or_default() + .insert(file_id); + } + self.missed_member_reads_by_file.insert(file_id, reads); + } + + fn clear_missed_member_reads(&mut self, file_id: FileId) { + let Some(reads) = self.missed_member_reads_by_file.remove(&file_id) else { + return; + }; + for read in reads { + if let Some(readers) = self.missed_member_readers.get_mut(&read) { + readers.remove(&file_id); + if readers.is_empty() { + self.missed_member_readers.remove(&read); + } + } + } + } + + /// Files whose read of `key` on `owner` found nothing when they were + /// analysed. + pub fn missed_member_readers( + &self, + owner: &LuaMemberOwner, + key: &LuaMemberKey, + ) -> impl Iterator + '_ { + self.missed_member_readers + .get(&(owner.clone(), key.clone())) + .into_iter() + .flat_map(|readers| readers.iter().copied()) } } @@ -1585,10 +2225,10 @@ impl LuaIndex for LuaMemberIndex { fn remove_files(&mut self, file_ids: &[FileId]) { for &file_id in file_ids { self.remove_file_owned_entries(file_id); + self.clear_missed_member_reads(file_id); } let removed: HashSet = file_ids.iter().copied().collect(); self.remove_files_members_from_owner_key_indexes(&removed); - self.assignment_contributions.remove_files(&removed); self.member_function_scope_ranges .retain(|member_id, _| !removed.contains(&member_id.file_id)); } @@ -1597,1385 +2237,34 @@ impl LuaIndex for LuaMemberIndex { self.members.clear(); self.in_filed.clear(); self.owner_members.clear(); + self.missed_member_reads_by_file.clear(); + self.missed_member_readers.clear(); self.member_current_owner.clear(); self.member_owner_key_index.clear(); self.member_owner_key_history_index.clear(); + self.owner_key_index_owners_by_file.clear(); self.current_owner_member_history.clear(); self.current_members_by_key.clear(); self.non_overwriting_assignment_members.clear(); - self.conditional_branch_assignment_members.clear(); + self.preserved_co_writer_reconciled.clear(); + self.homing_revision = 0; + self.owner_only_members.clear(); self.synthesized_owner_members.clear(); self.deferred_index_expr_members.clear(); self.function_scope_ranges.clear(); - self.conditional_branch_ranges.clear(); self.member_function_scope_ranges.clear(); - self.assignment_contributions.clear(); + self.definition_site_owner.clear(); + self.path_definition_sites.clear(); + self.definition_sites_by_file.clear(); + self.global_path_class_owner.clear(); + self.global_path_class_candidates.clear(); + self.member_path_provenance.clear(); } } -/// Picks the definition that wins when a key is redefined without a guard. -fn latest_defined_member(existing_ids: &[LuaMemberId], incoming_id: LuaMemberId) -> LuaMemberId { - existing_ids - .iter() - .copied() - .chain(std::iter::once(incoming_id)) - .max_by_key(|candidate| member_id_sort_key(*candidate)) - .unwrap_or(incoming_id) -} - fn member_ids_from_item(item: &LuaMemberIndexItem) -> Vec { match item { LuaMemberIndexItem::One(id) => vec![*id], LuaMemberIndexItem::Many(ids) => ids.clone(), } } - -#[cfg(test)] -mod tests { - use glua_parser::{LuaSyntaxId, LuaSyntaxKind}; - use rowan::{TextRange, TextSize}; - - use super::*; - use crate::{FileId, LuaTypeDeclId}; - - fn make_member(member_id: LuaMemberId, key: &str) -> LuaMember { - make_member_with_feature(member_id, key, LuaMemberFeature::FileFieldDecl) - } - - fn make_member_with_feature( - member_id: LuaMemberId, - key: &str, - feature: LuaMemberFeature, - ) -> LuaMember { - LuaMember::new(member_id, LuaMemberKey::Name(key.into()), feature, None) - } - - fn make_member_id(file_id: FileId, start: u32) -> LuaMemberId { - let range = TextRange::new(TextSize::new(start), TextSize::new(start + 1)); - LuaMemberId::new( - LuaSyntaxId::new(LuaSyntaxKind::NameExpr.into(), range), - file_id, - ) - } - - fn make_index_member_id(file_id: FileId, start: u32) -> LuaMemberId { - let range = TextRange::new(TextSize::new(start), TextSize::new(start + 1)); - LuaMemberId::new( - LuaSyntaxId::new(LuaSyntaxKind::IndexExpr.into(), range), - file_id, - ) - } - - fn make_member_id_with_kind_and_end( - file_id: FileId, - kind: LuaSyntaxKind, - start: u32, - end: u32, - ) -> LuaMemberId { - LuaMemberId::new( - LuaSyntaxId::new( - kind.into(), - TextRange::new(TextSize::new(start), TextSize::new(end)), - ), - file_id, - ) - } - - fn owner_member_ids(index: &LuaMemberIndex, owner: &LuaMemberOwner) -> Vec { - index - .get_members(owner) - .expect("owner should exist") - .into_iter() - .map(|member| member.get_id()) - .collect() - } - - #[test] - fn get_members_multi_key_owner_matches_member_id_sort_order() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let member_specs = [ - ( - make_member_id_with_kind_and_end(FileId::new(3), LuaSyntaxKind::NameExpr, 20, 24), - "gamma", - ), - ( - make_member_id_with_kind_and_end(FileId::new(1), LuaSyntaxKind::IndexExpr, 40, 45), - "alpha", - ), - ( - make_member_id_with_kind_and_end(FileId::new(1), LuaSyntaxKind::NameExpr, 10, 12), - "beta", - ), - ( - make_member_id_with_kind_and_end(FileId::new(2), LuaSyntaxKind::NameExpr, 5, 6), - "delta", - ), - ( - make_member_id_with_kind_and_end(FileId::new(1), LuaSyntaxKind::IndexExpr, 10, 11), - "epsilon", - ), - ]; - - let mut index = LuaMemberIndex::new(); - for (member_id, key) in member_specs { - index.add_member(owner.clone(), make_member(member_id, key)); - } - - let mut expected_ids = member_specs.map(|(member_id, _)| member_id).to_vec(); - expected_ids.sort_by_key(|member_id| member_id_sort_key(*member_id)); - - assert_eq!(owner_member_ids(&index, &owner), expected_ids); - } - - #[test] - fn batch_removal_matches_sequential_removal_for_surviving_members() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("BatchRemoveType")); - let first = FileId::new(1); - let second = FileId::new(2); - let survivor = FileId::new(3); - - let populate = || { - let mut index = LuaMemberIndex::new(); - index.add_member( - owner.clone(), - make_member(make_member_id(first, 10), "alpha"), - ); - index.add_member( - owner.clone(), - make_member(make_member_id(second, 20), "alpha"), - ); - index.add_member( - owner.clone(), - make_member(make_member_id(second, 30), "beta"), - ); - index.add_member( - owner.clone(), - make_member(make_member_id(survivor, 40), "alpha"), - ); - index.add_member( - owner.clone(), - make_member(make_member_id(survivor, 50), "gamma"), - ); - index - }; - - let mut sequential = populate(); - sequential.remove(first); - sequential.remove(second); - - let mut batched = populate(); - batched.remove_files(&[second, first, second]); - - assert_eq!( - owner_member_ids(&sequential, &owner), - owner_member_ids(&batched, &owner) - ); - assert_eq!( - owner_member_ids(&batched, &owner), - vec![make_member_id(survivor, 40), make_member_id(survivor, 50)] - ); - for index in [&sequential, &batched] { - assert!(index.get_file_members(first).is_empty()); - assert!(index.get_file_members(second).is_empty()); - assert!(index.get_member(&make_member_id(first, 10)).is_none()); - assert!(index.get_member(&make_member_id(second, 20)).is_none()); - assert!(index.get_member(&make_member_id(survivor, 40)).is_some()); - } - assert_eq!( - sequential.member_owner_key_index, - batched.member_owner_key_index - ); - assert_eq!( - sequential.member_owner_key_history_index, - batched.member_owner_key_history_index - ); - } - - #[test] - fn get_members_cache_invalidates_when_adding_earlier_member_after_warm() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let first_member_id = make_member_id(FileId::new(4), 20); - let second_member_id = make_member_id(FileId::new(5), 30); - let earlier_member_id = make_member_id(FileId::new(1), 5); - let mut index = LuaMemberIndex::new(); - - index.add_member(owner.clone(), make_member(first_member_id, "first")); - index.add_member(owner.clone(), make_member(second_member_id, "second")); - - assert_eq!( - owner_member_ids(&index, &owner), - vec![first_member_id, second_member_id] - ); - - index.add_member(owner.clone(), make_member(earlier_member_id, "third")); - - assert_eq!( - owner_member_ids(&index, &owner), - vec![earlier_member_id, first_member_id, second_member_id] - ); - } - - #[test] - fn set_member_owner_moves_member_between_owner_indexes() { - let file_id = FileId::new(1); - let old_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OldOwner")); - let new_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("NewOwner")); - let key = LuaMemberKey::Name("field".into()); - let member_id = make_member_id(file_id, 1); - - let mut index = LuaMemberIndex::new(); - index.add_member(old_owner.clone(), make_member(member_id, "field")); - assert!(index.get_member_item(&old_owner, &key).is_some()); - - index - .set_member_owner(new_owner.clone(), file_id, member_id) - .expect("owner reassignment should succeed"); - - assert!(index.get_member_item(&old_owner, &key).is_some()); - assert!(index.get_member_item(&new_owner, &key).is_none()); - assert!(index.get_members_for_owner_key(&old_owner, &key).is_empty()); - assert_eq!(index.get_members_for_owner_key(&new_owner, &key).len(), 1); - assert!( - index - .get_current_owner_member_history(&old_owner) - .is_empty() - ); - assert_eq!( - index - .get_current_owner_member_history(&new_owner) - .iter() - .map(|member| member.get_id()) - .collect::>(), - vec![member_id] - ); - } - - #[test] - fn set_member_owner_keeps_other_old_owner_members() { - let file_id = FileId::new(2); - let old_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OriginalOwner")); - let new_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("ReassignedOwner")); - let key = LuaMemberKey::Name("field".into()); - let first_member_id = make_member_id(file_id, 1); - let second_member_id = make_member_id(file_id, 3); - - let mut index = LuaMemberIndex::new(); - index.add_member(old_owner.clone(), make_member(first_member_id, "field")); - index.add_member(old_owner.clone(), make_member(second_member_id, "field")); - - index - .set_member_owner(new_owner.clone(), file_id, first_member_id) - .expect("owner reassignment should succeed"); - - assert_eq!(index.get_members_for_owner_key(&old_owner, &key).len(), 1); - assert_eq!(index.get_members_for_owner_key(&new_owner, &key).len(), 1); - - let old_owner_member_ids = index - .get_members_for_owner_key(&old_owner, &key) - .iter() - .map(|member| member.get_id()) - .collect::>(); - assert_eq!(old_owner_member_ids, vec![second_member_id]); - - let new_owner_member_ids = index - .get_members_for_owner_key(&new_owner, &key) - .iter() - .map(|member| member.get_id()) - .collect::>(); - assert_eq!(new_owner_member_ids, vec![first_member_id]); - assert_eq!( - index - .get_current_owner_member_history(&old_owner) - .iter() - .map(|member| member.get_id()) - .collect::>(), - vec![second_member_id] - ); - assert_eq!( - index - .get_current_owner_member_history(&new_owner) - .iter() - .map(|member| member.get_id()) - .collect::>(), - vec![first_member_id] - ); - - let new_owner_history_member_ids = index - .get_current_owner_members_for_key(&new_owner, &key) - .into_iter() - .map(|member| member.get_id()) - .collect::>(); - assert_eq!(new_owner_history_member_ids, vec![first_member_id]); - - let key_history_member_ids = index - .get_current_members_for_key(&key) - .into_iter() - .map(|member| member.get_id()) - .collect::>(); - assert_eq!( - key_history_member_ids, - vec![first_member_id, second_member_id] - ); - } - - #[test] - fn get_members_cache_invalidates_when_removing_file_after_warm() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let removed_member_id = make_member_id(FileId::new(4), 10); - let retained_member_id = make_member_id(FileId::new(5), 5); - let mut index = LuaMemberIndex::new(); - - index.add_member(owner.clone(), make_member(removed_member_id, "removed")); - index.add_member(owner.clone(), make_member(retained_member_id, "retained")); - - assert_eq!( - owner_member_ids(&index, &owner), - vec![removed_member_id, retained_member_id] - ); - - index.remove(FileId::new(4)); - - assert_eq!(owner_member_ids(&index, &owner), vec![retained_member_id]); - } - - #[test] - fn warming_get_members_does_not_break_owner_move_visibility() { - let file_id = FileId::new(6); - let old_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OldOwner")); - let new_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("NewOwner")); - let key = LuaMemberKey::Name("field".into()); - let member_id = make_member_id(file_id, 1); - let mut index = LuaMemberIndex::new(); - - index.add_member(old_owner.clone(), make_member(member_id, "field")); - assert_eq!(owner_member_ids(&index, &old_owner), vec![member_id]); - assert!(index.get_members(&new_owner).is_none()); - - index - .set_member_owner(new_owner.clone(), file_id, member_id) - .expect("owner reassignment should succeed"); - - assert!(index.get_members_for_owner_key(&old_owner, &key).is_empty()); - assert_eq!( - index - .get_members_for_owner_key(&new_owner, &key) - .into_iter() - .map(|member| member.get_id()) - .collect::>(), - vec![member_id] - ); - } - - #[test] - fn get_members_cache_invalidates_when_one_promotes_to_many_after_warm() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let key = LuaMemberKey::Name("field".into()); - let first_member_id = make_member_id(FileId::new(4), 20); - let second_member_id = make_member_id(FileId::new(4), 10); - let mut index = LuaMemberIndex::new(); - - index.add_member( - owner.clone(), - LuaMember::new( - first_member_id, - key.clone(), - LuaMemberFeature::FileFieldDecl, - None, - ), - ); - assert_eq!(owner_member_ids(&index, &owner), vec![first_member_id]); - - index.add_member( - owner.clone(), - LuaMember::new(second_member_id, key, LuaMemberFeature::FileFieldDecl, None), - ); - - assert_eq!( - owner_member_ids(&index, &owner), - vec![second_member_id, first_member_id] - ); - } - - #[test] - fn clear_resets_member_owner_tracking() { - let file_id = FileId::new(3); - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let member_id = make_member_id(file_id, 7); - - let mut index = LuaMemberIndex::new(); - index.add_member(owner, make_member(member_id, "field")); - assert!(index.get_member_owner(&member_id).is_some()); - assert!( - !index - .get_current_members_for_key(&LuaMemberKey::Name("field".into())) - .is_empty() - ); - - index.clear(); - - assert!(index.get_member_owner(&member_id).is_none()); - assert!( - index - .get_current_members_for_key(&LuaMemberKey::Name("field".into())) - .is_empty() - ); - } - - #[test] - fn key_lookup_cache_invalidates_after_member_mutation() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let key = LuaMemberKey::Name("field".into()); - let first_member_id = make_member_id(FileId::new(9), 1); - let second_member_id = make_member_id(FileId::new(10), 3); - let mut index = LuaMemberIndex::new(); - - index.add_member(owner.clone(), make_member(first_member_id, "field")); - assert_eq!( - index - .get_current_members_for_key(&key) - .into_iter() - .map(|member| member.get_id()) - .collect::>(), - vec![first_member_id] - ); - - index.add_member(owner.clone(), make_member(second_member_id, "field")); - assert_eq!( - index - .get_current_members_for_key(&key) - .into_iter() - .map(|member| member.get_id()) - .collect::>(), - vec![first_member_id, second_member_id] - ); - - index.remove(FileId::new(9)); - assert_eq!( - index - .get_current_members_for_key(&key) - .into_iter() - .map(|member| member.get_id()) - .collect::>(), - vec![second_member_id] - ); - } - - #[test] - fn file_define_assignment_history_stays_visible_for_owner_key_queries() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let key = LuaMemberKey::Name("field".into()); - let first_member_id = LuaMemberId::new( - LuaSyntaxId::new( - LuaSyntaxKind::IndexExpr.into(), - TextRange::new(TextSize::new(1), TextSize::new(2)), - ), - FileId::new(4), - ); - let second_member_id = LuaMemberId::new( - LuaSyntaxId::new( - LuaSyntaxKind::IndexExpr.into(), - TextRange::new(TextSize::new(3), TextSize::new(4)), - ), - FileId::new(5), - ); - - let mut index = LuaMemberIndex::new(); - index.add_member( - owner.clone(), - LuaMember::new( - first_member_id, - key.clone(), - LuaMemberFeature::FileDefine, - None, - ), - ); - index.add_member( - owner.clone(), - LuaMember::new( - second_member_id, - key.clone(), - LuaMemberFeature::FileDefine, - None, - ), - ); - - assert_eq!( - index.get_member_item(&owner, &key), - Some(&LuaMemberIndexItem::One(second_member_id)) - ); - let member_ids = index - .get_members_for_owner_key(&owner, &key) - .into_iter() - .map(|member| member.get_id()) - .collect::>(); - assert_eq!(member_ids, vec![first_member_id, second_member_id]); - } - - #[test] - fn retained_file_define_keeps_owner_key_history() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let key = LuaMemberKey::Name("field".into()); - let first_member_id = LuaMemberId::new( - LuaSyntaxId::new( - LuaSyntaxKind::IndexExpr.into(), - TextRange::new(TextSize::new(1), TextSize::new(2)), - ), - FileId::new(4), - ); - let second_member_id = LuaMemberId::new( - LuaSyntaxId::new( - LuaSyntaxKind::IndexExpr.into(), - TextRange::new(TextSize::new(3), TextSize::new(4)), - ), - FileId::new(4), - ); - - let mut index = LuaMemberIndex::new(); - index.add_member( - owner.clone(), - LuaMember::new( - first_member_id, - key.clone(), - LuaMemberFeature::FileDefine, - None, - ), - ); - index.add_member( - owner.clone(), - LuaMember::new( - second_member_id, - key.clone(), - LuaMemberFeature::FileDefine, - None, - ), - ); - - index - .retain_only_member_for_owner_key(second_member_id) - .expect("retain should succeed"); - - let visible_member_ids = index - .get_members_for_owner_key(&owner, &key) - .into_iter() - .map(|member| member.get_id()) - .collect::>(); - assert_eq!(visible_member_ids, vec![second_member_id]); - - let history_member_ids = index - .get_current_owner_members_for_key(&owner, &key) - .into_iter() - .map(|member| member.get_id()) - .collect::>(); - assert_eq!(history_member_ids, vec![first_member_id, second_member_id]); - } - - #[test] - fn visible_owner_key_other_member_check_uses_current_visible_members() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let key = LuaMemberKey::Name("field".into()); - let first_member_id = make_index_member_id(FileId::new(4), 1); - let second_member_id = make_index_member_id(FileId::new(4), 3); - - let mut index = LuaMemberIndex::new(); - index.add_member( - owner.clone(), - LuaMember::new( - first_member_id, - key.clone(), - LuaMemberFeature::FileDefine, - None, - ), - ); - - assert!(!index.has_visible_member_for_owner_key_other_than(&owner, &key, first_member_id)); - - index.add_member( - owner.clone(), - LuaMember::new( - second_member_id, - key.clone(), - LuaMemberFeature::FileDefine, - None, - ), - ); - - assert!(index.has_visible_member_for_owner_key_other_than(&owner, &key, second_member_id)); - } - - #[test] - fn meta_only_member_item_preserves_meta_when_assignment_is_added() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let key = LuaMemberKey::Name("field".into()); - let meta_member_id = make_member_id(FileId::new(4), 1); - let assignment_member_id = make_index_member_id(FileId::new(4), 3); - - let mut index = LuaMemberIndex::new(); - index.add_member( - owner.clone(), - make_member_with_feature(meta_member_id, "field", LuaMemberFeature::MetaFieldDecl), - ); - index.add_member( - owner.clone(), - make_member_with_feature(assignment_member_id, "field", LuaMemberFeature::FileDefine), - ); - - assert_eq!( - index.get_member_item(&owner, &key), - Some(&LuaMemberIndexItem::Many(vec![ - assignment_member_id, - meta_member_id, - ])) - ); - } - - #[test] - fn retain_only_member_for_owner_key_keeps_mixed_visible_members() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let key = LuaMemberKey::Name("field".into()); - let declaration_member_id = make_member_id(FileId::new(4), 1); - let assignment_member_id = make_index_member_id(FileId::new(4), 3); - - let mut index = LuaMemberIndex::new(); - index.add_member( - owner.clone(), - make_member_with_feature( - declaration_member_id, - "field", - LuaMemberFeature::FileFieldDecl, - ), - ); - index.add_member( - owner.clone(), - make_member_with_feature(assignment_member_id, "field", LuaMemberFeature::FileDefine), - ); - - index - .retain_only_member_for_owner_key(assignment_member_id) - .expect("retain should no-op for mixed visible members"); - - let visible_member_ids = index - .get_members_for_owner_key(&owner, &key) - .into_iter() - .map(|member| member.get_id()) - .collect::>(); - assert_eq!( - visible_member_ids, - vec![declaration_member_id, assignment_member_id] - ); - } - - #[test] - fn preserve_members_for_owner_key_filters_dedups_and_updates_visible_item() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let other_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OtherOwnedType")); - let key = LuaMemberKey::Name("field".into()); - let first_member_id = make_index_member_id(FileId::new(4), 1); - let second_member_id = make_index_member_id(FileId::new(4), 3); - let other_owner_member_id = make_index_member_id(FileId::new(5), 1); - let other_key_member_id = make_index_member_id(FileId::new(4), 5); - - let mut index = LuaMemberIndex::new(); - for member_id in [first_member_id, second_member_id] { - index.add_member( - owner.clone(), - make_member_with_feature(member_id, "field", LuaMemberFeature::FileDefine), - ); - } - index.add_member( - other_owner, - make_member_with_feature(other_owner_member_id, "field", LuaMemberFeature::FileDefine), - ); - index.add_member( - owner.clone(), - make_member_with_feature(other_key_member_id, "other", LuaMemberFeature::FileDefine), - ); - - index - .preserve_members_for_owner_key( - first_member_id, - vec![ - second_member_id, - other_owner_member_id, - first_member_id, - second_member_id, - other_key_member_id, - ], - ) - .expect("preserve should succeed"); - - assert_eq!( - index.get_member_item(&owner, &key), - Some(&LuaMemberIndexItem::Many(vec![ - second_member_id, - first_member_id, - ])) - ); - let visible_member_ids = index - .get_members_for_owner_key(&owner, &key) - .into_iter() - .map(|member| member.get_id()) - .collect::>(); - assert_eq!(visible_member_ids, vec![second_member_id, first_member_id]); - } - - #[test] - fn preserved_assignment_duplicate_insertions_keep_existing_item_order() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let key = LuaMemberKey::Name("field".into()); - let first_member_id = make_index_member_id(FileId::new(4), 1); - let second_member_id = make_index_member_id(FileId::new(4), 3); - - let mut index = LuaMemberIndex::new(); - for member_id in [first_member_id, second_member_id] { - index.mark_non_overwriting_assignment_member(member_id); - index.add_member( - owner.clone(), - make_member_with_feature(member_id, "field", LuaMemberFeature::FileDefine), - ); - } - - index.merge_member_into_owner_item(owner.clone(), key.clone(), second_member_id); - index.merge_member_into_owner_item(owner.clone(), key.clone(), first_member_id); - - assert_eq!( - index.get_member_item(&owner, &key), - Some(&LuaMemberIndexItem::Many(vec![ - first_member_id, - second_member_id, - ])) - ); - } - - #[test] - fn alias_merge_does_not_duplicate_an_id_already_in_an_unsorted_item() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let key = LuaMemberKey::Name("field".into()); - let earlier_member_id = make_member_id(FileId::new(1), 10); - let later_member_id = make_member_id(FileId::new(2), 20); - - // Decl inserts append in arrival order, so adding the later id first - // leaves the stored item unsorted. - let mut index = LuaMemberIndex::new(); - index.add_member(owner.clone(), make_member(later_member_id, "field")); - index.add_member(owner.clone(), make_member(earlier_member_id, "field")); - assert_eq!( - index.get_member_item(&owner, &key), - Some(&LuaMemberIndexItem::Many(vec![ - later_member_id, - earlier_member_id, - ])) - ); - - index.add_member_alias_to_owner(owner.clone(), later_member_id); - - let Some(LuaMemberIndexItem::Many(member_ids)) = index.get_member_item(&owner, &key) else { - panic!("the item should still hold both members"); - }; - assert_eq!( - member_ids.len(), - 2, - "aliasing an id already in the item must not add it again" - ); - assert_eq!(owner_member_ids(&index, &owner).len(), 2); - } - - #[test] - fn alias_to_owner_is_recorded_exactly_when_the_alias_is_a_no_op() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("SharedTable")); - let other_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OtherTable")); - let own_member_id = make_index_member_id(FileId::new(1), 10); - let aliased_member_id = make_index_member_id(FileId::new(2), 20); - - let mut index = LuaMemberIndex::new(); - index.add_member( - owner.clone(), - make_member_with_feature(own_member_id, "field", LuaMemberFeature::FileDefine), - ); - index.add_member( - other_owner, - make_member_with_feature(aliased_member_id, "field", LuaMemberFeature::FileDefine), - ); - - // Everything `add_member_alias_to_owner` writes, read back in a - // deterministic order. Comparing the index's `Debug` instead would - // compare `HashSet` iteration order, which varies per process and makes - // the assertion pass or fail at random. - let key = LuaMemberKey::Name("field".into()); - let written_state = |index: &LuaMemberIndex| { - let mut in_filed: Vec = index - .in_filed - .get(&aliased_member_id.file_id) - .map(|objects| objects.iter().map(|object| format!("{object:?}")).collect()) - .unwrap_or_default(); - in_filed.sort(); - ( - index.get_member_item(&owner, &key).cloned(), - index - .member_owner_key_index - .get(&owner) - .and_then(|keys| keys.get(&key)) - .cloned(), - index - .member_owner_key_history_index - .get(&owner) - .and_then(|keys| keys.get(&key)) - .cloned(), - in_filed, - ) - }; - - assert!(!index.alias_to_owner_is_recorded(&owner, aliased_member_id)); - index.add_member_alias_to_owner(owner.clone(), aliased_member_id); - assert!(index.alias_to_owner_is_recorded(&owner, aliased_member_id)); - - let recorded = written_state(&index); - index.add_member_alias_to_owner(owner.clone(), aliased_member_id); - assert_eq!( - recorded, - written_state(&index), - "a recorded alias must write nothing when applied again" - ); - } - - #[test] - fn alias_adds_to_an_existing_file_define_without_displacing_it() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("SharedTable")); - let other_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OtherTable")); - let key = LuaMemberKey::Name("field".into()); - let own_member_id = make_index_member_id(FileId::new(1), 10); - let aliased_member_id = make_index_member_id(FileId::new(2), 20); - - let mut index = LuaMemberIndex::new(); - index.add_member( - owner.clone(), - make_member_with_feature(own_member_id, "field", LuaMemberFeature::FileDefine), - ); - index.add_member( - other_owner, - make_member_with_feature(aliased_member_id, "field", LuaMemberFeature::FileDefine), - ); - - index.add_member_alias_to_owner(owner.clone(), aliased_member_id); - - assert_eq!( - index.get_member_item(&owner, &key), - Some(&LuaMemberIndexItem::Many(vec![ - own_member_id, - aliased_member_id, - ])), - "an alias only ever adds; it must never replace the owner's own writer" - ); - } - - #[test] - fn a_table_field_write_arriving_after_an_alias_still_takes_the_slot() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("SharedTable")); - let other_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OtherTable")); - let key = LuaMemberKey::Name("field".into()); - // A table-literal field is a `FileDefine` that is not an index-expr - // assignment, so it falls through to the latest-defined rule. - let own_member_id = make_member_id(FileId::new(1), 10); - let aliased_member_id = make_index_member_id(FileId::new(2), 20); - - let mut index = LuaMemberIndex::new(); - index.add_member( - other_owner, - make_member_with_feature(aliased_member_id, "field", LuaMemberFeature::FileDefine), - ); - index.add_member_alias_to_owner(owner.clone(), aliased_member_id); - index.add_member( - owner.clone(), - make_member_with_feature(own_member_id, "field", LuaMemberFeature::FileDefine), - ); - - assert_eq!( - index.get_member_item(&owner, &key), - Some(&LuaMemberIndexItem::Many(vec![ - own_member_id, - aliased_member_id, - ])), - "which of the two arrived first must not decide the slot" - ); - } - - #[test] - fn global_path_key_keeps_every_writer_in_history_while_one_wins_the_visible_slot() { - let owner = LuaMemberOwner::GlobalPath(crate::GlobalId::new("cityrp")); - let first_member_id = make_member_id(FileId::new(1), 10); - let second_member_id = make_member_id(FileId::new(2), 20); - - let mut index = LuaMemberIndex::new(); - for member_id in [first_member_id, second_member_id] { - index.add_member( - owner.clone(), - make_member_with_feature(member_id, "menu", LuaMemberFeature::FileDefine), - ); - } - - assert_eq!( - index - .get_member_history(&owner) - .iter() - .map(|member| member.get_id()) - .collect::>(), - vec![first_member_id, second_member_id], - "history must enumerate every file that wrote the key" - ); - assert_eq!( - owner_member_ids(&index, &owner), - vec![second_member_id], - "the visible slot is still last-writer-wins" - ); - } - - /// `cityrp.progresshud = {}` is written by both `cl_progress_hud.lua` and - /// `sv_progress_hud.lua`. Only one can hold the visible slot, and the - /// global-path reconciliation re-homes whichever one that is onto the - /// elected table — so if arrival decided the survivor, a re-index moved the - /// member's owner on unchanged source. - #[test] - fn cross_file_assignment_writers_elect_the_visible_slot_by_source_order() { - let owner = LuaMemberOwner::GlobalPath(crate::GlobalId::new("cityrp")); - let earlier_member_id = make_index_member_id(FileId::new(1), 10); - let later_member_id = make_index_member_id(FileId::new(2), 20); - - for arrival in [ - [earlier_member_id, later_member_id], - [later_member_id, earlier_member_id], - ] { - let mut index = LuaMemberIndex::new(); - for member_id in arrival { - index.add_member( - owner.clone(), - make_member_with_feature( - member_id, - "progresshud", - LuaMemberFeature::FileDefine, - ), - ); - } - - assert_eq!( - owner_member_ids(&index, &owner), - vec![later_member_id], - "the visible writer must not depend on which file was analysed first" - ); - } - } - - #[test] - fn marked_non_overwriting_file_defines_share_lookup_item() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let key = LuaMemberKey::Name("field".into()); - let first_member_id = LuaMemberId::new( - LuaSyntaxId::new( - LuaSyntaxKind::IndexExpr.into(), - TextRange::new(TextSize::new(1), TextSize::new(2)), - ), - FileId::new(4), - ); - let second_member_id = LuaMemberId::new( - LuaSyntaxId::new( - LuaSyntaxKind::IndexExpr.into(), - TextRange::new(TextSize::new(3), TextSize::new(4)), - ), - FileId::new(4), - ); - - let mut index = LuaMemberIndex::new(); - index.mark_non_overwriting_assignment_member(first_member_id); - index.add_member( - owner.clone(), - LuaMember::new( - first_member_id, - key.clone(), - LuaMemberFeature::FileDefine, - None, - ), - ); - index.mark_non_overwriting_assignment_member(second_member_id); - index.add_member( - owner.clone(), - LuaMember::new( - second_member_id, - key.clone(), - LuaMemberFeature::FileDefine, - None, - ), - ); - - assert_eq!( - index.get_member_item(&owner, &key), - Some(&LuaMemberIndexItem::Many(vec![ - first_member_id, - second_member_id - ])) - ); - } - - #[test] - fn preserved_assignment_insertion_invalidates_get_members_cache_after_warm() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let key = LuaMemberKey::Name("field".into()); - let later_member_id = make_index_member_id(FileId::new(4), 30); - let earlier_member_id = make_index_member_id(FileId::new(4), 10); - let mut index = LuaMemberIndex::new(); - - index.mark_non_overwriting_assignment_member(later_member_id); - index.add_member( - owner.clone(), - LuaMember::new( - later_member_id, - key.clone(), - LuaMemberFeature::FileDefine, - None, - ), - ); - assert_eq!(owner_member_ids(&index, &owner), vec![later_member_id]); - - index.mark_non_overwriting_assignment_member(earlier_member_id); - index.add_member( - owner.clone(), - LuaMember::new(earlier_member_id, key, LuaMemberFeature::FileDefine, None), - ); - - assert_eq!( - owner_member_ids(&index, &owner), - vec![earlier_member_id, later_member_id] - ); - } - - #[test] - fn many_marked_non_overwriting_file_defines_share_lookup_item() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let key = LuaMemberKey::Name("field".into()); - let mut index = LuaMemberIndex::new(); - - for i in 0..128 { - let member_id = LuaMemberId::new( - LuaSyntaxId::new( - LuaSyntaxKind::IndexExpr.into(), - TextRange::new(TextSize::new(i), TextSize::new(i + 1)), - ), - FileId::new(4), - ); - index.mark_non_overwriting_assignment_member(member_id); - index.add_member( - owner.clone(), - LuaMember::new(member_id, key.clone(), LuaMemberFeature::FileDefine, None), - ); - } - - let Some(LuaMemberIndexItem::Many(member_ids)) = index.get_member_item(&owner, &key) else { - panic!("marked assignments should be preserved as a shared lookup item"); - }; - - assert_eq!(member_ids.len(), 128); - assert_eq!( - member_ids.first().copied(), - Some(make_index_member_id(FileId::new(4), 0)) - ); - assert_eq!( - member_ids.last().copied(), - Some(make_index_member_id(FileId::new(4), 127)) - ); - } - - #[test] - fn marked_non_overwriting_file_defines_keep_stable_order_when_added_out_of_order() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let key = LuaMemberKey::Name("field".into()); - let mut index = LuaMemberIndex::new(); - - for start in [30, 10, 20] { - let member_id = make_index_member_id(FileId::new(4), start); - index.mark_non_overwriting_assignment_member(member_id); - index.add_member( - owner.clone(), - LuaMember::new(member_id, key.clone(), LuaMemberFeature::FileDefine, None), - ); - } - - assert_eq!( - index.get_member_item(&owner, &key), - Some(&LuaMemberIndexItem::Many(vec![ - make_index_member_id(FileId::new(4), 10), - make_index_member_id(FileId::new(4), 20), - make_index_member_id(FileId::new(4), 30), - ])) - ); - } - - #[test] - fn marked_non_overwriting_file_define_does_not_preserve_unmarked_assignment() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let key = LuaMemberKey::Name("field".into()); - let class_assignment_id = LuaMemberId::new( - LuaSyntaxId::new( - LuaSyntaxKind::IndexExpr.into(), - TextRange::new(TextSize::new(1), TextSize::new(2)), - ), - FileId::new(4), - ); - let guarded_assignment_id = LuaMemberId::new( - LuaSyntaxId::new( - LuaSyntaxKind::IndexExpr.into(), - TextRange::new(TextSize::new(3), TextSize::new(4)), - ), - FileId::new(4), - ); - - let mut index = LuaMemberIndex::new(); - index.add_member( - owner.clone(), - LuaMember::new( - class_assignment_id, - key.clone(), - LuaMemberFeature::FileDefine, - None, - ), - ); - index.mark_non_overwriting_assignment_member(guarded_assignment_id); - index.add_member( - owner.clone(), - LuaMember::new( - guarded_assignment_id, - key.clone(), - LuaMemberFeature::FileDefine, - None, - ), - ); - - assert_eq!( - index.get_member_item(&owner, &key), - Some(&LuaMemberIndexItem::One(guarded_assignment_id)) - ); - } - - /// A guarded bootstrap in one file and a plain assignment in another is - /// the order-dependent case: whichever was processed last used to take the - /// visible slot, so the surviving writer followed load order. The - /// bootstrap contributes no type of its own, so the plain writer must win - /// either way. - #[test] - fn cross_file_bootstrap_never_displaces_a_plain_writer() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let key = LuaMemberKey::Name("stock".into()); - let plain_id = make_index_member_id(FileId::new(1), 10); - let bootstrap_id = make_index_member_id(FileId::new(2), 20); - - let visible_after = |bootstrap_first: bool| { - let mut index = LuaMemberIndex::new(); - let order = if bootstrap_first { - [bootstrap_id, plain_id] - } else { - [plain_id, bootstrap_id] - }; - for member_id in order { - if member_id == bootstrap_id { - index.mark_non_overwriting_assignment_member(member_id); - } - index.add_member( - owner.clone(), - LuaMember::new(member_id, key.clone(), LuaMemberFeature::FileDefine, None), - ); - } - owner_member_ids(&index, &owner) - }; - - assert_eq!( - visible_after(false), - vec![plain_id], - "plain writer analysed first" - ); - assert_eq!( - visible_after(true), - vec![plain_id], - "bootstrap analysed first" - ); - } - - /// Transparency only applies when a real writer exists. With nothing but - /// bootstraps across files there is no placeholder to see through, so the - /// existing merge still has to keep every writer visible. - #[test] - fn cross_file_all_bootstrap_writers_still_merge() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let key = LuaMemberKey::Name("stock".into()); - let first_id = make_index_member_id(FileId::new(1), 10); - let second_id = make_index_member_id(FileId::new(2), 20); - - let mut index = LuaMemberIndex::new(); - for member_id in [first_id, second_id] { - index.mark_non_overwriting_assignment_member(member_id); - index.add_member( - owner.clone(), - LuaMember::new(member_id, key.clone(), LuaMemberFeature::FileDefine, None), - ); - } - - assert_eq!(owner_member_ids(&index, &owner), vec![first_id, second_id]); - } - - #[test] - fn file_removal_clears_previous_owner_history_entries() { - let file_id = FileId::new(6); - let old_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OldOwner")); - let new_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("NewOwner")); - let old_key = LuaMemberKey::Name("old_field".into()); - let new_key = LuaMemberKey::Name("new_field".into()); - let member_id = make_member_id(file_id, 10); - let mut index = LuaMemberIndex::new(); - - index.add_member(old_owner.clone(), make_member(member_id, "old_field")); - index - .set_member_owner(new_owner, file_id, member_id) - .expect("owner reassignment should succeed"); - index.remove(file_id); - index.add_member(old_owner.clone(), make_member(member_id, "new_field")); - - assert!( - index - .get_current_owner_members_for_key(&old_owner, &old_key) - .is_empty() - ); - assert!(index.get_current_members_for_key(&old_key).is_empty()); - assert_eq!( - index - .get_current_owner_members_for_key(&old_owner, &new_key) - .into_iter() - .map(|member| member.get_id()) - .collect::>(), - vec![member_id] - ); - assert_eq!( - index - .get_current_members_for_key(&new_key) - .into_iter() - .map(|member| member.get_id()) - .collect::>(), - vec![member_id] - ); - } - - #[test] - fn function_scope_lookup_returns_innermost_range() { - let file_id = FileId::new(7); - let outer = TextRange::new(TextSize::new(10), TextSize::new(100)); - let inner = TextRange::new(TextSize::new(30), TextSize::new(60)); - let mut index = LuaMemberIndex::new(); - - index.add_function_scope_range(file_id, outer); - index.add_function_scope_range(file_id, inner); - - assert_eq!( - index.enclosing_function_scope_range(file_id, TextSize::new(40)), - Some(inner) - ); - assert_eq!( - index.enclosing_function_scope_range(file_id, TextSize::new(80)), - Some(outer) - ); - assert_eq!( - index.enclosing_function_scope_range(file_id, TextSize::new(5)), - None - ); - } - - #[test] - fn file_removal_clears_function_scope_metadata() { - let file_id = FileId::new(8); - let range = TextRange::new(TextSize::new(10), TextSize::new(100)); - let member_id = make_member_id(file_id, 20); - let mut index = LuaMemberIndex::new(); - - index.add_function_scope_range(file_id, range); - index.set_member_function_scope_range(member_id, Some(range)); - assert_eq!( - index.enclosing_function_scope_range(file_id, TextSize::new(20)), - Some(range) - ); - assert_eq!(index.member_function_scope_range(member_id), Some(range)); - - index.remove(file_id); - - assert_eq!( - index.enclosing_function_scope_range(file_id, TextSize::new(20)), - None - ); - assert_eq!(index.member_function_scope_range(member_id), None); - - index.add_function_scope_range(file_id, range); - index.set_member_function_scope_range(member_id, Some(range)); - index.clear(); - - assert_eq!( - index.enclosing_function_scope_range(file_id, TextSize::new(20)), - None - ); - assert_eq!(index.member_function_scope_range(member_id), None); - } - - /// `MYTBL["net.handler"] = f` keys one member `net.handler` under `MYTBL`. - /// Its global path is `MYTBL.net.handler`, whose last dotted segment is - /// `handler` — a key nothing is stored under. - #[test] - fn history_for_a_global_path_finds_a_member_whose_key_contains_dots() { - let owner = LuaMemberOwner::GlobalPath(GlobalId::new("MYTBL")); - let global_id = GlobalId::new("MYTBL.net.handler"); - let member_id = make_index_member_id(FileId::new(1), 10); - - let mut index = LuaMemberIndex::new(); - index.add_member( - owner.clone(), - LuaMember::new( - member_id, - LuaMemberKey::Name("net.handler".into()), - LuaMemberFeature::FileFieldDecl, - Some(global_id.clone()), - ), - ); - - assert_eq!( - index.get_member_history_for_global_path(&owner, &global_id), - vec![member_id] - ); - } - - #[test] - fn history_for_a_global_path_still_finds_a_plain_nested_member() { - let owner = LuaMemberOwner::GlobalPath(GlobalId::new("MYTBL.net")); - let global_id = GlobalId::new("MYTBL.net.handler"); - let member_id = make_index_member_id(FileId::new(1), 10); - - let mut index = LuaMemberIndex::new(); - index.add_member( - owner.clone(), - LuaMember::new( - member_id, - LuaMemberKey::Name("handler".into()), - LuaMemberFeature::FileFieldDecl, - Some(global_id.clone()), - ), - ); - - assert_eq!( - index.get_member_history_for_global_path(&owner, &global_id), - vec![member_id] - ); - } - - #[test] - fn an_owner_whose_only_member_is_gone_has_no_live_member() { - let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); - let file_id = FileId::new(1); - let member_id = make_member_id(file_id, 10); - - let mut index = LuaMemberIndex::new(); - index.add_member(owner.clone(), make_member(member_id, "field")); - assert!(index.has_live_member(&owner)); - - index.remove(file_id); - assert!(!index.has_live_member(&owner)); - } -} diff --git a/crates/glua_code_analysis/src/db_index/member/test.rs b/crates/glua_code_analysis/src/db_index/member/test.rs new file mode 100644 index 000000000..708e272d0 --- /dev/null +++ b/crates/glua_code_analysis/src/db_index/member/test.rs @@ -0,0 +1,1160 @@ +#[cfg(test)] +mod tests { + use glua_parser::{LuaSyntaxId, LuaSyntaxKind}; + use rowan::{TextRange, TextSize}; + + use crate::db_index::member::*; + use crate::{FileId, LuaTypeDeclId}; + + fn make_member(member_id: LuaMemberId, key: &str) -> LuaMember { + make_member_with_feature(member_id, key, LuaMemberFeature::FileFieldDecl) + } + + fn make_member_with_feature( + member_id: LuaMemberId, + key: &str, + feature: LuaMemberFeature, + ) -> LuaMember { + LuaMember::new(member_id, LuaMemberKey::Name(key.into()), feature, None) + } + + fn make_member_id(file_id: FileId, start: u32) -> LuaMemberId { + let range = TextRange::new(TextSize::new(start), TextSize::new(start + 1)); + LuaMemberId::new( + LuaSyntaxId::new(LuaSyntaxKind::NameExpr.into(), range), + file_id, + ) + } + + fn make_index_member_id(file_id: FileId, start: u32) -> LuaMemberId { + let range = TextRange::new(TextSize::new(start), TextSize::new(start + 1)); + LuaMemberId::new( + LuaSyntaxId::new(LuaSyntaxKind::IndexExpr.into(), range), + file_id, + ) + } + + fn make_member_id_with_kind_and_end( + file_id: FileId, + kind: LuaSyntaxKind, + start: u32, + end: u32, + ) -> LuaMemberId { + LuaMemberId::new( + LuaSyntaxId::new( + kind.into(), + TextRange::new(TextSize::new(start), TextSize::new(end)), + ), + file_id, + ) + } + + fn owner_member_ids(index: &LuaMemberIndex, owner: &LuaMemberOwner) -> Vec { + index + .get_members(owner) + .expect("owner should exist") + .into_iter() + .map(|member| member.get_id()) + .collect() + } + + fn site(file: u32, start: u32, end: u32) -> crate::InFiled { + crate::InFiled::new( + FileId::new(file), + TextRange::new(TextSize::new(start), TextSize::new(end)), + ) + } + + fn path(name: &str) -> LuaMemberOwner { + LuaMemberOwner::GlobalPath(GlobalId::new(name)) + } + + #[test] + fn an_inline_literal_field_is_owned_by_the_path_not_the_literal() { + let mut index = LuaMemberIndex::new(); + let site = site(1, 10, 12); + index.set_definition_site(GlobalId::new("cityrp.util"), site.clone()); + let member_id = make_index_member_id(FileId::new(1), 11); + index.add_member( + LuaMemberOwner::Element(site.clone()), + make_member(member_id, "Bind"), + ); + + assert_eq!( + index.get_member_owner(&member_id), + Some(&path("cityrp.util")) + ); + assert_eq!( + owner_member_ids(&index, &LuaMemberOwner::Element(site)), + owner_member_ids(&index, &path("cityrp.util")) + ); + } + + #[test] + fn a_literal_that_is_not_a_definition_site_keeps_its_element_owner() { + let mut index = LuaMemberIndex::new(); + let anonymous = site(1, 40, 42); + let member_id = make_index_member_id(FileId::new(1), 41); + index.add_member( + LuaMemberOwner::Element(anonymous.clone()), + make_member(member_id, "Bind"), + ); + + assert_eq!( + index.get_member_owner(&member_id), + Some(&LuaMemberOwner::Element(anonymous)) + ); + } + + #[test] + fn three_literals_in_three_files_share_one_owner() { + let mut index = LuaMemberIndex::new(); + for file in 1..=3u32 { + index.set_definition_site(GlobalId::new("cityrp.util"), site(file, 10, 12)); + index.add_member( + LuaMemberOwner::Element(site(file, 10, 12)), + make_member(make_index_member_id(FileId::new(file), 20 + file), "Bind"), + ); + } + + assert_eq!(owner_member_ids(&index, &path("cityrp.util")).len(), 3); + assert_eq!( + index.definition_sites(&GlobalId::new("cityrp.util")).len(), + 3 + ); + assert_eq!( + index + .definition_sites(&GlobalId::new("cityrp.util")) + .iter() + .map(|s| s.file_id.id) + .collect::>(), + vec![1, 2, 3] + ); + } + + #[test] + fn removing_one_definition_site_file_keeps_the_other_files_members() { + let mut index = LuaMemberIndex::new(); + for file in 1..=3u32 { + index.set_definition_site(GlobalId::new("cityrp.util"), site(file, 10, 12)); + index.add_member( + LuaMemberOwner::Element(site(file, 10, 12)), + make_member(make_index_member_id(FileId::new(file), 20 + file), "Bind"), + ); + } + index.remove_files(&[FileId::new(2)]); + + assert_eq!( + index.definition_sites(&GlobalId::new("cityrp.util")).len(), + 2 + ); + for file in [1u32, 3] { + let member_id = make_index_member_id(FileId::new(file), 20 + file); + assert_eq!( + index.get_member_owner(&member_id), + Some(&path("cityrp.util")) + ); + } + } + + #[test] + fn insertion_order_does_not_change_owners() { + let owners_for = |order: [u32; 3]| { + let mut index = LuaMemberIndex::new(); + for file in order { + index.set_definition_site(GlobalId::new("cityrp.util"), site(file, 10, 12)); + index.add_member( + LuaMemberOwner::Element(site(file, 10, 12)), + make_member(make_index_member_id(FileId::new(file), 20 + file), "Bind"), + ); + } + let mut owners = (1..=3u32) + .map(|file| { + let member_id = make_index_member_id(FileId::new(file), 20 + file); + format!("{:?}", index.get_member_owner(&member_id)) + }) + .collect::>(); + owners.sort(); + let sites = index + .definition_sites(&GlobalId::new("cityrp.util")) + .iter() + .map(|s| s.file_id.id) + .collect::>(); + (owners, sites) + }; + + let baseline = owners_for([1, 2, 3]); + for order in [[3, 2, 1], [2, 1, 3], [2, 3, 1], [3, 1, 2], [1, 3, 2]] { + assert_eq!( + owners_for(order), + baseline, + "order {order:?} changed ownership" + ); + } + } + + #[test] + fn get_members_multi_key_owner_matches_member_id_sort_order() { + let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); + let member_specs = [ + ( + make_member_id_with_kind_and_end(FileId::new(3), LuaSyntaxKind::NameExpr, 20, 24), + "gamma", + ), + ( + make_member_id_with_kind_and_end(FileId::new(1), LuaSyntaxKind::IndexExpr, 40, 45), + "alpha", + ), + ( + make_member_id_with_kind_and_end(FileId::new(1), LuaSyntaxKind::NameExpr, 10, 12), + "beta", + ), + ( + make_member_id_with_kind_and_end(FileId::new(2), LuaSyntaxKind::NameExpr, 5, 6), + "delta", + ), + ( + make_member_id_with_kind_and_end(FileId::new(1), LuaSyntaxKind::IndexExpr, 10, 11), + "epsilon", + ), + ]; + + let mut index = LuaMemberIndex::new(); + for (member_id, key) in member_specs { + index.add_member(owner.clone(), make_member(member_id, key)); + } + + let mut expected_ids = member_specs.map(|(member_id, _)| member_id).to_vec(); + expected_ids.sort_by_key(|member_id| member_id_sort_key(*member_id)); + + assert_eq!(owner_member_ids(&index, &owner), expected_ids); + } + + #[test] + fn batch_removal_matches_sequential_removal_for_surviving_members() { + let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("BatchRemoveType")); + let first = FileId::new(1); + let second = FileId::new(2); + let survivor = FileId::new(3); + + let populate = || { + let mut index = LuaMemberIndex::new(); + index.add_member( + owner.clone(), + make_member(make_member_id(first, 10), "alpha"), + ); + index.add_member( + owner.clone(), + make_member(make_member_id(second, 20), "alpha"), + ); + index.add_member( + owner.clone(), + make_member(make_member_id(second, 30), "beta"), + ); + index.add_member( + owner.clone(), + make_member(make_member_id(survivor, 40), "alpha"), + ); + index.add_member( + owner.clone(), + make_member(make_member_id(survivor, 50), "gamma"), + ); + index + }; + + let mut sequential = populate(); + sequential.remove(first); + sequential.remove(second); + + let mut batched = populate(); + batched.remove_files(&[second, first, second]); + + assert_eq!( + owner_member_ids(&sequential, &owner), + owner_member_ids(&batched, &owner) + ); + assert_eq!( + owner_member_ids(&batched, &owner), + vec![make_member_id(survivor, 40), make_member_id(survivor, 50)] + ); + for index in [&sequential, &batched] { + assert!(index.get_file_members(first).is_empty()); + assert!(index.get_file_members(second).is_empty()); + assert!(index.get_member(&make_member_id(first, 10)).is_none()); + assert!(index.get_member(&make_member_id(second, 20)).is_none()); + assert!(index.get_member(&make_member_id(survivor, 40)).is_some()); + } + assert_eq!( + sequential.member_owner_key_index, + batched.member_owner_key_index + ); + assert_eq!( + sequential.member_owner_key_history_index, + batched.member_owner_key_history_index + ); + } + + #[test] + fn get_members_cache_invalidates_when_adding_earlier_member_after_warm() { + let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); + let first_member_id = make_member_id(FileId::new(4), 20); + let second_member_id = make_member_id(FileId::new(5), 30); + let earlier_member_id = make_member_id(FileId::new(1), 5); + let mut index = LuaMemberIndex::new(); + + index.add_member(owner.clone(), make_member(first_member_id, "first")); + index.add_member(owner.clone(), make_member(second_member_id, "second")); + + assert_eq!( + owner_member_ids(&index, &owner), + vec![first_member_id, second_member_id] + ); + + index.add_member(owner.clone(), make_member(earlier_member_id, "third")); + + assert_eq!( + owner_member_ids(&index, &owner), + vec![earlier_member_id, first_member_id, second_member_id] + ); + } + + #[test] + fn set_member_owner_moves_member_between_owner_indexes() { + let file_id = FileId::new(1); + let old_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OldOwner")); + let new_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("NewOwner")); + let key = LuaMemberKey::Name("field".into()); + let member_id = make_member_id(file_id, 1); + + let mut index = LuaMemberIndex::new(); + index.add_member(old_owner.clone(), make_member(member_id, "field")); + assert!(index.get_member_item(&old_owner, &key).is_some()); + + index + .set_member_owner(new_owner.clone(), file_id, member_id) + .expect("owner reassignment should succeed"); + + assert!(index.get_member_item(&old_owner, &key).is_some()); + assert!(index.get_member_item(&new_owner, &key).is_none()); + assert!(index.get_members_for_owner_key(&old_owner, &key).is_empty()); + assert_eq!(index.get_members_for_owner_key(&new_owner, &key).len(), 1); + assert!( + index + .get_current_owner_member_history(&old_owner) + .is_empty() + ); + assert_eq!( + index + .get_current_owner_member_history(&new_owner) + .iter() + .map(|member| member.get_id()) + .collect::>(), + vec![member_id] + ); + } + + #[test] + fn set_member_owner_keeps_other_old_owner_members() { + let file_id = FileId::new(2); + let old_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OriginalOwner")); + let new_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("ReassignedOwner")); + let key = LuaMemberKey::Name("field".into()); + let first_member_id = make_member_id(file_id, 1); + let second_member_id = make_member_id(file_id, 3); + + let mut index = LuaMemberIndex::new(); + index.add_member(old_owner.clone(), make_member(first_member_id, "field")); + index.add_member(old_owner.clone(), make_member(second_member_id, "field")); + + index + .set_member_owner(new_owner.clone(), file_id, first_member_id) + .expect("owner reassignment should succeed"); + + assert_eq!(index.get_members_for_owner_key(&old_owner, &key).len(), 1); + assert_eq!(index.get_members_for_owner_key(&new_owner, &key).len(), 1); + + let old_owner_member_ids = index + .get_members_for_owner_key(&old_owner, &key) + .iter() + .map(|member| member.get_id()) + .collect::>(); + assert_eq!(old_owner_member_ids, vec![second_member_id]); + + let new_owner_member_ids = index + .get_members_for_owner_key(&new_owner, &key) + .iter() + .map(|member| member.get_id()) + .collect::>(); + assert_eq!(new_owner_member_ids, vec![first_member_id]); + assert_eq!( + index + .get_current_owner_member_history(&old_owner) + .iter() + .map(|member| member.get_id()) + .collect::>(), + vec![second_member_id] + ); + assert_eq!( + index + .get_current_owner_member_history(&new_owner) + .iter() + .map(|member| member.get_id()) + .collect::>(), + vec![first_member_id] + ); + + let new_owner_history_member_ids = index + .get_current_owner_members_for_key(&new_owner, &key) + .into_iter() + .map(|member| member.get_id()) + .collect::>(); + assert_eq!(new_owner_history_member_ids, vec![first_member_id]); + + let key_history_member_ids = index + .get_current_members_for_key(&key) + .into_iter() + .map(|member| member.get_id()) + .collect::>(); + assert_eq!( + key_history_member_ids, + vec![first_member_id, second_member_id] + ); + } + + #[test] + fn get_members_cache_invalidates_when_removing_file_after_warm() { + let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); + let removed_member_id = make_member_id(FileId::new(4), 10); + let retained_member_id = make_member_id(FileId::new(5), 5); + let mut index = LuaMemberIndex::new(); + + index.add_member(owner.clone(), make_member(removed_member_id, "removed")); + index.add_member(owner.clone(), make_member(retained_member_id, "retained")); + + assert_eq!( + owner_member_ids(&index, &owner), + vec![removed_member_id, retained_member_id] + ); + + index.remove(FileId::new(4)); + + assert_eq!(owner_member_ids(&index, &owner), vec![retained_member_id]); + } + + #[test] + fn warming_get_members_does_not_break_owner_move_visibility() { + let file_id = FileId::new(6); + let old_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OldOwner")); + let new_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("NewOwner")); + let key = LuaMemberKey::Name("field".into()); + let member_id = make_member_id(file_id, 1); + let mut index = LuaMemberIndex::new(); + + index.add_member(old_owner.clone(), make_member(member_id, "field")); + assert_eq!(owner_member_ids(&index, &old_owner), vec![member_id]); + assert!(index.get_members(&new_owner).is_none()); + + index + .set_member_owner(new_owner.clone(), file_id, member_id) + .expect("owner reassignment should succeed"); + + assert!(index.get_members_for_owner_key(&old_owner, &key).is_empty()); + assert_eq!( + index + .get_members_for_owner_key(&new_owner, &key) + .into_iter() + .map(|member| member.get_id()) + .collect::>(), + vec![member_id] + ); + } + + #[test] + fn get_members_cache_invalidates_when_one_promotes_to_many_after_warm() { + let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); + let key = LuaMemberKey::Name("field".into()); + let first_member_id = make_member_id(FileId::new(4), 20); + let second_member_id = make_member_id(FileId::new(4), 10); + let mut index = LuaMemberIndex::new(); + + index.add_member( + owner.clone(), + LuaMember::new( + first_member_id, + key.clone(), + LuaMemberFeature::FileFieldDecl, + None, + ), + ); + assert_eq!(owner_member_ids(&index, &owner), vec![first_member_id]); + + index.add_member( + owner.clone(), + LuaMember::new(second_member_id, key, LuaMemberFeature::FileFieldDecl, None), + ); + + assert_eq!( + owner_member_ids(&index, &owner), + vec![second_member_id, first_member_id] + ); + } + + #[test] + fn clear_resets_member_owner_tracking() { + let file_id = FileId::new(3); + let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); + let member_id = make_member_id(file_id, 7); + + let mut index = LuaMemberIndex::new(); + index.add_member(owner, make_member(member_id, "field")); + assert!(index.get_member_owner(&member_id).is_some()); + assert!( + !index + .get_current_members_for_key(&LuaMemberKey::Name("field".into())) + .is_empty() + ); + + index.clear(); + + assert!(index.get_member_owner(&member_id).is_none()); + assert!( + index + .get_current_members_for_key(&LuaMemberKey::Name("field".into())) + .is_empty() + ); + } + + #[test] + fn key_lookup_cache_invalidates_after_member_mutation() { + let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); + let key = LuaMemberKey::Name("field".into()); + let first_member_id = make_member_id(FileId::new(9), 1); + let second_member_id = make_member_id(FileId::new(10), 3); + let mut index = LuaMemberIndex::new(); + + index.add_member(owner.clone(), make_member(first_member_id, "field")); + assert_eq!( + index + .get_current_members_for_key(&key) + .into_iter() + .map(|member| member.get_id()) + .collect::>(), + vec![first_member_id] + ); + + index.add_member(owner.clone(), make_member(second_member_id, "field")); + assert_eq!( + index + .get_current_members_for_key(&key) + .into_iter() + .map(|member| member.get_id()) + .collect::>(), + vec![first_member_id, second_member_id] + ); + + index.remove(FileId::new(9)); + assert_eq!( + index + .get_current_members_for_key(&key) + .into_iter() + .map(|member| member.get_id()) + .collect::>(), + vec![second_member_id] + ); + } + + #[test] + fn a_declaration_and_a_write_coexist_whichever_reaches_the_slot_first() { + let owner = path("holdem"); + let key = LuaMemberKey::Name("action".into()); + // `function holdem.action(...)` in the server file. + let declared = make_index_member_id(FileId::new(6), 30); + // `holdem.action = { ... }` in the client file. + let written = make_index_member_id(FileId::new(5), 20); + + let item_for = |first, second| { + let mut index = LuaMemberIndex::new(); + for (member_id, feature) in [first, second] { + index.add_member( + owner.clone(), + LuaMember::new(member_id, key.clone(), feature, None), + ); + } + index.get_member_item(&owner, &key).cloned() + }; + + let both = Some(LuaMemberIndexItem::Many(vec![written, declared])); + assert_eq!( + item_for( + (written, LuaMemberFeature::FileDefine), + (declared, LuaMemberFeature::FileMethodDecl), + ), + both + ); + assert_eq!( + item_for( + (declared, LuaMemberFeature::FileMethodDecl), + (written, LuaMemberFeature::FileDefine), + ), + both + ); + } + + #[test] + fn visible_owner_key_other_member_check_uses_current_visible_members() { + let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); + let key = LuaMemberKey::Name("field".into()); + let first_member_id = make_index_member_id(FileId::new(4), 1); + let second_member_id = make_index_member_id(FileId::new(4), 3); + + let mut index = LuaMemberIndex::new(); + index.add_member( + owner.clone(), + LuaMember::new( + first_member_id, + key.clone(), + LuaMemberFeature::FileDefine, + None, + ), + ); + + assert!(!index.has_visible_member_for_owner_key_other_than(&owner, &key, first_member_id)); + + index.add_member( + owner.clone(), + LuaMember::new( + second_member_id, + key.clone(), + LuaMemberFeature::FileDefine, + None, + ), + ); + + assert!(index.has_visible_member_for_owner_key_other_than(&owner, &key, second_member_id)); + } + + #[test] + fn meta_only_member_item_preserves_meta_when_assignment_is_added() { + let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); + let key = LuaMemberKey::Name("field".into()); + let meta_member_id = make_member_id(FileId::new(4), 1); + let assignment_member_id = make_index_member_id(FileId::new(4), 3); + + let mut index = LuaMemberIndex::new(); + index.add_member( + owner.clone(), + make_member_with_feature(meta_member_id, "field", LuaMemberFeature::MetaFieldDecl), + ); + index.add_member( + owner.clone(), + make_member_with_feature(assignment_member_id, "field", LuaMemberFeature::FileDefine), + ); + + assert_eq!( + index.get_member_item(&owner, &key), + Some(&LuaMemberIndexItem::Many(vec![ + meta_member_id, + assignment_member_id, + ])) + ); + } + + #[test] + fn preserved_assignment_duplicate_insertions_keep_existing_item_order() { + let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); + let key = LuaMemberKey::Name("field".into()); + let first_member_id = make_index_member_id(FileId::new(4), 1); + let second_member_id = make_index_member_id(FileId::new(4), 3); + + let mut index = LuaMemberIndex::new(); + for member_id in [first_member_id, second_member_id] { + index.mark_non_overwriting_assignment_member(member_id); + index.add_member( + owner.clone(), + make_member_with_feature(member_id, "field", LuaMemberFeature::FileDefine), + ); + } + + index.merge_member_into_owner_item(owner.clone(), key.clone(), second_member_id); + index.merge_member_into_owner_item(owner.clone(), key.clone(), first_member_id); + + assert_eq!( + index.get_member_item(&owner, &key), + Some(&LuaMemberIndexItem::Many(vec![ + first_member_id, + second_member_id, + ])) + ); + } + + #[test] + fn alias_adds_to_an_existing_file_define_without_displacing_it() { + let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("SharedTable")); + let other_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OtherTable")); + let key = LuaMemberKey::Name("field".into()); + let own_member_id = make_index_member_id(FileId::new(1), 10); + let aliased_member_id = make_index_member_id(FileId::new(2), 20); + + let mut index = LuaMemberIndex::new(); + index.add_member( + owner.clone(), + make_member_with_feature(own_member_id, "field", LuaMemberFeature::FileDefine), + ); + index.add_member( + other_owner, + make_member_with_feature(aliased_member_id, "field", LuaMemberFeature::FileDefine), + ); + + index.add_member_alias_to_owner(owner.clone(), aliased_member_id); + + assert_eq!( + index.get_member_item(&owner, &key), + Some(&LuaMemberIndexItem::Many(vec![ + own_member_id, + aliased_member_id, + ])), + "an alias only ever adds; it must never replace the owner's own writer" + ); + } + + #[test] + fn a_table_field_write_arriving_after_an_alias_still_takes_the_slot() { + let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("SharedTable")); + let other_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OtherTable")); + let key = LuaMemberKey::Name("field".into()); + // A table-literal field is a `FileDefine` that is not an index-expr + // assignment, so it falls through to the latest-defined rule. + let own_member_id = make_member_id(FileId::new(1), 10); + let aliased_member_id = make_index_member_id(FileId::new(2), 20); + + let mut index = LuaMemberIndex::new(); + index.add_member( + other_owner, + make_member_with_feature(aliased_member_id, "field", LuaMemberFeature::FileDefine), + ); + index.add_member_alias_to_owner(owner.clone(), aliased_member_id); + index.add_member( + owner.clone(), + make_member_with_feature(own_member_id, "field", LuaMemberFeature::FileDefine), + ); + + assert_eq!( + index.get_member_item(&owner, &key), + Some(&LuaMemberIndexItem::Many(vec![ + own_member_id, + aliased_member_id, + ])), + "which of the two arrived first must not decide the slot" + ); + } + + #[test] + fn marked_non_overwriting_file_defines_share_lookup_item() { + let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); + let key = LuaMemberKey::Name("field".into()); + let first_member_id = LuaMemberId::new( + LuaSyntaxId::new( + LuaSyntaxKind::IndexExpr.into(), + TextRange::new(TextSize::new(1), TextSize::new(2)), + ), + FileId::new(4), + ); + let second_member_id = LuaMemberId::new( + LuaSyntaxId::new( + LuaSyntaxKind::IndexExpr.into(), + TextRange::new(TextSize::new(3), TextSize::new(4)), + ), + FileId::new(4), + ); + + let mut index = LuaMemberIndex::new(); + index.mark_non_overwriting_assignment_member(first_member_id); + index.add_member( + owner.clone(), + LuaMember::new( + first_member_id, + key.clone(), + LuaMemberFeature::FileDefine, + None, + ), + ); + index.mark_non_overwriting_assignment_member(second_member_id); + index.add_member( + owner.clone(), + LuaMember::new( + second_member_id, + key.clone(), + LuaMemberFeature::FileDefine, + None, + ), + ); + + assert_eq!( + index.get_member_item(&owner, &key), + Some(&LuaMemberIndexItem::Many(vec![ + first_member_id, + second_member_id + ])) + ); + } + + #[test] + fn preserved_assignment_insertion_invalidates_get_members_cache_after_warm() { + let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); + let key = LuaMemberKey::Name("field".into()); + let later_member_id = make_index_member_id(FileId::new(4), 30); + let earlier_member_id = make_index_member_id(FileId::new(4), 10); + let mut index = LuaMemberIndex::new(); + + index.mark_non_overwriting_assignment_member(later_member_id); + index.add_member( + owner.clone(), + LuaMember::new( + later_member_id, + key.clone(), + LuaMemberFeature::FileDefine, + None, + ), + ); + assert_eq!(owner_member_ids(&index, &owner), vec![later_member_id]); + + index.mark_non_overwriting_assignment_member(earlier_member_id); + index.add_member( + owner.clone(), + LuaMember::new(earlier_member_id, key, LuaMemberFeature::FileDefine, None), + ); + + assert_eq!( + owner_member_ids(&index, &owner), + vec![earlier_member_id, later_member_id] + ); + } + + #[test] + fn many_marked_non_overwriting_file_defines_share_lookup_item() { + let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); + let key = LuaMemberKey::Name("field".into()); + let mut index = LuaMemberIndex::new(); + + for i in 0..128 { + let member_id = LuaMemberId::new( + LuaSyntaxId::new( + LuaSyntaxKind::IndexExpr.into(), + TextRange::new(TextSize::new(i), TextSize::new(i + 1)), + ), + FileId::new(4), + ); + index.mark_non_overwriting_assignment_member(member_id); + index.add_member( + owner.clone(), + LuaMember::new(member_id, key.clone(), LuaMemberFeature::FileDefine, None), + ); + } + + let Some(LuaMemberIndexItem::Many(member_ids)) = index.get_member_item(&owner, &key) else { + panic!("marked assignments should be preserved as a shared lookup item"); + }; + + assert_eq!(member_ids.len(), 128); + assert_eq!( + member_ids.first().copied(), + Some(make_index_member_id(FileId::new(4), 0)) + ); + assert_eq!( + member_ids.last().copied(), + Some(make_index_member_id(FileId::new(4), 127)) + ); + } + + #[test] + fn marked_non_overwriting_file_defines_keep_stable_order_when_added_out_of_order() { + let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); + let key = LuaMemberKey::Name("field".into()); + let mut index = LuaMemberIndex::new(); + + for start in [30, 10, 20] { + let member_id = make_index_member_id(FileId::new(4), start); + index.mark_non_overwriting_assignment_member(member_id); + index.add_member( + owner.clone(), + LuaMember::new(member_id, key.clone(), LuaMemberFeature::FileDefine, None), + ); + } + + assert_eq!( + index.get_member_item(&owner, &key), + Some(&LuaMemberIndexItem::Many(vec![ + make_index_member_id(FileId::new(4), 10), + make_index_member_id(FileId::new(4), 20), + make_index_member_id(FileId::new(4), 30), + ])) + ); + } + + /// Transparency only applies when a real writer exists. With nothing but + /// bootstraps across files there is no placeholder to see through, so the + /// existing merge still has to keep every writer visible. + #[test] + fn cross_file_all_bootstrap_writers_still_merge() { + let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); + let key = LuaMemberKey::Name("stock".into()); + let first_id = make_index_member_id(FileId::new(1), 10); + let second_id = make_index_member_id(FileId::new(2), 20); + + let mut index = LuaMemberIndex::new(); + for member_id in [first_id, second_id] { + index.mark_non_overwriting_assignment_member(member_id); + index.add_member( + owner.clone(), + LuaMember::new(member_id, key.clone(), LuaMemberFeature::FileDefine, None), + ); + } + + assert_eq!(owner_member_ids(&index, &owner), vec![first_id, second_id]); + } + + #[test] + fn file_removal_clears_previous_owner_history_entries() { + let file_id = FileId::new(6); + let old_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OldOwner")); + let new_owner = LuaMemberOwner::Type(LuaTypeDeclId::global("NewOwner")); + let old_key = LuaMemberKey::Name("old_field".into()); + let new_key = LuaMemberKey::Name("new_field".into()); + let member_id = make_member_id(file_id, 10); + let mut index = LuaMemberIndex::new(); + + index.add_member(old_owner.clone(), make_member(member_id, "old_field")); + index + .set_member_owner(new_owner, file_id, member_id) + .expect("owner reassignment should succeed"); + index.remove(file_id); + index.add_member(old_owner.clone(), make_member(member_id, "new_field")); + + assert!( + index + .get_current_owner_members_for_key(&old_owner, &old_key) + .is_empty() + ); + assert!(index.get_current_members_for_key(&old_key).is_empty()); + assert_eq!( + index + .get_current_owner_members_for_key(&old_owner, &new_key) + .into_iter() + .map(|member| member.get_id()) + .collect::>(), + vec![member_id] + ); + assert_eq!( + index + .get_current_members_for_key(&new_key) + .into_iter() + .map(|member| member.get_id()) + .collect::>(), + vec![member_id] + ); + } + + #[test] + fn function_scope_lookup_returns_innermost_range() { + let file_id = FileId::new(7); + let outer = TextRange::new(TextSize::new(10), TextSize::new(100)); + let inner = TextRange::new(TextSize::new(30), TextSize::new(60)); + let mut index = LuaMemberIndex::new(); + + index.add_function_scope_range(file_id, outer); + index.add_function_scope_range(file_id, inner); + + assert_eq!( + index.enclosing_function_scope_range(file_id, TextSize::new(40)), + Some(inner) + ); + assert_eq!( + index.enclosing_function_scope_range(file_id, TextSize::new(80)), + Some(outer) + ); + assert_eq!( + index.enclosing_function_scope_range(file_id, TextSize::new(5)), + None + ); + } + + #[test] + fn file_removal_clears_function_scope_metadata() { + let file_id = FileId::new(8); + let range = TextRange::new(TextSize::new(10), TextSize::new(100)); + let member_id = make_member_id(file_id, 20); + let mut index = LuaMemberIndex::new(); + + index.add_function_scope_range(file_id, range); + index.set_member_function_scope_range(member_id, Some(range)); + assert_eq!( + index.enclosing_function_scope_range(file_id, TextSize::new(20)), + Some(range) + ); + assert_eq!(index.member_function_scope_range(member_id), Some(range)); + + index.remove(file_id); + + assert_eq!( + index.enclosing_function_scope_range(file_id, TextSize::new(20)), + None + ); + assert_eq!(index.member_function_scope_range(member_id), None); + + index.add_function_scope_range(file_id, range); + index.set_member_function_scope_range(member_id, Some(range)); + index.clear(); + + assert_eq!( + index.enclosing_function_scope_range(file_id, TextSize::new(20)), + None + ); + assert_eq!(index.member_function_scope_range(member_id), None); + } + + /// `MYTBL["net.handler"] = f` keys one member `net.handler` under `MYTBL`. + /// Its global path is `MYTBL.net.handler`, whose last dotted segment is + /// `handler` — a key nothing is stored under. + #[test] + fn history_for_a_global_path_finds_a_member_whose_key_contains_dots() { + let owner = LuaMemberOwner::GlobalPath(GlobalId::new("MYTBL")); + let global_id = GlobalId::new("MYTBL.net.handler"); + let member_id = make_index_member_id(FileId::new(1), 10); + + let mut index = LuaMemberIndex::new(); + index.add_member( + owner.clone(), + LuaMember::new( + member_id, + LuaMemberKey::Name("net.handler".into()), + LuaMemberFeature::FileFieldDecl, + Some(global_id.clone()), + ), + ); + + assert_eq!( + index.get_member_history_for_global_path(&owner, &global_id), + vec![member_id] + ); + } + + #[test] + fn history_for_a_global_path_still_finds_a_plain_nested_member() { + let owner = LuaMemberOwner::GlobalPath(GlobalId::new("MYTBL.net")); + let global_id = GlobalId::new("MYTBL.net.handler"); + let member_id = make_index_member_id(FileId::new(1), 10); + + let mut index = LuaMemberIndex::new(); + index.add_member( + owner.clone(), + LuaMember::new( + member_id, + LuaMemberKey::Name("handler".into()), + LuaMemberFeature::FileFieldDecl, + Some(global_id.clone()), + ), + ); + + assert_eq!( + index.get_member_history_for_global_path(&owner, &global_id), + vec![member_id] + ); + } + + #[test] + fn an_owner_whose_only_member_is_gone_has_no_live_member() { + let owner = LuaMemberOwner::Type(LuaTypeDeclId::global("OwnedType")); + let file_id = FileId::new(1); + let member_id = make_member_id(file_id, 10); + + let mut index = LuaMemberIndex::new(); + index.add_member(owner.clone(), make_member(member_id, "field")); + assert!(index.has_live_member(&owner)); + + index.remove(file_id); + assert!(!index.has_live_member(&owner)); + } + + fn make_expr_member(member_id: LuaMemberId, key: LuaMemberKey) -> LuaMember { + LuaMember::new(member_id, key, LuaMemberFeature::FileFieldDecl, None) + } + + #[test] + fn rekey_member_moves_the_member_between_slots() { + let file = FileId::new(1); + let owner = LuaMemberOwner::Element(site(1, 10, 12)); + let old_key = LuaMemberKey::ExprType(crate::LuaType::Integer); + let new_key = LuaMemberKey::ExprType(crate::LuaType::String); + let mover = make_index_member_id(file, 20); + let stayer = make_index_member_id(file, 30); + + let mut index = LuaMemberIndex::new(); + index.add_member(owner.clone(), make_expr_member(mover, old_key.clone())); + index.add_member(owner.clone(), make_expr_member(stayer, old_key.clone())); + + index.rekey_member(mover, new_key.clone()); + + assert_eq!(index.get_member(&mover).unwrap().get_key(), &new_key); + assert_eq!( + index + .get_members_with_key(&owner, &new_key) + .unwrap() + .iter() + .map(|member| member.get_id()) + .collect::>(), + vec![mover] + ); + assert_eq!( + index + .get_members_with_key(&owner, &old_key) + .unwrap() + .iter() + .map(|member| member.get_id()) + .collect::>(), + vec![stayer] + ); + assert!( + index + .get_current_members_for_key(&old_key) + .iter() + .all(|member| member.get_id() == stayer) + ); + assert_eq!(index.get_current_members_for_key(&new_key).len(), 1); + // The whole-owner view still yields both, once each. + let mut owner_ids = index + .get_members(&owner) + .unwrap() + .iter() + .map(|member| member.get_id()) + .collect::>(); + owner_ids.sort_by_key(|id| member_id_sort_key(*id)); + assert_eq!(owner_ids, vec![mover, stayer]); + } + + #[test] + fn removing_a_rekeyed_members_file_leaves_no_residue() { + let file = FileId::new(1); + let owner = LuaMemberOwner::Element(site(1, 10, 12)); + let old_key = LuaMemberKey::ExprType(crate::LuaType::Integer); + let new_key = LuaMemberKey::ExprType(crate::LuaType::String); + let member_id = make_index_member_id(file, 20); + + let mut index = LuaMemberIndex::new(); + index.add_member(owner.clone(), make_expr_member(member_id, old_key.clone())); + index.rekey_member(member_id, new_key.clone()); + + index.remove_files(&[file]); + + assert!(index.get_member(&member_id).is_none()); + assert!(index.get_file_members(file).is_empty()); + assert!(index.get_members(&owner).is_none()); + assert!(!index.owner_members.contains_key(&owner)); + for key in [&old_key, &new_key] { + assert!(index.get_current_members_for_key(key).is_empty()); + assert!(!index.current_members_by_key.contains_key(key)); + } + assert!(!index.member_owner_key_index.contains_key(&owner)); + assert!(!index.member_owner_key_history_index.contains_key(&owner)); + assert!(!index.member_current_owner.contains_key(&member_id)); + } +} 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..f2f5f7887 100644 --- a/crates/glua_code_analysis/src/db_index/metatable/mod.rs +++ b/crates/glua_code_analysis/src/db_index/metatable/mod.rs @@ -1,4 +1,5 @@ -use std::collections::{HashMap, HashSet}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; +use std::sync::Arc; use rowan::{TextRange, TextSize}; use smol_str::SmolStr; @@ -7,12 +8,40 @@ use crate::{FileId, InFiled}; use super::LuaIndex; +/// `None` when the edit destroyed the literal this range named. +fn map_range(remap: &crate::FileRemap, range: &InFiled) -> Option> { + match remap.table_range(range) { + crate::Remap::Moved(new) => Some(new), + crate::Remap::Unrelated => Some(range.clone()), + crate::Remap::Lost => None, + } +} + #[derive(Debug)] pub struct LuaMetatableIndex { - pub metatables: HashMap, InFiled>, + /// Table literal -> the bindings written for it, ordered by writer. Both the key and the + /// metatable range can live in a file other than the one that wrote `setmetatable`, so a + /// binding is owned by its writer and only the writer's removal drops it. + metatables: HashMap, Vec>, + by_writer: HashMap>>, factory_bindings: HashMap>, } +#[derive(Debug, Clone)] +pub struct MetatableBinding { + pub metatable: InFiled, + pub writer_file_id: FileId, + /// Normalized path of the writing file. File ids are not stable between a cold build and an + /// incremental session, so the winner among several writers is ordered by path. + writer_sort_key: Arc, +} + +impl MetatableBinding { + fn order_key(&self) -> (&str, u32) { + (&self.writer_sort_key, self.writer_file_id.id) + } +} + #[derive(Debug, Clone)] pub struct SetmetatableFactoryBinding { pub file_id: FileId, @@ -32,17 +61,62 @@ impl Default for LuaMetatableIndex { impl LuaMetatableIndex { pub fn new() -> Self { Self { - metatables: HashMap::new(), - factory_bindings: HashMap::new(), + metatables: HashMap::default(), + by_writer: HashMap::default(), + factory_bindings: HashMap::default(), } } - pub fn add(&mut self, table: InFiled, metatable: InFiled) { - self.metatables.insert(table, metatable); + pub fn add( + &mut self, + table: InFiled, + metatable: InFiled, + writer_file_id: FileId, + writer_sort_key: Arc, + ) { + let bindings = self.metatables.entry(table.clone()).or_default(); + if let Some(existing) = bindings + .iter_mut() + .find(|binding| binding.writer_file_id == writer_file_id) + { + existing.metatable = metatable; + return; + } + + let binding = MetatableBinding { + metatable, + writer_file_id, + writer_sort_key, + }; + let position = bindings.partition_point(|other| other.order_key() < binding.order_key()); + bindings.insert(position, binding); + self.by_writer + .entry(writer_file_id) + .or_default() + .push(table); + } + + /// 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.values().map(Vec::len).sum() } pub fn get(&self, table: &InFiled) -> Option<&InFiled> { - self.metatables.get(table) + self.metatables + .get(table) + .and_then(|bindings| bindings.first()) + .map(|binding| &binding.metatable) + } + + /// Every recorded binding, paired with the table literal it is keyed on. + pub fn iter_bindings( + &self, + ) -> impl Iterator, &MetatableBinding)> + '_ { + self.metatables + .iter() + .flat_map(|(table, bindings)| bindings.iter().map(move |binding| (table, binding))) } pub fn add_factory_binding(&mut self, binding: SetmetatableFactoryBinding) { @@ -52,59 +126,204 @@ impl LuaMetatableIndex { .push(binding); } + /// Rewrites every `setmetatable` binding that names a table literal in the + /// edited file. + /// + /// Both halves of a binding are table literals and either can live in a + /// file other than the one that wrote the call, so both sides are mapped. + /// Returns the files whose binding named a literal the edit destroyed. + pub fn remap_file_ranges(&mut self, remap: &crate::FileRemap) -> HashSet { + let mut dirty = HashSet::default(); + let mut rebuilt: HashMap, Vec> = + HashMap::with_capacity_and_hasher(self.metatables.len(), Default::default()); + for (table, bindings) in std::mem::take(&mut self.metatables) { + let new_table = map_range(remap, &table); + for binding in bindings { + match (new_table.clone(), map_range(remap, &binding.metatable)) { + (Some(new_table), Some(new_metatable)) => { + rebuilt + .entry(new_table) + .or_default() + .push(MetatableBinding { + metatable: new_metatable, + ..binding + }); + } + // Only the writer can record this binding again. + _ => { + dirty.insert(binding.writer_file_id); + } + } + } + } + + self.by_writer.clear(); + for (table, bindings) in &mut rebuilt { + bindings.sort_by(|a, b| a.order_key().cmp(&b.order_key())); + for binding in bindings.iter() { + self.by_writer + .entry(binding.writer_file_id) + .or_default() + .push(table.clone()); + } + } + self.metatables = rebuilt; + + for (file_id, bindings) in &mut self.factory_bindings { + let before = bindings.len(); + bindings.retain_mut(|binding| { + let (Some(table_range), Some(metatable_range)) = ( + map_range(remap, &binding.table_range), + map_range(remap, &binding.metatable_range), + ) else { + return false; + }; + binding.table_range = table_range; + binding.metatable_range = metatable_range; + true + }); + if bindings.len() != before { + dirty.insert(*file_id); + } + } + self.factory_bindings + .retain(|_, bindings| !bindings.is_empty()); + dirty.remove(&remap.file_id); + dirty + } + pub fn factory_bindings_for_file( &self, file_id: FileId, ) -> Option<&[SetmetatableFactoryBinding]> { self.factory_bindings.get(&file_id).map(Vec::as_slice) } + + fn remove_writer(&mut self, file_id: FileId) { + for table in self.by_writer.remove(&file_id).unwrap_or_default() { + let Some(bindings) = self.metatables.get_mut(&table) else { + continue; + }; + bindings.retain(|binding| binding.writer_file_id != file_id); + if bindings.is_empty() { + self.metatables.remove(&table); + } + } + self.factory_bindings.remove(&file_id); + } } impl LuaIndex for LuaMetatableIndex { fn remove(&mut self, file_id: FileId) { - self.metatables.retain(|key, _| key.file_id != file_id); - self.factory_bindings.remove(&file_id); + self.remove_writer(file_id); } fn remove_files(&mut self, file_ids: &[FileId]) { - let removed_file_ids = file_ids.iter().copied().collect::>(); - self.metatables - .retain(|table, _| !removed_file_ids.contains(&table.file_id)); - self.factory_bindings - .retain(|file_id, _| !removed_file_ids.contains(file_id)); + for file_id in file_ids { + self.remove_writer(*file_id); + } } fn clear(&mut self) { self.metatables.clear(); + self.by_writer.clear(); self.factory_bindings.clear(); } } #[cfg(test)] mod tests { + use std::sync::Arc; + use rowan::{TextRange, TextSize}; use super::{LuaIndex, LuaMetatableIndex}; use crate::{FileId, InFiled}; + fn key(path: &str) -> Arc { + Arc::from(path) + } + #[test] - fn batch_removal_preserves_metatables_from_surviving_files() { + fn batch_removal_drops_only_bindings_the_removed_file_wrote() { let removed = FileId::new(1); let other_removed = FileId::new(2); let surviving = FileId::new(3); let range = TextRange::new(TextSize::new(0), TextSize::new(1)); - let removed_table = InFiled::new(removed, range); - let surviving_table = InFiled::new(surviving, range); + // Both table literals live in `removed`; the writers differ. + let written_by_removed = InFiled::new(removed, range); + let written_by_surviving = InFiled::new(removed, TextRange::new(range.end(), range.end())); let mut index = LuaMetatableIndex::new(); - index.add(removed_table.clone(), InFiled::new(surviving, range)); - index.add(surviving_table.clone(), InFiled::new(removed, range)); + index.add( + written_by_removed.clone(), + InFiled::new(surviving, range), + removed, + key("a.lua"), + ); + index.add( + written_by_surviving.clone(), + InFiled::new(removed, range), + surviving, + key("c.lua"), + ); index.remove_files(&[other_removed, removed, other_removed]); - assert!(index.get(&removed_table).is_none()); + assert!(index.get(&written_by_removed).is_none()); assert_eq!( - index.get(&surviving_table), + index.get(&written_by_surviving), Some(&InFiled::new(removed, range)) ); } + + #[test] + fn reindexing_the_table_file_keeps_a_foreign_writers_binding() { + let table_file = FileId::new(1); + let writer = FileId::new(2); + let range = TextRange::new(TextSize::new(0), TextSize::new(1)); + let table = InFiled::new(table_file, range); + let mut index = LuaMetatableIndex::new(); + index.add( + table.clone(), + InFiled::new(writer, range), + writer, + key("writer.lua"), + ); + + index.remove(table_file); + + assert_eq!(index.get(&table), Some(&InFiled::new(writer, range))); + + index.remove(writer); + + assert!(index.get(&table).is_none()); + } + + #[test] + fn several_writers_of_one_table_resolve_by_lowest_path() { + let table_file = FileId::new(1); + let late_writer = FileId::new(2); + let early_writer = FileId::new(3); + let range = TextRange::new(TextSize::new(0), TextSize::new(1)); + let table = InFiled::new(table_file, range); + let mut index = LuaMetatableIndex::new(); + index.add( + table.clone(), + InFiled::new(late_writer, range), + late_writer, + key("b.lua"), + ); + index.add( + table.clone(), + InFiled::new(early_writer, range), + early_writer, + key("a.lua"), + ); + + assert_eq!(index.get(&table), Some(&InFiled::new(early_writer, range))); + + index.remove(early_writer); + + assert_eq!(index.get(&table), Some(&InFiled::new(late_writer, range))); + } } diff --git a/crates/glua_code_analysis/src/db_index/mod.rs b/crates/glua_code_analysis/src/db_index/mod.rs index 13b4bd751..256b353e0 100644 --- a/crates/glua_code_analysis/src/db_index/mod.rs +++ b/crates/glua_code_analysis/src/db_index/mod.rs @@ -4,6 +4,7 @@ mod declaration; mod dependency; mod diagnostic; mod dynamic_field; +mod edit; mod flow; mod global; mod gmod_class; @@ -22,21 +23,23 @@ mod semantic_decl; mod signature; mod traits; mod r#type; +pub(crate) use r#type::read_set; -use std::{ - collections::{HashMap, HashSet}, - path::PathBuf, - sync::Arc, -}; +use rustc_hash::{FxHashMap, FxHashSet}; +use std::{path::PathBuf, sync::Arc}; 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}; pub use diagnostic::{AnalyzeError, DiagnosticAction, DiagnosticActionKind, DiagnosticIndex}; -pub use dynamic_field::{DynamicFieldIndex, DynamicFieldOwner, is_pure_wildcard_registry}; +pub use dynamic_field::{ + DynamicFieldIndex, DynamicFieldOwner, canonical_dynamic_field_owner, dynamic_field_owner_of, + is_pure_wildcard_registry, +}; +pub use edit::*; pub use flow::*; pub use global::{GlobalId, LuaGlobalIndex}; pub use gmod_class::*; @@ -88,7 +91,7 @@ pub struct DbIndex { /// type-erased so `db_index` stays decoupled from the analyzer crate layer. /// Invalidated automatically by comparing `Vfs::content_revision`. helper_registry_cache: RevisionedCache, - file_helper_scan_cache: HashMap>, + file_helper_scan_cache: FxHashMap>, /// Bumped on every *mutable* handle to the type, member, signature or module /// index; memos over facts derived from those key on it. It covers those four /// and no others — the decl, global, dynamic-field and gmod-infer indexes all @@ -157,7 +160,7 @@ impl DbIndex { json_schema_index: JsonSchemaIndex::new(), emmyrc: Arc::new(Emmyrc::default()), helper_registry_cache: RevisionedCache::default(), - file_helper_scan_cache: HashMap::new(), + file_helper_scan_cache: FxHashMap::default(), } } @@ -249,6 +252,12 @@ impl DbIndex { &mut self.references_index } + /// Rebuilds the inference-derived state left stale by a batch of fact + /// bindings. See [`LuaTypeIndex::flush_inference_derived_state`]. + pub fn flush_inference_derived_state(&mut self) { + self.types_index.flush_inference_derived_state(); + } + pub fn get_type_index_mut(&mut self) -> &mut LuaTypeIndex { self.type_structure_revision = next_type_structure_revision(); &mut self.types_index @@ -282,13 +291,13 @@ impl DbIndex { pub fn publish_inference_facts( &mut self, mut updates: Vec<(LuaInferenceNodeId, LuaTypeFact)>, - ) -> HashSet { + ) -> FxHashSet { // Writes into `types_index` below go direct rather than through // `get_type_index_mut`, so bump the revision here too. self.type_structure_revision = next_type_structure_revision(); updates.sort_by(|(left_node, _), (right_node, _)| left_node.stable_cmp(right_node)); - let mut conflicting_nodes = HashSet::new(); + let mut conflicting_nodes = FxHashSet::default(); for pair in updates.windows(2) { let [(left_node, left_fact), (right_node, right_fact)] = pair else { unreachable!(); @@ -298,7 +307,7 @@ impl DbIndex { } } - let mut changed_files = HashSet::new(); + let mut changed_files = FxHashSet::default(); let mut previous_node = None; for (node, fact) in updates { if conflicting_nodes.contains(&node) || previous_node.as_ref() == Some(&node) { @@ -336,8 +345,10 @@ impl DbIndex { } } - self.types_index - .rebuild_inference_derived_state(&changed_files); + if !changed_files.is_empty() { + self.types_index.mark_inference_derived_state_dirty(); + } + self.types_index.flush_inference_derived_state(); changed_files } diff --git a/crates/glua_code_analysis/src/db_index/module/mod.rs b/crates/glua_code_analysis/src/db_index/module/mod.rs index 440a9fdc7..e107899a9 100644 --- a/crates/glua_code_analysis/src/db_index/module/mod.rs +++ b/crates/glua_code_analysis/src/db_index/module/mod.rs @@ -11,14 +11,14 @@ pub use module_info::ModuleInfo; pub use module_node::{ModuleNode, ModuleNodeId}; use regex::Regex; use rowan::TextSize; -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; +use std::collections::HashMap; pub(crate) use workspace::WorkspaceResolutionKey; pub use workspace::{Workspace, WorkspaceId, WorkspaceKind}; use super::traits::LuaIndex; use crate::{Emmyrc, FileId}; use std::{ - collections::{HashMap, HashSet}, path::{Path, PathBuf}, sync::Arc, }; @@ -165,7 +165,7 @@ impl LuaModuleIndex { if let std::collections::hash_map::Entry::Vacant(e) = self.module_nodes.entry(child_id) { let new_node = ModuleNode { - children: HashMap::new(), + children: HashMap::default(), file_ids: Vec::new(), parent: Some(parent_node_id), }; @@ -872,7 +872,7 @@ impl LuaModuleIndex { } pub fn next_main_workspace_id(&self) -> u32 { - let used: HashSet = self.workspaces.iter().map(|w| w.id.id).collect(); + let used: FxHashSet = self.workspaces.iter().map(|w| w.id.id).collect(); let mut candidate = WorkspaceId::MAIN.id; while candidate == WorkspaceId::REMOTE.id || used.contains(&candidate) { candidate += 1; @@ -881,7 +881,7 @@ impl LuaModuleIndex { } pub fn next_library_workspace_id(&self) -> u32 { - let used: HashSet = self.workspaces.iter().map(|w| w.id.id).collect(); + let used: FxHashSet = self.workspaces.iter().map(|w| w.id.id).collect(); let mut candidate = WorkspaceId::REMOTE.id + 1; while used.contains(&candidate) { candidate += 1; diff --git a/crates/glua_code_analysis/src/db_index/module/workspace.rs b/crates/glua_code_analysis/src/db_index/module/workspace.rs index f58573549..c3317b0d1 100644 --- a/crates/glua_code_analysis/src/db_index/module/workspace.rs +++ b/crates/glua_code_analysis/src/db_index/module/workspace.rs @@ -8,6 +8,27 @@ pub enum WorkspaceKind { Library, } +impl WorkspaceKind { + /// Rank for a file that belongs to no known workspace, which sorts after + /// every kind [`Self::merge_rank`] ranks. + pub const MERGE_RANK_NONE: u8 = 4; + + /// Where this workspace's contribution sorts when several files document + /// one class. + /// + /// Annotations and other libraries are the baseline a workspace refines, so + /// they merge before the main workspace and the main workspace's wording + /// wins. Ties fall through to the file's own ordering key. + pub fn merge_rank(self) -> u8 { + match self { + WorkspaceKind::Std => 0, + WorkspaceKind::Library => 1, + WorkspaceKind::Remote => 2, + WorkspaceKind::Main => 3, + } + } +} + #[derive(Debug)] pub struct Workspace { pub root: PathBuf, diff --git a/crates/glua_code_analysis/src/db_index/numeric_range_population.rs b/crates/glua_code_analysis/src/db_index/numeric_range_population.rs index 38dd5ba04..9717dbde5 100644 --- a/crates/glua_code_analysis/src/db_index/numeric_range_population.rs +++ b/crates/glua_code_analysis/src/db_index/numeric_range_population.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use rustc_hash::FxHashMap; use rowan::TextRange; @@ -18,8 +18,8 @@ pub struct TableNumericRangePopulation { #[derive(Debug, Default)] pub struct NumericRangePopulationIndex { - by_file: HashMap>, - by_global: HashMap>, + by_file: FxHashMap>, + by_global: FxHashMap>, } impl NumericRangePopulationIndex { 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/decl_feature.rs b/crates/glua_code_analysis/src/db_index/property/decl_feature.rs index 3a8f381e1..d4a973c8f 100644 --- a/crates/glua_code_analysis/src/db_index/property/decl_feature.rs +++ b/crates/glua_code_analysis/src/db_index/property/decl_feature.rs @@ -19,4 +19,8 @@ impl DeclFeatureFlag { pub fn has_feature(&self, feature: PropertyDeclFeature) -> bool { (self.0 & (feature as u32)) != 0 } + + pub fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } } 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..d858d93ee 100644 --- a/crates/glua_code_analysis/src/db_index/property/mod.rs +++ b/crates/glua_code_analysis/src/db_index/property/mod.rs @@ -2,6 +2,9 @@ mod decl_feature; #[allow(clippy::module_inception)] mod property; +use std::collections::BTreeMap; +use std::sync::Arc; + use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; pub use decl_feature::{DeclFeatureFlag, PropertyDeclFeature}; @@ -26,12 +29,30 @@ pub struct LuaInferredStringDefault { pub source_range: TextRange, } +/// Merge order of one file's contribution to a shared property: annotation and library +/// workspaces before the main workspace, then lowest normalized path. File ids are not stable +/// between a cold build and an incremental session, so they are only the final tie-break. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct PropertyContributorKey { + workspace_rank: u8, + normalized_path: Arc, + file_id: u32, +} + #[derive(Debug)] pub struct LuaPropertyIndex { + /// Merged view, read by `get_property`. For the common single-contributor property this is + /// the contribution itself and nothing else is stored. properties: HashMap, property_owners_map: HashMap, signature_owner_by_property: HashMap, + /// `TypeDecl` properties are keyed by class name alone, so several files can document one + /// class. Only those properties get per-file contributions; everything else stays sole. + sole_contributor: HashMap, + contributions: HashMap>, + file_merge_order: HashMap)>, + id_count: u32, in_filed_owner: HashMap>, @@ -57,6 +78,9 @@ impl LuaPropertyIndex { properties: HashMap::default(), property_owners_map: HashMap::default(), signature_owner_by_property: HashMap::default(), + sole_contributor: HashMap::default(), + contributions: HashMap::default(), + file_merge_order: HashMap::default(), inferred_string_defaults: HashMap::default(), inferred_string_defaults_file_owners: HashMap::default(), } @@ -82,6 +106,91 @@ impl LuaPropertyIndex { } } + /// Records the merge order of a file's documentation contributions. Called once per file + /// before its doc tags are analyzed; a file that never registers sorts last. + pub fn set_file_merge_order(&mut self, file_id: FileId, workspace_rank: u8, path: Arc) { + self.file_merge_order + .insert(file_id, (workspace_rank, path)); + } + + fn contributor_key(&self, file_id: FileId) -> PropertyContributorKey { + let (workspace_rank, normalized_path) = self + .file_merge_order + .get(&file_id) + .cloned() + .unwrap_or((u8::MAX, Arc::from(""))); + PropertyContributorKey { + workspace_rank, + normalized_path, + file_id: file_id.id, + } + } + + /// Applies one file's write to that file's own contribution, then republishes the merged view. + fn contribute( + &mut self, + file_id: FileId, + owner_id: LuaSemanticDeclId, + write: impl FnOnce(&mut LuaCommonProperty), + ) -> Option<()> { + let (_, property_id) = self.get_or_create_property(owner_id.clone())?; + + self.in_filed_owner + .entry(file_id) + .or_default() + .insert(owner_id); + + if !self.contributions.contains_key(&property_id) { + match self.sole_contributor.get(&property_id).copied() { + None => { + self.sole_contributor.insert(property_id, file_id); + write(self.properties.get_mut(&property_id)?); + return Some(()); + } + Some(sole) if sole == file_id => { + write(self.properties.get_mut(&property_id)?); + return Some(()); + } + Some(sole) => { + // Second writer: the merged value so far is the first file's contribution. + let sole_value = self + .properties + .get(&property_id) + .cloned() + .unwrap_or_default(); + let sole_key = self.contributor_key(sole); + self.contributions + .entry(property_id) + .or_default() + .insert(sole_key, sole_value); + self.sole_contributor.remove(&property_id); + } + } + } + + let key = self.contributor_key(file_id); + write( + self.contributions + .get_mut(&property_id)? + .entry(key) + .or_default(), + ); + self.rebuild_merged(property_id); + + Some(()) + } + + fn rebuild_merged(&mut self, property_id: LuaPropertyId) { + let Some(contributions) = self.contributions.get(&property_id) else { + return; + }; + let mut merged = LuaCommonProperty::new(); + for contribution in contributions.values() { + merged.merge_from(contribution); + } + self.properties.insert(property_id, merged); + } + pub fn add_owner_map( &mut self, source_owner_id: LuaSemanticDeclId, @@ -89,6 +198,9 @@ impl LuaPropertyIndex { file_id: FileId, ) -> Option<()> { let (_, property_id) = self.get_or_create_property(source_owner_id.clone())?; + // Owner aliases are always within one file, so this file owns the property outright + // unless a doc contribution has already promoted it. + self.sole_contributor.entry(property_id).or_insert(file_id); self.property_owners_map .insert(same_property_owner_id.clone(), property_id); if let LuaSemanticDeclId::Signature(signature_id) = &source_owner_id { @@ -116,15 +228,9 @@ impl LuaPropertyIndex { owner_id: LuaSemanticDeclId, description: String, ) -> Option<()> { - let (property, _) = self.get_or_create_property(owner_id.clone())?; - property.add_extra_description(description); - - self.in_filed_owner - .entry(file_id) - .or_default() - .insert(owner_id); - - Some(()) + self.contribute(file_id, owner_id, |property| { + property.add_extra_description(description) + }) } pub fn add_visibility( @@ -133,15 +239,9 @@ impl LuaPropertyIndex { owner_id: LuaSemanticDeclId, visibility: VisibilityKind, ) -> Option<()> { - let (property, _) = self.get_or_create_property(owner_id.clone())?; - property.visibility = visibility; - - self.in_filed_owner - .entry(file_id) - .or_default() - .insert(owner_id); - - Some(()) + self.contribute(file_id, owner_id, |property| { + property.visibility = visibility; + }) } pub fn add_source( @@ -150,15 +250,9 @@ impl LuaPropertyIndex { owner_id: LuaSemanticDeclId, source: String, ) -> Option<()> { - let (property, _) = self.get_or_create_property(owner_id.clone())?; - property.add_extra_source(source); - - self.in_filed_owner - .entry(file_id) - .or_default() - .insert(owner_id); - - Some(()) + self.contribute(file_id, owner_id, |property| { + property.add_extra_source(source) + }) } pub fn add_default_value( @@ -167,15 +261,9 @@ impl LuaPropertyIndex { owner_id: LuaSemanticDeclId, default_value: LuaDocDefaultValue, ) -> Option<()> { - let (property, _) = self.get_or_create_property(owner_id.clone())?; - property.add_extra_default_value(default_value); - - self.in_filed_owner - .entry(file_id) - .or_default() - .insert(owner_id); - - Some(()) + self.contribute(file_id, owner_id, |property| { + property.add_extra_default_value(default_value) + }) } pub fn add_deprecated( @@ -184,15 +272,9 @@ impl LuaPropertyIndex { owner_id: LuaSemanticDeclId, message: Option, ) -> Option<()> { - let (property, _) = self.get_or_create_property(owner_id.clone())?; - property.add_extra_deprecated(message); - - self.in_filed_owner - .entry(file_id) - .or_default() - .insert(owner_id); - - Some(()) + self.contribute(file_id, owner_id, |property| { + property.add_extra_deprecated(message) + }) } pub fn add_version( @@ -201,15 +283,9 @@ impl LuaPropertyIndex { owner_id: LuaSemanticDeclId, version_conds: Vec, ) -> Option<()> { - let (property, _) = self.get_or_create_property(owner_id.clone())?; - property.add_extra_version_cond(version_conds); - - self.in_filed_owner - .entry(file_id) - .or_default() - .insert(owner_id); - - Some(()) + self.contribute(file_id, owner_id, |property| { + property.add_extra_version_cond(version_conds) + }) } pub fn add_see( @@ -219,21 +295,14 @@ impl LuaPropertyIndex { mut see_content: String, see_description: Option, ) -> Option<()> { - let (property, _) = self.get_or_create_property(owner_id.clone())?; - if let Some(see_description) = see_description { see_content += " "; see_content += &see_description; } - property.add_extra_tag("see".into(), see_content); - - self.in_filed_owner - .entry(file_id) - .or_default() - .insert(owner_id); - - Some(()) + self.contribute(file_id, owner_id, |property| { + property.add_extra_tag("see".into(), see_content) + }) } pub fn add_other( @@ -243,15 +312,9 @@ impl LuaPropertyIndex { tag_name: String, other_content: String, ) -> Option<()> { - let (property, _) = self.get_or_create_property(owner_id.clone())?; - property.add_extra_tag(tag_name, other_content); - - self.in_filed_owner - .entry(file_id) - .or_default() - .insert(owner_id); - - Some(()) + self.contribute(file_id, owner_id, |property| { + property.add_extra_tag(tag_name, other_content) + }) } pub fn add_export( @@ -260,15 +323,9 @@ impl LuaPropertyIndex { owner_id: LuaSemanticDeclId, export: property::LuaExport, ) -> Option<()> { - let (property, _) = self.get_or_create_property(owner_id.clone())?; - property.add_extra_export(export); - - self.in_filed_owner - .entry(file_id) - .or_default() - .insert(owner_id); - - Some(()) + self.contribute(file_id, owner_id, |property| { + property.add_extra_export(export) + }) } pub fn add_decl_feature( @@ -277,15 +334,9 @@ impl LuaPropertyIndex { owner_id: LuaSemanticDeclId, feature: PropertyDeclFeature, ) -> Option<()> { - let (property, _) = self.get_or_create_property(owner_id.clone())?; - property.add_decl_feature(feature); - - self.in_filed_owner - .entry(file_id) - .or_default() - .insert(owner_id); - - Some(()) + self.contribute(file_id, owner_id, |property| { + property.add_decl_feature(feature) + }) } pub fn add_attribute_use( @@ -294,14 +345,23 @@ impl LuaPropertyIndex { owner_id: LuaSemanticDeclId, attribute_use: LuaAttributeUse, ) -> Option<()> { - let (property, _) = self.get_or_create_property(owner_id.clone())?; - property.add_attribute_use(attribute_use); + self.contribute(file_id, owner_id, |property| { + property.add_attribute_use(attribute_use) + }) + } - self.in_filed_owner - .entry(file_id) - .or_default() - .insert(owner_id); - 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> { @@ -363,16 +423,60 @@ impl LuaPropertyIndex { } } +impl LuaPropertyIndex { + /// Drops one file's contribution to a property. The property and the owner mapping that + /// reaches it only go when no other file still documents it. + fn drop_contribution(&mut self, file_id: FileId, owner_id: &LuaSemanticDeclId) { + let Some(property_id) = self.property_owners_map.get(owner_id).copied() else { + return; + }; + + if let Some(contributions) = self.contributions.get_mut(&property_id) { + contributions.retain(|key, _| key.file_id != file_id.id); + match contributions.len() { + 0 => { + self.contributions.remove(&property_id); + } + 1 => { + let (key, value) = self + .contributions + .remove(&property_id) + .and_then(|mut map| map.pop_first()) + .expect("length checked above"); + self.sole_contributor + .insert(property_id, FileId::new(key.file_id)); + self.properties.insert(property_id, value); + return; + } + _ => { + self.rebuild_merged(property_id); + return; + } + } + } else if self + .sole_contributor + .get(&property_id) + .is_some_and(|sole| *sole != file_id) + { + // Another file created and owns this property; nothing of ours to drop. + return; + } + + self.property_owners_map.remove(owner_id); + self.properties.remove(&property_id); + self.signature_owner_by_property.remove(&property_id); + self.sole_contributor.remove(&property_id); + } +} + impl LuaIndex for LuaPropertyIndex { fn remove(&mut self, file_id: FileId) { if let Some(property_owner_ids) = self.in_filed_owner.remove(&file_id) { for property_owner_id in property_owner_ids { - if let Some(property_id) = self.property_owners_map.remove(&property_owner_id) { - self.properties.remove(&property_id); - self.signature_owner_by_property.remove(&property_id); - } + self.drop_contribution(file_id, &property_owner_id); } } + self.file_merge_order.remove(&file_id); // Clean up inferred string defaults owned by this file. if let Some(decl_ids) = self.inferred_string_defaults_file_owners.remove(&file_id) { for decl_id in decl_ids { @@ -385,6 +489,9 @@ impl LuaIndex for LuaPropertyIndex { self.properties.clear(); self.property_owners_map.clear(); self.signature_owner_by_property.clear(); + self.sole_contributor.clear(); + self.contributions.clear(); + self.file_merge_order.clear(); self.in_filed_owner.clear(); self.inferred_string_defaults.clear(); self.inferred_string_defaults_file_owners.clear(); @@ -422,3 +529,41 @@ pub fn try_extract_signature_id_from_field( _ => None, } } + +#[cfg(test)] +mod tests { + use rowan::TextSize; + + use super::*; + + /// A file's owner set holds both an alias and its source. Removing the file has to unmap + /// both: a leftover mapping to a deleted property makes every later write to that owner + /// disappear, because `get_or_create_property` finds the id and no value behind it. + #[test] + fn removing_a_file_unmaps_every_alias_of_its_property() { + let file_id = FileId::new(1); + let source = LuaSemanticDeclId::LuaDecl(LuaDeclId::new(file_id, TextSize::new(0))); + let alias = LuaSemanticDeclId::LuaDecl(LuaDeclId::new(file_id, TextSize::new(10))); + + let mut index = LuaPropertyIndex::new(); + index.add_owner_map(source.clone(), alias.clone(), file_id); + index.add_description(file_id, source.clone(), "first".to_string()); + + index.remove(file_id); + assert!(index.get_property(&source).is_none()); + assert!(index.get_property(&alias).is_none()); + + // Whichever owner the removal visited last is the one a stale mapping would strand, + // so both have to accept a write again. + index.add_description(file_id, source.clone(), "second".to_string()); + index.add_description(file_id, alias.clone(), "third".to_string()); + assert_eq!( + index.get_property(&source).and_then(|p| p.description()), + Some(&"second".to_string()) + ); + assert_eq!( + index.get_property(&alias).and_then(|p| p.description()), + Some(&"third".to_string()) + ); + } +} diff --git a/crates/glua_code_analysis/src/db_index/property/property.rs b/crates/glua_code_analysis/src/db_index/property/property.rs index 6de17ad99..250d31f67 100644 --- a/crates/glua_code_analysis/src/db_index/property/property.rs +++ b/crates/glua_code_analysis/src/db_index/property/property.rs @@ -123,6 +123,70 @@ impl LuaCommonProperty { self.attribute_uses.as_ref() } + /// Folds a later contributor's documentation into an earlier one's. Scalar fields keep the + /// first value supplied; the additive fields union in contributor order. + pub fn merge_from(&mut self, other: &LuaCommonProperty) { + if self.visibility == VisibilityKind::Public { + self.visibility = other.visibility; + } + // A bare `---@class Foo` records an empty description, which must not mask the docs + // another file wrote for the same class. + if self + .description + .as_deref() + .is_none_or(|text| text.is_empty()) + && other + .description + .as_deref() + .is_some_and(|text| !text.is_empty()) + { + self.description.clone_from(&other.description); + } + if self.default_value.is_none() { + self.default_value.clone_from(&other.default_value); + } + if self.source.is_none() { + self.source.clone_from(&other.source); + } + if self.deprecated.is_none() { + self.deprecated.clone_from(&other.deprecated); + } + if self.version_conds.is_none() { + self.version_conds.clone_from(&other.version_conds); + } + if self.export.is_none() { + self.export.clone_from(&other.export); + } + self.decl_features = self.decl_features.union(other.decl_features); + + if let Some(tags) = other.tag_content() { + for (tag, content) in tags.get_all_tags() { + let merged = self + .tag_content + .get_or_insert_with(|| Box::new(LuaTagContent::new())); + if !merged + .tags + .iter() + .any(|existing| existing == &(tag.clone(), content.clone())) + { + merged.add_tag(tag.clone(), content.clone()); + } + } + } + + if let Some(uses) = other.attribute_uses() { + for attribute_use in uses.iter() { + let merged = Arc::make_mut( + self.attribute_uses + .get_or_insert_with(|| Arc::new(Vec::new())), + ); + if !merged.contains(attribute_use) { + merged.push(attribute_use.clone()); + } + } + } + } + pub fn find_attribute_use(&self, id: &str) -> Option<&LuaAttributeUse> { self.attribute_uses.as_ref().and_then(|attribute_uses| { attribute_uses diff --git a/crates/glua_code_analysis/src/db_index/reference/mod.rs b/crates/glua_code_analysis/src/db_index/reference/mod.rs index 204642f9d..11a5f9a68 100644 --- a/crates/glua_code_analysis/src/db_index/reference/mod.rs +++ b/crates/glua_code_analysis/src/db_index/reference/mod.rs @@ -5,7 +5,7 @@ use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; pub use file_reference::{DeclReference, DeclReferenceCell, FileReference}; use glua_parser::LuaSyntaxId; -use rowan::TextRange; +use rowan::{TextRange, TextSize}; use smol_str::SmolStr; use string_reference::StringReference; @@ -19,6 +19,15 @@ pub struct LuaReferenceIndex { global_references: HashMap>>, string_references: HashMap, type_references: HashMap>>, + /// Where each of a file's locals is first reassigned, computed once when the + /// file is indexed. + local_reassignments: HashMap>, + /// Bumped whenever a file's references change, so a memo over facts derived + /// from one file's references can tell a hit from a stale entry. Values come + /// from a counter that never restarts, so a revision is never reused after a + /// removal. + file_revision: HashMap, + next_revision: u64, } impl Default for LuaReferenceIndex { @@ -35,9 +44,56 @@ impl LuaReferenceIndex { global_references: HashMap::default(), string_references: HashMap::default(), type_references: HashMap::default(), + local_reassignments: HashMap::default(), + file_revision: HashMap::default(), + next_revision: 1, } } + /// Revision of `file_id`'s references. Zero until the file records one. + pub fn file_reference_revision(&self, file_id: FileId) -> u64 { + self.file_revision.get(&file_id).copied().unwrap_or(0) + } + + fn bump_file_revision(&mut self, file_id: FileId) { + let revision = self.next_revision; + self.next_revision += 1; + self.file_revision.insert(file_id, revision); + } + + /// Every file that references `name`, from both name-keyed tables. The sites + /// themselves are not materialized. + pub fn files_referencing_name(&self, name: &SmolStr) -> HashSet { + let member_key = LuaMemberKey::Name(name.clone()); + self.index_reference + .get(&member_key) + .into_iter() + .flatten() + .chain(self.global_references.get(name).into_iter().flatten()) + .map(|(file_id, _)| *file_id) + .collect() + } + + /// Every site in `file_id` that references `name`, from both name-keyed + /// tables. + pub fn name_references_in_file(&self, name: &SmolStr, file_id: FileId) -> Vec { + let member_key = LuaMemberKey::Name(name.clone()); + self.index_reference + .get(&member_key) + .and_then(|references| references.get(&file_id)) + .into_iter() + .flatten() + .chain( + self.global_references + .get(name) + .and_then(|references| references.get(&file_id)) + .into_iter() + .flatten(), + ) + .copied() + .collect() + } + pub fn add_decl_reference( &mut self, decl_id: LuaDeclId, @@ -49,6 +105,7 @@ impl LuaReferenceIndex { .entry(file_id) .or_default() .add_decl_reference(decl_id, range, is_write); + self.bump_file_revision(file_id); } pub fn add_global_reference(&mut self, name: &str, file_id: FileId, syntax_id: LuaSyntaxId) { @@ -59,6 +116,7 @@ impl LuaReferenceIndex { .entry(file_id) .or_default() .insert(syntax_id); + self.bump_file_revision(file_id); } pub fn add_index_reference( @@ -73,6 +131,7 @@ impl LuaReferenceIndex { .entry(file_id) .or_default() .insert(syntax_id); + self.bump_file_revision(file_id); } pub fn add_string_reference(&mut self, file_id: FileId, string: &str, range: TextRange) { @@ -96,6 +155,26 @@ impl LuaReferenceIndex { .insert(range); } + pub fn set_local_reassignments( + &mut self, + file_id: FileId, + positions: HashMap, + ) { + self.local_reassignments.insert(file_id, positions); + } + + /// Where `decl_id` is first reassigned within `file_id`, if at all. + pub fn first_local_reassignment( + &self, + file_id: FileId, + decl_id: &LuaDeclId, + ) -> Option { + self.local_reassignments + .get(&file_id)? + .get(decl_id) + .copied() + } + pub fn get_local_reference(&self, file_id: &FileId) -> Option<&FileReference> { self.file_references.get(file_id) } @@ -214,9 +293,11 @@ impl LuaReferenceIndex { impl LuaIndex for LuaReferenceIndex { fn remove(&mut self, file_id: FileId) { + self.bump_file_revision(file_id); self.file_references.remove(&file_id); self.string_references.remove(&file_id); self.type_references.remove(&file_id); + self.local_reassignments.remove(&file_id); let mut to_be_remove = Vec::new(); for (key, references) in self.index_reference.iter_mut() { references.remove(&file_id); @@ -243,6 +324,9 @@ impl LuaIndex for LuaReferenceIndex { } fn remove_files(&mut self, file_ids: &[FileId]) { + for &file_id in file_ids { + self.bump_file_revision(file_id); + } let removed_file_ids = file_ids.iter().copied().collect::>(); self.file_references .retain(|file_id, _| !removed_file_ids.contains(file_id)); @@ -250,6 +334,8 @@ impl LuaIndex for LuaReferenceIndex { .retain(|file_id, _| !removed_file_ids.contains(file_id)); self.type_references .retain(|file_id, _| !removed_file_ids.contains(file_id)); + self.local_reassignments + .retain(|file_id, _| !removed_file_ids.contains(file_id)); self.index_reference.retain(|_, references| { references.retain(|file_id, _| !removed_file_ids.contains(file_id)); @@ -262,9 +348,11 @@ impl LuaIndex for LuaReferenceIndex { } fn clear(&mut self) { + self.file_revision.clear(); self.file_references.clear(); self.string_references.clear(); self.type_references.clear(); + self.local_reassignments.clear(); self.index_reference.clear(); self.global_references.clear(); } @@ -278,6 +366,49 @@ mod tests { use super::{LuaIndex, LuaReferenceIndex}; use crate::{FileId, LuaMemberKey, db_index::LuaTypeDeclId}; + #[test] + fn name_lookups_union_both_tables_and_a_write_moves_the_file_revision() { + let first = FileId::new(1); + let second = FileId::new(2); + let range = TextRange::new(TextSize::new(0), TextSize::new(1)); + let other_range = TextRange::new(TextSize::new(4), TextSize::new(5)); + let mut index = LuaReferenceIndex::new(); + let global_site = LuaSyntaxId::new(LuaSyntaxKind::NameExpr.into(), range); + let index_site = LuaSyntaxId::new(LuaSyntaxKind::IndexExpr.into(), other_range); + let name: smol_str::SmolStr = "Send".into(); + + index.add_global_reference(&name, first, global_site); + index.add_index_reference(LuaMemberKey::Name(name.clone()), first, index_site); + index.add_global_reference(&name, second, global_site); + let first_revision = index.file_reference_revision(first); + + assert_eq!( + index.files_referencing_name(&name), + [first, second].into_iter().collect() + ); + let mut sites = index.name_references_in_file(&name, first); + sites.sort_by_key(|site| site.get_range().start()); + assert_eq!(sites, vec![global_site, index_site]); + assert_eq!( + index.name_references_in_file(&name, second), + vec![global_site] + ); + + index.add_decl_reference( + crate::db_index::LuaDeclId::new(first, TextSize::new(0)), + first, + range, + false, + ); + assert_ne!(index.file_reference_revision(first), first_revision); + + let before_removal = index.file_reference_revision(second); + index.remove(second); + assert!(index.files_referencing_name(&name).contains(&first)); + assert!(!index.files_referencing_name(&name).contains(&second)); + assert_ne!(index.file_reference_revision(second), before_removal); + } + #[test] fn batch_removal_keeps_references_from_surviving_files() { let first = FileId::new(1); diff --git a/crates/glua_code_analysis/src/db_index/reference/string_reference.rs b/crates/glua_code_analysis/src/db_index/reference/string_reference.rs index eb8e5049f..a776491b7 100644 --- a/crates/glua_code_analysis/src/db_index/reference/string_reference.rs +++ b/crates/glua_code_analysis/src/db_index/reference/string_reference.rs @@ -1,16 +1,16 @@ use rowan::TextRange; +use rustc_hash::FxHashMap; use smol_str::SmolStr; -use std::collections::HashMap; #[derive(Debug)] pub struct StringReference { - string_references: HashMap>, + string_references: FxHashMap>, } impl StringReference { pub fn new() -> Self { Self { - string_references: HashMap::new(), + string_references: FxHashMap::default(), } } diff --git a/crates/glua_code_analysis/src/db_index/schema/mod.rs b/crates/glua_code_analysis/src/db_index/schema/mod.rs index c97e64d35..d28c52ce0 100644 --- a/crates/glua_code_analysis/src/db_index/schema/mod.rs +++ b/crates/glua_code_analysis/src/db_index/schema/mod.rs @@ -1,6 +1,6 @@ mod schema_file; -use std::collections::HashMap; +use rustc_hash::FxHashMap; use url::Url; @@ -9,13 +9,13 @@ pub use schema_file::*; #[derive(Debug)] pub struct JsonSchemaIndex { - schema_files: HashMap, + schema_files: FxHashMap, } impl JsonSchemaIndex { pub fn new() -> Self { Self { - schema_files: HashMap::new(), + schema_files: FxHashMap::default(), } } 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..b657b0ad7 100644 --- a/crates/glua_code_analysis/src/db_index/signature/mod.rs +++ b/crates/glua_code_analysis/src/db_index/signature/mod.rs @@ -3,8 +3,8 @@ mod gmod_domains; #[allow(clippy::module_inception)] mod signature; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use smol_str::SmolStr; -use std::collections::{HashMap, HashSet}; pub use async_state::AsyncState; pub use gmod_domains::{ @@ -31,10 +31,11 @@ pub use signature::{ LuaDocParamInfo, LuaDocReturnInfo, LuaGenericParamInfo, LuaNoDiscard, LuaOutParamInfo, LuaOutParamRoot, LuaReturnCorrelation, LuaSignature, LuaSignatureId, OVERLOAD_CALL_ARG_ATTRIBUTE, OVERLOAD_CALL_ARG_FIELD_ATTRIBUTE, ReturnTypeKind, - SignatureReturnStatus, find_call_arg_role_from_type, visit_call_arg_roles_from_type, + SignaturePayloadRemap, SignatureReturnStatus, find_call_arg_role_from_type, + visit_call_arg_roles_from_type, }; -use crate::{FileId, GmodStateMask, LuaType, db_index::LuaDeclId}; +use crate::{FileId, GmodStateMask, LuaType, TypeVisitTrait, db_index::LuaDeclId}; use super::traits::LuaIndex; @@ -50,7 +51,17 @@ pub struct LuaSignatureIndex { inferred_guard_owners_by_file: HashMap>, inferred_guard_consumers: HashMap>, inferred_guard_dependencies: HashMap>, + inferred_return_dependencies: HashMap>, + inferred_return_consumers: HashMap>, + settled_reads_by_file: HashMap>, + settled_read_dependents: HashMap>, + payload_sources_by_holder: HashMap>, + payload_holders_by_source: HashMap>, inferred_positive_guards_changed: bool, + /// Count of inferred-return changes, for telling a pass that moved a + /// return from one that did not. + return_writes: u64, + return_write_versions: HashMap, receiver_out_param_member_names: HashMap, in_file_receiver_out_param_member_names: HashMap>, } @@ -116,21 +127,48 @@ impl Default for LuaSignatureIndex { } impl LuaSignatureIndex { + /// Compare it across an operation to tell whether the operation changed + /// any inferred return. + pub fn return_writes(&self) -> u64 { + self.return_writes + } + + pub(crate) fn note_return_write(&mut self, signature_id: LuaSignatureId) { + self.return_writes += 1; + self.return_write_versions + .insert(signature_id, self.return_writes); + } + + pub(crate) fn return_write_version(&self, signature_id: &LuaSignatureId) -> u64 { + self.return_write_versions + .get(signature_id) + .copied() + .unwrap_or(0) + } + pub fn new() -> Self { Self { - signatures: HashMap::new(), - in_file_signatures: HashMap::new(), - local_func_decls: HashMap::new(), - effective_valid_guard_signatures: HashMap::new(), - inferred_positive_guards: HashMap::new(), - inferred_guard_owners: HashMap::new(), - inferred_guard_facts: HashMap::new(), - inferred_guard_owners_by_file: HashMap::new(), - inferred_guard_consumers: HashMap::new(), - inferred_guard_dependencies: HashMap::new(), + signatures: HashMap::default(), + in_file_signatures: HashMap::default(), + local_func_decls: HashMap::default(), + effective_valid_guard_signatures: HashMap::default(), + inferred_positive_guards: HashMap::default(), + inferred_guard_owners: HashMap::default(), + inferred_guard_facts: HashMap::default(), + inferred_guard_owners_by_file: HashMap::default(), + inferred_guard_consumers: HashMap::default(), + inferred_guard_dependencies: HashMap::default(), + inferred_return_dependencies: HashMap::default(), + inferred_return_consumers: HashMap::default(), + settled_reads_by_file: HashMap::default(), + settled_read_dependents: HashMap::default(), + payload_sources_by_holder: HashMap::default(), + payload_holders_by_source: HashMap::default(), inferred_positive_guards_changed: false, - receiver_out_param_member_names: HashMap::new(), - in_file_receiver_out_param_member_names: HashMap::new(), + return_writes: 0, + return_write_versions: HashMap::default(), + receiver_out_param_member_names: HashMap::default(), + in_file_receiver_out_param_member_names: HashMap::default(), } } @@ -143,6 +181,7 @@ impl LuaSignatureIndex { } pub fn get(&self, signature_id: &LuaSignatureId) -> Option<&LuaSignature> { + crate::db_index::read_set::record_signature(signature_id); self.signatures.get(signature_id) } @@ -336,6 +375,345 @@ impl LuaSignatureIndex { } } + pub(crate) fn set_inferred_return_dependencies( + &mut self, + signature_id: LuaSignatureId, + source_file_ids: HashSet, + ) { + self.clear_inferred_return_dependencies(signature_id); + for source_file_id in &source_file_ids { + self.inferred_return_consumers + .entry(*source_file_id) + .or_default() + .insert(signature_id); + } + if !source_file_ids.is_empty() { + self.inferred_return_dependencies + .insert(signature_id, source_file_ids); + } + } + + fn clear_inferred_return_dependencies(&mut self, signature_id: LuaSignatureId) { + let Some(source_file_ids) = self.inferred_return_dependencies.remove(&signature_id) else { + return; + }; + for source_file_id in source_file_ids { + if let Some(consumers) = self.inferred_return_consumers.get_mut(&source_file_id) { + consumers.remove(&signature_id); + if consumers.is_empty() { + self.inferred_return_consumers.remove(&source_file_id); + } + } + } + } + + pub(crate) fn inferred_return_dependents_for_files( + &self, + source_file_ids: &HashSet, + ) -> HashSet { + source_file_ids + .iter() + .filter_map(|file_id| self.inferred_return_consumers.get(file_id)) + .flatten() + .map(LuaSignatureId::get_file_id) + .collect() + } + + pub(crate) fn set_settled_reads( + &mut self, + file_id: FileId, + reads: impl IntoIterator, + ) { + self.clear_settled_reads(file_id); + let reads = reads + .into_iter() + .filter(|signature_id| signature_id.get_file_id() != file_id) + .collect::>(); + for signature_id in &reads { + self.settled_read_dependents + .entry(*signature_id) + .or_default() + .insert(file_id); + } + if !reads.is_empty() { + self.settled_reads_by_file.insert(file_id, reads); + } + } + + fn clear_settled_reads(&mut self, file_id: FileId) { + let Some(reads) = self.settled_reads_by_file.remove(&file_id) else { + return; + }; + for signature_id in reads { + if let Some(dependents) = self.settled_read_dependents.get_mut(&signature_id) { + dependents.remove(&file_id); + if dependents.is_empty() { + self.settled_read_dependents.remove(&signature_id); + } + } + } + } + + pub(crate) fn settled_read_dependents_for_signatures( + &self, + signature_ids: &[LuaSignatureId], + ) -> HashSet { + signature_ids + .iter() + .filter_map(|signature_id| self.settled_read_dependents.get(signature_id)) + .flatten() + .copied() + .collect() + } + + pub(crate) fn settled_read_dependents_for_files( + &self, + file_ids: &HashSet, + ) -> HashSet { + self.settled_read_dependents + .iter() + .filter(|(signature_id, _)| file_ids.contains(&signature_id.get_file_id())) + .flat_map(|(_, dependents)| dependents.iter().copied()) + .collect() + } + + pub(crate) fn remap_settled_reads(&mut self, remap: &crate::FileRemap) -> HashSet { + let old_ids = self + .settled_read_dependents + .keys() + .filter(|signature_id| signature_id.get_file_id() == remap.file_id) + .copied() + .collect::>(); + let mut dirty = HashSet::default(); + let mut updates = Vec::new(); + for old_id in old_ids { + let new_id = match remap.signature_id(old_id) { + crate::Remap::Moved(new_id) if new_id != old_id => Some(new_id), + crate::Remap::Moved(_) | crate::Remap::Unrelated => continue, + crate::Remap::Lost => None, + }; + let dependents = self + .settled_read_dependents + .get(&old_id) + .cloned() + .unwrap_or_default(); + if new_id.is_none() { + dirty.extend(dependents.iter().copied()); + } + updates.push((old_id, new_id, dependents)); + } + + for (old_id, _, _) in &updates { + self.settled_read_dependents.remove(old_id); + } + for (old_id, new_id, dependents) in updates { + for dependent in &dependents { + if let Some(reads) = self.settled_reads_by_file.get_mut(dependent) { + reads.remove(&old_id); + if let Some(new_id) = new_id { + reads.insert(new_id); + } + } + } + if let Some(new_id) = new_id { + self.settled_read_dependents + .entry(new_id) + .or_default() + .extend(dependents); + } + } + dirty + } + + /// Indexed per-holder payload population shared by snapshot and remap. + /// + /// `in_file_signatures[holder]` owns the holder's signature ids (position + /// sorted); looking each id up in `inferred_positive_guards` covers the + /// holder's positive guards without scanning the workspace map. + /// `inferred_guard_owners_by_file[holder]` owns the holder's guard-fact + /// owners directly. Owner order does not affect the observable `HashSet` + /// outputs, so owners are left unsorted. + fn holder_payload_keys( + &self, + holder: FileId, + ) -> (Vec, Vec) { + let mut signature_ids: Vec<_> = self + .in_file_signatures + .get(&holder) + .cloned() + .unwrap_or_default() + .into_iter() + .collect(); + signature_ids.sort_by_key(|id| u32::from(id.get_position())); + let fact_owners: Vec<_> = self + .inferred_guard_owners_by_file + .get(&holder) + .cloned() + .unwrap_or_default() + .into_iter() + .collect(); + (signature_ids, fact_owners) + } + + /// Rebuilds the file-granularity reverse edges for one holder file. + /// + /// Inspects every signature owned by `holder` plus the typed + /// inferred-guard payloads owned/filed there, collecting the source files + /// named by nested `TableConst`, `Instance` and `Signature` identities. + /// The holder's old reverse edges are removed and the new ones added + /// atomically, so reanalysis never leaves stale edges behind. Self-file + /// references are excluded from the cross-file set. + pub(crate) fn snapshot_payload_refs(&mut self, holder: FileId) { + let mut sources = HashSet::default(); + let mut collect = |ty: &LuaType| { + ty.visit_type(&mut |inner| match inner { + LuaType::TableConst(range) => { + if range.file_id != holder { + sources.insert(range.file_id); + } + } + LuaType::Instance(instance) => { + let source = instance.get_range().file_id; + if source != holder { + sources.insert(source); + } + } + LuaType::Signature(signature_id) => { + let source = signature_id.get_file_id(); + if source != holder { + sources.insert(source); + } + } + _ => {} + }); + }; + + let (signature_ids, fact_owners) = self.holder_payload_keys(holder); + for signature_id in &signature_ids { + if let Some(signature) = self.signatures.get(signature_id) { + signature.visit_payload_types(&mut |ty| collect(ty)); + } + if let Some(guard) = self.inferred_positive_guards.get(signature_id) { + collect(&guard.narrowed_type); + } + } + + for owner in &fact_owners { + if let Some(guard) = self.inferred_guard_facts.get(owner) { + collect(&guard.narrowed_type); + } + } + + let old_sources = self + .payload_sources_by_holder + .get(&holder) + .cloned() + .unwrap_or_default(); + if old_sources == sources { + return; + } + let mut old_sorted: Vec = old_sources.into_iter().collect(); + old_sorted.sort(); + for source in old_sorted { + if let Some(holders) = self.payload_holders_by_source.get_mut(&source) { + holders.remove(&holder); + if holders.is_empty() { + self.payload_holders_by_source.remove(&source); + } + } + } + let mut new_sorted: Vec = sources.iter().copied().collect(); + new_sorted.sort(); + for source in new_sorted { + self.payload_holders_by_source + .entry(source) + .or_default() + .insert(holder); + } + if sources.is_empty() { + self.payload_sources_by_holder.remove(&holder); + } else { + self.payload_sources_by_holder.insert(holder, sources); + } + } + + fn drop_payload_holder_edges(&mut self, holder: FileId) { + let Some(old_sources) = self.payload_sources_by_holder.remove(&holder) else { + return; + }; + let mut old_sorted: Vec = old_sources.into_iter().collect(); + old_sorted.sort(); + for source in old_sorted { + if let Some(holders) = self.payload_holders_by_source.get_mut(&source) { + holders.remove(&holder); + if holders.is_empty() { + self.payload_holders_by_source.remove(&source); + } + } + } + } + + /// Rewrites foreign signature payloads that name the edited file. + /// + /// Candidates come only from the reverse payload map, never from a scan. + /// Each affected signature/guard payload is atomic: a lost nested identity + /// leaves that payload untouched and dirties its holder file instead. + /// Coordinate-only moves never call `note_return_write`, so export hashing + /// with the same `FileRemap` stays stable. + pub(crate) fn remap_payload_types(&mut self, remap: &crate::FileRemap) -> HashSet { + let mut candidates: Vec = self + .payload_holders_by_source + .get(&remap.file_id) + .cloned() + .unwrap_or_default() + .into_iter() + .collect(); + candidates.sort(); + let mut dirty = HashSet::default(); + for holder in candidates { + if holder == remap.file_id { + continue; + } + let mut holder_lost = false; + let (signature_ids, fact_owners) = self.holder_payload_keys(holder); + for signature_id in signature_ids { + if let Some(signature) = self.signatures.get_mut(&signature_id) { + let outcome = signature.remap_payload_types(remap); + if outcome.lost { + holder_lost = true; + } + } + if let Some(guard) = self.inferred_positive_guards.get_mut(&signature_id) { + let result = + crate::db_index::remap_identities_in_type(&guard.narrowed_type, remap); + if result.lost { + holder_lost = true; + } else if let Some(new_ty) = result.typ { + guard.narrowed_type = new_ty; + } + } + } + + for owner in fact_owners { + if let Some(guard) = self.inferred_guard_facts.get_mut(&owner) { + let result = + crate::db_index::remap_identities_in_type(&guard.narrowed_type, remap); + if result.lost { + holder_lost = true; + } else if let Some(new_ty) = result.typ { + guard.narrowed_type = new_ty; + } + } + } + + if holder_lost { + dirty.insert(holder); + } + } + dirty.remove(&remap.file_id); + dirty + } + pub fn clear_inferred_positive_guards_for_file(&mut self, file_id: FileId) { let old_owners = self .inferred_guard_owners_by_file @@ -394,14 +772,25 @@ 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 { fn remove(&mut self, file_id: FileId) { + // Drop only the removed file's holder edges. Reverse edges where it is + // the referenced source stay so a later remap can still report loss. + self.drop_payload_holder_edges(file_id); + self.clear_settled_reads(file_id); self.clear_inferred_guard_dependencies(file_id); if let Some(signature_ids) = self.in_file_signatures.remove(&file_id) { for signature_id in signature_ids { - self.signatures.remove(&signature_id); + self.clear_inferred_return_dependencies(signature_id); + if self.signatures.remove(&signature_id).is_some() { + self.note_return_write(signature_id); + } self.local_func_decls.remove(&signature_id); } } @@ -434,6 +823,7 @@ impl LuaIndex for LuaSignatureIndex { self.signatures.clear(); self.in_file_signatures.clear(); self.local_func_decls.clear(); + self.return_write_versions.clear(); self.effective_valid_guard_signatures.clear(); self.inferred_positive_guards.clear(); self.inferred_guard_owners.clear(); @@ -441,6 +831,12 @@ impl LuaIndex for LuaSignatureIndex { self.inferred_guard_owners_by_file.clear(); self.inferred_guard_consumers.clear(); self.inferred_guard_dependencies.clear(); + self.inferred_return_dependencies.clear(); + self.inferred_return_consumers.clear(); + self.settled_reads_by_file.clear(); + self.settled_read_dependents.clear(); + self.payload_sources_by_holder.clear(); + self.payload_holders_by_source.clear(); self.inferred_positive_guards_changed = false; self.receiver_out_param_member_names.clear(); self.in_file_receiver_out_param_member_names.clear(); @@ -470,7 +866,8 @@ mod tests { .entry(source_file_id) .or_default() .insert(owner.clone()); - index.set_inferred_guard_dependencies(consumer_file_id, HashSet::from([owner.clone()])); + index + .set_inferred_guard_dependencies(consumer_file_id, HashSet::from_iter([owner.clone()])); index.remove(consumer_file_id); @@ -478,7 +875,7 @@ mod tests { assert!(index.inferred_guard_consumers.is_empty()); assert!( index - .inferred_guard_consumers_for_files(&HashSet::from([source_file_id])) + .inferred_guard_consumers_for_files(&HashSet::from_iter([source_file_id])) .is_empty() ); } @@ -501,7 +898,7 @@ mod tests { narrowed_type: LuaType::String, }, ); - index.set_inferred_guard_dependencies(consumer_file_id, HashSet::from([owner])); + index.set_inferred_guard_dependencies(consumer_file_id, HashSet::from_iter([owner])); index.remove(source_file_id); @@ -510,4 +907,230 @@ mod tests { assert!(index.inferred_guard_dependencies.is_empty()); assert!(index.inferred_guard_consumers.is_empty()); } + + fn table_ref(source: FileId, start: u32, end: u32) -> LuaType { + LuaType::TableConst(crate::InFiled::new( + source, + rowan::TextRange::new(start.into(), end.into()), + )) + } + + fn signature_ref(source: FileId, position: u32) -> LuaType { + LuaType::Signature(LuaSignatureId::new(source, position.into())) + } + + #[test] + fn payload_snapshot_replaces_stale_reverse_edges() { + let holder = FileId::new(10); + let source_a = FileId::new(20); + let source_b = FileId::new(30); + let source_c = FileId::new(40); + let mut index = LuaSignatureIndex::new(); + + let signature_id = LuaSignatureId::new(holder, 0.into()); + { + let signature = index.get_or_create(signature_id); + signature + .return_docs + .push(crate::db_index::signature::LuaDocReturnInfo { + name: None, + type_ref: table_ref(source_a, 0, 1), + default_value: None, + description: None, + attributes: None, + return_kind: crate::db_index::signature::ReturnTypeKind::Reference, + }); + signature.generic_params.push(std::sync::Arc::new( + crate::db_index::signature::LuaGenericParamInfo::new( + "T".to_string(), + Some(signature_ref(source_a, 7)), + None, + ), + )); + } + index.set_inferred_positive_guard( + signature_id, + LuaInferredPositiveGuard { + param_idx: 0, + narrowed_type: table_ref(source_b, 0, 1), + }, + ); + let fact_owner = LuaInferredGuardOwner::GlobalPath { + signature_id: LuaSignatureId::new(holder, 5.into()), + state_mask: GmodStateMask::empty(), + path: vec!["Fact".into()].into_boxed_slice(), + }; + index.set_owned_inferred_positive_guard( + fact_owner.signature_id(), + fact_owner.clone(), + LuaInferredPositiveGuard { + param_idx: 0, + narrowed_type: table_ref(source_c, 0, 1), + }, + ); + + index.snapshot_payload_refs(holder); + + assert_eq!( + index + .payload_sources_by_holder + .get(&holder) + .cloned() + .unwrap_or_default(), + HashSet::from_iter([source_a, source_b, source_c]), + ); + for source in [source_a, source_b, source_c] { + assert!( + index + .payload_holders_by_source + .get(&source) + .is_some_and(|holders| holders.contains(&holder)), + "reverse edge for {source:?} should hold {holder:?}", + ); + } + + // Drop the signature payloads; only the guard/fact edges survive. + if let Some(signature) = index.signatures.get_mut(&signature_id) { + for ret in &mut signature.return_docs { + ret.type_ref = LuaType::String; + } + for generic in &mut signature.generic_params { + let rebuilt = crate::db_index::signature::LuaGenericParamInfo::new( + generic.name.clone(), + None, + generic.attributes.clone(), + ); + *generic = std::sync::Arc::new(rebuilt); + } + } + index.snapshot_payload_refs(holder); + + assert_eq!( + index + .payload_sources_by_holder + .get(&holder) + .cloned() + .unwrap_or_default(), + HashSet::from_iter([source_b, source_c]), + ); + assert!( + !index.payload_holders_by_source.contains_key(&source_a), + "stale reverse edge for {source_a:?} should be gone", + ); + for source in [source_b, source_c] { + assert!( + index + .payload_holders_by_source + .get(&source) + .is_some_and(|holders| holders.contains(&holder)), + ); + } + + // Self-file references never enter the cross-file set. + if let Some(signature) = index.signatures.get_mut(&signature_id) { + for ret in &mut signature.return_docs { + ret.type_ref = table_ref(holder, 0, 1); + } + } + index + .inferred_positive_guards + .get_mut(&signature_id) + .expect("guard") + .narrowed_type = LuaType::String; + index + .inferred_guard_facts + .get_mut(&fact_owner) + .expect("fact") + .narrowed_type = LuaType::String; + index.snapshot_payload_refs(holder); + + assert!( + index + .payload_sources_by_holder + .get(&holder) + .cloned() + .unwrap_or_default() + .is_empty() + || !index + .payload_sources_by_holder + .get(&holder) + .cloned() + .unwrap_or_default() + .contains(&holder), + "self-file refs must stay out of the forward set", + ); + assert!(!index.payload_holders_by_source.contains_key(&holder)); + assert!(!index.payload_holders_by_source.contains_key(&source_b)); + assert!(!index.payload_holders_by_source.contains_key(&source_c)); + } + + #[test] + fn removing_holder_cleans_payload_edges_but_removing_source_preserves_them() { + let holder_a = FileId::new(11); + let holder_b = FileId::new(12); + let source = FileId::new(20); + let mut index = LuaSignatureIndex::new(); + + for (holder, position) in [(holder_a, 0), (holder_b, 10)] { + let signature_id = LuaSignatureId::new(holder, position.into()); + let signature = index.get_or_create(signature_id); + signature + .return_docs + .push(crate::db_index::signature::LuaDocReturnInfo { + name: None, + type_ref: table_ref(source, 0, 1), + default_value: None, + description: None, + attributes: None, + return_kind: crate::db_index::signature::ReturnTypeKind::Reference, + }); + index.snapshot_payload_refs(holder); + } + + assert_eq!( + index + .payload_holders_by_source + .get(&source) + .cloned() + .unwrap_or_default(), + HashSet::from_iter([holder_a, holder_b]), + ); + + index.remove(holder_a); + + assert!(!index.payload_sources_by_holder.contains_key(&holder_a)); + assert_eq!( + index + .payload_holders_by_source + .get(&source) + .cloned() + .unwrap_or_default(), + HashSet::from_iter([holder_b]), + ); + + // The removed source held no payloads of its own; foreign holders that + // still name it must stay so a later remap can report the loss. + index.remove(source); + + assert_eq!( + index + .payload_holders_by_source + .get(&source) + .cloned() + .unwrap_or_default(), + HashSet::from_iter([holder_b]), + ); + assert_eq!( + index + .payload_sources_by_holder + .get(&holder_b) + .cloned() + .unwrap_or_default(), + HashSet::from_iter([source]), + ); + + index.clear(); + assert!(index.payload_sources_by_holder.is_empty()); + assert!(index.payload_holders_by_source.is_empty()); + } } 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..f9fd5d0bc 100644 --- a/crates/glua_code_analysis/src/db_index/signature/signature.rs +++ b/crates/glua_code_analysis/src/db_index/signature/signature.rs @@ -31,6 +31,13 @@ pub struct LuaSignature { pub async_state: AsyncState, pub nodiscard: Option, pub is_vararg: bool, + /// Whether `param_docs` was filled in from the slot this closure was assigned + /// into rather than from its own `@param` tags. + /// + /// Those params are one receiver's answer, not a contract: the next closure + /// filling the same slot must not inherit them, or its receiver would be + /// whichever sibling the batch happened to resolve first. + pub params_filled_from_slot: bool, require_guard_param: Option, nil_return_guard_params: Vec, return_correlations: Vec, @@ -102,6 +109,7 @@ impl LuaSignature { async_state: AsyncState::None, nodiscard: None, is_vararg: false, + params_filled_from_slot: false, require_guard_param: None, nil_return_guard_params: Vec::new(), return_correlations: Vec::new(), @@ -112,6 +120,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; } @@ -408,6 +420,211 @@ impl LuaSignature { .with_optional_params(optional_params); Arc::new(func_type) } + + /// Every top-level `LuaType` this signature stores. + /// + /// Covers generic constraints, overload params/returns, param doc + /// `type_ref`s, out-param `type_ref`s and return doc `type_ref`s. Fields + /// holding only indexes, names or correlations carry no `LuaType` and are + /// intentionally absent. + pub(crate) fn visit_payload_types(&self, visitor: &mut impl FnMut(&LuaType)) { + for generic in &self.generic_params { + if let Some(constraint) = &generic.constraint { + visitor(constraint); + } + } + for overload in &self.overloads { + for (_, param_ty) in overload.get_params() { + if let Some(ty) = param_ty { + visitor(ty); + } + } + visitor(overload.get_ret()); + } + for param in self.param_docs.values() { + visitor(¶m.type_ref); + } + for out in &self.out_params { + visitor(&out.type_ref); + } + for ret in &self.return_docs { + visitor(&ret.type_ref); + } + } + + /// Rewrites every nested position-bearing identity in the typed payloads. + /// + /// Atomic per signature: when any nested identity is lost nothing is + /// rewritten and `lost` is reported, so the caller dirties the holder + /// instead of leaving a guessed mix of old and new coordinates. + pub(crate) fn remap_payload_types( + &mut self, + remap: &crate::FileRemap, + ) -> SignaturePayloadRemap { + let mut lost = false; + let mut changed = false; + + let mut new_constraints: Vec> = + Vec::with_capacity(self.generic_params.len()); + for generic in &self.generic_params { + match &generic.constraint { + Some(constraint) => { + let result = crate::db_index::remap_identities_in_type(constraint, remap); + if result.lost { + lost = true; + } + if result.typ.is_some() { + changed = true; + } + new_constraints.push(result.typ); + } + None => new_constraints.push(None), + } + } + + let mut new_overloads: Vec>> = + Vec::with_capacity(self.overloads.len()); + for overload in &self.overloads { + let mut overload_changed = false; + let mut new_params = Vec::with_capacity(overload.get_params().len()); + for (name, ty) in overload.get_params() { + match ty { + Some(ty) => { + let result = crate::db_index::remap_identities_in_type(ty, remap); + if result.lost { + lost = true; + } + match result.typ { + Some(new_ty) => { + overload_changed = true; + new_params.push((name.clone(), Some(new_ty))); + } + None => new_params.push((name.clone(), Some(ty.clone()))), + } + } + None => new_params.push((name.clone(), None)), + } + } + let ret_result = crate::db_index::remap_identities_in_type(overload.get_ret(), remap); + if ret_result.lost { + lost = true; + } + let new_ret = match ret_result.typ { + Some(new_ty) => { + overload_changed = true; + new_ty + } + None => overload.get_ret().clone(), + }; + if overload_changed { + changed = true; + let rebuilt = LuaFunctionType::new( + overload.get_async_state(), + overload.is_colon_define(), + overload.is_variadic(), + new_params, + new_ret, + ) + .with_optional_params(overload.get_optional_params().to_vec()) + .with_call_arg_roles(overload.get_call_arg_roles().to_vec()); + new_overloads.push(Some(Arc::new(rebuilt))); + } else { + new_overloads.push(None); + } + } + + let mut new_param_types: std::collections::HashMap = + std::collections::HashMap::new(); + for (idx, info) in &self.param_docs { + let result = crate::db_index::remap_identities_in_type(&info.type_ref, remap); + if result.lost { + lost = true; + } + if let Some(new_ty) = result.typ { + changed = true; + new_param_types.insert(*idx, new_ty); + } + } + + let mut new_out_types: Vec> = Vec::with_capacity(self.out_params.len()); + for out in &self.out_params { + let result = crate::db_index::remap_identities_in_type(&out.type_ref, remap); + if result.lost { + lost = true; + } + if result.typ.is_some() { + changed = true; + } + new_out_types.push(result.typ); + } + + let mut new_return_types: Vec> = Vec::with_capacity(self.return_docs.len()); + for ret in &self.return_docs { + let result = crate::db_index::remap_identities_in_type(&ret.type_ref, remap); + if result.lost { + lost = true; + } + if result.typ.is_some() { + changed = true; + } + new_return_types.push(result.typ); + } + + if lost { + return SignaturePayloadRemap { + lost: true, + changed: false, + }; + } + if !changed { + return SignaturePayloadRemap { + lost: false, + changed: false, + }; + } + + for (generic, new_constraint) in self.generic_params.iter_mut().zip(new_constraints) { + if let Some(new_ty) = new_constraint { + let rebuilt = LuaGenericParamInfo::new( + generic.name.clone(), + Some(new_ty), + generic.attributes.clone(), + ); + *generic = Arc::new(rebuilt); + } + } + for (overload, new_overload) in self.overloads.iter_mut().zip(new_overloads) { + if let Some(new_ty) = new_overload { + *overload = new_ty; + } + } + for (idx, new_ty) in new_param_types { + if let Some(info) = self.param_docs.get_mut(&idx) { + info.type_ref = new_ty; + } + } + for (out, new_ty) in self.out_params.iter_mut().zip(new_out_types) { + if let Some(new_ty) = new_ty { + out.type_ref = new_ty; + } + } + for (ret, new_ty) in self.return_docs.iter_mut().zip(new_return_types) { + if let Some(new_ty) = new_ty { + ret.type_ref = new_ty; + } + } + SignaturePayloadRemap { + lost: false, + changed: true, + } + } +} + +/// Whether remapping one signature's typed payloads moved or lost anything. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SignaturePayloadRemap { + pub lost: bool, + pub changed: bool, } fn visit_call_arg_roles_from_param_attribute( @@ -499,12 +716,12 @@ pub fn visit_call_arg_roles_from_type( LuaType::Instance(instance) => { visit_call_arg_roles_from_type(db, instance.get_base(), arg_idx, visitor); } - LuaType::Union(union) => match union.as_ref() { - crate::db_index::LuaUnionType::Nullable(inner) => { + LuaType::Union(union) => match union.nullable_inner() { + Some(inner) => { visit_call_arg_roles_from_type(db, inner, arg_idx, visitor); } - crate::db_index::LuaUnionType::Multi(types) => { - for typ in types { + None => { + for typ in union.types() { visit_call_arg_roles_from_type(db, typ, arg_idx, visitor); } } @@ -804,7 +1021,6 @@ impl<'de> Deserialize<'de> for LuaSignatureId { } impl LuaSignatureId { - #[cfg(test)] pub(crate) fn new(file_id: FileId, position: TextSize) -> Self { Self { file_id, position } } diff --git a/crates/glua_code_analysis/src/db_index/type/humanize_type.rs b/crates/glua_code_analysis/src/db_index/type/humanize_type.rs index 873245c93..f8b7265a7 100644 --- a/crates/glua_code_analysis/src/db_index/type/humanize_type.rs +++ b/crates/glua_code_analysis/src/db_index/type/humanize_type.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use rustc_hash::FxHashSet; use itertools::Itertools; @@ -227,7 +227,13 @@ fn humanize_simple_type( let member_owner = LuaMemberOwner::Type(id.clone()); let member_index = db.get_member_index(); - let members = member_index.get_sorted_members(&member_owner)?; + let mut members = member_index.get_sorted_members(&member_owner)?; + // One key names one field. Several files writing it -- a `---@field` and a + // `function X.k() end`, say -- describe the same field, and listing it once + // per writer says nothing extra. Keeps the first in the index's own order, + // so the choice does not depend on the batch. + let mut seen_keys = rustc_hash::FxHashSet::default(); + members.retain(|member| seen_keys.insert(member.get_key().clone())); let all_count = members.len(); let mut member_strings = String::new(); let mut function_vec = Vec::new(); @@ -312,7 +318,7 @@ where RenderLevel::Minimal => 2, }; // Sort before truncation so rendered subsets stay canonical across runs. - let mut seen = HashSet::new(); + let mut seen = FxHashSet::default(); let mut type_strings = Vec::new(); let mut has_nil = false; let mut has_function = false; @@ -738,6 +744,7 @@ fn humanize_table_const_type_detail_and_simple( fn owner_has_dynamic_wildcard(db: &DbIndex, owner: &LuaMemberOwner) -> bool { let dynamic_owner = match owner { LuaMemberOwner::Type(type_id) => DynamicFieldOwner::Type(type_id.clone()), + LuaMemberOwner::GlobalPath(path) => DynamicFieldOwner::GlobalPath(path.clone()), LuaMemberOwner::Element(table_range) => DynamicFieldOwner::Table(table_range.clone()), _ => return false, }; 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..cb2f7d76c 100644 --- a/crates/glua_code_analysis/src/db_index/type/mod.rs +++ b/crates/glua_code_analysis/src/db_index/type/mod.rs @@ -1,6 +1,7 @@ mod generic_param; mod humanize_type; mod inference_fact; +pub(crate) mod read_set; mod test; mod type_decl; mod type_ops; @@ -10,7 +11,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, LuaSignatureId, + db_index::r#type::type_decl::LuaTypeIdentifier, }; pub use generic_param::GenericParam; pub use humanize_type::{ @@ -18,14 +20,16 @@ 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 use type_owner::{LuaTypeCache, LuaTypeOwner, is_informative_type}; +pub(crate) use type_owner::is_undetermined_type; +pub(crate) use type_owner::widens_primitive; +pub use type_owner::{LuaTypeCache, LuaTypeOwner, is_informative_type, leaks_unsubstituted_tpl}; pub use type_visit_trait::TypeVisitTrait; pub use types::*; @@ -185,6 +189,520 @@ fn replace_table_consts_in_type( } } +/// What remapping one stored type through a [`crate::FileRemap`] found. +/// +/// `typ` is the rewritten type when something in it moved; `lost` reports a +/// nested identity that did not survive. A value that is both moved and lost +/// reports `lost` with no rewrite: the holder keeps its stored value and its +/// owning file is re-analysed instead of being left on a guessed mix of old +/// and new coordinates. +#[derive(Debug, Clone)] +pub struct TypeRemapResult { + pub typ: Option, + pub lost: bool, +} + +impl TypeRemapResult { + fn unchanged() -> Self { + Self { + typ: None, + lost: false, + } + } + + fn moved(typ: LuaType) -> Self { + Self { + typ: Some(typ), + lost: false, + } + } + + fn lost() -> Self { + Self { + typ: None, + lost: true, + } + } + + fn from_child_list( + remapped: crate::Remap>, + rebuild: impl FnOnce(Vec) -> LuaType, + ) -> Self { + match remapped { + crate::Remap::Lost => Self::lost(), + crate::Remap::Unrelated => Self::unchanged(), + crate::Remap::Moved(types) => Self::moved(rebuild(types)), + } + } + + fn from_single(result: TypeRemapResult, rebuild: impl FnOnce(LuaType) -> LuaType) -> Self { + if result.lost { + Self::lost() + } else if let Some(inner) = result.typ { + Self::moved(rebuild(inner)) + } else { + Self::unchanged() + } + } +} + +/// Remaps one homogeneous child list, sharing the loop every composite arm +/// would otherwise repeat. +/// +/// Returns `Lost` as soon as any child is lost (the caller publishes no +/// partial rewrite), `Moved` with the rebuilt child vec when anything moved, +/// and `Unrelated` when nothing changed. Nothing is cloned while the list is +/// unchanged: the prefix is cloned only once the first moved child appears. +fn remap_child_types<'a>( + children: impl IntoIterator< + Item = &'a LuaType, + IntoIter: ExactSizeIterator + Clone, + >, + remap: &crate::FileRemap, +) -> crate::Remap> { + let source = children.into_iter(); + let len = source.len(); + let mut rebuilt: Option> = None; + for (idx, child) in source.clone().enumerate() { + let result = remap_identities_in_type(child, remap); + if result.lost { + return crate::Remap::Lost; + } + match (result.typ, rebuilt.as_mut()) { + (Some(moved), Some(vec)) => vec.push(moved), + (Some(moved), None) => { + let mut vec = Vec::with_capacity(len); + vec.extend(source.clone().take(idx).cloned()); + vec.push(moved); + rebuilt = Some(vec); + } + (None, Some(vec)) => vec.push(child.clone()), + (None, None) => {} + } + } + match rebuilt { + Some(vec) => crate::Remap::Moved(vec), + None => crate::Remap::Unrelated, + } +} + +/// Rewrites every position-carrying identity inside a stored type. +/// +/// Identities are resolved directly through `remap` (`signature_id`, +/// `table_range` for both `TableConst` and the `Instance` range), so a nested +/// identity no store enumerates is still moved or reported lost. `lost` +/// propagates out of every nested arm: a holder of a lost identity keeps its +/// stored value and its owning file is dirtied instead. +/// +/// The arm list must stay in step with `db_index::edit::export_map::hash_type`: +/// a composite one walk descends into and the other does not leaves a nested +/// identity unmoved while the diff still calls the export unchanged. +pub fn remap_identities_in_type(typ: &LuaType, remap: &crate::FileRemap) -> TypeRemapResult { + match typ { + LuaType::Signature(old) => match remap.signature_id(*old) { + crate::Remap::Moved(new) if new != *old => { + TypeRemapResult::moved(LuaType::Signature(new)) + } + crate::Remap::Moved(_) | crate::Remap::Unrelated => TypeRemapResult::unchanged(), + crate::Remap::Lost => TypeRemapResult::lost(), + }, + LuaType::TableConst(old) => match remap.table_range(old) { + crate::Remap::Moved(new) if new != *old => { + TypeRemapResult::moved(LuaType::TableConst(new)) + } + crate::Remap::Moved(_) | crate::Remap::Unrelated => TypeRemapResult::unchanged(), + crate::Remap::Lost => TypeRemapResult::lost(), + }, + LuaType::Instance(inst) => { + let mut changed = false; + let mut lost = false; + let mut new_base = inst.get_base().clone(); + let base = remap_identities_in_type(inst.get_base(), remap); + if base.lost { + lost = true; + } else if let Some(nb) = base.typ { + new_base = nb; + changed = true; + } + let mut new_range = inst.get_range().clone(); + match remap.table_range(inst.get_range()) { + crate::Remap::Moved(mapped) if mapped != *inst.get_range() => { + new_range = mapped; + changed = true; + } + crate::Remap::Moved(_) | crate::Remap::Unrelated => {} + crate::Remap::Lost => lost = true, + } + if lost { + TypeRemapResult::lost() + } else if changed { + TypeRemapResult::moved(LuaType::Instance(Arc::new( + crate::db_index::r#type::types::LuaInstanceType::new(new_base, new_range), + ))) + } else { + TypeRemapResult::unchanged() + } + } + LuaType::Union(union) => match union.as_ref() { + crate::LuaUnionType::Multi(types) => TypeRemapResult::from_child_list( + remap_child_types(types.iter(), remap), + LuaType::from_vec, + ), + crate::LuaUnionType::Nullable(inner) => { + TypeRemapResult::from_single(remap_identities_in_type(inner, remap), |new_inner| { + LuaType::from_vec(vec![new_inner, LuaType::Nil]) + }) + } + }, + LuaType::Intersection(inter) => TypeRemapResult::from_child_list( + remap_child_types(inter.get_types().iter(), remap), + |new_types| LuaType::Intersection(Arc::new(crate::LuaIntersectionType::new(new_types))), + ), + LuaType::MergedTable(merged) => TypeRemapResult::from_child_list( + remap_child_types(merged.get_types().iter(), remap), + |new_types| LuaType::MergedTable(Arc::new(crate::LuaMergedTableType::new(new_types))), + ), + LuaType::Array(arr) => TypeRemapResult::from_single( + remap_identities_in_type(arr.get_base(), remap), + |new_base| { + LuaType::Array(Arc::new(crate::LuaArrayType::new( + new_base, + arr.get_len().clone(), + ))) + }, + ), + LuaType::Tuple(tuple) => TypeRemapResult::from_child_list( + remap_child_types(tuple.get_types().iter(), remap), + |new_types| LuaType::Tuple(Arc::new(crate::LuaTupleType::new(new_types, tuple.status))), + ), + LuaType::Object(obj) => { + let mut changed = false; + let mut lost = false; + let mut new_fields = std::collections::BTreeMap::new(); + for (k, v) in obj.get_fields() { + let remapped = remap_identities_in_type(v, remap); + if remapped.lost { + lost = true; + } + if let Some(nv) = remapped.typ { + changed = true; + new_fields.insert(k.clone(), nv); + } else { + new_fields.insert(k.clone(), v.clone()); + } + } + let mut new_index_access = Vec::with_capacity(obj.get_index_access().len()); + for (k, v) in obj.get_index_access() { + let remapped_k = remap_identities_in_type(k, remap); + let remapped_v = remap_identities_in_type(v, remap); + if remapped_k.lost || remapped_v.lost { + lost = true; + } + let nk = remapped_k.typ.unwrap_or_else(|| k.clone()); + let nv = remapped_v.typ.unwrap_or_else(|| v.clone()); + if &nk != k || &nv != v { + changed = true; + } + new_index_access.push((nk, nv)); + } + if lost { + TypeRemapResult::lost() + } else if changed { + TypeRemapResult::moved(LuaType::Object(Arc::new( + crate::LuaObjectType::new_with_fields(new_fields, new_index_access), + ))) + } else { + TypeRemapResult::unchanged() + } + } + LuaType::Generic(r#gen) => TypeRemapResult::from_child_list( + remap_child_types(r#gen.get_params().iter(), remap), + |new_params| { + LuaType::Generic(Arc::new(crate::LuaGenericType::new( + r#gen.get_base_type_id(), + new_params, + ))) + }, + ), + LuaType::TableGeneric(params) => TypeRemapResult::from_child_list( + remap_child_types(params.iter(), remap), + |new_params| LuaType::TableGeneric(Arc::new(new_params)), + ), + LuaType::DocFunction(func) => { + let mut changed = false; + let mut lost = false; + let mut new_params = Vec::with_capacity(func.get_params().len()); + for (name, ty) in func.get_params() { + if let Some(ty) = ty { + let remapped = remap_identities_in_type(ty, remap); + if remapped.lost { + lost = true; + } + if let Some(nt) = remapped.typ { + 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 remapped_ret = remap_identities_in_type(func.get_ret(), remap); + if remapped_ret.lost { + lost = true; + } + let new_ret = remapped_ret.typ.unwrap_or_else(|| func.get_ret().clone()); + if &new_ret != func.get_ret() { + changed = true; + } + if lost { + TypeRemapResult::lost() + } else 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()); + TypeRemapResult::moved(LuaType::DocFunction(Arc::new(new_func))) + } else { + TypeRemapResult::unchanged() + } + } + LuaType::Variadic(var) => match var.as_ref() { + crate::VariadicType::Multi(types) => TypeRemapResult::from_child_list( + remap_child_types(types.iter(), remap), + |new_types| LuaType::Variadic(Arc::new(crate::VariadicType::Multi(new_types))), + ), + crate::VariadicType::Base(base) => { + TypeRemapResult::from_single(remap_identities_in_type(base, remap), |new_base| { + LuaType::Variadic(Arc::new(crate::VariadicType::Base(new_base))) + }) + } + }, + LuaType::MultiLineUnion(mlu) => { + fn project(pair: &(LuaType, Option)) -> &LuaType { + &pair.0 + } + TypeRemapResult::from_child_list( + remap_child_types(mlu.get_unions().iter().map(project as fn(_) -> _), remap), + |new_types| { + let unions = new_types + .into_iter() + .zip(mlu.get_unions().iter().map(|(_, doc)| doc.clone())) + .collect(); + LuaType::MultiLineUnion(Arc::new(crate::LuaMultiLineUnion::new(unions))) + }, + ) + } + LuaType::TypeGuard(inner) => { + TypeRemapResult::from_single(remap_identities_in_type(inner, remap), |new_inner| { + LuaType::TypeGuard(Arc::new(new_inner)) + }) + } + LuaType::Conditional(cond) => { + let mut lost = false; + let remapped_cond = remap_identities_in_type(cond.get_condition(), remap); + let remapped_true = remap_identities_in_type(cond.get_true_type(), remap); + let remapped_false = remap_identities_in_type(cond.get_false_type(), remap); + if remapped_cond.lost || remapped_true.lost || remapped_false.lost { + lost = true; + } + let new_cond = remapped_cond + .typ + .unwrap_or_else(|| cond.get_condition().clone()); + let new_true = remapped_true + .typ + .unwrap_or_else(|| cond.get_true_type().clone()); + let new_false = remapped_false + .typ + .unwrap_or_else(|| cond.get_false_type().clone()); + let changed = &new_cond != cond.get_condition() + || &new_true != cond.get_true_type() + || &new_false != cond.get_false_type(); + if lost { + TypeRemapResult::lost() + } else if changed { + TypeRemapResult::moved(LuaType::Conditional(Arc::new( + crate::LuaConditionalType::new( + new_cond, + new_true, + new_false, + cond.get_infer_params().to_vec(), + cond.has_new, + ), + ))) + } else { + TypeRemapResult::unchanged() + } + } + LuaType::Mapped(mapped) => TypeRemapResult::from_single( + remap_identities_in_type(&mapped.value, remap), + |new_value| { + LuaType::Mapped(Arc::new(crate::LuaMappedType::new( + mapped.param.clone(), + new_value, + mapped.is_readonly, + mapped.is_optional, + ))) + }, + ), + LuaType::TableOf(inner) => { + TypeRemapResult::from_single(remap_identities_in_type(inner, remap), |new_inner| { + LuaType::TableOf(Box::new(new_inner)) + }) + } + LuaType::Call(call) => TypeRemapResult::from_child_list( + remap_child_types(call.get_operands().iter(), remap), + |new_ops| { + LuaType::Call(Arc::new(crate::LuaAliasCallType::new( + call.get_call_kind(), + new_ops, + ))) + }, + ), + // The remaining variants nest no type that can carry a table literal's + // range, so there is nothing under them to move. Listed rather than + // matched with a wildcard so that a new `LuaType` variant fails to + // compile here, which is what keeps this arm list in step with + // `hash_type`. + LuaType::Unknown + | LuaType::Any + | LuaType::Nil + | LuaType::Table + | LuaType::Userdata + | LuaType::Function + | LuaType::Thread + | LuaType::Boolean + | LuaType::String + | LuaType::Integer + | LuaType::Number + | LuaType::Io + | LuaType::SelfInfer + | LuaType::Global + | LuaType::Never + | LuaType::BooleanConst(_) + | LuaType::StringConst(_) + | LuaType::IntegerConst(_) + | LuaType::FloatConst(_) + | LuaType::DocStringConst(_) + | LuaType::DocIntegerConst(_) + | LuaType::DocBooleanConst(_) + | LuaType::Ref(_) + | LuaType::Def(_) + | LuaType::TplRef(_) + | LuaType::ConstTplRef(_) + | LuaType::StrTplRef(_) + | LuaType::Namespace(_) + | LuaType::Language(_) + | LuaType::ModuleRef(_) + | LuaType::DocAttribute(_) + | LuaType::ConditionalInfer(_) => TypeRemapResult::unchanged(), + } +} + +/// Rewrites the nodes, source sites and types one fact's provenance names. +/// +/// Returns `None` when nothing in it belonged to the edited file. `lost` is set +/// when something did belong to it and did not survive - the fact stays as it +/// is and its owner is re-analysed instead. +fn remap_provenance( + provenance: &[LuaInferenceStep], + remap: &crate::FileRemap, + lost: &mut bool, +) -> Option> { + fn node( + current: &LuaInferenceNodeId, + remap: &crate::FileRemap, + changed: &mut bool, + lost: &mut bool, + ) -> LuaInferenceNodeId { + match remap.inference_node(current) { + crate::Remap::Moved(new) => { + if &new != current { + *changed = true; + } + new + } + crate::Remap::Unrelated => current.clone(), + crate::Remap::Lost => { + *lost = true; + current.clone() + } + } + } + + fn remap_step_type( + current: &Arc, + remap: &crate::FileRemap, + changed: &mut bool, + lost: &mut bool, + ) -> Arc { + let result = remap_identities_in_type(current, remap); + if result.lost { + *lost = true; + } + match result.typ { + Some(new) => { + *changed = true; + Arc::new(new) + } + None => current.clone(), + } + } + + let mut changed = false; + let mut steps: Vec = Vec::with_capacity(provenance.len()); + for step in provenance { + let event_node = node(&step.event.node, remap, &mut changed, lost); + let source = match remap.syntax_id(step.event.source.file_id, step.event.source.value) { + crate::Remap::Moved(value) => { + if value != step.event.source.value { + changed = true; + } + InFiled::new(step.event.source.file_id, value) + } + crate::Remap::Unrelated => step.event.source.clone(), + crate::Remap::Lost => { + *lost = true; + step.event.source.clone() + } + }; + let mut support: Vec = step + .support + .iter() + .map(|id| node(id, remap, &mut changed, lost)) + .collect(); + // The producers file support in `stable_cmp` order and a remapped + // position can cross a neighbour, so the order is re-established + // rather than inherited. + support.sort_by(|left, right| left.stable_cmp(right)); + let inferred_type = step + .inferred_type + .as_ref() + .map(|typ| remap_step_type(typ, remap, &mut changed, lost)); + let found_type = step + .found_type + .as_ref() + .map(|typ| remap_step_type(typ, remap, &mut changed, lost)); + steps.push(LuaInferenceStep { + event: LuaInferenceEventId { + node: event_node, + kind: step.event.kind, + source, + }, + support: support.into(), + inferred_type, + found_type, + }); + } + changed.then(|| steps.into()) +} + pub(crate) fn widen_literal_type_for_assignment(typ: &LuaType) -> LuaType { match typ { LuaType::IntegerConst(_) => LuaType::Integer, @@ -231,122 +749,25 @@ fn widen_table_literals_for_assignment(typ: &LuaType) -> LuaType { } } -pub(crate) fn widen_file_define_member_type(typ: &LuaType, widen_table_literals: bool) -> LuaType { - match typ { - LuaType::TableConst(_) if widen_table_literals => LuaType::Table, - _ => widen_literal_type_for_assignment(typ), - } -} - pub(crate) fn is_table_assignment_merge_type(typ: &LuaType) -> bool { - matches!( - typ, - LuaType::Table - | LuaType::TableConst(_) - | LuaType::Object(_) - | LuaType::MergedTable(_) - | LuaType::TableOf(_) - ) -} - -pub(crate) fn prefer_class_assignment_type(typ: &LuaType) -> Option { match typ { - LuaType::Def(def_id) => Some(LuaType::Def(def_id.clone())), - LuaType::Ref(ref_id) => Some(LuaType::Ref(ref_id.clone())), - LuaType::Instance(instance) => prefer_class_assignment_type(instance.get_base()), - LuaType::TypeGuard(inner) => prefer_class_assignment_type(inner), - LuaType::Union(union) => prefer_class_assignment_type_from_iter(union.types()), - LuaType::Intersection(intersection) => { - prefer_class_assignment_type_from_iter(intersection.get_types().iter()) - } - LuaType::MultiLineUnion(union) => { - prefer_class_assignment_type_from_iter(union.get_unions().iter().map(|(typ, _)| typ)) - } - _ => None, - } -} - -fn prefer_class_assignment_type_from_iter<'a>( - types: impl Iterator, -) -> Option { - for typ in types { - if let Some(class_type) = prefer_class_assignment_type(typ) { - return Some(class_type); - } - } - - None -} - -pub(crate) fn is_class_bootstrap_compatible_type(typ: &LuaType, class_type: &LuaType) -> bool { - if is_same_class_type(typ, class_type) { - return true; - } - - match typ { - LuaType::TypeGuard(inner) => is_class_bootstrap_compatible_type(inner, class_type), - LuaType::Instance(instance) => { - is_class_bootstrap_compatible_type(instance.get_base(), class_type) - || is_table_bootstrap_type(typ) - } + LuaType::Table + | LuaType::TableConst(_) + | LuaType::Object(_) + | LuaType::MergedTable(_) + | LuaType::TableGeneric(_) + | LuaType::TableOf(_) => true, LuaType::Union(union) => union .types() - .all(|sub_type| is_class_bootstrap_compatible_type(sub_type, class_type)), - LuaType::Intersection(intersection) => intersection - .get_types() - .iter() - .all(|sub_type| is_class_bootstrap_compatible_type(sub_type, class_type)), - LuaType::MultiLineUnion(union) => union + .all(|t| matches!(t, LuaType::Nil) || is_table_assignment_merge_type(t)), + LuaType::MultiLineUnion(multi) => multi .get_unions() .iter() - .all(|(sub_type, _)| is_class_bootstrap_compatible_type(sub_type, class_type)), - _ => is_table_bootstrap_type(typ), - } -} - -pub(crate) fn is_class_neutral_bootstrap_type(typ: &LuaType) -> bool { - if is_table_bootstrap_type(typ) { - return true; - } - - match typ { - LuaType::TypeGuard(inner) => is_class_neutral_bootstrap_type(inner), - LuaType::Union(union) => union.types().all(is_class_neutral_bootstrap_type), - LuaType::Intersection(intersection) => intersection - .get_types() - .iter() - .all(is_class_neutral_bootstrap_type), - LuaType::MultiLineUnion(union) => union - .get_unions() - .iter() - .all(|(sub_type, _)| is_class_neutral_bootstrap_type(sub_type)), + .all(|(t, _)| matches!(t, LuaType::Nil) || is_table_assignment_merge_type(t)), _ => false, } } -pub(crate) fn is_same_class_type(left: &LuaType, right: &LuaType) -> bool { - match ( - class_decl_id_from_type(left), - class_decl_id_from_type(right), - ) { - (Some(left_id), Some(right_id)) => left_id == right_id, - _ => false, - } -} - -pub(crate) fn class_decl_id_from_type(typ: &LuaType) -> Option { - match typ { - LuaType::Def(def_id) | LuaType::Ref(def_id) => Some(def_id.clone()), - LuaType::Instance(instance) => class_decl_id_from_type(instance.get_base()), - LuaType::TypeGuard(inner) => class_decl_id_from_type(inner), - _ => None, - } -} - -pub(crate) fn is_table_bootstrap_type(typ: &LuaType) -> bool { - typ.is_table() || matches!(typ, LuaType::Unknown | LuaType::Nil | LuaType::Never) -} - pub(crate) fn prune_redundant_guarded_table_bootstrap_type(db: &DbIndex, typ: LuaType) -> LuaType { let LuaType::Union(union) = typ else { return typ; @@ -360,7 +781,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)) @@ -370,32 +792,62 @@ 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); } - 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(); for typ in types { collect_guarded_table_merge_components(typ, &mut table_components, &mut other_components); } + let mut seen: Vec = Vec::new(); + table_components.retain(|component| { + if seen.contains(component) { + return false; + } + seen.push(component.clone()); + true + }); 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)) { @@ -434,25 +886,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() @@ -483,17 +965,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 { @@ -503,6 +1002,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() @@ -515,6 +1020,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; @@ -524,17 +1030,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) { @@ -542,18 +1047,53 @@ 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 super_entry_sort_key(entry: &InFiled) -> (FileId, TextSize, TextSize) { + ( + entry.file_id, + entry.value.source_range.start(), + entry.value.source_range.end(), + ) } fn collect_type_cache_refs(typ: &LuaType) -> HashSet { @@ -561,16 +1101,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())); @@ -593,10 +1133,22 @@ pub struct LuaTypeIndex { types: HashMap, cache_refs: TypeCacheRefIndex, in_filed_type_owner: HashMap>, + settled_reads_by_file: HashMap>, + settled_read_dependents: HashMap>, fact_metadata: 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, + type_write_versions: HashMap, definition_facts: HashMap, inference_events_by_file: HashMap>, + support_dependents: HashMap>, support_file_dependents: HashMap>, + derived_state_dirty: bool, } impl Default for LuaTypeIndex { @@ -617,10 +1169,17 @@ impl LuaTypeIndex { types: HashMap::default(), cache_refs: TypeCacheRefIndex::default(), in_filed_type_owner: HashMap::default(), + settled_reads_by_file: HashMap::default(), + settled_read_dependents: HashMap::default(), fact_metadata: HashMap::default(), + decl_write_claims: HashMap::default(), + type_writes: 0, + type_write_versions: HashMap::default(), definition_facts: HashMap::default(), inference_events_by_file: HashMap::default(), + support_dependents: HashMap::default(), support_file_dependents: HashMap::default(), + derived_state_dirty: false, } } @@ -778,13 +1337,22 @@ impl LuaTypeIndex { source_range: TextRange, super_type: LuaType, ) { - self.supers.entry(decl_id).or_default().push(InFiled::new( + // Kept in source order rather than the order the files happened to be + // analysed in: an incremental re-analysis visits the declaring files in + // a different order than a cold build, and the first entry decides + // which declaration a consumer resolves against. + let entry = InFiled::new( file_id, LuaSuperType { source_range, typ: super_type, }, - )); + ); + let supers = self.supers.entry(decl_id).or_default(); + let at = supers.partition_point(|existing| { + super_entry_sort_key(existing) <= super_entry_sort_key(&entry) + }); + supers.insert(at, entry); } fn has_super_type_at_source( @@ -906,6 +1474,13 @@ impl LuaTypeIndex { self.full_name_type_map.values().collect() } + /// [`get_all_types`](Self::get_all_types) without collecting, for the + /// analyzer paths that only scan and would otherwise allocate a vector of + /// every type in the workspace on each call. + pub fn iter_type_decls(&self) -> impl Iterator { + self.full_name_type_map.values() + } + pub fn get_file_namespaces(&self) -> Vec { self.file_namespace .values() @@ -931,6 +1506,36 @@ impl LuaTypeIndex { { return; } + 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 + } + + pub(crate) fn type_write_version(&self, owner: &LuaTypeOwner) -> u64 { + self.type_write_versions.get(owner).copied().unwrap_or(0) + } + + /// 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, + 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) { let file_id = owner.get_file_id(); let replaced = self.insert_type_cache(owner.clone(), cache); self.in_filed_type_owner @@ -938,10 +1543,105 @@ impl LuaTypeIndex { .or_default() .insert(owner.clone()); if replaced && self.fact_metadata.remove(&owner).is_some() { - self.rebuild_inference_derived_state(&[file_id].into_iter().collect::>()); + self.mark_inference_derived_state_dirty(); } } + /// 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(crate) fn set_settled_reads( + &mut self, + file_id: FileId, + reads: impl IntoIterator, + ) { + self.clear_settled_reads(file_id); + let reads = reads + .into_iter() + .filter(|owner| owner.get_file_id() != file_id) + .collect::>(); + if reads.is_empty() { + return; + } + for owner in &reads { + self.settled_read_dependents + .entry(owner.clone()) + .or_default() + .insert(file_id); + } + self.settled_reads_by_file.insert(file_id, reads); + } + + fn clear_settled_reads(&mut self, file_id: FileId) { + let Some(reads) = self.settled_reads_by_file.remove(&file_id) else { + return; + }; + for owner in reads { + if let Some(dependents) = self.settled_read_dependents.get_mut(&owner) { + dependents.remove(&file_id); + if dependents.is_empty() { + self.settled_read_dependents.remove(&owner); + } + } + } + } + + fn remap_settled_reads(&mut self, remap: &crate::FileRemap) -> HashSet { + let old_owners = self + .settled_read_dependents + .keys() + .filter(|owner| owner.get_file_id() == remap.file_id) + .cloned() + .collect::>(); + let mut dirty = HashSet::default(); + let mut updates = Vec::new(); + for old_owner in old_owners { + let new_owner = match remap.type_owner(&old_owner) { + crate::Remap::Moved(new_owner) if new_owner != old_owner => Some(new_owner), + crate::Remap::Moved(_) | crate::Remap::Unrelated => continue, + crate::Remap::Lost => None, + }; + let dependents = self + .settled_read_dependents + .get(&old_owner) + .cloned() + .unwrap_or_default(); + if new_owner.is_none() { + dirty.extend(dependents.iter().copied()); + } + updates.push((old_owner, new_owner, dependents)); + } + + for (old_owner, _, _) in &updates { + self.settled_read_dependents.remove(old_owner); + } + for (old_owner, new_owner, dependents) in updates { + for dependent in &dependents { + if let Some(reads) = self.settled_reads_by_file.get_mut(dependent) { + reads.remove(&old_owner); + if let Some(new_owner) = &new_owner { + reads.insert(new_owner.clone()); + } + } + } + if let Some(new_owner) = new_owner { + self.settled_read_dependents + .entry(new_owner) + .or_default() + .extend(dependents); + } + } + dirty + } + + 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); @@ -950,7 +1650,7 @@ impl LuaTypeIndex { .or_default() .insert(owner.clone()); if self.fact_metadata.remove(&owner).is_some() { - self.rebuild_inference_derived_state(&[file_id].into_iter().collect::>()); + self.mark_inference_derived_state_dirty(); } } @@ -976,7 +1676,7 @@ impl LuaTypeIndex { .or_default() .insert(owner.clone()); self.fact_metadata.insert(owner, metadata); - self.rebuild_inference_derived_state(&[file_id].into_iter().collect::>()); + self.mark_inference_derived_state_dirty(); } pub fn force_bind_type_fact( @@ -985,12 +1685,12 @@ impl LuaTypeIndex { cache: LuaTypeCache, metadata: LuaTypeFactMetadata, ) { - let file_id = self.force_bind_type_fact_unchecked(owner, cache, metadata); - self.rebuild_inference_derived_state(&[file_id].into_iter().collect::>()); + self.force_bind_type_fact_unchecked(owner, cache, metadata); + self.mark_inference_derived_state_dirty(); } pub fn get_type_fact(&self, owner: &LuaTypeOwner) -> Option { - let cache = self.types.get(owner)?; + let cache = self.read_type_cache(owner)?; let fact = match self.fact_metadata.get(owner) { Some(metadata) => LuaTypeFact::from_normalized_parts( cache.as_type().clone(), @@ -1004,8 +1704,8 @@ impl LuaTypeIndex { } pub fn bind_definition_fact(&mut self, definition: LuaDefinitionId, fact: LuaTypeFact) { - let file_id = self.bind_definition_fact_unchecked(definition, fact); - self.rebuild_inference_derived_state(&[file_id].into_iter().collect::>()); + self.bind_definition_fact_unchecked(definition, fact); + self.mark_inference_derived_state_dirty(); } pub fn get_definition_fact(&self, definition: &LuaDefinitionId) -> Option<&LuaTypeFact> { @@ -1029,10 +1729,48 @@ impl LuaTypeIndex { dependents.extend(files.iter().copied()); } } + for (owner, files) in &self.settled_read_dependents { + if file_ids.contains(&owner.get_file_id()) { + dependents.extend(files.iter().copied()); + } + } + 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()); + } + if let LuaInferenceNodeId::TypeOwner(owner) = node + && let Some(files) = self.settled_read_dependents.get(owner) + { + dependents.extend(files.iter().copied()); + } + } dependents } pub fn get_type_cache(&self, owner: &LuaTypeOwner) -> Option<&LuaTypeCache> { + self.read_type_cache(owner) + } + + /// The one way an inference reads a stored type, so every such read lands in + /// the read set. + /// + /// The settled tail skips a candidate whose reads have not moved, so a read + /// that goes unrecorded is a candidate that never gets re-derived — a stale + /// answer with nothing to report it. Recording one that did not matter only + /// costs a re-derivation, so anything reaching `types` on behalf of a caller + /// goes through here. The write paths compare against `types` directly: + /// what a write overwrites is not something it depends on. + fn read_type_cache(&self, owner: &LuaTypeOwner) -> Option<&LuaTypeCache> { + read_set::record(owner); self.types.get(owner) } @@ -1056,6 +1794,27 @@ 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) + .is_none_or(|existing| existing.as_type() != cache.as_type()) + { + self.note_type_write(&owner); + } 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 { @@ -1065,6 +1824,12 @@ impl LuaTypeIndex { true } + fn note_type_write(&mut self, owner: &LuaTypeOwner) { + self.type_writes += 1; + self.type_write_versions + .insert(owner.clone(), self.type_writes); + } + pub(crate) fn bind_definition_fact_unchecked( &mut self, definition: LuaDefinitionId, @@ -1075,17 +1840,28 @@ impl LuaTypeIndex { file_id } - pub(crate) fn rebuild_inference_derived_state( - &mut self, - changed_files: &std::collections::HashSet, - ) { - if changed_files.is_empty() { + /// Marks the inference-derived state stale. + /// + /// The rebuild reads every fact in the index, so it is deferred to + /// [`flush_inference_derived_state`](Self::flush_inference_derived_state) + /// rather than run once per binding. No reader runs inside an analysis + /// batch: the events are read by the inference-trust diagnostic and the + /// support dependents by reindex expansion, both after `analyze` returns. + pub(crate) fn mark_inference_derived_state_dirty(&mut self) { + self.derived_state_dirty = true; + } + + /// Rebuilds the inference-derived state if any binding marked it stale. + pub(crate) fn flush_inference_derived_state(&mut self) { + if !self.derived_state_dirty { return; } + self.derived_state_dirty = false; 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 { @@ -1101,7 +1877,7 @@ impl LuaTypeIndex { owner.get_file_id(), &fact, &mut events_by_file, - &mut support_file_dependents, + &mut support_dependents, ); } @@ -1110,7 +1886,7 @@ impl LuaTypeIndex { definition.file_id(), fact, &mut events_by_file, - &mut support_file_dependents, + &mut support_dependents, ); } @@ -1122,7 +1898,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 { @@ -1155,7 +1938,9 @@ impl LuaTypeIndex { changed_files.insert(owner.get_file_id()); self.insert_type_cache(owner, new_cache); } - self.rebuild_inference_derived_state(&changed_files); + if !changed_files.is_empty() { + self.mark_inference_derived_state_dirty(); + } } pub fn replace_table_const_types( @@ -1185,7 +1970,149 @@ impl LuaTypeIndex { changed_files.insert(owner.get_file_id()); self.insert_type_cache(owner, new_cache); } - self.rebuild_inference_derived_state(&changed_files); + if !changed_files.is_empty() { + self.mark_inference_derived_state_dirty(); + } + } + + /// 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.note_type_write(&owner); + } + self.fact_metadata.remove(&owner); + } + } + + /// Rewrites every cached type, definition fact and inference-support array + /// that names an identity in the edited file. + /// + /// Returns the files whose entry named something the edit destroyed: those + /// cannot be rewritten and have to be re-analysed instead. + pub fn remap_file_identities(&mut self, remap: &crate::FileRemap) -> HashSet { + let mut dirty = HashSet::default(); + + // Only caches that actually name an identity in the edited file can + // contain something the edit moved or destroyed, 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 candidate_owners: Vec = self + .cache_refs + .owners_referencing_file(remap.file_id) + .filter_map(|owner_file_id| self.in_filed_type_owner.get(&owner_file_id)) + .flatten() + .cloned() + .collect(); + + let mut updates = Vec::new(); + for owner in candidate_owners { + let Some(cache) = self.types.get(&owner) else { + continue; + }; + let result = remap_identities_in_type(cache.as_type(), remap); + if result.lost { + // A lost nested identity is left in place; the owner is + // re-analysed instead of being rewritten to a guessed value. + dirty.insert(owner.get_file_id()); + } else if let Some(new_type) = result.typ { + let new_cache = match cache { + LuaTypeCache::DocType(_) => LuaTypeCache::DocType(new_type), + LuaTypeCache::InferType(_) => LuaTypeCache::InferType(new_type), + }; + updates.push((owner, 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()); + self.insert_type_cache(owner, new_cache); + } + if !changed_files.is_empty() { + self.mark_inference_derived_state_dirty(); + } + dirty.extend(self.remap_inference_facts(remap)); + dirty.extend(self.remap_settled_reads(remap)); + dirty.remove(&remap.file_id); + dirty + } + + /// Rewrites the provenance of every stored fact, and the payload of every + /// definition fact, through `remap`. + /// + /// A definition fact's payload comes from inference and can name another + /// file's table literal or signature; its provenance names the inference + /// nodes the fact was derived from, which is what + /// `files_depending_on_inference_nodes` indexes. Neither is reachable from + /// `cache_refs`, so both are visited here rather than located by a reverse + /// index - the same walk `flush_inference_derived_state` already makes on + /// every analyse. + fn remap_inference_facts(&mut self, remap: &crate::FileRemap) -> HashSet { + let mut dirty = HashSet::default(); + let mut metadata_updates = Vec::new(); + for (owner, metadata) in &self.fact_metadata { + let mut lost = false; + if let Some(provenance) = remap_provenance(&metadata.provenance, remap, &mut lost) { + metadata_updates.push(( + owner.clone(), + LuaTypeFactMetadata { + confidence: metadata.confidence, + base_provenance_kind: metadata.base_provenance_kind, + provenance, + }, + )); + } + if lost { + dirty.insert(owner.get_file_id()); + } + } + for (owner, metadata) in metadata_updates { + self.fact_metadata.insert(owner, metadata); + } + + let mut fact_updates = Vec::new(); + for (definition, fact) in &self.definition_facts { + let mut lost = false; + let remapped = remap_identities_in_type(fact.typ(), remap); + if remapped.lost { + lost = true; + } + let provenance = remap_provenance(fact.provenance(), remap, &mut lost); + if remapped.typ.is_some() || provenance.is_some() { + fact_updates.push(( + *definition, + LuaTypeFact::from_normalized_parts( + remapped.typ.unwrap_or_else(|| fact.typ().clone()), + fact.confidence(), + fact.base_provenance_kind(), + provenance.unwrap_or_else(|| fact.provenance().into()), + ), + )); + } + if lost { + dirty.insert(definition.file_id()); + } + } + for (definition, fact) in fact_updates { + self.definition_facts.insert(definition, fact); + } + dirty } pub fn files_with_type_caches_referencing_files( @@ -1195,9 +2122,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 @@ -1245,6 +2174,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, @@ -1386,7 +2338,10 @@ impl LuaIndex for LuaTypeIndex { self.definition_facts .retain(|definition, _| !changed_files.contains(&definition.file_id())); - self.rebuild_inference_derived_state(&changed_files); + if !changed_files.is_empty() { + self.mark_inference_derived_state_dirty(); + } + self.flush_inference_derived_state(); } fn clear(&mut self) { @@ -1399,15 +2354,22 @@ impl LuaIndex for LuaTypeIndex { self.types.clear(); self.cache_refs = TypeCacheRefIndex::default(); self.in_filed_type_owner.clear(); + self.settled_reads_by_file.clear(); + self.settled_read_dependents.clear(); self.fact_metadata.clear(); + self.decl_write_claims.clear(); + self.type_write_versions.clear(); self.definition_facts.clear(); self.inference_events_by_file.clear(); + self.support_dependents.clear(); self.support_file_dependents.clear(); + self.derived_state_dirty = false; } } impl LuaTypeIndex { fn remove_file_raw(&mut self, file_id: FileId) { + self.clear_settled_reads(file_id); self.file_namespace.remove(&file_id); self.file_using_namespace.remove(&file_id); if let Some(type_id_list) = self.file_types.remove(&file_id) { @@ -1435,7 +2397,12 @@ impl LuaTypeIndex { if let Some(type_owners) = self.in_filed_type_owner.remove(&file_id) { for type_owner in type_owners { - self.types.remove(&type_owner); + if let LuaTypeOwner::Decl(decl_id) = &type_owner { + self.decl_write_claims.remove(decl_id); + } + if self.types.remove(&type_owner).is_some() { + self.note_type_write(&type_owner); + } self.fact_metadata.remove(&type_owner); } } @@ -1448,7 +2415,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 @@ -1459,8 +2426,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); } @@ -1770,6 +2737,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); } @@ -1807,3 +2775,69 @@ mod batch_removal_tests { ); } } + +#[cfg(test)] +mod remap_walker_tests { + use rowan::{TextRange, TextSize}; + + use super::*; + use crate::db_index::edit::{FileRemap, PositionMap}; + + fn file_id(id: u32) -> FileId { + FileId::new(id) + } + + fn table(file: u32, start: u32, end: u32) -> LuaType { + LuaType::TableConst(InFiled::new( + file_id(file), + TextRange::new(TextSize::new(start), TextSize::new(end)), + )) + } + + fn signature(file: u32, position: u32) -> LuaType { + LuaType::Signature(LuaSignatureId::new(file_id(file), TextSize::new(position))) + } + + #[test] + fn moved_table_and_signature_nested_in_union_are_rewritten() { + let edited = file_id(1); + // Insertion at offset 3 shifts everything after it by two. + let remap = FileRemap::unvalidated(edited, PositionMap::new("abcdef", "abcXXdef")); + let composite = LuaType::from_vec(vec![table(1, 4, 6), signature(1, 5), LuaType::String]); + + let result = remap_identities_in_type(&composite, &remap); + assert!(!result.lost, "a shift must not report loss"); + let rewritten = result.typ.expect("a shift must rewrite the composite"); + let expected = LuaType::from_vec(vec![table(1, 6, 8), signature(1, 7), LuaType::String]); + assert_eq!(rewritten, expected); + } + + #[test] + fn lost_table_and_signature_nested_in_union_are_reported_without_rewrite() { + let edited = file_id(1); + // Deletion of offsets 3..5 destroys what sits inside it. + let remap = FileRemap::unvalidated(edited, PositionMap::new("abcXXdef", "abcdef")); + let composite = LuaType::from_vec(vec![table(1, 3, 4), signature(1, 4)]); + + let result = remap_identities_in_type(&composite, &remap); + assert!(result.lost, "a destroyed identity must report loss"); + assert!( + result.typ.is_none(), + "a lost identity must not be rewritten to a guessed value" + ); + } + + #[test] + fn unrelated_identities_are_unchanged() { + let edited = file_id(1); + let remap = FileRemap::unvalidated(edited, PositionMap::new("abcdef", "abcXXdef")); + let composite = LuaType::from_vec(vec![table(2, 4, 6), signature(2, 5), LuaType::String]); + + let result = remap_identities_in_type(&composite, &remap); + assert!(!result.lost, "another file's identities are never lost"); + assert!( + result.typ.is_none(), + "another file's identities must remain unchanged" + ); + } +} diff --git a/crates/glua_code_analysis/src/db_index/type/read_set.rs b/crates/glua_code_analysis/src/db_index/type/read_set.rs new file mode 100644 index 000000000..21a8926d6 --- /dev/null +++ b/crates/glua_code_analysis/src/db_index/type/read_set.rs @@ -0,0 +1,99 @@ +//! Which cached facts an inference actually consulted, recorded as it runs. +//! +//! The settled tail runs to a fixpoint, and re-deriving every candidate every +//! round costs the same as the first round however little moved. A candidate +//! only needs re-deriving when a cache it *read* has since changed, and the +//! read set is free to collect. +//! +//! Stored types, signature returns, and failed member lookups record here at +//! their shared read boundaries. A read that bypassed those boundaries would +//! leave a candidate looking unaffected by a fact it actually depends on, and +//! the fixpoint would settle on the stale answer. Over-recording is harmless — +//! it costs one re-derivation — so a new reader belongs at its shared boundary. +//! +//! Worker-local by construction — each file is re-derived on one thread, and +//! the collector is a thread local armed around that file's inference. The +//! armed flag is a plain `Cell` so a call outside the settled tail (the +//! walk asks for type caches constantly) pays one thread-local bool read. + +use rustc_hash::FxHashSet; +use std::cell::{Cell, RefCell}; + +use super::type_owner::LuaTypeOwner; +use crate::{LuaMemberKey, LuaMemberOwner, LuaSignatureId}; + +#[derive(Debug, Default, Clone)] +pub(crate) struct InferenceReadSet { + pub type_owners: FxHashSet, + pub signatures: FxHashSet, + pub missing_member_slots: FxHashSet<(LuaMemberOwner, LuaMemberKey)>, +} + +impl InferenceReadSet { + pub(crate) fn extend(&mut self, other: Self) { + self.type_owners.extend(other.type_owners); + self.signatures.extend(other.signatures); + self.missing_member_slots.extend(other.missing_member_slots); + } +} + +#[derive(Debug, Clone)] +pub(crate) struct InferredReturnReadRecord { + pub reads: InferenceReadSet, + pub type_epoch: u64, + pub return_epoch: u64, +} + +thread_local! { + static ARMED: Cell = const { Cell::new(false) }; + static READS: RefCell> = RefCell::new(FxHashSet::default()); + static SIGNATURES: RefCell> = RefCell::new(FxHashSet::default()); + static MISSING_MEMBER_SLOTS: RefCell> = RefCell::new(FxHashSet::default()); +} + +/// Starts collecting on this thread, discarding anything left over. +pub(crate) fn arm() { + READS.with(|reads| reads.borrow_mut().clear()); + SIGNATURES.with(|signatures| signatures.borrow_mut().clear()); + MISSING_MEMBER_SLOTS.with(|slots| slots.borrow_mut().clear()); + ARMED.with(|armed| armed.set(true)); +} + +/// Stops collecting and hands back what was read since [`arm`]. +pub(crate) fn disarm() -> InferenceReadSet { + ARMED.with(|armed| armed.set(false)); + InferenceReadSet { + type_owners: READS.with(|reads| std::mem::take(&mut *reads.borrow_mut())), + signatures: SIGNATURES.with(|signatures| std::mem::take(&mut *signatures.borrow_mut())), + missing_member_slots: MISSING_MEMBER_SLOTS + .with(|slots| std::mem::take(&mut *slots.borrow_mut())), + } +} + +#[inline] +pub(crate) fn record(owner: &LuaTypeOwner) { + if !ARMED.with(Cell::get) { + return; + } + READS.with(|reads| { + reads.borrow_mut().insert(owner.clone()); + }); +} + +#[inline] +pub(crate) fn record_signature(signature_id: &LuaSignatureId) { + if ARMED.with(Cell::get) { + SIGNATURES.with(|signatures| { + signatures.borrow_mut().insert(*signature_id); + }); + } +} + +#[inline] +pub(crate) fn record_missing_member_slot(owner: &LuaMemberOwner, key: &LuaMemberKey) { + if ARMED.with(Cell::get) { + MISSING_MEMBER_SLOTS.with(|slots| { + slots.borrow_mut().insert((owner.clone(), key.clone())); + }); + } +} 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..f9afd6a65 100644 --- a/crates/glua_code_analysis/src/db_index/type/test.rs +++ b/crates/glua_code_analysis/src/db_index/type/test.rs @@ -7,14 +7,14 @@ 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, 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 { @@ -136,6 +136,7 @@ mod test { ); index.force_bind_type(owner(), LuaTypeCache::InferType(LuaType::Number)); + index.flush_inference_derived_state(); let fact = index.get_type_fact(&owner()).unwrap(); assert_eq!(fact.typ(), &LuaType::Number); assert_eq!(fact.confidence(), LuaInferenceConfidence::Certain); @@ -359,7 +360,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, @@ -412,6 +416,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); @@ -521,13 +634,16 @@ mod test { LuaTypeCache::InferType(LuaType::String), metadata.clone(), ); + index.flush_inference_derived_state(); assert!(index.get_inference_events_for_file(owner_file).is_empty()); assert_eq!(index.get_inference_events_for_file(source_file).len(), 1); index.force_bind_type(owner.clone(), LuaTypeCache::InferType(LuaType::Number)); + index.flush_inference_derived_state(); assert!(index.get_inference_events_for_file(source_file).is_empty()); index.force_bind_type_fact(owner, LuaTypeCache::InferType(LuaType::String), metadata); + index.flush_inference_derived_state(); assert_eq!(index.get_inference_events_for_file(source_file).len(), 1); index.remove(owner_file); assert!(index.get_inference_events_for_file(source_file).is_empty()); @@ -566,6 +682,7 @@ mod test { index.force_bind_type(first_owner, LuaTypeCache::InferType(LuaType::Boolean)); + index.flush_inference_derived_state(); let remaining = index.get_inference_events_for_file(source_file); assert_eq!(remaining.len(), 1); assert_eq!( @@ -642,6 +759,7 @@ mod test { ); index.bind_definition_fact(definition, fact.clone()); + index.flush_inference_derived_state(); assert_eq!(index.get_definition_fact(&definition), Some(&fact)); assert_eq!( @@ -682,6 +800,7 @@ mod test { index.replace_table_const_type(&table_range, &LuaType::Table); + index.flush_inference_derived_state(); assert_eq!( index.get_type_fact(&owner()).unwrap().typ(), &LuaType::Table @@ -725,6 +844,7 @@ mod test { metadata, ); + index.flush_inference_derived_state(); let events = index.get_inference_events_for_file(file_id()); assert_eq!(events.len(), 1, "{events:?}"); let step = events[0] @@ -748,9 +868,7 @@ mod test { assert_eq!( db.publish_inference_facts(vec![(node.clone(), fact.clone())]), - [file_id()] - .into_iter() - .collect::>() + [file_id()].into_iter().collect::>() ); assert_eq!(db.get_inference_fact(&node), Some(fact)); } 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..3021e2289 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() @@ -385,9 +417,9 @@ impl LuaTypeDeclId { } pub fn collect_super_types(&self, db: &DbIndex, collected_types: &mut Vec) { - // BFS with HashSet for O(1) visited check instead of Vec::contains O(n). + // BFS with FxHashSet for O(1) visited check instead of Vec::contains O(n). let mut queue = Vec::new(); - let mut visited = std::collections::HashSet::new(); + let mut visited = rustc_hash::FxHashSet::default(); visited.insert(self.clone()); queue.push(self.clone()); 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 + } + }) + } + } +} diff --git a/crates/glua_code_analysis/src/db_index/type/type_ops/union_type.rs b/crates/glua_code_analysis/src/db_index/type/type_ops/union_type.rs index 3df247ddf..52cbbb5bf 100644 --- a/crates/glua_code_analysis/src/db_index/type/type_ops/union_type.rs +++ b/crates/glua_code_analysis/src/db_index/type/type_ops/union_type.rs @@ -1,7 +1,6 @@ -use std::ops::Deref; - -use crate::db_index::r#type::types::lua_type_sort_key; +use crate::db_index::r#type::types::{lua_type_sort_key, lua_type_sort_ordinal}; use crate::{DbIndex, LuaMultiLineUnion, LuaType, LuaUnionType, get_real_type}; +use rustc_hash::FxHashSet; // Union member *order* is preserved here, but the member *set* is // canonical. @@ -68,6 +67,9 @@ pub(crate) fn union_type_all(types: Vec) -> LuaType { /// [`visiting_order_is_observable`] decides for the caller. fn union_all_absorbed(types: Vec) -> LuaType { let mut members: Vec = Vec::with_capacity(types.len()); + let mut present = 0u64; + let mut hashed: FxHashSet = + FxHashSet::with_capacity_and_hasher(types.len(), Default::default()); for typ in types { match typ { // `never` is absorbed by any sibling, so it only survives when it is @@ -77,11 +79,11 @@ fn union_all_absorbed(types: Vec) -> LuaType { LuaType::Union(union) => { for member in union.into_vec() { if !matches!(member, LuaType::Never) { - absorb(&mut members, member); + add_member(&mut members, &mut present, &mut hashed, member); } } } - other => absorb(&mut members, other), + other => add_member(&mut members, &mut present, &mut hashed, other), } } @@ -91,6 +93,74 @@ fn union_all_absorbed(types: Vec) -> LuaType { LuaType::from_vec_structural(members) } +/// [`absorb`], with the common case answered by a hash set instead of a scan. +/// +/// `absorb` scans every member sharing a discriminant with the incoming type, +/// and the rule that decides them is a full structural equality — so a union of +/// n distinct `Ref`s or `TableConst`s costs O(n²). When nothing already present +/// can collapse with `ty` across discriminants, and `ty`'s own discriminant has +/// no same-discriminant rule but equality, that scan is exactly a de-duplication +/// and `hashed` gives the same answer in one hash. +/// +/// `hashed` holds every member this shortcut inserted, and only those: a member +/// `absorb` inserted or merged away is one whose partner discriminant is set in +/// `present`, and `present` is only ever widened, so an equal type arriving +/// later cannot reach the shortcut and find the set out of date. +fn add_member( + members: &mut Vec, + present: &mut u64, + hashed: &mut FxHashSet, + ty: LuaType, +) { + if dedups_by_equality(&ty) + && collapse_partner_ordinals(&ty) + .iter() + .all(|ordinal| *present & 1 << ordinal == 0) + { + if hashed.insert(ty.clone()) { + *present |= 1 << lua_type_sort_ordinal(&ty); + members.push(ty); + } + return; + } + absorb(members, present, ty); +} + +/// Whether two members of this type's discriminant can only collapse by being +/// equal, and that equality agrees with `Hash`. +/// +/// `BooleanConst` is the one discriminant with a rule of its own (two different +/// literals give `boolean`). `FloatConst` is left out because `0.0 == -0.0` +/// while their bit patterns — what `Hash` uses — differ. Every other type is +/// decided by [`try_collapse`]'s trailing equality rule, including `Ref`, whose +/// own arm compares the same ids that `PartialEq` does. +fn dedups_by_equality(ty: &LuaType) -> bool { + matches!( + ty, + LuaType::Nil + | LuaType::Boolean + | LuaType::Integer + | LuaType::Number + | LuaType::String + | LuaType::Table + | LuaType::Userdata + | LuaType::Function + | LuaType::Thread + | LuaType::SelfInfer + | LuaType::Global + | LuaType::Unknown + | LuaType::Io + | LuaType::IntegerConst(_) + | LuaType::DocIntegerConst(_) + | LuaType::StringConst(_) + | LuaType::DocStringConst(_) + | LuaType::TableConst(_) + | LuaType::Ref(_) + | LuaType::Def(_) + | LuaType::Signature(_) + ) +} + /// Whether the order `union_type_all` visits members in can change its answer. /// /// A `MultiLineUnion` always matters: it matches an incoming literal against its @@ -217,8 +287,9 @@ fn union_type_impl(match_source: &LuaType, source: &LuaType, target: &LuaType) - if let Some(merged) = union_sorted_insert(left, source, right) { return merged; } - let mut members = left.deref().clone().into_vec(); - absorb(&mut members, right.clone()); + let mut members = left.into_vec(); + let mut present = ordinal_mask(&members); + absorb(&mut members, &mut present, right.clone()); LuaType::from_vec_structural(members) } // The *source* joins the union, not the dereferenced view of it: @@ -228,15 +299,17 @@ fn union_type_impl(match_source: &LuaType, source: &LuaType, target: &LuaType) - // `try_collapse`), and `absorb` matches an alias by identity, which // is the same answer the other two union arms give. (left, LuaType::Union(right)) if !left.is_union() => { - let mut members = right.deref().clone().into_vec(); - absorb(&mut members, source.clone()); + let mut members = right.into_vec(); + let mut present = ordinal_mask(&members); + absorb(&mut members, &mut present, source.clone()); LuaType::from_vec_structural(members) } // two union (LuaType::Union(left), LuaType::Union(right)) => { let mut members = left.into_vec(); + let mut present = ordinal_mask(&members); for member in right.into_vec() { - absorb(&mut members, member); + absorb(&mut members, &mut present, member); } LuaType::from_vec_structural(members) } @@ -306,8 +379,44 @@ fn multi_line_union_contains(union: &LuaMultiLineUnion, other: &LuaType) -> bool }) } +/// This type's bit in a discriminant mask. +/// +/// The masks are `u64`, so every ordinal has to stay under 64; the largest +/// [`lua_type_sort_ordinal`] hands out today is 50. Going over would silently +/// produce a wrong mask in release, so it is checked here, the one place a bit +/// is taken. +fn ordinal_bit(ty: &LuaType) -> u64 { + let ordinal = lua_type_sort_ordinal(ty); + debug_assert!( + ordinal < 64, + "sort ordinal {ordinal} does not fit in a u64 discriminant mask" + ); + 1 << ordinal +} + +/// Bitset of the sort discriminants present in a member list. +fn ordinal_mask(members: &[LuaType]) -> u64 { + members + .iter() + .fold(0, |mask, member| mask | ordinal_bit(member)) +} + +/// The discriminants of every member `ty` could collapse with: its collapse +/// partners, plus its own for the equality rules, plus `never`. +fn collapse_candidate_mask(ty: &LuaType) -> u64 { + collapse_partner_ordinals(ty).iter().fold( + ordinal_bit(ty) | ordinal_bit(&LuaType::Never), + |mask, ordinal| mask | 1 << ordinal, + ) +} + /// Add `ty` to an existing union's member list, applying absorption rules. -fn absorb(members: &mut Vec, ty: LuaType) { +/// +/// `present` is [`ordinal_mask`] over `members`, carried across calls so that a +/// member with no possible partner costs a mask test rather than a scan. It is +/// only ever widened, so a member removed by a merge leaves a stale bit behind: +/// that costs a scan that finds nothing, never a missed collapse. +fn absorb(members: &mut Vec, present: &mut u64, ty: LuaType) { let mut ty = ty; // A merged member keeps the slot of the member it merged with, so absorbing // never reorders the survivors. Member order is semantic for overloads and @@ -316,17 +425,27 @@ fn absorb(members: &mut Vec, ty: LuaType) { let mut slot = members.len(); 'restart: loop { - for i in 0..members.len() { - if let Some(merged) = try_collapse(&members[i], &members[i], &ty) { - members.remove(i); - ty = merged; - slot = slot.min(i); - continue 'restart; + let candidates = collapse_candidate_mask(&ty); + if candidates & *present != 0 { + for i in 0..members.len() { + // Anything that collapses sorts under a candidate discriminant, + // so the rest are skipped without reaching `try_collapse`, whose + // last rule is a full structural equality. + if candidates & 1 << lua_type_sort_ordinal(&members[i]) == 0 { + continue; + } + if let Some(merged) = try_collapse(&members[i], &members[i], &ty) { + members.remove(i); + ty = merged; + slot = slot.min(i); + continue 'restart; + } } } break; } + *present |= 1 << lua_type_sort_ordinal(&ty); members.insert(slot.min(members.len()), ty); } @@ -429,10 +548,10 @@ fn collapse_partner_ordinals(typ: &LuaType) -> &'static [u8] { /// Whether a sorted member list holds any type with this sort discriminant. fn contains_ordinal(members: &[LuaType], ordinal: u8) -> bool { - let at = members.partition_point(|member| lua_type_sort_key(member).0 < ordinal); + let at = members.partition_point(|member| lua_type_sort_ordinal(member) < ordinal); members .get(at) - .is_some_and(|member| lua_type_sort_key(member).0 == ordinal) + .is_some_and(|member| lua_type_sort_ordinal(member) == ordinal) } #[cfg(test)] @@ -526,8 +645,9 @@ mod union_shortcut_tests { let source = LuaType::Union(union.clone()); let general = { - let mut rebuilt = union.deref().clone().into_vec(); - absorb(&mut rebuilt, incoming.clone()); + let mut rebuilt = union.into_vec(); + let mut present = ordinal_mask(&rebuilt); + absorb(&mut rebuilt, &mut present, incoming.clone()); LuaType::from_vec_structural(rebuilt) }; if let Some(fast) = union_sorted_insert(&union, &source, &incoming) { @@ -561,6 +681,32 @@ mod union_shortcut_tests { assert_eq!(union_all_absorbed(types.clone()), fold(types)); } + /// The shape the hash-set shortcut exists for: a wide union of members + /// sharing one discriminant, mixed with the one same-discriminant rule that + /// is not plain equality. + #[test] + fn many_references_beside_boolean_literals_match_the_fold() { + let mut next = rng(0x1234_5678_9abc_def1); + for _ in 0..200 { + let mut types = (0..40) + .map(|i| LuaType::Ref(LuaTypeDeclId::global(&format!("Class{}", i % 25)))) + .collect::>(); + types.insert( + (next() % 40) as usize, + LuaType::BooleanConst(next() & 1 == 0), + ); + types.insert( + (next() % 40) as usize, + LuaType::BooleanConst(next() & 1 == 0), + ); + types.insert((next() % 40) as usize, LuaType::Boolean); + if visiting_order_is_observable(&types) { + continue; + } + assert_eq!(union_all_absorbed(types.clone()), fold(types.clone())); + } + } + #[test] fn distinct_class_references_are_not_confused_by_sharing_a_variant() { let types = vec![ 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..1b1360659 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() @@ -132,6 +133,29 @@ pub(crate) fn uninformative_rank(typ: &LuaType) -> Option { } } +/// Whether `typ` carries a template parameter in value position — on its own or +/// as a union arm. +/// +/// Such a parameter is not a type: it records that the generic call it came out +/// of was never instantiated, so an answer carrying one is a placeholder rather +/// than an inference. Deliberately kept out of [`is_informative_type`], which +/// the checkers read — the arms beside the leak are real, so narrowing and +/// nil-checking against them stay sound. +/// +/// Only value positions are checked: a function or table type that is generic +/// *over* a parameter is a real type. +pub fn leaks_unsubstituted_tpl(typ: &LuaType) -> bool { + match typ { + LuaType::TplRef(_) | LuaType::StrTplRef(_) | LuaType::ConstTplRef(_) => true, + LuaType::Union(union) => union.types().any(leaks_unsubstituted_tpl), + LuaType::MultiLineUnion(union) => union + .get_unions() + .iter() + .any(|(typ, _)| leaks_unsubstituted_tpl(typ)), + _ => false, + } +} + /// Total order over the "carries no type information" band: /// [`uninformative_rank`] first, then the nullable variant ahead of the /// bare one. @@ -140,7 +164,7 @@ fn uninformative_key(typ: &LuaType) -> Option<(u8, u8)> { } /// Whether `wider` is the widened primitive of the literal `narrower`. -fn widens_primitive(wider: &LuaType, narrower: &LuaType) -> bool { +pub(crate) fn widens_primitive(wider: &LuaType, narrower: &LuaType) -> bool { matches!( (wider, narrower), ( @@ -165,6 +189,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`]. diff --git a/crates/glua_code_analysis/src/db_index/type/types.rs b/crates/glua_code_analysis/src/db_index/type/types.rs index 1b76a9b6d..81eb12cd8 100644 --- a/crates/glua_code_analysis/src/db_index/type/types.rs +++ b/crates/glua_code_analysis/src/db_index/type/types.rs @@ -321,7 +321,11 @@ impl LuaType { pub fn is_always_truthy(&self) -> bool { match self { - LuaType::Nil | LuaType::Boolean | LuaType::Any | LuaType::Unknown => false, + // `never` has no values to be truthy; it is inference that found + // nothing, and must not decide a branch or swallow an `or` arm. + LuaType::Nil | LuaType::Boolean | LuaType::Any | LuaType::Unknown | LuaType::Never => { + false + } LuaType::BooleanConst(boolean) | LuaType::DocBooleanConst(boolean) => *boolean, LuaType::Union(u) => u.is_always_truthy(), LuaType::TypeGuard(_) => false, @@ -1115,6 +1119,32 @@ impl LuaUnionType { Self::from_vec(types) } + /// Builds a `Multi` union out of members that are already in their final + /// form, skipping the normalisation [`from_vec`](Self::from_vec) applies. + pub(crate) fn from_multi_unchecked(types: Vec) -> Self { + Self::Multi(types) + } + + /// The `T` of a `T|nil` union, or `None` for any other union. + pub fn nullable_inner(&self) -> Option<&LuaType> { + match self { + LuaUnionType::Nullable(ty) => Some(ty), + LuaUnionType::Multi(_) => None, + } + } + + /// Number of members, counting the implicit `nil` of a nullable union. + pub fn len(&self) -> usize { + match self { + LuaUnionType::Nullable(_) => 2, + LuaUnionType::Multi(types) => types.len(), + } + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + pub fn into_vec(&self) -> Vec { match self { LuaUnionType::Nullable(ty) => vec![ty.clone(), LuaType::Nil], @@ -1938,7 +1968,15 @@ impl LuaMappedType { /// `variant_detail` differentiates same-variant entries cheaply /// (e.g., by pointer address for Arc-wrapped types, or by value for Copy types). pub(crate) fn lua_type_sort_key(ty: &LuaType) -> (u8, u64) { - let disc: u8 = match ty { + (lua_type_sort_ordinal(ty), lua_type_sort_detail(ty)) +} + +/// The discriminant half of [`lua_type_sort_key`], on its own. +/// +/// Callers that only compare variants pay nothing for the detail half, which +/// for the complex variants means hashing a `Debug` rendering of the type. +pub(crate) fn lua_type_sort_ordinal(ty: &LuaType) -> u8 { + match ty { LuaType::Nil => 0, LuaType::Boolean => 1, LuaType::BooleanConst(_) => 2, @@ -1990,12 +2028,14 @@ pub(crate) fn lua_type_sort_key(ty: &LuaType) -> (u8, u64) { LuaType::Conditional(_) => 48, LuaType::ConditionalInfer(_) => 49, LuaType::Mapped(_) => 50, - }; + } +} - // For same-variant tiebreaking, use a deterministic identity value. - // Copy types use their value; Arc-wrapped types use content hashing - // for determinism (pointer addresses vary across runs/edits). - let detail: u64 = match ty { +// For same-variant tiebreaking, use a deterministic identity value. +// Copy types use their value; Arc-wrapped types use content hashing +// for determinism (pointer addresses vary across runs/edits). +fn lua_type_sort_detail(ty: &LuaType) -> u64 { + match ty { LuaType::BooleanConst(b) => *b as u64, LuaType::IntegerConst(n) => *n as u64, LuaType::DocIntegerConst(n) => *n as u64, @@ -2031,10 +2071,81 @@ pub(crate) fn lua_type_sort_key(ty: &LuaType) -> (u8, u64) { | LuaType::Io => 0, // Arc-wrapped complex types: hash Debug representation for determinism. // Pointer addresses are non-deterministic across runs/edits. - _ => hash_str_content(&format!("{ty:?}")), - }; + _ => memoized_debug_detail(ty), + } +} - (disc, detail) +/// [`lua_type_sort_detail`]'s `Debug` hash, computed once per allocation. +/// +/// Rendering a whole type through `Debug` to hash it is the expensive half of +/// the sort key, and a union re-sorts the same members on every rebuild. The +/// hash is a pure function of the type's contents, which are immutable, so it +/// is cached against the identity of the allocation the type points at. The +/// cache owns a clone of the type, which keeps that allocation alive: no other +/// live type can reuse the address while it is a key, and dropping an entry +/// drops the type with it. Nothing derived from the address leaves this +/// function. +fn memoized_debug_detail(ty: &LuaType) -> u64 { + /// Entries are dropped wholesale rather than individually, so a session that + /// churns through types cannot grow the cache without bound. + const CAPACITY: usize = 1 << 16; + + thread_local! { + static MEMO: std::cell::RefCell> = + Default::default(); + } + + let Some(address) = payload_address(ty) else { + return hash_str_content(&format!("{ty:?}")); + }; + // Two variants can wrap the same allocation (`TplRef` and `ConstTplRef`), + // and they render differently. + let id = (lua_type_sort_ordinal(ty), address); + + MEMO.with(|memo| { + if let Some((_, detail)) = memo.borrow().get(&id) { + return *detail; + } + let detail = hash_str_content(&format!("{ty:?}")); + let mut memo = memo.borrow_mut(); + if memo.len() >= CAPACITY { + memo.clear(); + } + memo.insert(id, (ty.clone(), detail)); + detail + }) +} + +/// The address of the allocation a complex type points at, or `None` for the +/// variants that hold their payload inline. +fn payload_address(ty: &LuaType) -> Option { + fn addr(ptr: *const T) -> usize { + ptr as usize + } + + Some(match ty { + LuaType::Array(a) => addr(Arc::as_ptr(a)), + LuaType::Tuple(a) => addr(Arc::as_ptr(a)), + LuaType::DocFunction(a) => addr(Arc::as_ptr(a)), + LuaType::Object(a) => addr(Arc::as_ptr(a)), + LuaType::Union(a) => addr(Arc::as_ptr(a)), + LuaType::Intersection(a) => addr(Arc::as_ptr(a)), + LuaType::MergedTable(a) => addr(Arc::as_ptr(a)), + LuaType::Generic(a) => addr(Arc::as_ptr(a)), + LuaType::TableGeneric(a) => addr(Arc::as_ptr(a)), + LuaType::TplRef(a) | LuaType::ConstTplRef(a) => addr(Arc::as_ptr(a)), + LuaType::StrTplRef(a) => addr(Arc::as_ptr(a)), + LuaType::Variadic(a) => addr(Arc::as_ptr(a)), + LuaType::Instance(a) => addr(Arc::as_ptr(a)), + LuaType::Call(a) => addr(Arc::as_ptr(a)), + LuaType::MultiLineUnion(a) => addr(Arc::as_ptr(a)), + LuaType::TypeGuard(a) => addr(Arc::as_ptr(a)), + LuaType::DocAttribute(a) => addr(Arc::as_ptr(a)), + LuaType::Conditional(a) => addr(Arc::as_ptr(a)), + LuaType::Mapped(a) => addr(Arc::as_ptr(a)), + LuaType::TableOf(a) => addr(a.as_ref() as *const LuaType), + _ => return None, + }) } /// Deterministic hash of a string's content for use as a sort key detail. @@ -2178,4 +2289,30 @@ mod tests { ); } } + + /// The sort key's `Debug` hash is memoised against the address of the type's + /// allocation, which only holds while the cache pins that allocation: two + /// equal types allocated apart still key alike, and two variants sharing one + /// allocation still key apart. + #[test] + fn memoised_sort_keys_follow_contents_not_allocations() { + let build = || LuaType::TableGeneric(Arc::new(vec![LuaType::String, LuaType::Integer])); + let (left, right) = (build(), build()); + assert_eq!(lua_type_sort_key(&left), lua_type_sort_key(&right)); + assert_eq!( + lua_type_sort_key(&left).1, + hash_str_content(&format!("{left:?}")), + "memoised detail drifted from the hash it caches" + ); + + let shared = Arc::new(GenericTpl::new( + GenericTplId::Type(3), + ArcIntern::new(SmolStr::new("T")), + None, + )); + assert_ne!( + lua_type_sort_key(&LuaType::TplRef(shared.clone())).1, + lua_type_sort_key(&LuaType::ConstTplRef(shared)).1 + ); + } } 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..20610c8d5 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,19 +189,34 @@ 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. - let source_type = pre_write_index_expr_type(semantic_model, index_expr).or_else(|| { - infer_index_expr( - semantic_model.get_db(), - &mut semantic_model.get_cache().borrow_mut(), - index_expr.clone(), - false, - ) - .ok() - }); + let source_type = pre_write_index_expr_type(semantic_model, index_expr) + .or_else(|| prior_writers_type(semantic_model, index_expr)) + .or_else(|| { + infer_index_expr( + semantic_model.get_db(), + &mut semantic_model.get_cache().borrow_mut(), + index_expr.clone(), + false, + ) + .ok() + }); let value_type = semantic_model.infer_expr_list_value_type_at(exprs, value_idx)?; check_assign_type_mismatch( @@ -265,43 +280,61 @@ fn is_only_current_inferred_member_assignment( let Some(owner) = member_index.get_member_owner(&member_id) else { return false; }; - !has_visible_prior_member_for_owner_key( - semantic_model, - owner, - member.get_key(), - member_id, - index_expr.get_range().start(), - ) + // Every writer of the slot is visible and a reader unions them, so a write + // of a new type widens an inferred slot rather than violating it. Only a + // doc-typed sibling states a type this write can mismatch. + !member_index + .get_current_owner_members_for_key(owner, member.get_key()) + .into_iter() + .filter(|sibling| sibling.get_id() != member_id) + .any(|sibling| { + semantic_model + .get_db() + .get_type_index() + .get_type_cache(&sibling.get_id().into()) + .is_some_and(|cache| cache.is_doc()) + }) } -fn has_visible_prior_member_for_owner_key( +/// A class field's type as the writers visible before this write left it. The +/// slot itself unions every writer, this one included, so reading it back +/// would compare the write against itself. An anonymous table's fields are +/// shaped by all their writes, so only class-owned fields are held to this. +fn prior_writers_type( semantic_model: &SemanticModel, - owner: &crate::LuaMemberOwner, - member_key: &LuaMemberKey, - current_member_id: crate::LuaMemberId, - position: rowan::TextSize, -) -> bool { - let member_ids = semantic_model - .get_db() - .get_member_index() - .get_current_owner_members_for_key(owner, member_key) + index_expr: &LuaIndexExpr, +) -> Option { + let db = semantic_model.get_db(); + let member_id = + crate::LuaMemberId::new(index_expr.get_syntax_id(), semantic_model.get_file_id()); + let member_index = db.get_member_index(); + let member = member_index.get_member(&member_id)?; + let owner = member_index.get_member_owner(&member_id)?; + if !matches!(owner, crate::LuaMemberOwner::Type(_)) { + return None; + } + let position = index_expr.get_range().start(); + let prior_ids = member_index + .get_current_owner_members_for_key(owner, member.get_key()) .into_iter() - .filter(|member| { - let member_id = member.get_id(); - member_id != current_member_id - && (member_id.file_id != semantic_model.get_file_id() - || member_id.get_position() < position) + .map(|sibling| sibling.get_id()) + .filter(|sibling_id| { + *sibling_id != member_id + && (sibling_id.file_id != member_id.file_id || sibling_id.get_position() < position) }) - .map(|member| member.get_id()) - .collect(); - - !crate::LuaMemberIndexItem::Many(member_ids) + .collect::>(); + let visible_ids = crate::LuaMemberIndexItem::Many(prior_ids) .visible_member_ids_with_realm_at_offset_from_history( - semantic_model.get_db(), + db, &semantic_model.get_file_id(), position, - ) - .is_empty() + ); + if visible_ids.is_empty() { + return None; + } + crate::LuaMemberIndexItem::Many(visible_ids) + .resolve_type(db) + .ok() } /// Resolve the **pre-write** source type for an indexed assignment target by @@ -702,7 +735,14 @@ fn is_inferred_collection_member_type(db: &DbIndex, typ: &LuaType) -> bool { fn is_lenient_inferred_member_type(db: &DbIndex, typ: &LuaType) -> bool { match typ { - LuaType::Nil | LuaType::Unknown | LuaType::Never | LuaType::Array(_) => true, + // An inferred `false` is the Lua idiom for "not set yet", the same role + // `nil` plays here, so it is evidence rather than a contract. `true` is + // a positive assertion and stays strict, as does a declared boolean. + LuaType::Nil + | LuaType::Unknown + | LuaType::Never + | LuaType::Array(_) + | LuaType::BooleanConst(false) => true, LuaType::Tuple(tuple) => tuple.is_infer_resolve(), // Shaped sequential literals infer as TableConst and are mutable dynamic // tables, so later modification must not be flagged. Object/keyed diff --git a/crates/glua_code_analysis/src/diagnostic/checker/attribute_check.rs b/crates/glua_code_analysis/src/diagnostic/checker/attribute_check.rs index ba2ebf3af..015146b45 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/attribute_check.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/attribute_check.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use rustc_hash::FxHashSet; use crate::{ DiagnosticCode, DocTypeInferContext, LuaType, SemanticModel, TypeCheckFailReason, @@ -216,7 +216,7 @@ fn add_type_check_diagnostic( fn is_nullable(typ: &LuaType) -> bool { let mut stack: Vec = Vec::new(); stack.push(typ.clone()); - let mut visited = HashSet::new(); + let mut visited = FxHashSet::default(); while let Some(typ) = stack.pop() { if visited.contains(&typ) { continue; diff --git a/crates/glua_code_analysis/src/diagnostic/checker/cast_type_mismatch.rs b/crates/glua_code_analysis/src/diagnostic/checker/cast_type_mismatch.rs index 1cd6deb99..d3cb953a3 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/cast_type_mismatch.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/cast_type_mismatch.rs @@ -1,5 +1,6 @@ use glua_parser::{LuaAst, LuaAstNode, LuaDocTagCast}; use rowan::TextRange; +use rustc_hash::FxHashSet; use std::collections::HashSet; use crate::{ @@ -211,14 +212,14 @@ fn cast_type_check( } fn expand_type(db: &DbIndex, typ: &LuaType) -> Option { - let mut visited = HashSet::new(); + let mut visited = FxHashSet::default(); expand_type_recursive(db, typ, &mut visited) } fn expand_type_recursive( db: &DbIndex, typ: &LuaType, - visited: &mut HashSet, + visited: &mut FxHashSet, ) -> Option { // TODO: 优化性能 // 防止无限递归, 性能很有问题, 但 @cast 使用频率不高, 这是可以接受的 @@ -247,7 +248,7 @@ fn expand_type_recursive( } LuaType::Union(union_type) => { // 递归展开 union 中的每个类型 - let mut expanded_types = HashSet::new(); + let mut expanded_types = HashSet::default(); let mut has_nil = false; for inner_type in union_type.types() { if inner_type.is_nil() { diff --git a/crates/glua_code_analysis/src/diagnostic/checker/check_export.rs b/crates/glua_code_analysis/src/diagnostic/checker/check_export.rs index e0d64fcdf..523f35080 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/check_export.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/check_export.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use glua_parser::{ LuaAst, LuaAstNode, LuaCallExpr, LuaExpr, LuaIndexExpr, LuaSyntaxKind, LuaVarExpr, @@ -20,8 +20,8 @@ impl Checker for CheckExportChecker { fn check(context: &mut DiagnosticContext, semantic_model: &SemanticModel) { let root = semantic_model.get_root().clone(); - let mut checked_index_expr = HashSet::new(); - let mut exported_key_cache: ExportedKeyCache = HashMap::new(); + let mut checked_index_expr = HashSet::default(); + let mut exported_key_cache: ExportedKeyCache = HashMap::default(); for node in root.descendants::() { if context.is_cancelled() { return; @@ -266,10 +266,10 @@ fn module_source_declares_exported_key( return false; }; - let mut exported_local_names = HashSet::new(); - let mut exported_keys = HashSet::new(); - let mut local_table_init_keys: HashMap> = HashMap::new(); - let mut local_assigned_keys: HashMap> = HashMap::new(); + let mut exported_local_names = HashSet::default(); + let mut exported_keys = HashSet::default(); + let mut local_table_init_keys: HashMap> = HashMap::default(); + let mut local_assigned_keys: HashMap> = HashMap::default(); for node in module_root.descendants().filter_map(LuaAst::cast) { match node { 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..2d83ffd90 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/check_field.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/check_field.rs @@ -1,7 +1,5 @@ -use std::{ - collections::{HashMap, HashSet}, - time::Duration, -}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; +use std::time::Duration; use glua_parser::{ LuaAssignStat, LuaAst, LuaAstNode, LuaBinaryExpr, LuaCallExpr, LuaElseIfClauseStat, LuaExpr, @@ -13,7 +11,7 @@ use smol_str::SmolStr; use crate::{ DbIndex, DiagnosticCode, FileId, GlobalId, InferFailReason, LuaAliasCallKind, LuaAliasCallType, - LuaInferenceConfidence, LuaMemberKey, LuaMemberOwner, LuaType, LuaUnionType, SemanticModel, + LuaInferenceConfidence, LuaMemberKey, LuaMemberOwner, LuaType, SemanticModel, check_type_compact, enum_variable_is_param, get_keyof_members, get_real_type, semantic::{ infer_owner_raw_member_type_with_realm, infer_param_is_weak, is_doc_tag_table_const, @@ -38,13 +36,13 @@ impl Checker for CheckFieldChecker { fn check(context: &mut DiagnosticContext, semantic_model: &SemanticModel) { let root = semantic_model.get_root().clone(); - let mut checked_index_expr = HashSet::new(); + let mut checked_index_expr = HashSet::default(); let assignment_prefixes = context.get_assignment_prefix_events(&root); let initialized_assignment_accesses = if has_reusable_table_literal_assignment(&assignment_prefixes) { collect_initialized_assignment_accesses(&root, &assignment_prefixes) } else { - HashSet::new() + HashSet::default() }; let mut state = CheckFieldState::default(); let profile_enabled = log::log_enabled!(log::Level::Info); @@ -190,7 +188,7 @@ fn collect_initialized_assignment_accesses( root: &glua_parser::LuaChunk, assignment_prefixes: &AssignmentPrefixEvents, ) -> HashSet { - let mut initialized_accesses = HashSet::new(); + let mut initialized_accesses = HashSet::default(); for assign in root.descendants::() { let (vars, _) = assign.get_var_and_expr_list(); for var in vars { @@ -447,10 +445,10 @@ fn is_invalid_prefix_type(typ: &LuaType) -> bool { // Treating it as invalid suppresses diagnostics like typed-object key mismatches. LuaType::TableConst(_) => return false, LuaType::Union(union) => { - return match union.as_ref() { - LuaUnionType::Nullable(typ) => typ.is_nil() || is_invalid_prefix_type(typ), - LuaUnionType::Multi(types) => types - .iter() + return match union.nullable_inner() { + Some(typ) => typ.is_nil() || is_invalid_prefix_type(typ), + None => union + .types() .all(|typ| typ.is_nil() || is_invalid_prefix_type(typ)), }; } @@ -512,6 +510,7 @@ fn has_unresolved_metatable_index( &index_member_key, semantic_model.get_file_id(), position, + None, ); // A present but unresolved Lua `__index` can provide fields at runtime, so field @@ -521,11 +520,9 @@ fn has_unresolved_metatable_index( LuaType::Instance(instance) => { has_unresolved_metatable_index(db, semantic_model, instance.get_base(), position) } - LuaType::Union(union) => match union.as_ref() { - LuaUnionType::Nullable(typ) => { - has_unresolved_metatable_index(db, semantic_model, typ, position) - } - LuaUnionType::Multi(types) => types.iter().any(|typ| { + LuaType::Union(union) => match union.nullable_inner() { + Some(typ) => has_unresolved_metatable_index(db, semantic_model, typ, position), + None => union.types().any(|typ| { !typ.is_nil() && has_unresolved_metatable_index(db, semantic_model, typ, position) }), }, @@ -693,6 +690,7 @@ fn is_valid_member_inner( &key, semantic_model.get_file_id(), Some(index_expr.get_position()), + None, ) { Ok(_) => return Some(()), Err(InferFailReason::FieldNotFound) @@ -1080,9 +1078,9 @@ fn check_enum_self_reference( } fn get_prefix_types(context: &DiagnosticContext, prefix_typ: &LuaType) -> HashSet { - let mut type_set = HashSet::new(); + let mut type_set = HashSet::default(); let mut stack = vec![prefix_typ.clone()]; - let mut visited = HashSet::new(); + let mut visited = HashSet::default(); while let Some(current_type) = stack.pop() { if context.is_cancelled() { @@ -1108,9 +1106,9 @@ fn get_prefix_types(context: &DiagnosticContext, prefix_typ: &LuaType) -> HashSe } fn get_key_types(context: &DiagnosticContext, db: &DbIndex, typ: &LuaType) -> HashSet { - let mut type_set = HashSet::new(); + let mut type_set = HashSet::default(); let mut stack = vec![typ.clone()]; - let mut visited = HashSet::new(); + let mut visited = HashSet::default(); while let Some(current_type) = stack.pop() { if context.is_cancelled() { @@ -1519,8 +1517,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, } } @@ -2242,7 +2240,10 @@ fn is_shapeless_table_const(db: &DbIndex, typ: &LuaType) -> bool { .is_none_or(|members| members.is_empty()) && db .get_dynamic_field_index() - .get_fields(&crate::DynamicFieldOwner::Table(table_range.clone())) + .get_fields(&crate::canonical_dynamic_field_owner( + db, + crate::DynamicFieldOwner::Table(table_range.clone()), + )) .is_none_or(|fields| fields.is_empty()) } @@ -2297,7 +2298,10 @@ fn has_dynamic_field_for_type( }) } LuaType::TableConst(table_range) => { - let owner = crate::DynamicFieldOwner::Table(table_range.clone()); + let owner = crate::canonical_dynamic_field_owner( + db, + crate::DynamicFieldOwner::Table(table_range.clone()), + ); index.has_field(&owner, field_name) || owner_wildcard_covers_any_field(db, &owner) } LuaType::Instance(instance) => { 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..7e9ac8100 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 @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use rustc_hash::FxHashSet; use glua_parser::{ LuaAst, LuaAstNode, LuaAstToken, LuaCallExpr, LuaClosureExpr, LuaExpr, LuaForRangeStat, @@ -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; @@ -376,7 +406,7 @@ fn get_params_len(params: &[(String, Option)]) -> Option { fn is_nullable(db: &DbIndex, typ: &LuaType) -> bool { let mut stack: Vec = Vec::new(); stack.push(typ.clone()); - let mut visited = HashSet::new(); + let mut visited = FxHashSet::default(); while let Some(typ) = stack.pop() { if visited.contains(&typ) { continue; diff --git a/crates/glua_code_analysis/src/diagnostic/checker/circle_doc_class.rs b/crates/glua_code_analysis/src/diagnostic/checker/circle_doc_class.rs index 646bb3316..5cc18306a 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/circle_doc_class.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/circle_doc_class.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use rustc_hash::FxHashSet; use glua_parser::{LuaAstNode, LuaAstToken, LuaDocTagClass}; use rowan::TextRange; @@ -46,7 +46,7 @@ fn check_doc_tag_class( let name = class_decl.get_full_name(); let mut queue = Vec::new(); - let mut visited = HashSet::new(); + let mut visited = FxHashSet::default(); queue.push(class_decl.get_id()); while let Some(current_id) = queue.pop() { diff --git a/crates/glua_code_analysis/src/diagnostic/checker/code_style/preferred_local_alias.rs b/crates/glua_code_analysis/src/diagnostic/checker/code_style/preferred_local_alias.rs index c8fc67757..6a347b0da 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/code_style/preferred_local_alias.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/code_style/preferred_local_alias.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use glua_parser::{ LuaAst, LuaAstNode, LuaAstToken, LuaExpr, LuaIndexExpr, LuaLocalStat, LuaSyntaxKind, PathTrait, @@ -186,13 +186,13 @@ struct LocalAliasInfo { impl LocalAliasSet { fn new() -> Self { LocalAliasSet { - local_alias_stack: vec![HashMap::new()], - disable_check: HashSet::new(), + local_alias_stack: vec![HashMap::default()], + disable_check: HashSet::default(), } } fn push(&mut self) { - self.local_alias_stack.push(HashMap::new()); + self.local_alias_stack.push(HashMap::default()); } fn pop(&mut self) { 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..fd19d8b31 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/duplicate_field.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/duplicate_field.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use glua_parser::{ LuaAstNode, LuaDocTagClass, LuaDocTagField, LuaIndexExpr, LuaStat, LuaSyntaxKind, LuaSyntaxNode, @@ -47,7 +47,7 @@ fn get_decl_set( .get_db() .get_decl_index() .get_decl_tree(&file_id)?; - let mut type_decl_id_set = HashSet::new(); + let mut type_decl_id_set = HashSet::default(); for (decl_id, decl) in decl_tree.get_decls() { if context.is_cancelled() { return Some(type_decl_id_set); @@ -126,7 +126,7 @@ fn check_decl_duplicate_field( .get_member_index() .get_members(&type_decl.get_id().into())?; - let mut member_map: HashMap<&LuaMemberKey, Vec<&LuaMember>> = HashMap::new(); + let mut member_map: HashMap<&LuaMemberKey, Vec<&LuaMember>> = HashMap::default(); for member in members.iter() { if context.is_cancelled() { @@ -178,8 +178,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) + matches!(info.typ, LuaType::Signature(_)) && !info.member.is_assignment_define() }); if signatures.clone().count() > 1 { for signature in signatures { @@ -233,11 +232,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/duplicate_index.rs b/crates/glua_code_analysis/src/diagnostic/checker/duplicate_index.rs index a20a1355d..6113d3d5d 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/duplicate_index.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/duplicate_index.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use rustc_hash::FxHashMap; use glua_parser::{LuaAstNode, LuaIndexKey, LuaTableExpr}; @@ -37,7 +37,7 @@ fn check_table_duplicate_index( return Some(()); } - let mut index_map: HashMap> = HashMap::new(); + let mut index_map: FxHashMap> = FxHashMap::default(); for field in fields { if context.is_cancelled() { diff --git a/crates/glua_code_analysis/src/diagnostic/checker/gmod_network.rs b/crates/glua_code_analysis/src/diagnostic/checker/gmod_network.rs index 524fd4091..4f6d2b70a 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/gmod_network.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/gmod_network.rs @@ -1,7 +1,5 @@ use std::sync::Arc; -use rustc_hash::FxHashMap; - use crate::{ DiagnosticCode, FileId, GmodRealm, NetOpEntry, NetReceiveFlow, NetSendFlow, SemanticModel, flows_can_match, is_opposite_strict_realm_pair, is_strict_realm, @@ -195,7 +193,8 @@ pub struct SenderSortKey { /// Precomputed send flows sorted by SenderSortKey for each message name. /// Built once per diagnostic batch run instead of per file. -pub type SortedSendFlowCache = FxHashMap>; +pub type SortedSendFlowCache = + std::collections::HashMap>; /// Precompute and sort all send flows by message name, using VFS path /// ordering so that all files in a workspace sort consistently. Called @@ -204,7 +203,7 @@ pub fn precompute_sorted_send_flows( network_index: &crate::GmodNetworkIndex, vfs: &crate::vfs::Vfs, ) -> SortedSendFlowCache { - let mut cache: SortedSendFlowCache = FxHashMap::default(); + let mut cache: SortedSendFlowCache = SortedSendFlowCache::default(); for (file_id, flow_idx, send_flow) in network_index.iter_send_flows() { let sort_key = sender_sort_key(vfs, file_id, flow_idx, send_flow); cache @@ -466,7 +465,7 @@ fn check_bits_mismatch( sorted_send_flows: &SortedSendFlowCache, infer_index: &crate::GmodInferIndex, ) { - use std::collections::HashSet; + use rustc_hash::FxHashSet; for receive_flow in receive_flows { if receive_flow.reads_opaque { @@ -483,7 +482,7 @@ fn check_bits_mismatch( continue; }; - let mut reported: HashSet<(usize, u32, u32)> = HashSet::new(); + let mut reported: FxHashSet<(usize, u32, u32)> = FxHashSet::default(); for (send_file_id, send_flow, _) in matching_send_flows { if send_flow.is_wrapped { diff --git a/crates/glua_code_analysis/src/diagnostic/checker/gmod_realm_misuse.rs b/crates/glua_code_analysis/src/diagnostic/checker/gmod_realm_misuse.rs index c126cf34a..58a18a386 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/gmod_realm_misuse.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/gmod_realm_misuse.rs @@ -1,13 +1,11 @@ use rustc_hash::FxHashMap; -use std::collections::HashSet; +use rustc_hash::FxHashSet; +use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; -use glua_parser::{ - LuaAstNode, LuaCallExpr, LuaCommentOwner, LuaExpr, LuaFuncStat, LuaIndexExpr, LuaIndexKey, - LuaLocalFuncStat, PathTrait, -}; -use rowan::{NodeOrToken, TextRange, TextSize}; +use glua_parser::{LuaAstNode, LuaCallExpr, LuaExpr, LuaIndexExpr, LuaIndexKey, PathTrait}; +use rowan::{NodeOrToken, TextSize}; use crate::{ DiagnosticCode, FileId, GmodRealm, GmodRealmFileMetadata, GmodStateMask, LuaDeclarationTree, @@ -16,10 +14,9 @@ use crate::{ }; use super::{Checker, DiagnosticContext}; -use crate::compilation::analyzer::gmod::realm_from_doc_comment; /// Immutable, workspace-scoped callee realm map keyed by semantic declaration. -pub type PrecomputedCalleeRealmMap = FxHashMap>; +pub type PrecomputedCalleeRealmMap = HashMap>; #[derive(Debug, Default)] pub struct PrecomputedRealmCallCandidates { @@ -135,7 +132,6 @@ impl Checker for GmodRealmMisuseChecker { } }; - let mut decl_annotation_cache: DeclAnnotationRealmCache = FxHashMap::default(); let mut callee_realm_cache: CalleeRealmCache = FxHashMap::default(); let mut member_candidate_cache: MemberCandidateCache = FxHashMap::default(); let mut owner_key_member_candidate_cache: OwnerKeyMemberCandidateCache = @@ -195,7 +191,6 @@ impl Checker for GmodRealmMisuseChecker { &call_expr, call_realm, &gm_method_realms, - &mut decl_annotation_cache, &mut decl_realm_cache, &mut callee_realm_cache, &mut member_candidate_cache, @@ -294,8 +289,7 @@ impl Checker for GmodRealmMisuseChecker { } } - if let Some(mut profile) = profile { - profile.annotation_cache_files_loaded = decl_annotation_cache.len(); + if let Some(profile) = profile { profile.log(file_id); } } @@ -318,14 +312,13 @@ struct GmodRealmMisuseProfile { member_candidate_cache_misses: usize, member_candidate_time: Duration, call_realm_resolution_time: Duration, - annotation_cache_files_loaded: usize, callee_resolution_time: Duration, } impl GmodRealmMisuseProfile { fn log(&self, file_id: FileId) { log::info!( - "gmod realm misuse profile: file={:?} calls_scanned={} calls_checked={} shared_skips={} static_skips={} empty_callee={} diagnostics={} gm_fast_hits={} decl_cache_hits={} decl_cache_misses={} precomputed_hits={} member_realms_cache_hits={} member_cache_hits={} member_cache_misses={} member_time={:?} call_realm_time={:?} annotation_files_loaded={} callee_time={:?}", + "gmod realm misuse profile: file={:?} calls_scanned={} calls_checked={} shared_skips={} static_skips={} empty_callee={} diagnostics={} gm_fast_hits={} decl_cache_hits={} decl_cache_misses={} precomputed_hits={} member_realms_cache_hits={} member_cache_hits={} member_cache_misses={} member_time={:?} call_realm_time={:?} callee_time={:?}", file_id, self.calls_scanned, self.calls_checked, @@ -342,7 +335,6 @@ impl GmodRealmMisuseProfile { self.member_candidate_cache_misses, self.member_candidate_time, self.call_realm_resolution_time, - self.annotation_cache_files_loaded, self.callee_resolution_time, ); } @@ -401,14 +393,7 @@ impl ResolvedRealm { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub struct AnnotatedRealmRange { - pub range: TextRange, - pub realm: GmodRealm, -} - -pub type GmMethodRealmMap = FxHashMap>; -type DeclAnnotationRealmCache = FxHashMap>; +pub type GmMethodRealmMap = HashMap>; type CalleeRealmCache = FxHashMap>; type MemberCandidateCache = FxHashMap<(LuaType, LuaMemberKey, bool), Vec>; type OwnerKeyMemberCandidateCache = FxHashMap<(LuaMemberOwner, LuaMemberKey), Vec>; @@ -452,7 +437,6 @@ fn resolve_callee_realms( call_expr: &LuaCallExpr, call_realm: ResolvedRealm, gm_method_realms: &GmMethodRealmMap, - decl_annotation_cache: &mut DeclAnnotationRealmCache, decl_realm_cache: &mut DeclRealmCache, callee_realm_cache: &mut CalleeRealmCache, member_candidate_cache: &mut MemberCandidateCache, @@ -469,7 +453,7 @@ fn resolve_callee_realms( let is_bare_name_call = matches!(prefix_expr, LuaExpr::NameExpr(_)); - // Fast path: GM method annotations (O(1) HashMap lookup, no inference needed). + // Fast path: GM method annotations (O(1) hash lookup, no inference needed). // Only applies to member calls (index expressions like GM:Method or GAMEMODE.Method). if !is_bare_name_call { if let Some(index_expr) = LuaIndexExpr::cast(prefix_expr.syntax().clone()) { @@ -520,7 +504,7 @@ fn resolve_callee_realms( } } - // Resolve realms: try precomputed first (O(1) HashMap lookup), then + // Resolve realms: try precomputed first (O(1) hash lookup), then // fall back to expensive member/global resolution paths. let mut realms = Vec::new(); @@ -549,7 +533,6 @@ fn resolve_callee_realms( semantic_model, &prefix_expr, decl, - decl_annotation_cache, decl_realm_cache, precomputed_callee_realms, ) { @@ -571,7 +554,6 @@ fn resolve_callee_realms( semantic_model, call_expr, semantic_decl.as_ref(), - decl_annotation_cache, decl_realm_cache, member_candidate_cache, owner_key_member_candidate_cache, @@ -591,10 +573,8 @@ fn resolve_callee_realms( if realms.is_empty() && !is_bare_name_call { if let Some(ref decl) = semantic_decl { if let Some(realm) = resolve_decl_realm_cached( - context, semantic_model, decl, - decl_annotation_cache, decl_realm_cache, precomputed_callee_realms, ) { @@ -605,10 +585,8 @@ fn resolve_callee_realms( && let Some(origin_owner) = semantic_model.get_member_origin_owner(member_id) { if let Some(realm) = resolve_decl_realm_cached( - context, semantic_model, &origin_owner, - decl_annotation_cache, decl_realm_cache, precomputed_callee_realms, ) { @@ -636,7 +614,6 @@ fn resolve_global_name_candidate_realms( semantic_model: &SemanticModel, prefix_expr: &LuaExpr, semantic_decl: &LuaSemanticDeclId, - decl_annotation_cache: &mut DeclAnnotationRealmCache, decl_realm_cache: &mut DeclRealmCache, precomputed_callee_realms: Option<&PrecomputedCalleeRealmMap>, ) -> Vec { @@ -670,10 +647,8 @@ fn resolve_global_name_candidate_realms( continue; }; if let Some(realm) = resolve_decl_realm_cached( - context, semantic_model, &property_owner_id, - decl_annotation_cache, decl_realm_cache, precomputed_callee_realms, ) { @@ -690,7 +665,6 @@ fn resolve_member_candidate_realms( semantic_model: &SemanticModel, call_expr: &LuaCallExpr, semantic_decl: Option<&LuaSemanticDeclId>, - decl_annotation_cache: &mut DeclAnnotationRealmCache, decl_realm_cache: &mut DeclRealmCache, member_candidate_cache: &mut MemberCandidateCache, owner_key_member_candidate_cache: &mut OwnerKeyMemberCandidateCache, @@ -756,7 +730,7 @@ fn resolve_member_candidate_realms( && resolved_member.get_key() == &member_key && let Some(resolved_owner) = member_index.get_current_owner(resolved_member_id) { - let mut seen: HashSet = all_member_ids.iter().copied().collect(); + let mut seen: FxHashSet = all_member_ids.iter().copied().collect(); push_cached_member_ids_for_owner_key( member_index, resolved_owner, @@ -774,10 +748,8 @@ fn resolve_member_candidate_realms( } let property_owner_id = LuaSemanticDeclId::Member(member_id); if let Some(realm) = resolve_decl_realm_cached( - context, semantic_model, &property_owner_id, - decl_annotation_cache, decl_realm_cache, precomputed_callee_realms, ) { @@ -812,21 +784,15 @@ fn is_gmod_baseclass_receiver(semantic_model: &SemanticModel, owner_expr: &LuaEx } fn resolve_decl_realm( - context: &DiagnosticContext, semantic_model: &SemanticModel, semantic_decl: &LuaSemanticDeclId, - decl_annotation_cache: &mut DeclAnnotationRealmCache, ) -> Option { let (decl_file_id, decl_offset) = semantic_decl_position(semantic_decl)?; let infer_index = semantic_model.get_db().get_gmod_infer_index(); let metadata = infer_index.get_realm_file_metadata(&decl_file_id)?; - if let Some(annotation_realm) = resolve_decl_annotation_realm_at_offset( - context, - semantic_model, - &decl_file_id, - decl_offset, - decl_annotation_cache, - ) { + if let Some(annotation_realm) = + resolve_decl_annotation_realm_at_offset(semantic_model, &decl_file_id, decl_offset) + { return Some(ResolvedRealm::new( annotation_realm, RealmEvidence::ExplicitAnnotation, @@ -839,10 +805,8 @@ fn resolve_decl_realm( /// Cached wrapper around `resolve_decl_realm`. The result (including `None`) is memoized /// per `LuaSemanticDeclId` so that the same member/decl is never resolved twice in a file pass. fn resolve_decl_realm_cached( - context: &DiagnosticContext, semantic_model: &SemanticModel, semantic_decl: &LuaSemanticDeclId, - decl_annotation_cache: &mut DeclAnnotationRealmCache, decl_realm_cache: &mut DeclRealmCache, precomputed_callee_realms: Option<&PrecomputedCalleeRealmMap>, ) -> Option { @@ -858,182 +822,29 @@ fn resolve_decl_realm_cached( decl_realm_cache.insert(semantic_decl.clone(), Some(resolved)); return Some(resolved); } - let result = resolve_decl_realm( - context, - semantic_model, - semantic_decl, - decl_annotation_cache, - ); + let result = resolve_decl_realm(semantic_model, semantic_decl); decl_realm_cache.insert(semantic_decl.clone(), result); result } fn resolve_decl_annotation_realm_at_offset( - context: &DiagnosticContext, semantic_model: &SemanticModel, file_id: &FileId, offset: TextSize, - decl_annotation_cache: &mut DeclAnnotationRealmCache, ) -> Option { - if let Some(file_entries) = context - .get_shared_data() - .and_then(|shared_data| shared_data.decl_annotation_realms.get(file_id)) - { - return file_entries - .iter() - .find(|entry| entry.range.contains(offset)) - .map(|entry| entry.realm); - } - - // Fast path: GmodInferIndex already holds the per-file member realm ranges computed - // during gmod_pre. Prefer its O(log n) binary-search over an AST re-walk. - let infer_index = semantic_model.get_db().get_gmod_infer_index(); - if infer_index.has_member_realm_ranges(file_id) { - return infer_index.get_member_annotation_realm_at_offset(file_id, offset); - } - - let file_entries = decl_annotation_cache - .entry(file_id.clone()) - .or_insert_with(|| { - collect_decl_annotation_realms_for_file(context, semantic_model, file_id) - }); - - file_entries - .iter() - .find(|entry| entry.range.contains(offset)) - .map(|entry| entry.realm) + semantic_model + .get_db() + .get_gmod_infer_index() + .get_member_annotation_realm_at_offset(file_id, offset) } fn resolve_decl_annotation_realm_at_offset_from_db( db: &crate::DbIndex, file_id: &FileId, offset: TextSize, - decl_annotation_cache: &mut DeclAnnotationRealmCache, ) -> Option { - // Fast path: GmodInferIndex already holds the per-file member realm ranges computed - // during gmod_pre. Prefer its O(log n) binary-search over an AST re-walk. - let infer_index = db.get_gmod_infer_index(); - if infer_index.has_member_realm_ranges(file_id) { - return infer_index.get_member_annotation_realm_at_offset(file_id, offset); - } - - let file_entries = decl_annotation_cache - .entry(*file_id) - .or_insert_with(|| collect_decl_annotation_realms_for_file_from_db(db, file_id)); - - file_entries - .iter() - .find(|entry| entry.range.contains(offset)) - .map(|entry| entry.realm) -} - -fn collect_decl_annotation_realms_for_file( - context: &DiagnosticContext, - semantic_model: &SemanticModel, - file_id: &FileId, -) -> Vec { - let Some(tree) = semantic_model.get_db().get_vfs().get_syntax_tree(file_id) else { - return Vec::new(); - }; - - let mut realms = Vec::new(); - for func_stat in tree.get_chunk_node().descendants::() { - if context.is_cancelled() { - return realms; - } - if let Some(comment) = func_stat.get_left_comment() - && let Some(realm) = realm_from_doc_comment(&comment) - { - realms.push(AnnotatedRealmRange { - range: func_stat.get_range(), - realm, - }); - } - } - - for local_func_stat in tree.get_chunk_node().descendants::() { - if context.is_cancelled() { - return realms; - } - if let Some(comment) = local_func_stat.get_left_comment() - && let Some(realm) = realm_from_doc_comment(&comment) - { - realms.push(AnnotatedRealmRange { - range: local_func_stat.get_range(), - realm, - }); - } - } - - realms -} - -fn collect_decl_annotation_realms_for_file_from_db( - db: &crate::DbIndex, - file_id: &FileId, -) -> Vec { - let Some(tree) = db.get_vfs().get_syntax_tree(file_id) else { - return Vec::new(); - }; - - let mut realms = Vec::new(); - for func_stat in tree.get_chunk_node().descendants::() { - if let Some(comment) = func_stat.get_left_comment() - && let Some(realm) = realm_from_doc_comment(&comment) - { - realms.push(AnnotatedRealmRange { - range: func_stat.get_range(), - realm, - }); - } - } - - for local_func_stat in tree.get_chunk_node().descendants::() { - if let Some(comment) = local_func_stat.get_left_comment() - && let Some(realm) = realm_from_doc_comment(&comment) - { - realms.push(AnnotatedRealmRange { - range: local_func_stat.get_range(), - realm, - }); - } - } - - realms -} - -pub fn collect_decl_annotation_realms_for_file_precompute( - db: &crate::DbIndex, - file_id: &FileId, -) -> Vec { - let Some(tree) = db.get_vfs().get_syntax_tree(file_id) else { - return Vec::new(); - }; - - let mut realms = Vec::new(); - for func_stat in tree.get_chunk_node().descendants::() { - if let Some(comment) = func_stat.get_left_comment() - && let Some(realm) = realm_from_doc_comment(&comment) - { - realms.push(AnnotatedRealmRange { - range: func_stat.get_range(), - realm, - }); - } - } - - for local_func_stat in tree.get_chunk_node().descendants::() { - if let Some(comment) = local_func_stat.get_left_comment() - && let Some(realm) = realm_from_doc_comment(&comment) - { - realms.push(AnnotatedRealmRange { - range: local_func_stat.get_range(), - realm, - }); - } - } - - realms + db.get_gmod_infer_index() + .get_member_annotation_realm_at_offset(file_id, offset) } fn resolve_annotated_gm_method_realms( @@ -1101,7 +912,7 @@ fn collect_all_member_ids_for_type_key( owners }; let mut result = Vec::new(); - let mut seen = std::collections::HashSet::new(); + let mut seen = rustc_hash::FxHashSet::default(); for owner in &owners { push_cached_member_ids_for_owner_key( @@ -1156,7 +967,7 @@ fn subtype_member_owners_for_key( // every missing member name. let member_index = db.get_member_index(); let mut subtype_ids = Vec::new(); - let mut seen = HashSet::new(); + let mut seen = FxHashSet::default(); for member in member_index.get_current_members_for_key(member_key) { let Some(owner) = member_index.get_current_owner(&member.get_id()) else { continue; @@ -1186,7 +997,7 @@ fn push_cached_member_ids_for_owner_key( member_key: &LuaMemberKey, owner_key_member_candidate_cache: &mut OwnerKeyMemberCandidateCache, result: &mut Vec, - seen: &mut HashSet, + seen: &mut FxHashSet, ) -> bool { let cache_key = (owner.clone(), member_key.clone()); if let Some(member_ids) = owner_key_member_candidate_cache.get(&cache_key) { @@ -1208,7 +1019,7 @@ fn push_cached_member_ids_for_owner_key( owner, member_key, &mut owner_key_member_ids, - &mut HashSet::new(), + &mut FxHashSet::default(), ); owner_key_member_candidate_cache.insert(cache_key, owner_key_member_ids.clone()); if !found { @@ -1228,7 +1039,7 @@ fn push_member_ids_for_owner_key( owner: &LuaMemberOwner, member_key: &LuaMemberKey, result: &mut Vec, - seen: &mut HashSet, + seen: &mut FxHashSet, ) -> bool { let indexed_members = member_index.get_members_for_owner_key(owner, member_key); if indexed_members.is_empty() { @@ -1271,14 +1082,14 @@ fn owner_type_to_member_owners( db: &crate::DbIndex, include_inherited_members: bool, ) -> Vec { - let mut visited = HashSet::new(); + let mut visited = FxHashSet::default(); owner_type_to_member_owners_inner(typ, db, &mut visited, include_inherited_members) } fn owner_type_to_member_owners_inner( typ: &LuaType, db: &crate::DbIndex, - visited: &mut HashSet, + visited: &mut FxHashSet, include_inherited_members: bool, ) -> Vec { if !visited.insert(typ.clone()) { @@ -1360,7 +1171,7 @@ fn owner_type_to_member_owners_inner( fn expand_type_decl_member_owners( type_decl_id: &crate::LuaTypeDeclId, db: &crate::DbIndex, - visited: &mut HashSet, + visited: &mut FxHashSet, include_inherited_members: bool, ) -> Vec { let mut owners = vec![LuaMemberOwner::Type(type_decl_id.clone())]; @@ -1383,7 +1194,7 @@ fn collect_annotated_gm_method_realms( context: &DiagnosticContext, semantic_model: &SemanticModel, ) -> GmMethodRealmMap { - let mut gm_method_realms = FxHashMap::default(); + let mut gm_method_realms = GmMethodRealmMap::default(); let db = semantic_model.get_db(); let module_index = db.get_module_index(); @@ -1673,18 +1484,14 @@ fn realm_label(realm: GmodRealm) -> &'static str { fn resolve_precomputed_decl_realm( db: &crate::DbIndex, semantic_decl: &LuaSemanticDeclId, - decl_annotation_cache: &mut DeclAnnotationRealmCache, ) -> Option { let (decl_file_id, decl_offset) = semantic_decl_position(semantic_decl)?; let metadata = db .get_gmod_infer_index() .get_realm_file_metadata(&decl_file_id)?; - if let Some(annotation_realm) = resolve_decl_annotation_realm_at_offset_from_db( - db, - &decl_file_id, - decl_offset, - decl_annotation_cache, - ) { + if let Some(annotation_realm) = + resolve_decl_annotation_realm_at_offset_from_db(db, &decl_file_id, decl_offset) + { return Some(ResolvedRealm::new( annotation_realm, RealmEvidence::ExplicitAnnotation, @@ -1701,14 +1508,12 @@ pub fn precompute_callee_realm_data_for_workspace( workspace_file_ids: &[FileId], ) -> PrecomputedCalleeRealmData { let module_index = db.get_module_index(); - let mut callee_realms = FxHashMap::default(); + let mut callee_realms = PrecomputedCalleeRealmMap::default(); let mut realm_call_candidates = PrecomputedRealmCallCandidates::default(); - let mut decl_annotation_cache = FxHashMap::default(); // Resolving one file's declaration and member realms reads only // immutable `&DbIndex` state, so the resolution is derived - // concurrently. The annotation cache is a pure memo, so giving each - // worker its own only costs repeated lookups. + // concurrently. let resolve_files: Vec = workspace_file_ids .iter() .copied() @@ -1725,16 +1530,13 @@ pub fn precompute_callee_realm_data_for_workspace( db, &resolve_files, |db, file_id| { - let mut cache = FxHashMap::default(); let mut resolved = Vec::new(); if let Some(decl_tree) = db.get_decl_index().get_decl_tree(&file_id) { let mut decl_ids: Vec<_> = decl_tree.get_decls().keys().copied().collect(); decl_ids.sort_unstable_by_key(|decl_id| decl_id.position); for decl_id in decl_ids { let semantic_decl = LuaSemanticDeclId::LuaDecl(decl_id); - if let Some(realm) = - resolve_precomputed_decl_realm(db, &semantic_decl, &mut cache) - { + if let Some(realm) = resolve_precomputed_decl_realm(db, &semantic_decl) { resolved.push((semantic_decl, realm)); } } @@ -1749,23 +1551,15 @@ pub fn precompute_callee_realm_data_for_workspace( member_ids.sort_unstable_by_key(|member_id| member_id.get_position()); for member_id in member_ids { let semantic_decl = LuaSemanticDeclId::Member(member_id); - if let Some(realm) = resolve_precomputed_decl_realm(db, &semantic_decl, &mut cache) - { + if let Some(realm) = resolve_precomputed_decl_realm(db, &semantic_decl) { resolved.push((semantic_decl, realm)); } } - (resolved, cache) + resolved }, ); - // The annotation memo the workers filled is keyed by file and holds the same - // value for the same key whoever computed it, so folding the fragments back - // in leaves the signature pass below reading a warm cache, exactly as it did - // when the whole function ran on one thread. - for (semantic_decl, resolved) in resolved_by_file.into_iter().flat_map(|(resolved, cache)| { - decl_annotation_cache.extend(cache); - resolved - }) { + for (semantic_decl, resolved) in resolved_by_file.into_iter().flatten() { match semantic_decl { LuaSemanticDeclId::LuaDecl(decl_id) => { if let Some(decl) = db.get_decl_index().get_decl(&decl_id) { @@ -1808,9 +1602,7 @@ pub fn precompute_callee_realm_data_for_workspace( }); for signature_id in signature_ids { let semantic_decl = LuaSemanticDeclId::Signature(signature_id); - if let Some(resolved) = - resolve_precomputed_decl_realm(db, &semantic_decl, &mut decl_annotation_cache) - { + if let Some(resolved) = resolve_precomputed_decl_realm(db, &semantic_decl) { callee_realms.insert(semantic_decl, vec![resolved]); } } @@ -1828,7 +1620,7 @@ pub fn precompute_gm_method_realms( db: &crate::db_index::DbIndex, workspace_id: WorkspaceId, ) -> GmMethodRealmMap { - let mut gm_method_realms = FxHashMap::default(); + let mut gm_method_realms = GmMethodRealmMap::default(); let module_index = db.get_module_index(); for (file_id, method_realms) in db.get_gmod_infer_index().iter_gm_method_realm_annotations() { diff --git a/crates/glua_code_analysis/src/diagnostic/checker/incomplete_signature_doc.rs b/crates/glua_code_analysis/src/diagnostic/checker/incomplete_signature_doc.rs index dd84690bf..2cdcb014a 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/incomplete_signature_doc.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/incomplete_signature_doc.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use rustc_hash::FxHashSet; use glua_parser::{LuaAstNode, LuaClosureExpr, LuaDocTagParam, LuaDocTagReturn, LuaStat}; @@ -70,7 +70,7 @@ fn check_doc( DiagnosticCode::IncompleteSignatureDoc }; - let doc_param_names: HashSet = comment + let doc_param_names: FxHashSet = comment .children::() .filter_map(|param| { param @@ -114,7 +114,7 @@ fn check_doc( fn check_params( context: &mut DiagnosticContext, closure_expr: &LuaClosureExpr, - doc_param_names: &HashSet, + doc_param_names: &FxHashSet, code: DiagnosticCode, is_global: bool, function_name: &str, 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..8b1558dbf 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/missing_fields.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/missing_fields.rs @@ -1,7 +1,5 @@ -use std::{ - collections::{HashMap, HashSet}, - sync::Arc, -}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use glua_parser::{ LuaAssignStat, LuaAstNode, LuaCallArgList, LuaCallExpr, LuaClosureExpr, LuaExpr, LuaIndexKey, @@ -24,7 +22,7 @@ impl Checker for MissingFieldsChecker { fn check(context: &mut DiagnosticContext, semantic_model: &SemanticModel) { let root = semantic_model.get_root().clone(); - let mut type_cache: HashMap>> = HashMap::new(); + let mut type_cache: HashMap>> = HashMap::default(); for expr in root.descendants::() { if context.is_cancelled() { return; @@ -148,7 +146,7 @@ fn check_table_expr( LuaType::Intersection(intersections) => type_cache .entry(table_type.clone()) .or_insert_with(|| { - let mut computed_fields = HashSet::new(); + let mut computed_fields = HashSet::default(); for intersection_component in intersections.get_types() { if context.is_cancelled() { return Arc::new(computed_fields); @@ -422,9 +420,9 @@ fn get_required_fields_for_types( mut is_cancelled: impl FnMut() -> bool, ) -> Option> { let member_index = db.get_member_index(); - let mut required_fields: HashSet = HashSet::new(); + let mut required_fields: HashSet = HashSet::default(); - let mut optional_type = HashSet::new(); + let mut optional_type = HashSet::default(); for super_type in types { if is_cancelled() { return Some(required_fields); @@ -476,8 +474,8 @@ fn get_required_fields_for_types( is_cancelled: &mut impl FnMut() -> bool, ) -> Option<()> { let members = member_index.get_members(&LuaMemberOwner::Type(type_decl_id))?; - let mut type_required_fields = HashSet::new(); - let mut type_optional_fields = HashSet::new(); + let mut type_required_fields = HashSet::default(); + let mut type_optional_fields = HashSet::default(); for member in members { if is_cancelled() { @@ -490,6 +488,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/checker/mod.rs b/crates/glua_code_analysis/src/diagnostic/checker/mod.rs index 5db5cfbe9..3ae2ea6bf 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}; @@ -55,8 +56,6 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use tokio_util::sync::CancellationToken; -use rustc_hash::FxHashMap; - use crate::{ FileId, LuaSemanticDeclId, LuaType, LuaTypeDeclId, RenderLevel, SemanticDeclLevel, WorkspaceId, db_index::DbIndex, humanize_type, semantic::SemanticModel, @@ -72,11 +71,9 @@ pub use await_in_sync::{PrecomputedAwaitCandidates, precompute_await_candidates} pub use discard_returns::{PrecomputedNoDiscardCandidates, precompute_nodiscard_candidates}; pub use gmod_network::SortedSendFlowCache; pub use gmod_network::precompute_sorted_send_flows; -pub use gmod_realm_misuse::AnnotatedRealmRange; pub use gmod_realm_misuse::GmMethodRealmMap; pub use gmod_realm_misuse::PrecomputedCalleeRealmMap; pub use gmod_realm_misuse::PrecomputedRealmCallCandidates; -pub(crate) use gmod_realm_misuse::collect_decl_annotation_realms_for_file_precompute; pub use gmod_realm_misuse::precompute_callee_realm_data_for_workspace; pub use gmod_realm_misuse::precompute_gm_method_realms; pub use missing_fields::precompute_missing_required_fields; @@ -296,9 +293,6 @@ pub struct SharedDiagnosticData { pub nodiscard_candidates: Arc, /// Names the deprecated, readonly and visibility checkers could report on. pub property_name_candidates: Arc, - /// Precomputed declaration annotation realms for all workspace files. - /// Avoids re-scanning syntax trees for @realm annotations per file. - pub decl_annotation_realms: Arc>>, /// Precomputed sorted send flows by message name. Built once per batch /// run instead of per-file, avoiding repeated VFS path lookups and sorts. pub sorted_send_flows: Arc, @@ -354,10 +348,6 @@ impl<'a> DiagnosticContext<'a> { self.shared_data.clone() } - pub fn get_shared_data(&self) -> Option<&SharedDiagnosticData> { - self.shared_data.as_deref() - } - pub fn get_db(&self) -> &DbIndex { self.db } @@ -514,7 +504,7 @@ impl<'a> DiagnosticContext<'a> { } fn collect_assignment_prefix_events(root: &LuaChunk) -> AssignmentPrefixEvents { - let mut events: AssignmentPrefixEvents = HashMap::new(); + let mut events: AssignmentPrefixEvents = HashMap::default(); for node in root.descendants::() { let LuaAst::LuaAssignStat(assign_stat) = node else { continue; @@ -535,6 +525,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 +552,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; @@ -576,23 +637,45 @@ 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; } - 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; + } + } + + 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/checker/need_check_nil.rs b/crates/glua_code_analysis/src/diagnostic/checker/need_check_nil.rs index 6954d1bc0..6091a9e20 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; @@ -696,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 @@ -721,6 +725,11 @@ fn check_index_expr( is_expr_guarded_by_prior_nil_early_return(semantic_model, &prefix) || is_expr_guarded_by_correlated_multi_return(semantic_model, &prefix) || is_expr_proven_by_falsy_param_nil_free_return_slot(semantic_model, &prefix) + // `if x then x.f end` proves `x` truthy for a field read exactly + // as it does for `x:m()`, which reads the same guard through + // `report_unsafe_receiver`. Without it the guard held for the + // call form and not the index form of the same access. + || is_expr_guarded_by_current_truthiness_condition(semantic_model, &prefix) }; if guarded { return Some(()); @@ -747,26 +756,119 @@ 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(); + // Member-level visibility parity with inference + // (infer_custom_type_member): check realm/visibility before + // resolving, so the most-derived visible member decides nullability + // instead of a nullable child being masked by a non-nullable + // parent. Residual gap: collect_super_types_with_self does not + // verify visibility of the super-edge itself, so a + // realm-incompatible `super` declaration is still traversed here. + for t in def_id.collect_super_types_with_self(db, typ.clone()) { + let owner = match t { + LuaType::Ref(id) | LuaType::Def(id) => LuaMemberOwner::Type(id), + _ => continue, + }; + let Some(member_item) = member_index.get_member_item(&owner, key) else { + continue; + }; + let visible = member_item.visible_member_ids_with_realm_at_offset( + db, + caller_file_id, + position, + ); + // A missing or realm-incompatible member must not hide a + // compatible inherited one (mirrors infer_custom_type_member). + if visible.is_empty() { + continue; + } + let visible_item = match visible.as_slice() { + [member_id] => crate::LuaMemberIndexItem::One(*member_id), + _ => crate::LuaMemberIndexItem::Many(visible), + }; + // Fail closed: if the winning member's type cannot be + // resolved, do not fall back to a parent. + let Ok(member_type) = visible_item.resolve_type(db) else { + return false; + }; + // First visible hit wins; do not fall through to parents. + return !member_type.is_nullable(); + } + 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 { @@ -856,8 +958,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; } @@ -909,10 +1021,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; @@ -996,14 +1111,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() { @@ -1035,12 +1147,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 @@ -1234,6 +1346,129 @@ 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(); + // 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() { + // A guard outside a closure proved the value when the closure was + // built, not when it runs. A deferred callback reads it later, by + // which time anything may have written it, so the test does not carry + // across the boundary. + if LuaClosureExpr::cast(ancestor.clone()).is_some() { + break; + } + + if let Some(if_stat) = LuaIfStat::cast(ancestor.clone()) { + 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 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()) + && guard_holds( + while_stat.get_block(), + while_stat.get_condition_expr(), + while_stat.syntax(), + ) + { + return true; + } + } + + false +} + +/// 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. +/// +/// The tested expression has to be one that reads the same value twice, so the +/// match goes through [`stable_expr_text_matches`]: `if f() then f():m() end` +/// spells the same text either side of the guard but calls the function again. +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) => { + 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) && stable_expr_text_matches(&left, expr)) + || (is_nil_literal_expr(&left) && stable_expr_text_matches(&right, expr)) + }), + _ => false, + } + } + _ => stable_expr_text_matches(condition, expr), + } +} + +/// [`expr_text_matches`], restricted to expressions that name the same value +/// each time they are evaluated. +fn stable_expr_text_matches(condition: &LuaExpr, expr: &LuaExpr) -> bool { + is_stable_guard_expr(condition) && 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, @@ -3171,6 +3406,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, @@ -3219,31 +3480,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, + ); + } } } @@ -3279,6 +3556,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) { @@ -3445,6 +3735,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), diff --git a/crates/glua_code_analysis/src/diagnostic/checker/param_type_check.rs b/crates/glua_code_analysis/src/diagnostic/checker/param_type_check.rs index 0b1552b48..6599dd08d 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/param_type_check.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/param_type_check.rs @@ -9,8 +9,8 @@ use rowan::TextRange; use crate::{ DbIndex, DiagnosticCode, LuaDeclExtra, LuaFunctionType, LuaMemberId, LuaMemberKey, LuaMemberOwner, LuaOperatorMetaMethod, LuaOperatorOwner, LuaSemanticDeclId, LuaSignature, - LuaSignatureId, LuaType, LuaTypeOwner, LuaUnionType, RenderLevel, SemanticDeclLevel, - SemanticModel, TypeCheckFailReason, TypeCheckResult, TypeOps, TypeVisitTrait, VariadicType, + LuaSignatureId, LuaType, LuaTypeOwner, RenderLevel, SemanticDeclLevel, SemanticModel, + TypeCheckFailReason, TypeCheckResult, TypeOps, TypeVisitTrait, VariadicType, diagnostic::checker::assign_type_mismatch::check_table_expr, humanize_type, infer_index_expr, is_authoritative_self_receiver_type, resolve_alias_type, }; @@ -1805,16 +1805,13 @@ fn should_suppress_lua_primitive_coercion(param_type: &LuaType, expr_type: &LuaT let param_is_nullable = !std::ptr::eq(core_param, param_type); match expr_type { - LuaType::Union(union) => match union.as_ref() { + LuaType::Union(union) => match union.nullable_inner() { // `T|nil` — T must be coercible and param must accept nil. - LuaUnionType::Nullable(inner) => { - param_is_nullable && is_primitive_coercible_to(core_param, inner) - } + Some(inner) => param_is_nullable && is_primitive_coercible_to(core_param, inner), // General union — all members must be coercible; nil passes only if param is nullable. - // Iterates the inner Vec directly — no allocation. - LuaUnionType::Multi(types) => { - !types.is_empty() - && types.iter().all(|t| { + None => { + !union.is_empty() + && union.types().all(|t| { if matches!(t, LuaType::Nil) { param_is_nullable } else { @@ -1829,10 +1826,10 @@ fn should_suppress_lua_primitive_coercion(param_type: &LuaType, expr_type: &LuaT /// Unwraps a nullable param type (`T?` → `T`), returning the inner type. fn strip_nullable_param(ty: &LuaType) -> &LuaType { - if let LuaType::Union(union) = ty { - if let LuaUnionType::Nullable(inner) = union.as_ref() { - return inner; - } + if let LuaType::Union(union) = ty + && let Some(inner) = union.nullable_inner() + { + return inner; } ty } diff --git a/crates/glua_code_analysis/src/diagnostic/checker/redefined_local.rs b/crates/glua_code_analysis/src/diagnostic/checker/redefined_local.rs index 6fec39790..11d21b9c6 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/redefined_local.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/redefined_local.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use crate::{ DbIndex, DiagnosticCode, GmodClassCallArgSource, GmodClassCallLiteral, @@ -29,8 +29,8 @@ impl Checker for RedefinedLocalChecker { let Some(root_scope) = decl_tree.get_root_scope() else { return; }; - let mut diagnostics = HashSet::new(); - let mut visible_locals = HashMap::new(); + let mut diagnostics = HashSet::default(); + let mut visible_locals = HashMap::default(); let mut changes = Vec::new(); let gmod_enabled = semantic_model.get_emmyrc().gmod.enabled; let syntax_registrations = diff --git a/crates/glua_code_analysis/src/diagnostic/checker/undefined_global.rs b/crates/glua_code_analysis/src/diagnostic/checker/undefined_global.rs index 4e2f81764..3df879b4f 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/undefined_global.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/undefined_global.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use glua_parser::{ BinaryOperator, LuaAssignStat, LuaAstNode, LuaBinaryExpr, LuaBlock, LuaCallExpr, @@ -24,7 +24,7 @@ impl Checker for UndefinedGlobalChecker { fn check(context: &mut DiagnosticContext, semantic_model: &SemanticModel) { let root = semantic_model.get_root().clone(); - let mut use_range_set = HashSet::new(); + let mut use_range_set = HashSet::default(); let guarded_range_set = calc_guarded_name_expr_ranges(semantic_model); // Positions where an undefined-global read is "silent" (the read itself // can't crash; the resulting nil just propagates). We demote these from @@ -49,7 +49,7 @@ impl Checker for UndefinedGlobalChecker { } fn calc_guarded_name_expr_ranges(semantic_model: &SemanticModel) -> HashSet { - let mut guarded_ranges = HashSet::new(); + let mut guarded_ranges = HashSet::default(); let root = semantic_model.get_root(); for if_stat in root.descendants::() { @@ -104,8 +104,8 @@ fn calc_continuation_guarded_name_expr_ranges( semantic_model: &SemanticModel, root: &glua_parser::LuaChunk, ) -> HashSet { - let mut guarded_ranges = HashSet::new(); - let mut guard_rules_by_name = HashMap::>::new(); + let mut guarded_ranges = HashSet::default(); + let mut guard_rules_by_name = HashMap::>::default(); for block in root.descendants::() { let block_range = block.get_range(); @@ -187,7 +187,7 @@ fn collect_short_circuit_guarded_name_expr_ranges( return; }; - let mut lhs_guard_ranges = HashSet::new(); + let mut lhs_guard_ranges = HashSet::default(); let lhs_guarded_names = collect_truthy_guarded_names(semantic_model, &left_expr, &mut lhs_guard_ranges); guarded_ranges.extend(lhs_guard_ranges); @@ -251,7 +251,7 @@ fn extract_continuation_guarded_names( semantic_model: &SemanticModel, expr: &LuaExpr, ) -> HashSet { - let mut names = HashSet::new(); + let mut names = HashSet::default(); match expr { LuaExpr::ParenExpr(paren_expr) => { @@ -267,11 +267,11 @@ fn extract_continuation_guarded_names( .get_op_token() .is_some_and(|op| op.get_op() == UnaryOperator::OpNot); if !is_not { - return HashSet::new(); + return HashSet::default(); } if let Some(inner_expr) = unary_expr.get_expr() { - let mut condition_guard_ranges = HashSet::new(); + let mut condition_guard_ranges = HashSet::default(); names.extend(collect_truthy_guarded_names( semantic_model, &inner_expr, @@ -284,7 +284,7 @@ fn extract_continuation_guarded_names( .get_op_token() .is_some_and(|op| op.get_op() == BinaryOperator::OpEq); if !is_eq { - return HashSet::new(); + return HashSet::default(); } let Some((left_expr, right_expr)) = binary_expr.get_exprs() else { @@ -308,7 +308,7 @@ fn collect_clause_guarded_name_ranges( block: &glua_parser::LuaBlock, guarded_ranges: &mut HashSet, ) { - let mut condition_guard_ranges = HashSet::new(); + let mut condition_guard_ranges = HashSet::default(); let truthy_names = collect_truthy_guarded_names(semantic_model, condition, &mut condition_guard_ranges); guarded_ranges.extend(condition_guard_ranges); @@ -335,7 +335,7 @@ fn collect_truthy_guarded_names( ) -> HashSet { match expr { LuaExpr::NameExpr(name_expr) => { - let mut names = HashSet::new(); + let mut names = HashSet::default(); if let Some(name_text) = name_expr.get_name_text() { condition_guard_ranges.insert(name_expr.get_range()); names.insert(name_text.to_string()); @@ -350,7 +350,7 @@ fn collect_truthy_guarded_names( .unwrap_or_default(), LuaExpr::UnaryExpr(unary_expr) => { let Some(inner_expr) = unary_expr.get_expr() else { - return HashSet::new(); + return HashSet::default(); }; let is_not = unary_expr @@ -368,7 +368,7 @@ fn collect_truthy_guarded_names( } LuaExpr::BinaryExpr(binary_expr) => { let Some((left_expr, right_expr)) = binary_expr.get_exprs() else { - return HashSet::new(); + return HashSet::default(); }; let op = binary_expr @@ -401,10 +401,10 @@ fn collect_truthy_guarded_names( &right_expr, condition_guard_ranges, ); - HashSet::new() + HashSet::default() } BinaryOperator::OpNe => { - let mut names = HashSet::new(); + let mut names = HashSet::default(); if let Some(name_expr) = name_compared_with_nil(&left_expr, &right_expr) && let Some(name_text) = name_expr.get_name_text() { @@ -417,7 +417,7 @@ fn collect_truthy_guarded_names( if let Some(name_expr) = name_compared_with_nil(&left_expr, &right_expr) { condition_guard_ranges.insert(name_expr.get_range()); } - HashSet::new() + HashSet::default() } // Comparison / arithmetic / bitwise operators do not produce // truthy names (e.g. `x.y < 4` doesn't make `x.y` a truthy @@ -436,12 +436,12 @@ fn collect_truthy_guarded_names( &right_expr, condition_guard_ranges, ); - HashSet::new() + HashSet::default() } } } LuaExpr::CallExpr(call_expr) => { - let mut names = HashSet::new(); + let mut names = HashSet::default(); if let Some(guarded_target) = guarded_call_target_name(semantic_model, call_expr) && let Some(name_text) = guarded_target.name() { @@ -457,7 +457,7 @@ fn collect_truthy_guarded_names( // If we're checking `if ctp.Disable then`, it implies `ctp` exists. // For nested chains like `foo.bar.baz`, recurse into the prefix so // the deepest base name (`foo`) is still registered as guarded. - let mut names = HashSet::new(); + let mut names = HashSet::default(); if let Some(prefix_expr) = index_expr.get_prefix_expr() { if let Some(name_expr) = unwrap_paren_to_name_expr(&prefix_expr) && let Some(name_text) = name_expr.get_name_text() @@ -474,7 +474,7 @@ fn collect_truthy_guarded_names( } names } - _ => HashSet::new(), + _ => HashSet::default(), } } @@ -604,7 +604,7 @@ fn collect_truthy_guarded_names_with_not_chain( match ¤t_expr { LuaExpr::ParenExpr(paren_expr) => { let Some(inner_expr) = paren_expr.get_expr() else { - return HashSet::new(); + return HashSet::default(); }; current_expr = inner_expr; } @@ -618,7 +618,7 @@ fn collect_truthy_guarded_names_with_not_chain( not_count += 1; let Some(inner_expr) = unary_expr.get_expr() else { - return HashSet::new(); + return HashSet::default(); }; current_expr = inner_expr; } @@ -630,7 +630,7 @@ fn collect_truthy_guarded_names_with_not_chain( if not_count.is_multiple_of(2) { names } else { - HashSet::new() + HashSet::default() } } @@ -851,7 +851,7 @@ fn collect_silent_assignment_rhs_names(expr: &LuaExpr, ranges: &mut HashSet HashSet { - let mut ranges = HashSet::new(); + let mut ranges = HashSet::default(); // Direct call arguments: `f(UNDEF)` and `f((UNDEF))`. for call_expr in root.descendants::() { diff --git a/crates/glua_code_analysis/src/diagnostic/checker/unknown_doc_tag.rs b/crates/glua_code_analysis/src/diagnostic/checker/unknown_doc_tag.rs index 24b11316d..4628969f3 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/unknown_doc_tag.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/unknown_doc_tag.rs @@ -1,7 +1,7 @@ use crate::{DiagnosticCode, SemanticModel}; use glua_parser::{LuaAstNode, LuaAstToken, LuaDocTagOther, LuaTokenKind}; +use rustc_hash::FxHashSet; use serde_json::Value; -use std::collections::HashSet; use super::{Checker, DiagnosticContext}; @@ -14,7 +14,7 @@ impl Checker for UnknownDocTag { ]; fn check(context: &mut DiagnosticContext, semantic_model: &SemanticModel) { - let known_tags: HashSet<_> = semantic_model + let known_tags: FxHashSet<_> = semantic_model .get_emmyrc() .doc .known_tags @@ -38,7 +38,7 @@ impl Checker for UnknownDocTag { fn check_tag( context: &mut DiagnosticContext, tag_other: &LuaDocTagOther, - known_tags: &HashSet<&str>, + known_tags: &FxHashSet<&str>, ) -> Option<()> { if let Some(token) = tag_other.token_by_kind(LuaTokenKind::TkTagOther) && !known_tags.contains(token.get_text()) diff --git a/crates/glua_code_analysis/src/diagnostic/checker/unused.rs b/crates/glua_code_analysis/src/diagnostic/checker/unused.rs index e9614df5c..1bb52304d 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/unused.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/unused.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use rustc_hash::FxHashMap; use glua_parser::{ LuaAstNode, LuaAstToken, LuaChunk, LuaExpr, LuaForRangeStat, LuaLocalName, LuaLocalStat, @@ -30,7 +30,7 @@ impl Checker for UnusedChecker { .get_decls() .values() .map(|decl| (decl.get_range(), decl)) - .collect::>(); + .collect::>(); for decl in decl_tree.get_decls().values() { if decl.is_global() || decl.is_param() && decl.get_name() == "..." { continue; @@ -128,7 +128,7 @@ fn get_unused_check_result( fn should_ignore_positional_placeholder( ref_index: &LuaReferenceIndex, - decls_by_range: &HashMap, + decls_by_range: &FxHashMap, decl: &LuaDecl, root: &LuaChunk, ) -> bool { @@ -138,7 +138,7 @@ fn should_ignore_positional_placeholder( fn is_generic_for_placeholder( ref_index: &LuaReferenceIndex, - decls_by_range: &HashMap, + decls_by_range: &FxHashMap, decl: &LuaDecl, root: &LuaChunk, ) -> bool { @@ -167,7 +167,7 @@ fn is_generic_for_placeholder( fn is_local_multireturn_placeholder( ref_index: &LuaReferenceIndex, - decls_by_range: &HashMap, + decls_by_range: &FxHashMap, decl: &LuaDecl, root: &LuaChunk, ) -> bool { diff --git a/crates/glua_code_analysis/src/diagnostic/lua_diagnostic.rs b/crates/glua_code_analysis/src/diagnostic/lua_diagnostic.rs index c04ce5855..965ecbd08 100644 --- a/crates/glua_code_analysis/src/diagnostic/lua_diagnostic.rs +++ b/crates/glua_code_analysis/src/diagnostic/lua_diagnostic.rs @@ -1,9 +1,9 @@ +use rustc_hash::FxHashMap; use std::collections::HashMap; use std::sync::Arc; use std::time::Instant; use log::info; -use rustc_hash::FxHashMap; pub use super::checker::DiagnosticContext; use super::checker::SharedDiagnosticData; @@ -25,7 +25,7 @@ use tokio_util::sync::CancellationToken; pub struct LuaDiagnostic { enable: bool, config: Arc, - workspace_configs: HashMap>, + workspace_configs: FxHashMap>, } impl Default for LuaDiagnostic { @@ -39,7 +39,7 @@ impl LuaDiagnostic { Self { enable: true, config: Arc::new(LuaDiagnosticConfig::default()), - workspace_configs: HashMap::new(), + workspace_configs: FxHashMap::default(), } } @@ -53,7 +53,7 @@ impl LuaDiagnostic { &mut self, configs: HashMap>, ) { - self.workspace_configs = configs; + self.workspace_configs = configs.into_iter().collect(); } // 只开启指定的诊断 @@ -127,22 +127,20 @@ impl LuaDiagnostic { // precompute off the critical path. The per-workspace realm loop keeps its // own sequential ordering inside a single task (insertion order is // significant for the "first definition wins" realm-candidate rule). - let workspace_file_ids_ref = &workspace_file_ids; let realm_candidate_file_ids_ref = &realm_candidate_file_ids; - let ( - workspace_realm_data, - missing_required_fields, - await_candidates, - param_type_candidates, - nodiscard_candidates, - decl_annotation_realms, - sorted_send_flows, - property_name_candidates, - ) = std::thread::scope(|s| { - let workspace_realms = s.spawn(|| { - let mut gm_method_realms = HashMap::new(); - let mut callee_realms_by_workspace = HashMap::new(); - let mut realm_call_candidates_by_workspace = HashMap::new(); + crate::compilation::analyzer::parallel::init_pool(); + let mut workspace_realm_data = None; + let mut missing_required_fields = None; + let mut await_candidates = None; + let mut param_type_candidates = None; + let mut nodiscard_candidates = None; + let mut sorted_send_flows = None; + let mut property_name_candidates = None; + rayon::scope(|s| { + s.spawn(|_| { + let mut gm_method_realms = HashMap::default(); + let mut callee_realms_by_workspace = HashMap::default(); + let mut realm_call_candidates_by_workspace = HashMap::default(); for workspace_id in module_index.get_main_workspace_ids() { let realms = Arc::new(precompute_gm_method_realms(db, workspace_id)); let mut callee_realm_data = precompute_callee_realm_data_for_workspace( @@ -161,50 +159,36 @@ impl LuaDiagnostic { Arc::new(callee_realm_data.realm_call_candidates), ); } - ( + workspace_realm_data = Some(( gm_method_realms, callee_realms_by_workspace, realm_call_candidates_by_workspace, - ) + )); + }); + s.spawn(|_| missing_required_fields = Some(precompute_missing_required_fields(db))); + s.spawn(|_| await_candidates = Some(precompute_await_candidates(db))); + s.spawn(|_| param_type_candidates = Some(precompute_param_type_candidates(db))); + s.spawn(|_| nodiscard_candidates = Some(precompute_nodiscard_candidates(db))); + s.spawn(|_| property_name_candidates = Some(precompute_property_name_candidates(db))); + s.spawn(|_| { + sorted_send_flows = Some(Arc::new(precompute_sorted_send_flows( + db.get_gmod_network_index(), + db.get_vfs(), + ))) }); - let missing = s.spawn(|| precompute_missing_required_fields(db)); - let await_c = s.spawn(|| precompute_await_candidates(db)); - let param_type = s.spawn(|| precompute_param_type_candidates(db)); - let nodiscard = s.spawn(|| precompute_nodiscard_candidates(db)); - let property_names = s.spawn(|| precompute_property_name_candidates(db)); - let decl_realms = - s.spawn(|| precompute_decl_annotation_realms(db, workspace_file_ids_ref)); - let send_flows = - s.spawn(|| precompute_sorted_send_flows(db.get_gmod_network_index(), db.get_vfs())); - ( - workspace_realms - .join() - .expect("workspace realm precompute panicked"), - missing - .join() - .expect("precompute_missing_required_fields panicked"), - await_c - .join() - .expect("precompute_await_candidates panicked"), - param_type - .join() - .expect("precompute_param_type_candidates panicked"), - nodiscard - .join() - .expect("precompute_nodiscard_candidates panicked"), - decl_realms - .join() - .expect("precompute_decl_annotation_realms panicked"), - Arc::new( - send_flows - .join() - .expect("precompute_sorted_send_flows panicked"), - ), - property_names - .join() - .expect("precompute_property_name_candidates panicked"), - ) }); + // `rayon::scope` propagates task panics, so every slot is filled here. + let workspace_realm_data = workspace_realm_data.expect("workspace realm precompute ran"); + let missing_required_fields = + missing_required_fields.expect("precompute_missing_required_fields ran"); + let await_candidates = await_candidates.expect("precompute_await_candidates ran"); + let param_type_candidates = + param_type_candidates.expect("precompute_param_type_candidates ran"); + let nodiscard_candidates = + nodiscard_candidates.expect("precompute_nodiscard_candidates ran"); + let sorted_send_flows = sorted_send_flows.expect("precompute_sorted_send_flows ran"); + let property_name_candidates = + property_name_candidates.expect("precompute_property_name_candidates ran"); let (gm_method_realms, callee_realms_by_workspace, realm_call_candidates_by_workspace) = workspace_realm_data; Arc::new(SharedDiagnosticData { @@ -217,7 +201,6 @@ impl LuaDiagnostic { param_type_candidates: Arc::new(param_type_candidates), nodiscard_candidates: Arc::new(nodiscard_candidates), property_name_candidates: Arc::new(property_name_candidates), - decl_annotation_realms: Arc::new(decl_annotation_realms), sorted_send_flows, }) } @@ -278,18 +261,3 @@ impl LuaDiagnostic { Some(context.get_diagnostics()) } } - -fn precompute_decl_annotation_realms( - db: &crate::DbIndex, - workspace_file_ids: &[FileId], -) -> FxHashMap> { - use super::checker::collect_decl_annotation_realms_for_file_precompute; - let mut cache = FxHashMap::default(); - for &file_id in workspace_file_ids { - let realms = collect_decl_annotation_realms_for_file_precompute(db, &file_id); - if !realms.is_empty() { - cache.insert(file_id, realms); - } - } - cache -} diff --git a/crates/glua_code_analysis/src/diagnostic/lua_diagnostic_config.rs b/crates/glua_code_analysis/src/diagnostic/lua_diagnostic_config.rs index 4fa4242b8..b7328b028 100644 --- a/crates/glua_code_analysis/src/diagnostic/lua_diagnostic_config.rs +++ b/crates/glua_code_analysis/src/diagnostic/lua_diagnostic_config.rs @@ -43,7 +43,7 @@ impl LuaDiagnosticConfig { }) .collect(); - let mut severity = HashMap::new(); + let mut severity = HashMap::default(); for (code, sev) in &emmyrc.diagnostics.severity { severity.insert(*code, (*sev).into()); } @@ -57,3 +57,15 @@ impl LuaDiagnosticConfig { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_collections_are_std() { + let cfg = LuaDiagnosticConfig::default(); + let _: &std::collections::HashSet = &cfg.workspace_disabled; + let _: &std::collections::HashMap = &cfg.severity; + } +} 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..2e3edf548 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 @@ -2168,6 +2168,7 @@ return t let file_id = ws .analysis .update_file_by_uri(&uri, Some(content.to_string())) + .map(|(id, _)| id) .expect("file id should exist"); ws.analysis.compilation.clear_index(); @@ -2215,18 +2216,20 @@ return t ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("ents.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@class Entity ---@param class string ---@return Entity function ents.Create(class) end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); // Main workspace: unannotated override + test call assert!(ws.check_code_for( @@ -2263,18 +2266,20 @@ return t ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("ents.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@class Entity ---@param class string ---@return Entity function ents.Create(class) end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); // Main workspace: override with extra parameters + test call assert!(ws.check_code_for( @@ -2308,18 +2313,20 @@ return t ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("ents.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@class Entity ---@param class string ---@return Entity function ents.Create(class) end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); // Main workspace: declaration-style non-variadic override with extra parameter. assert!(ws.check_code_for( @@ -2353,18 +2360,20 @@ return t ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("ents.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@class Entity ---@param class string ---@return Entity function ents.Create(class) end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); assert!(ws.check_code_for( DiagnosticCode::AssignTypeMismatch, @@ -2401,18 +2410,20 @@ return t ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("ents.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@class Entity ---@param class string ---@return Entity function ents.Create(class) end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); assert!(ws.check_code_for( DiagnosticCode::RedundantParameter, @@ -2449,18 +2460,20 @@ return t ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("ents.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@class Entity ---@param class string ---@return Entity function ents.Create(class) end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); // Main workspace: 2-param call with typed local should NOT produce // assign-type-mismatch because the meta return type (Entity) wins. @@ -2496,18 +2509,20 @@ return t ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("ents.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@class Entity ---@param class string ---@return Entity function ents.Create(class) end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); assert!(!ws.check_code_for( DiagnosticCode::AssignTypeMismatch, @@ -2564,19 +2579,21 @@ return t ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("ents.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@class Entity ---@realm shared ---@param class string ---@return Entity function ents.Create(class) end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); ws.def_file( "gamemode/modules/workarounds/sv_workarounds.lua", @@ -3076,3 +3093,148 @@ 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) + "# + )); +} + +/// `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 +/// 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(); + + 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 + "# + )); +} + +/// A `false` seed in a table literal is an inferred "not set yet" placeholder, +/// not a declared contract, so a later write of another type is not a mismatch. +#[test] +fn inferred_false_seed_is_lenient() { + let mut ws = crate::VirtualWorkspace::new(); + + assert!(ws.check_code_for( + crate::DiagnosticCode::AssignTypeMismatch, + r#" + ---@class FalseSeed.Player + local game = { betting = { curPlayer = false } } + + ---@param ply FalseSeed.Player + local function setTurn(ply) + game.betting.curPlayer = ply + end + "# + )); +} + +/// A declared `boolean` field is a contract and still reports. +#[test] +fn declared_boolean_field_still_reports() { + let mut ws = crate::VirtualWorkspace::new(); + + assert!(!ws.check_code_for( + crate::DiagnosticCode::AssignTypeMismatch, + r#" + ---@class DeclaredBool.Player + ---@class DeclaredBool.Betting + ---@field curPlayer boolean + ---@type DeclaredBool.Betting + local betting + + ---@param ply DeclaredBool.Player + local function setTurn(ply) + betting.curPlayer = ply + end + "# + )); +} diff --git a/crates/glua_code_analysis/src/diagnostic/test/determinism_test.rs b/crates/glua_code_analysis/src/diagnostic/test/determinism_test.rs index 7461e8323..88835dc27 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/determinism_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/determinism_test.rs @@ -406,6 +406,7 @@ mod tests { let debug_id = ws .analysis .update_file_by_uri(&debug_uri, Some(original.to_string())) + .map(|(id, _)| id) .expect("debug file id"); fn param_snapshots(ws: &VirtualWorkspace, file_id: FileId) -> BTreeSet { @@ -442,12 +443,14 @@ mod tests { let baseline_engine_type = inferred_field_type(&ws, debug_id, "engineTypeId"); ws.analysis - .update_file_by_uri(&debug_uri, Some(format!("\n{original}"))); + .update_file_by_uri(&debug_uri, Some(format!("\n{original}"))) + .map(|(id, _)| id); let after_add = param_snapshots(&ws, debug_id); let after_add_engine_type = inferred_field_type(&ws, debug_id, "engineTypeId"); ws.analysis - .update_file_by_uri(&debug_uri, Some(original.to_string())); + .update_file_by_uri(&debug_uri, Some(original.to_string())) + .map(|(id, _)| id); let after_remove = param_snapshots(&ws, debug_id); let after_remove_engine_type = inferred_field_type(&ws, debug_id, "engineTypeId"); @@ -505,6 +508,7 @@ mod tests { let file_id = ws .analysis .update_file_by_uri(&uri, Some(original.to_string())) + .map(|(id, _)| id) .expect("animation file id"); fn undefined_snapshots( @@ -542,10 +546,12 @@ mod tests { let baseline = undefined_snapshots(&ws, file_id); ws.analysis - .update_file_by_uri(&uri, Some(format!("\n{original}"))); + .update_file_by_uri(&uri, Some(format!("\n{original}"))) + .map(|(id, _)| id); let after_add = undefined_snapshots(&ws, file_id); ws.analysis - .update_file_by_uri(&uri, Some(original.to_string())); + .update_file_by_uri(&uri, Some(original.to_string())) + .map(|(id, _)| id); let after_remove = undefined_snapshots(&ws, file_id); assert_that!(baseline.len(), eq(1)); @@ -599,7 +605,7 @@ mod tests { ); let engine_type = inferred_field_type(&ws, debug_file, "engineTypeId"); - assert_that!(engine_type.contains("any"), eq(false)); + assert_that!(engine_type, eq("1")); } #[gtest] @@ -641,6 +647,6 @@ mod tests { ); let engine_type = inferred_field_type(&ws, debug_file, "engineTypeId"); - assert_that!(engine_type.contains("any"), eq(false)); + assert_that!(engine_type, eq("any")); } } diff --git a/crates/glua_code_analysis/src/diagnostic/test/gmod_compilefile_environment_test.rs b/crates/glua_code_analysis/src/diagnostic/test/gmod_compilefile_environment_test.rs index b71041dbe..74242deb5 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/gmod_compilefile_environment_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/gmod_compilefile_environment_test.rs @@ -367,60 +367,72 @@ mod test { let mut ws = VirtualWorkspace::new_with_init_std_lib(); ws.def_gmod_call_arg_builtins(); let loader_uri = ws.virtual_url_generator.new_uri("lua/loader.lua"); - ws.analysis.update_file_by_uri( - &loader_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &loader_uri, + Some( + r#" local chunk = CompileFile("target.lua") setfenv(chunk, { simple = true }) "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); let target_uri = ws.virtual_url_generator.new_uri("lua/target.lua"); let target_id = ws .analysis .update_file_by_uri(&target_uri, Some("simple()".to_string())) + .map(|(id, _)| id) .expect("target file is created"); assert!(undefined_global_names(&mut ws, target_id).is_empty()); - ws.analysis.update_file_by_uri( - &loader_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &loader_uri, + Some( + r#" local chunk = CompileFile("target.lua") setfenv(chunk, { other = true }) "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); assert_eq!( undefined_global_names(&mut ws, target_id), BTreeSet::from(["simple".to_string()]), ); - ws.analysis.update_file_by_uri(&loader_uri, None); + ws.analysis + .update_file_by_uri(&loader_uri, None) + .map(|(id, _)| id); assert_eq!( undefined_global_names(&mut ws, target_id), BTreeSet::from(["simple".to_string()]), ); - ws.analysis.update_file_by_uri( - &loader_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &loader_uri, + Some( + r#" local chunk = CompileFile("target.lua") setfenv(chunk, { simple = true }) "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); assert!(undefined_global_names(&mut ws, target_id).is_empty()); - ws.analysis.update_file_by_uri(&target_uri, None); + ws.analysis + .update_file_by_uri(&target_uri, None) + .map(|(id, _)| id); let reopened_target_id = ws .analysis .update_file_by_uri(&target_uri, Some("simple()".to_string())) + .map(|(id, _)| id) .expect("target file is reopened"); assert!(undefined_global_names(&mut ws, reopened_target_id).is_empty()); } diff --git a/crates/glua_code_analysis/src/diagnostic/test/gmod_dynamic_field_test.rs b/crates/glua_code_analysis/src/diagnostic/test/gmod_dynamic_field_test.rs index c0e5de580..4c9941f12 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/gmod_dynamic_field_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/gmod_dynamic_field_test.rs @@ -510,6 +510,7 @@ mod test { ws.analysis .remove_file_by_uri(&producer_uri) + .0 .expect("producer must be removable"); let removed_font_width = latest_member_type(&ws, consumer_file, "FontWidth"); let removed_font_width_desc = ws.humanize_type(removed_font_width); @@ -520,6 +521,7 @@ mod test { ws.analysis .update_file_by_uri(&producer_uri, Some(producer_source.to_string())) + .map(|(id, _)| id) .expect("producer must reopen"); let reopened_font_width = latest_member_type(&ws, consumer_file, "FontWidth"); let reopened_font_width_desc = ws.humanize_type(reopened_font_width); @@ -1684,4 +1686,80 @@ mod test { vec!["Undefined field `bogus`. "] ); } + + fn panel_annotations(ws: &mut VirtualWorkspace) { + ws.def_gmod_call_arg_builtins(); + ws.def_file( + "annotations/gmod.lua", + r#" + ---@meta + ---@class Entity + ---@class Panel : Entity + ---@class DPanel : Panel + ---@class Vector + ---@field x number + ---@return Vector + function Vector(x, y, z) end + ---@generic T: Panel + ---@param classname `T` + ---@param parent? Panel + ---@return (instance) T? + function vgui.Create(classname, parent) end + "#, + ); + } + + /// A write in the enclosing function runs before the closure defined inside + /// it can be called, so the field is not nil-able at the closure's read. + #[gtest] + fn test_enclosing_function_write_is_visible_in_nested_closure() { + let mut ws = VirtualWorkspace::new(); + panel_annotations(&mut ws); + let file_id = ws.def_file( + "lua/nested_closure_write.lua", + r#" + local ENT = {} + function ENT:Initialize() + self.frame = vgui.Create("DPanel") + self.frame.col = Vector(1, 2, 3) + self.frame.Paint = function(pnl, w, h) + print(pnl.col.x) + end + end + "#, + ); + + assert_eq!( + nil_diagnostic_messages_for_file(&mut ws, file_id), + Vec::::new() + ); + } + + /// A write in a sibling function proves nothing about ordering, so the read + /// inside the closure stays nil-able. + #[gtest] + fn test_sibling_function_write_stays_nilable() { + let mut ws = VirtualWorkspace::new(); + panel_annotations(&mut ws); + let file_id = ws.def_file( + "lua/sibling_function_write.lua", + r#" + local ENT = {} + function ENT:Initialize() + self.frame = vgui.Create("DPanel") + self.frame.Paint = function(pnl, w, h) + print(pnl.col.x) + end + end + function ENT:Draw() + self.frame.col = Vector(1, 2, 3) + end + "#, + ); + + assert_eq!( + nil_diagnostic_messages_for_file(&mut ws, file_id), + vec!["pnl.col may be nil"] + ); + } } diff --git a/crates/glua_code_analysis/src/diagnostic/test/gmod_mixin_receiver_test.rs b/crates/glua_code_analysis/src/diagnostic/test/gmod_mixin_receiver_test.rs index fdea3c6e8..e63a35c55 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/gmod_mixin_receiver_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/gmod_mixin_receiver_test.rs @@ -363,17 +363,17 @@ mod tests { .get_member_index() .get_current_owner_members_for_key(&owner, &key); assert_eq!(history.len(), 2); - let latest_member_id = history + let mut history_ids = history .iter() .map(|member| member.get_id()) - .max_by_key(|member_id| member_id.get_position()) - .expect("registered class history must contain the latest write"); + .collect::>(); + history_ids.sort_by_key(|member_id| crate::db_index::member_id_sort_key(*member_id)); let current_member_ids = db .get_member_index() .get_member_item(&owner, &key) .expect("registered class member must exist") .get_member_ids(); - assert_eq!(current_member_ids, vec![latest_member_id]); + assert_eq!(current_member_ids, history_ids); } #[test] @@ -1854,6 +1854,7 @@ mod tests { .to_string(), ), ) + .map(|(id, _)| id) .expect("target file must be created"); ws.def_file( "lua/autorun/incremental_edit_consumer.lua", @@ -1886,19 +1887,21 @@ mod tests { before_receiver_slot )); - ws.analysis.update_file_by_uri( - &target_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &target_uri, + Some( + r#" local MIXIN = {} function MIXIN:After() self:OnlyOnReceiver() end return MIXIN "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); assert!(!has_inferred_param( &ws, @@ -1924,6 +1927,7 @@ mod tests { let target_file_id = ws .analysis .update_file_by_uri(&target_uri, Some(target_content.to_string())) + .map(|(id, _)| id) .expect("target file must be created"); ws.def_file( "lua/autorun/incremental_reopen_consumer.lua", @@ -1954,12 +1958,14 @@ mod tests { ws.analysis .remove_file_by_uri(&target_uri) + .0 .expect("target file must be removed"); assert!(!has_inferred_param(&ws, original_signature, receiver_slot)); let reopened_file_id = ws .analysis .update_file_by_uri(&target_uri, Some(target_content.to_string())) + .map(|(id, _)| id) .expect("target file must reopen"); assert!(has_inferred_receiver(&ws, reopened_file_id, "MIXIN.Run")); } @@ -1980,6 +1986,7 @@ mod tests { let target_file_id = ws .analysis .update_file_by_uri(&target_uri, Some(target_content.to_string())) + .map(|(id, _)| id) .expect("target file must be created"); ws.def_file( "lua/autorun/compilefile_reopen_consumer.lua", @@ -2010,12 +2017,14 @@ mod tests { ws.analysis .remove_file_by_uri(&target_uri) + .0 .expect("target file must be removed"); assert!(!has_inferred_param(&ws, original_signature, receiver_slot)); let reopened_file_id = ws .analysis .update_file_by_uri(&target_uri, Some(target_content.to_string())) + .map(|(id, _)| id) .expect("target file must reopen"); assert!(has_inferred_receiver(&ws, reopened_file_id, "MIXIN.Run")); } 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..d0265bc5a --- /dev/null +++ b/crates/glua_code_analysis/src/diagnostic/test/incremental_edit_test.rs @@ -0,0 +1,3406 @@ +#[cfg(test)] +mod tests { + use crate::{ + DiagnosticCode, Emmyrc, ExportMap, FileId, FileRemap, LuaMemberKey, LuaType, PositionMap, + VirtualWorkspace, export_map, + }; + 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 revision_of(ws: &VirtualWorkspace, file_id: FileId) -> u64 { + ws.analysis + .compilation + .get_db() + .get_reference_index() + .file_reference_revision(file_id) + } + + fn write(ws: &mut VirtualWorkspace, uri: &Uri, text: &str) -> FileId { + ws.analysis + .update_file_by_uri(uri, Some(text.to_string())) + .map(|(id, _)| id) + .expect("file id") + } + + fn write_deferred(ws: &mut VirtualWorkspace, uri: &Uri, text: &str) -> FileId { + let file_id = ws + .analysis + .update_file_text_only(uri, text.to_string()) + .expect("file id"); + let dirty = ws.analysis.self_index_and_diff(vec![file_id]); + ws.analysis.ripple(dirty); + file_id + } + + fn decl_type(ws: &VirtualWorkspace, file_id: FileId, name: &str) -> LuaType { + let db = ws.analysis.compilation.get_db(); + let decl_id = db + .get_decl_index() + .get_decl_tree(&file_id) + .and_then(|tree| { + tree.get_decls() + .values() + .find(|decl| decl.get_name() == name) + .map(|decl| decl.get_id()) + }) + .expect("declaration"); + db.get_type_index() + .get_type_cache(&decl_id.into()) + .map(|cache| cache.as_type().clone()) + .expect("declaration type") + } + + /// 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 `---@module` reader caches `LuaType::ModuleRef(provider)`. The reverse + /// index files that under the providing file, not under any symbol the + /// provider exports, so no key in the export diff names it and the reader is + /// reached only because the provider's own file identity is seeded. + #[gtest] + fn module_annotation_reader_sees_provider_edit() { + let mut ws = workspace_with(vec![DiagnosticCode::AssignTypeMismatch]); + let provider_uri = ws.virtual_url_generator.new_uri("modprovider.lua"); + write( + &mut ws, + &provider_uri, + r#" + local M = {} + M.value = 1 + return M + "#, + ); + + let consumer_uri = ws.virtual_url_generator.new_uri("modconsumer.lua"); + let consumer_id = write( + &mut ws, + &consumer_uri, + r#" + ---@module "modprovider" + ModuleProvider = {} + + local observed = ModuleProvider.value + "#, + ); + + expect_that!( + decl_type(&ws, consumer_id, "observed"), + eq(&LuaType::IntegerConst(1)) + ); + + write( + &mut ws, + &provider_uri, + r#" + local M = {} + M.value = "text" + return M + "#, + ); + + let observed = decl_type(&ws, consumer_id, "observed"); + expect_true!( + matches!(&observed, LuaType::StringConst(value) if value.as_str() == "text"), + "module reader kept {observed:?} instead of the provider's new string" + ); + } + + /// 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::AssignTypeMismatch]); + 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#" + ---@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, + &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)); + } + + /// 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) + .map(|(id, _)| id); + + expect_that!( + codes_in(&ws, consumer_id), + contains(eq(DiagnosticCode::UndefinedGlobal.get_name())) + ); + } + + /// A name defined only by a *later* edit: the reader failed to resolve it, + /// so it holds no reference the export graph can follow back. The added + /// key still has to reach it, without the reader being edited. + #[gtest] + fn defining_a_global_later_clears_its_caller() { + let mut ws = workspace_with(vec![DiagnosticCode::UndefinedGlobal]); + let provider_uri = ws.virtual_url_generator.new_uri("lua/provider.lua"); + write( + &mut ws, + &provider_uri, + r#" + function AlreadyThere() end + "#, + ); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/calls_later.lua"); + let consumer_id = write( + &mut ws, + &consumer_uri, + r#" + CallsLater() + "#, + ); + expect_that!( + codes_in(&ws, consumer_id), + contains(eq(DiagnosticCode::UndefinedGlobal.get_name())) + ); + + write( + &mut ws, + &provider_uri, + r#" + function AlreadyThere() end + function CallsLater() end + "#, + ); + + expect_that!( + codes_in(&ws, consumer_id), + not(contains(eq(DiagnosticCode::UndefinedGlobal.get_name()))) + ); + } + + /// The same for a member on a global path table several files already + /// write to: the reader's failed lookup has to be repaired when the key + /// appears. + #[gtest] + fn defining_a_member_later_clears_its_reader() { + let mut ws = workspace_with(vec![ + DiagnosticCode::UndefinedField, + DiagnosticCode::UndefinedGlobal, + ]); + let base_uri = ws.virtual_url_generator.new_uri("lua/util_base.lua"); + write( + &mut ws, + &base_uri, + r#" + gutil = gutil or {} + function gutil.Existing() end + "#, + ); + + let provider_uri = ws.virtual_url_generator.new_uri("lua/util_more.lua"); + write( + &mut ws, + &provider_uri, + r#" + function gutil.AlsoThere() end + "#, + ); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/reads_new_fn.lua"); + let consumer_id = write( + &mut ws, + &consumer_uri, + r#" + gutil.NewFn() + "#, + ); + expect_that!( + codes_in(&ws, consumer_id), + contains(eq(DiagnosticCode::UndefinedField.get_name())) + ); + + write( + &mut ws, + &provider_uri, + r#" + function gutil.AlsoThere() end + function gutil.NewFn() end + "#, + ); + + expect_that!( + codes_in(&ws, consumer_id), + not(contains(eq(DiagnosticCode::UndefinedField.get_name()))) + ); + } + + /// 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 + /// prove which dimension the fingerprint actually reads. + /// The file's exports as the diff sees them. Comparing two of these is + /// the successor to comparing two export fingerprints, and is strictly + /// more precise: an inequality names the key that moved. + fn fingerprint_of(ws: &VirtualWorkspace, file_id: FileId) -> ExportMap { + export_map( + ws.analysis.compilation.get_db(), + file_id, + &FileRemap::identity(file_id), + ) + } + + /// The file's exports before an edit, expressed in the edited text's + /// coordinates, so an offset shift alone leaves them equal to the map + /// taken after. + fn fingerprint_before_edit( + ws: &VirtualWorkspace, + file_id: FileId, + first: &str, + second: &str, + ) -> ExportMap { + export_map( + ws.analysis.compilation.get_db(), + file_id, + &FileRemap::unvalidated(file_id, PositionMap::new(first, second)), + ) + } + + /// The before/after export maps of an edit, with the before map already + /// expressed in the after text's coordinates - so an offset shift alone + /// leaves the two equal, which is the property the fast path rests on. + fn fingerprint_after_edit(first: &str, second: &str) -> (ExportMap, ExportMap) { + 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 = export_map( + ws.analysis.compilation.get_db(), + file_id, + &FileRemap::unvalidated(file_id, PositionMap::new(first, second)), + ); + 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))); + } + + #[gtest] + fn fingerprint_moves_when_a_global_table_field_value_changes() { + let (before, after) = + fingerprint_after_edit("State = { value = 1 }", "State = { value = \"new\" }"); + 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)); + } + + /// 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 moved = format!( + "-- a comment that moves the function below it +{provider}" + ); + let before_fingerprint = fingerprint_before_edit(&ws, provider_id, provider, &moved); + + write(&mut ws, &provider_uri, &moved); + + // 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 shifted = format!("{prefix}{body}"); + let before = fingerprint_before_edit(&ws, file_id, body, &shifted); + let file_id = write(&mut ws, &uri, &shifted); + 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 dirty = ws.analysis.self_index_and_diff(vec![file_id]); + + expect_that!(dirty.is_empty(), is_true()); + } + + /// The same sequence, for an edit that does change an export: the file + /// that reads it has to come back dirty. + #[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 dirty = ws.analysis.self_index_and_diff(vec![file_id]); + + expect_that!(dirty.changed_sources(), contains(eq(&file_id))); + } + + /// 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_iter([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("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}"); + } + } + + /// What the batch reports. + /// + /// It reports the files it settled plus diagnostic-refresh-only textual + /// candidates: this consumer starts reporting a mismatch without ever + /// becoming a change-driven dependent, because it resolves the provider's + /// return type when it is diagnosed rather than caching a fact the diff + /// could name. The settle set is an index-dependency set; the refresh set + /// widens it textually via `files_referencing_name` without reindexing. + /// + /// A caller that pushes diagnostics uses the whole result as its refresh set. + #[gtest] + fn a_filesystem_batch_reports_the_dependents_it_settled() { + let mut ws = workspace_with(vec![DiagnosticCode::AssignTypeMismatch]); + + let provider_uri = ws.virtual_url_generator.new_uri("lua/fsprovider.lua"); + write( + &mut ws, + &provider_uri, + r#" + fsprovider = fsprovider or {} + ---@return string + function fsprovider.Describe() end + "#, + ); + + let consumer_uri = ws.virtual_url_generator.new_uri("lua/fsconsumer.lua"); + let consumer_id = write( + &mut ws, + &consumer_uri, + r#" + ---@type string + local described = fsprovider.Describe() + "#, + ); + + let unrelated_uri = ws.virtual_url_generator.new_uri("lua/fsunrelated.lua"); + let unrelated_id = write( + &mut ws, + &unrelated_uri, + r#" + local untouched = 1 + "#, + ); + + expect_that!( + codes_in(&ws, consumer_id), + not(contains(eq(DiagnosticCode::AssignTypeMismatch.get_name()))) + ); + + let consumer_revision_before = ws + .analysis + .compilation + .get_db() + .get_reference_index() + .file_reference_revision(consumer_id); + let consumer_exports_before = fingerprint_of(&ws, consumer_id); + + // The provider's return type moves, so the consumer has to be re-diagnosed + // even though nothing edited it. It arrives as a textual refresh candidate, + // not as a change-driven dependent. + let affected = ws.analysis.apply_file_system_changes(vec![( + provider_uri.clone(), + Some( + r#" + fsprovider = fsprovider or {} + ---@return number + function fsprovider.Describe() end + "# + .to_string(), + ), + )]); + + let provider_id = ws.analysis.get_file_id(&provider_uri).expect("provider id"); + expect_that!(affected, contains(eq(&provider_id))); + expect_that!(affected, contains(eq(&consumer_id))); + expect_that!(affected, not(contains(eq(&unrelated_id)))); + // Deterministic output: sorted and deduped. + let mut sorted = affected.clone(); + sorted.sort_unstable(); + sorted.dedup(); + expect_that!(affected, eq(&sorted)); + // Refresh-only: no reindex, so the revision and exports are untouched. + expect_that!( + ws.analysis + .compilation + .get_db() + .get_reference_index() + .file_reference_revision(consumer_id), + eq(consumer_revision_before) + ); + expect_that!( + fingerprint_of(&ws, consumer_id), + eq(&consumer_exports_before) + ); + // The consumer's diagnostics still move, which is why it is returned. + expect_that!( + codes_in(&ws, consumer_id), + contains(eq(DiagnosticCode::AssignTypeMismatch.get_name())) + ); + } + + /// A refresh candidate owes diagnostics, not reanalysis. + /// + /// The same return-type move through the staged edit path (`text_only` + + /// `self_index_and_diff`) leaves the uncached consumer out of the dirty + /// set entirely, so `dirty_len` never sees it; only the watched-file path's + /// textual widening returns it, and its reference revision stays put. + #[gtest] + fn watched_refresh_candidate_keeps_reference_revision() { + let mut ws = workspace_with(vec![DiagnosticCode::AssignTypeMismatch]); + let first = r#" + fsprovider = fsprovider or {} + ---@return string + function fsprovider.Describe() end + "#; + let second = r#" + fsprovider = fsprovider or {} + ---@return number + function fsprovider.Describe() end + "#; + let provider_uri = ws.virtual_url_generator.new_uri("lua/revprovider.lua"); + let provider_id = write(&mut ws, &provider_uri, first); + let consumer_uri = ws.virtual_url_generator.new_uri("lua/revconsumer.lua"); + let consumer_id = write( + &mut ws, + &consumer_uri, + r#" + ---@type string + local described = fsprovider.Describe() + "#, + ); + + // Change-driven settle alone: the consumer is not a dependent. + ws.analysis + .update_file_text_only(&provider_uri, second.to_string()) + .expect("staged provider text"); + let dirty = ws.analysis.self_index_and_diff(vec![provider_id]); + expect_that!(dirty.files(), not(contains(eq(&consumer_id)))); + let dirty_len = dirty.dirty_len(); + // The export change is real (so the textual path has something to name), + // but the dirty set still does not owe the consumer. + expect_that!(dirty.changed_sources(), contains(eq(&provider_id))); + ws.analysis.ripple(dirty); + // Staged settle re-derived the same diagnostics a watched batch would, + // so reset to the first text and exercise the watched path from clean. + write(&mut ws, &provider_uri, first); + let revision_before = ws + .analysis + .compilation + .get_db() + .get_reference_index() + .file_reference_revision(consumer_id); + let exports_before = fingerprint_of(&ws, consumer_id); + + let affected = ws + .analysis + .apply_file_system_changes(vec![(provider_uri.clone(), Some(second.to_string()))]); + expect_that!(affected, contains(eq(&consumer_id))); + expect_that!( + ws.analysis + .compilation + .get_db() + .get_reference_index() + .file_reference_revision(consumer_id), + eq(revision_before) + ); + expect_that!(fingerprint_of(&ws, consumer_id), eq(&exports_before)); + // `dirty_len` above never included the candidate; the watched result only + // widens diagnostics. The staged dirty set owed `dirty_len` files and no + // more. + expect_that!(dirty_len, eq(0)); + } + + /// A watched-file delete/create pair in one batch settles to the create. + /// + /// The watcher can deliver `DELETED` for a file the editor immediately + /// re-created (branch switches, atomic saves). Sorting keeps the two + /// events adjacent and only the last event per URI survives, so the file + /// must end present with the new text, not detached. + #[gtest] + fn watched_batch_delete_then_create_keeps_new_content() { + let mut ws = workspace_with(vec![]); + let uri = ws.virtual_url_generator.new_uri("lua/toggle.lua"); + write(&mut ws, &uri, "local old = 1"); + + ws.analysis.apply_file_system_changes(vec![ + (uri.clone(), None), + (uri.clone(), Some("local new = 2".to_string())), + ]); + + let file_id = ws.analysis.get_file_id(&uri).expect("file present"); + expect_that!( + ws.analysis + .compilation + .get_db() + .get_vfs() + .get_file_content(&file_id), + some(eq(&"local new = 2".to_string())) + ); + expect_that!( + decl_type(&ws, file_id, "new"), + eq(&LuaType::IntegerConst(2)) + ); + } + + /// The reverse order — create then delete — must not resurrect the file. + /// + /// The last event per URI is the state on disk, so the batch leaves the + /// URI detached and no FileId is reachable through it. + #[gtest] + fn watched_batch_create_then_delete_detaches_uri() { + let mut ws = workspace_with(vec![]); + let uri = ws.virtual_url_generator.new_uri("lua/reverted.lua"); + write(&mut ws, &uri, "local keep = 1"); + + ws.analysis.apply_file_system_changes(vec![ + (uri.clone(), Some("local resurrected = 1".to_string())), + (uri.clone(), None), + ]); + + expect_that!(ws.analysis.get_file_id(&uri), none()); + } + + /// Distinct URIs in one batch settle independently. + #[gtest] + fn watched_batch_handles_distinct_uris() { + let mut ws = workspace_with(vec![]); + let kept_uri = ws.virtual_url_generator.new_uri("lua/kept.lua"); + let dropped_uri = ws.virtual_url_generator.new_uri("lua/dropped.lua"); + write(&mut ws, &kept_uri, "local kept_old = 1"); + write(&mut ws, &dropped_uri, "local dropped = 1"); + + ws.analysis.apply_file_system_changes(vec![ + (kept_uri.clone(), Some("local kept_new = 2".to_string())), + (dropped_uri.clone(), None), + ]); + + let kept_id = ws.analysis.get_file_id(&kept_uri).expect("kept present"); + expect_that!( + ws.analysis + .compilation + .get_db() + .get_vfs() + .get_file_content(&kept_id), + some(eq(&"local kept_new = 2".to_string())) + ); + expect_that!(ws.analysis.get_file_id(&dropped_uri), none()); + } + + /// A textual collision is still diagnostic-only. + /// + /// Both files reference the member key `Field`, but on different owners, so + /// the provider's type change has no semantic edge to the collider. The + /// collider is still returned (same textual name), yet its exports, revision + /// and diagnostics are unchanged. A library file referencing the same name + /// is not returned: annotations are not diagnostic targets. + #[gtest] + fn textual_collision_keeps_unchanged_index_snapshot() { + let mut ws = workspace_with(vec![DiagnosticCode::AssignTypeMismatch]); + let library_root = ws.virtual_url_generator.new_path("textual_collision_lib"); + ws.analysis.add_library_workspace(library_root); + + let provider_uri = ws.virtual_url_generator.new_uri("lua/collideprovider.lua"); + write(&mut ws, &provider_uri, "OwnerA = { Field = 1 }\n"); + let collider_uri = ws.virtual_url_generator.new_uri("lua/collideother.lua"); + let collider_id = write( + &mut ws, + &collider_uri, + r#" + OwnerB = { Field = "kept" } + ---@type string + local observed = OwnerB.Field + "#, + ); + let library_uri = ws + .virtual_url_generator + .new_uri("textual_collision_lib/annot.lua"); + let library_id = write( + &mut ws, + &library_uri, + "OwnerC = { Field = 1 }\nlocal _ = OwnerC.Field\n", + ); + // Sanity: the collider actually references `Field`, or the test is vacuous. + expect_that!( + ws.analysis + .compilation + .get_db() + .get_reference_index() + .files_referencing_name(&"Field".into()), + contains(eq(&collider_id)) + ); + + let revision_before = ws + .analysis + .compilation + .get_db() + .get_reference_index() + .file_reference_revision(collider_id); + let exports_before = fingerprint_of(&ws, collider_id); + let codes_before = codes_in(&ws, collider_id); + + let affected = ws.analysis.apply_file_system_changes(vec![( + provider_uri.clone(), + Some("OwnerA = { Field = \"changed\" }\n".to_string()), + )]); + + expect_that!(affected, contains(eq(&collider_id))); + expect_that!(affected, not(contains(eq(&library_id)))); + expect_that!( + ws.analysis + .compilation + .get_db() + .get_reference_index() + .file_reference_revision(collider_id), + eq(revision_before) + ); + expect_that!(fingerprint_of(&ws, collider_id), eq(&exports_before)); + expect_that!(codes_in(&ws, collider_id), eq(&codes_before)); + } + + /// Renames and removals cover both the old and the new name. + /// + /// The provider renames the member `Old` to `New` on the same owner and at + /// the same position, so the export diff holds one changed key whose + /// post-edit entry names `New` while only the pre-edit snapshot still holds + /// `Old` (a removed entry's `get_member` returns `None`, verified + /// empirically). Both referencers are returned, without reindexing either. + #[gtest] + fn rename_and_removal_cover_old_and_new_names() { + let mut ws = workspace_with(vec![DiagnosticCode::UndefinedField]); + let provider_uri = ws.virtual_url_generator.new_uri("lua/renameprovider.lua"); + write(&mut ws, &provider_uri, "RenameLib = { Old = 1 }\n"); + + let old_consumer_uri = ws.virtual_url_generator.new_uri("lua/renameold.lua"); + let old_consumer_id = write(&mut ws, &old_consumer_uri, "local _ = RenameLib.Old\n"); + let new_consumer_uri = ws.virtual_url_generator.new_uri("lua/renamenew.lua"); + let new_consumer_id = write(&mut ws, &new_consumer_uri, "local _ = RenameLib.New\n"); + expect_that!(codes_in(&ws, old_consumer_id), is_empty()); + expect_that!( + codes_in(&ws, new_consumer_id), + contains(eq(DiagnosticCode::UndefinedField.get_name())) + ); + + let affected = ws.analysis.apply_file_system_changes(vec![( + provider_uri.clone(), + Some("RenameLib = { New = 1 }\n".to_string()), + )]); + + expect_that!(affected, contains(eq(&old_consumer_id))); + expect_that!(affected, contains(eq(&new_consumer_id))); + // Old referencer now dangles; new referencer now resolves. + expect_that!( + codes_in(&ws, old_consumer_id), + contains(eq(DiagnosticCode::UndefinedField.get_name())) + ); + expect_that!(codes_in(&ws, new_consumer_id), is_empty()); + } + + /// Duplicate changed names yield one sorted, deduped entry. + /// + /// Two files in one batch change the same global `Dup`; the single + /// consumer referencing it must appear exactly once and the whole result + /// must already be sorted. + #[gtest] + fn duplicate_changed_names_yield_sorted_deduped_output() { + let mut ws = workspace_with(vec![]); + let provider_a_uri = ws.virtual_url_generator.new_uri("lua/dupa.lua"); + write(&mut ws, &provider_a_uri, "Dup = 1\n"); + let provider_b_uri = ws.virtual_url_generator.new_uri("lua/dupb.lua"); + write(&mut ws, &provider_b_uri, "Dup = 2\n"); + let consumer_uri = ws.virtual_url_generator.new_uri("lua/dupconsumer.lua"); + let consumer_id = write(&mut ws, &consumer_uri, "local observed = Dup\n"); + + let affected = ws.analysis.apply_file_system_changes(vec![ + (provider_b_uri.clone(), Some("Dup = \"b\"\n".to_string())), + (provider_a_uri.clone(), Some("Dup = \"a\"\n".to_string())), + ]); + + expect_that!(affected, contains(eq(&consumer_id))); + expect_that!( + affected.iter().filter(|id| **id == consumer_id).count(), + eq(1) + ); + let mut sorted = affected.clone(); + sorted.sort_unstable(); + sorted.dedup(); + expect_that!(affected, eq(&sorted)); + } + + /// A hub name fans out to every referencer, with no cap. + /// + /// Twenty-five consumers all reference `HubField`; changing its type must + /// return all of them. The count is asserted exactly so any truncation or + /// budget would fail the test. + #[gtest] + fn hub_name_fanout_is_reported_without_cap() { + const CONSUMERS: usize = 25; + let mut ws = workspace_with(vec![]); + let provider_uri = ws.virtual_url_generator.new_uri("lua/hubprovider.lua"); + write(&mut ws, &provider_uri, "Hub = { HubField = 1 }\n"); + + let mut consumer_ids = Vec::new(); + for index in 0..CONSUMERS { + let uri = ws + .virtual_url_generator + .new_uri(&format!("lua/hubconsumer{index}.lua")); + let file_id = write(&mut ws, &uri, "local observed = Hub.HubField\n"); + consumer_ids.push(file_id); + } + + let affected = ws.analysis.apply_file_system_changes(vec![( + provider_uri.clone(), + Some("Hub = { HubField = \"changed\" }\n".to_string()), + )]); + + for consumer_id in &consumer_ids { + expect_that!(affected, contains(eq(consumer_id))); + } + let returned_consumers = affected + .iter() + .filter(|file_id| consumer_ids.contains(file_id)) + .count(); + expect_that!(returned_consumers, eq(CONSUMERS)); + } + + /// A deletion through the filesystem batch detaches the URI rather than + /// leaving a tombstone, and the detach happens after the settle, not before + /// it: until the settle is done the path is what finds the files whose reads + /// of it have to be re-derived. + /// + /// The reader here resolved the deleted file through `include`, so it has to + /// come back to the state a workspace that never had the provider would be + /// in, and the provider's path has to be free for a later file to take. + #[gtest] + fn a_filesystem_deletion_detaches_the_uri_after_settling_its_readers() { + let mut ws = workspace_with(vec![DiagnosticCode::UndefinedGlobal]); + + let provider_uri = ws.virtual_url_generator.new_uri("lua/detachprovider.lua"); + write( + &mut ws, + &provider_uri, + r#" + DetachProvided = 1 + "#, + ); + let provider_id = ws.analysis.get_file_id(&provider_uri).expect("provider id"); + + let reader_uri = ws.virtual_url_generator.new_uri("lua/detachreader.lua"); + let reader_id = write( + &mut ws, + &reader_uri, + r#" + local _ = DetachProvided + "#, + ); + expect_that!(codes_in(&ws, reader_id), is_empty()); + + let affected = ws + .analysis + .apply_file_system_changes(vec![(provider_uri.clone(), None)]); + + // The deleted file is gone, so it cannot be in a set of live files. + expect_that!(affected, not(contains(eq(&provider_id)))); + // Detached, not tombstoned: the path is free again. + expect_that!(ws.analysis.get_file_id(&provider_uri), none()); + // Its reader was settled against the deletion. + expect_that!(codes_in(&ws, reader_id), not(is_empty())); + + // A new file at the same path is a new file, and restores the reader. + write( + &mut ws, + &provider_uri, + r#" + DetachProvided = 1 + "#, + ); + expect_that!(codes_in(&ws, reader_id), is_empty()); + } + + /// 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) + .map(|(id, _)| id); + // 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))); + } + + #[test] + fn editing_a_nested_record_refreshes_cross_file_pairs_value_caches() { + const CONSUMER: &str = r#" + for _, value in pairs(cityrp.configuration["Contraband"]) do end + "#; + const BEFORE: &str = r#" + cityrp = {} + cityrp.configuration = {} + cityrp.configuration["Contraband"] = {} + cityrp.configuration["Contraband"]["first"] = { + damageReduction = 0.05, + } + cityrp.configuration["Contraband"]["second"] = { + maximum = 2, + } + "#; + const AFTER: &str = r#" + -- shifted during the edit + cityrp = {} + cityrp.configuration = {} + cityrp.configuration["Contraband"] = {} + cityrp.configuration["Contraband"]["first"] = { + damageReduction = 0.06, + } + cityrp.configuration["Contraband"]["second"] = { + maximum = 2, + } + "#; + + fn pairs_value_field(ws: &VirtualWorkspace, file_id: FileId) -> LuaType { + let db = ws.analysis.compilation.get_db(); + let value_id = db + .get_decl_index() + .get_decl_tree(&file_id) + .and_then(|tree| { + tree.get_decls() + .values() + .find(|decl| decl.get_name() == "value") + .map(|decl| decl.get_id()) + }) + .expect("pairs value declaration"); + let LuaType::Object(value) = db + .get_type_index() + .get_type_cache(&value_id.into()) + .map(|cache| cache.as_type()) + .expect("pairs value type") + else { + panic!("pairs value should be a compact record"); + }; + value + .get_field(&LuaMemberKey::Name("damageReduction".into())) + .cloned() + .expect("damageReduction field") + } + + let mut warm = VirtualWorkspace::new_with_init_std_lib(); + let provider_uri = warm.virtual_url_generator.new_uri("lua/a_config.lua"); + let consumer_uri = warm.virtual_url_generator.new_uri("lua/b_consumer.lua"); + warm.analysis.update_files_by_uri_sorted(vec![ + (provider_uri.clone(), Some(BEFORE.to_string())), + (consumer_uri.clone(), Some(CONSUMER.to_string())), + ]); + let consumer_id = warm + .analysis + .get_file_id(&consumer_uri) + .expect("consumer file id"); + write(&mut warm, &provider_uri, AFTER); + + let mut fresh = VirtualWorkspace::new_with_init_std_lib(); + let fresh_provider_uri = fresh.virtual_url_generator.new_uri("lua/a_config.lua"); + let fresh_consumer_uri = fresh.virtual_url_generator.new_uri("lua/b_consumer.lua"); + fresh.analysis.update_files_by_uri_sorted(vec![ + (fresh_provider_uri, Some(AFTER.to_string())), + (fresh_consumer_uri.clone(), Some(CONSUMER.to_string())), + ]); + let fresh_consumer_id = fresh + .analysis + .get_file_id(&fresh_consumer_uri) + .expect("fresh consumer file id"); + + let warm_field = pairs_value_field(&warm, consumer_id); + let fresh_field = pairs_value_field(&fresh, fresh_consumer_id); + assert_eq!(warm.humanize_type(warm_field.clone()), "0.06?"); + assert_eq!(warm_field, fresh_field); + } + + #[test] + fn semantic_edit_rebuilds_an_ordinary_structural_generic_cache() { + const CONSUMER: &str = r#" + ---@generic T: table, U: table + ---@param a T + ---@param b U + ---@return Merge + local function extend(a, b) end + + local merged = extend(Provider, {}) + local observed = merged.value + "#; + + let mut warm = VirtualWorkspace::new_with_init_std_lib(); + let provider_uri = warm.virtual_url_generator.new_uri("lua/a_provider.lua"); + let consumer_uri = warm.virtual_url_generator.new_uri("lua/b_consumer.lua"); + warm.analysis.update_files_by_uri_sorted(vec![ + ( + provider_uri.clone(), + Some("Provider = { value = 1 }".to_string()), + ), + (consumer_uri.clone(), Some(CONSUMER.to_string())), + ]); + let consumer_id = warm + .analysis + .get_file_id(&consumer_uri) + .expect("consumer file id"); + write(&mut warm, &provider_uri, "Provider = { value = \"new\" }"); + + let mut fresh = VirtualWorkspace::new_with_init_std_lib(); + let fresh_provider_uri = fresh.virtual_url_generator.new_uri("lua/a_provider.lua"); + let fresh_consumer_uri = fresh.virtual_url_generator.new_uri("lua/b_consumer.lua"); + fresh.analysis.update_files_by_uri_sorted(vec![ + ( + fresh_provider_uri, + Some("Provider = { value = \"new\" }".to_string()), + ), + (fresh_consumer_uri.clone(), Some(CONSUMER.to_string())), + ]); + let fresh_consumer_id = fresh + .analysis + .get_file_id(&fresh_consumer_uri) + .expect("fresh consumer file id"); + + let warm_observed = decl_type(&warm, consumer_id, "observed"); + let fresh_observed = decl_type(&fresh, fresh_consumer_id, "observed"); + assert!( + matches!(&warm_observed, LuaType::StringConst(value) if value.as_str() == "new"), + "edited structural cache should contain the new source value, got {warm_observed:?}" + ); + assert_eq!(warm_observed, fresh_observed); + assert_eq!( + decl_type(&warm, consumer_id, "merged"), + decl_type(&fresh, fresh_consumer_id, "merged") + ); + } + + #[test] + fn producer_edit_refreshes_cross_file_inferred_return_consumers() { + const RETURNER: &str = "function GetStateValue() return State.value end"; + const MOVED_RETURNER: &str = + "local padding = true\nfunction GetStateValue() return State.value end"; + const CONSUMER: &str = "local observed = GetStateValue()"; + + let mut warm = VirtualWorkspace::new_with_init_std_lib(); + let producer_uri = warm.virtual_url_generator.new_uri("lua/a_producer.lua"); + let returner_uri = warm.virtual_url_generator.new_uri("lua/b_returner.lua"); + let consumer_uri = warm.virtual_url_generator.new_uri("lua/c_consumer.lua"); + warm.analysis.update_files_by_uri_sorted(vec![ + ( + producer_uri.clone(), + Some("State = { value = 1 }".to_string()), + ), + (returner_uri.clone(), Some(RETURNER.to_string())), + (consumer_uri.clone(), Some(CONSUMER.to_string())), + ]); + let producer_id = warm + .analysis + .get_file_id(&producer_uri) + .expect("producer file id"); + let returner_id = warm + .analysis + .get_file_id(&returner_uri) + .expect("returner file id"); + let consumer_id = warm + .analysis + .get_file_id(&consumer_uri) + .expect("consumer file id"); + let expansion = warm.analysis.expand_reindex_file_ids(vec![producer_id]); + assert!(expansion.contains(&returner_id)); + assert!(expansion.contains(&consumer_id)); + write_deferred(&mut warm, &returner_uri, MOVED_RETURNER); + write_deferred(&mut warm, &producer_uri, "State = { value = \"new\" }"); + + let mut fresh = VirtualWorkspace::new_with_init_std_lib(); + let fresh_producer_uri = fresh.virtual_url_generator.new_uri("lua/a_producer.lua"); + let fresh_returner_uri = fresh.virtual_url_generator.new_uri("lua/b_returner.lua"); + let fresh_consumer_uri = fresh.virtual_url_generator.new_uri("lua/c_consumer.lua"); + fresh.analysis.update_files_by_uri_sorted(vec![ + ( + fresh_producer_uri, + Some("State = { value = \"new\" }".to_string()), + ), + (fresh_returner_uri, Some(MOVED_RETURNER.to_string())), + (fresh_consumer_uri.clone(), Some(CONSUMER.to_string())), + ]); + let fresh_consumer_id = fresh + .analysis + .get_file_id(&fresh_consumer_uri) + .expect("fresh consumer file id"); + + let warm_observed = decl_type(&warm, consumer_id, "observed"); + assert!( + matches!(&warm_observed, LuaType::StringConst(value) if value.as_str() == "new"), + "edited inferred return should contain the new source value, got {warm_observed:?}" + ); + assert_eq!( + warm_observed, + decl_type(&fresh, fresh_consumer_id, "observed") + ); + } + + #[test] + fn member_addition_refreshes_cross_file_failed_lookup_consumers() { + const CONSUMER: &str = "local observed = Provider.value"; + + let mut warm = VirtualWorkspace::new_with_init_std_lib(); + let provider_uri = warm.virtual_url_generator.new_uri("lua/a_provider.lua"); + let consumer_uri = warm.virtual_url_generator.new_uri("lua/b_consumer.lua"); + warm.analysis.update_files_by_uri_sorted(vec![ + (provider_uri.clone(), Some("Provider = {}".to_string())), + (consumer_uri.clone(), Some(CONSUMER.to_string())), + ]); + let provider_id = warm + .analysis + .get_file_id(&provider_uri) + .expect("provider file id"); + let consumer_id = warm + .analysis + .get_file_id(&consumer_uri) + .expect("consumer file id"); + let expansion = warm.analysis.expand_reindex_file_ids(vec![provider_id]); + assert!(expansion.contains(&consumer_id)); + write_deferred(&mut warm, &provider_uri, "Provider = { value = \"new\" }"); + + let mut fresh = VirtualWorkspace::new_with_init_std_lib(); + let fresh_provider_uri = fresh.virtual_url_generator.new_uri("lua/a_provider.lua"); + let fresh_consumer_uri = fresh.virtual_url_generator.new_uri("lua/b_consumer.lua"); + fresh.analysis.update_files_by_uri_sorted(vec![ + ( + fresh_provider_uri, + Some("Provider = { value = \"new\" }".to_string()), + ), + (fresh_consumer_uri.clone(), Some(CONSUMER.to_string())), + ]); + let fresh_consumer_id = fresh + .analysis + .get_file_id(&fresh_consumer_uri) + .expect("fresh consumer file id"); + + let warm_observed = decl_type(&warm, consumer_id, "observed"); + assert!( + matches!(&warm_observed, LuaType::StringConst(value) if value.as_str() == "new"), + "new member should replace the failed lookup, got {warm_observed:?}" + ); + assert_eq!( + warm_observed, + decl_type(&fresh, fresh_consumer_id, "observed") + ); + } + + #[test] + fn failed_lookup_survives_anonymous_table_owner_remap() { + const INITIAL: &str = "function GetContainer() return { inner = {} } end"; + const MOVED: &str = "function GetContainer() return { -- shift\n inner = {} } end"; + const POPULATED: &str = + "function GetContainer() return { -- shift\n inner = { value = \"new\" } } end"; + const CONSUMER: &str = + "local container = GetContainer()\nlocal observed = container.inner.value"; + + let mut warm = VirtualWorkspace::new_with_init_std_lib(); + let provider_uri = warm.virtual_url_generator.new_uri("lua/a_provider.lua"); + let consumer_uri = warm.virtual_url_generator.new_uri("lua/b_consumer.lua"); + warm.analysis.update_files_by_uri_sorted(vec![ + (provider_uri.clone(), Some(INITIAL.to_string())), + (consumer_uri.clone(), Some(CONSUMER.to_string())), + ]); + let consumer_id = warm + .analysis + .get_file_id(&consumer_uri) + .expect("consumer file id"); + write_deferred(&mut warm, &provider_uri, MOVED); + write_deferred(&mut warm, &provider_uri, POPULATED); + + let mut fresh = VirtualWorkspace::new_with_init_std_lib(); + let fresh_provider_uri = fresh.virtual_url_generator.new_uri("lua/a_provider.lua"); + let fresh_consumer_uri = fresh.virtual_url_generator.new_uri("lua/b_consumer.lua"); + fresh.analysis.update_files_by_uri_sorted(vec![ + (fresh_provider_uri, Some(POPULATED.to_string())), + (fresh_consumer_uri.clone(), Some(CONSUMER.to_string())), + ]); + let fresh_consumer_id = fresh + .analysis + .get_file_id(&fresh_consumer_uri) + .expect("fresh consumer file id"); + + let warm_observed = decl_type(&warm, consumer_id, "observed"); + assert!( + matches!(&warm_observed, LuaType::StringConst(value) if value.as_str() == "new"), + "new member should replace the remapped failed lookup, got {warm_observed:?}" + ); + assert_eq!( + warm_observed, + decl_type(&fresh, fresh_consumer_id, "observed") + ); + } + + /// Cold-versus-single-file assignment-policy regression: a `target[k] = v` + /// write inside a loop body must carry the non-overwriting mark no matter + /// which pass created the member. + /// + /// The writer sorts before the file that defines its tables, so during the + /// cold batch the `Target` prefix is not inferable while the writer is + /// walked and the loop-body member is created later by the deferred + /// index-expression path. A single-file re-index finds the prefix + /// immediately and marks the member through + /// `is_member_assignment_in_conditional_branch`. The export map hashes the + /// mark, so a cold member that misses it makes every unrelated edit to the + /// file look like an export change and ripples dependents. + #[test] + fn deferred_loop_body_write_marks_non_overwriting_from_cold_index() { + use glua_parser::LuaAstNode; + + const DEFS: &str = r#" +Source = { + alpha = 1, + beta = 2, +} +Target = {} +"#; + const WRITER: &str = r#" +for k in pairs(Source) do + Target[k] = true +end +"#; + const READER: &str = r#" +local direct = Target.alpha +for _, value in pairs(Target) do + local _ = value +end +"#; + const WRITER_CHANGED: &str = r#" +for k in pairs(Source) do + Target[k] = 1 +end +"#; + + /// The loop write's member, located from syntax rather than from a + /// hard-coded offset: the member id is the write's own syntax node in + /// this file. + fn loop_write_member_id(ws: &VirtualWorkspace, file_id: FileId) -> crate::LuaMemberId { + let db = ws.analysis.compilation.get_db(); + let tree = db + .get_vfs() + .get_syntax_tree(&file_id) + .expect("writer syntax tree"); + let chunk = tree.get_chunk_node(); + for assign_stat in LuaAstNode::descendants::(&chunk) { + let (vars, _) = assign_stat.get_var_and_expr_list(); + for var in vars { + if var.syntax().text() == "Target[k]" + && let glua_parser::LuaVarExpr::IndexExpr(index_expr) = var + { + return crate::LuaMemberId::new(index_expr.get_syntax_id(), file_id); + } + } + } + panic!("loop write `Target[k] = true` not found in writer"); + } + + fn is_non_overwriting(ws: &VirtualWorkspace, member_id: crate::LuaMemberId) -> bool { + ws.analysis + .compilation + .get_db() + .get_member_index() + .is_non_overwriting_assignment_member(member_id) + } + + // Cold build: every file lands in one batch with the writer ordered + // first, so the writer's `Target` prefix only settles after its own + // Lua walk and the loop-body member goes through deferred creation. + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let writer_uri = ws.virtual_url_generator.new_uri("lua/a_writer.lua"); + let defs_uri = ws.virtual_url_generator.new_uri("lua/b_defs.lua"); + let reader_uri = ws.virtual_url_generator.new_uri("lua/c_reader.lua"); + ws.analysis.update_files_by_uri_sorted(vec![ + (writer_uri.clone(), Some(WRITER.to_string())), + (defs_uri.clone(), Some(DEFS.to_string())), + (reader_uri.clone(), Some(READER.to_string())), + ]); + let writer_id = ws + .analysis + .get_file_id(&writer_uri) + .expect("writer file id"); + let reader_id = ws + .analysis + .get_file_id(&reader_uri) + .expect("reader file id"); + + let member_id = loop_write_member_id(&ws, writer_id); + { + let db = ws.analysis.compilation.get_db(); + let member_index = db.get_member_index(); + let member = member_index + .get_member(&member_id) + .expect("cold index must create the loop-body member"); + assert!( + member_index.is_non_overwriting_assignment_member(member_id), + "cold loop-body write must carry the non-overwriting mark; \ + got non_overwriting=false for member {member_id:?} \ + key={:?} owner={:?} feature={:?}", + member.get_key(), + member_index.get_member_owner(&member_id), + member.get_feature(), + ); + } + + let codes_before = codes_in(&ws, reader_id); + let direct_before = decl_type(&ws, reader_id, "direct"); + let value_before = decl_type(&ws, reader_id, "value"); + + // Production staged edit path for a semantic no-op: text first, then + // phase 1, then ripple. It must move no export — including the + // assignment-policy mark — and owe dependents no work. + let noop = format!("{WRITER}-- unrelated touch\n"); + let staged = ws + .analysis + .update_file_text_only(&writer_uri, noop) + .expect("staged file id"); + assert_eq!(staged, writer_id); + let dirty = ws.analysis.self_index_and_diff(vec![writer_id]); + assert!( + dirty.changed_sources().is_empty(), + "unrelated EOF comment must move no export" + ); + assert!( + dirty.is_empty(), + "unrelated EOF comment owes dependents no work" + ); + ws.analysis.ripple(dirty); + + // The EOF comment shifts no loop offset, so the member keeps its + // identity and mark, and downstream facts are untouched. + assert_eq!(loop_write_member_id(&ws, writer_id), member_id); + assert!(is_non_overwriting(&ws, member_id)); + assert_eq!(codes_in(&ws, reader_id), codes_before); + assert_eq!(decl_type(&ws, reader_id, "direct"), direct_before); + assert_eq!(decl_type(&ws, reader_id, "value"), value_before); + + // Paired real edit: changing what the loop stores must still reach the + // dependent, or the no-op assertions above could be met by never + // invalidating anything. + let staged = ws + .analysis + .update_file_text_only(&writer_uri, WRITER_CHANGED.to_string()) + .expect("staged file id"); + assert_eq!(staged, writer_id); + let dirty = ws.analysis.self_index_and_diff(vec![writer_id]); + assert!( + dirty.changed_sources().contains(&writer_id), + "loop-semantics change must move the writer's exports" + ); + assert!( + dirty.files().contains(&reader_id), + "loop-semantics change must dirty the target reader" + ); + ws.analysis.ripple(dirty); + + // The rippled workspace agrees with a fresh cold build of the same + // texts: no stale dependent facts survive the propagation. + let mut fresh = VirtualWorkspace::new_with_init_std_lib(); + let fresh_writer_uri = fresh.virtual_url_generator.new_uri("lua/a_writer.lua"); + let fresh_defs_uri = fresh.virtual_url_generator.new_uri("lua/b_defs.lua"); + let fresh_reader_uri = fresh.virtual_url_generator.new_uri("lua/c_reader.lua"); + fresh.analysis.update_files_by_uri_sorted(vec![ + (fresh_writer_uri, Some(WRITER_CHANGED.to_string())), + (fresh_defs_uri, Some(DEFS.to_string())), + (fresh_reader_uri.clone(), Some(READER.to_string())), + ]); + let fresh_reader_id = fresh + .analysis + .get_file_id(&fresh_reader_uri) + .expect("fresh reader file id"); + assert_eq!( + decl_type(&ws, reader_id, "value"), + decl_type(&fresh, fresh_reader_id, "value") + ); + assert_eq!( + decl_type(&ws, reader_id, "direct"), + decl_type(&fresh, fresh_reader_id, "direct") + ); + } + + /// A `---@module` holder caches `LuaType::ModuleRef(provider)` without + /// reading any member, so no key in the export diff names it. The provider + /// keeps the same table identity across the edit, so the diff holds no + /// file-level key either and neither the reverse type index nor the file + /// dependencies can reach the holder: only the seeded provider identity + /// does. Without it the holder stays settled. + #[gtest] + fn module_holder_is_dirtied_by_a_member_type_change() { + let mut ws = workspace_with(vec![]); + let first = r#" + local M = {} + M.value = 1 + return M + "#; + let second = r#" + local M = {} + M.value = "text" + return M + "#; + let provider_uri = ws.virtual_url_generator.new_uri("modprovider.lua"); + let provider_id = write(&mut ws, &provider_uri, first); + + let consumer_uri = ws.virtual_url_generator.new_uri("modconsumer.lua"); + let consumer_id = write( + &mut ws, + &consumer_uri, + r#" + ---@module "modprovider" + ModuleProvider = {} + + local holder = ModuleProvider + "#, + ); + // The holder has to actually cache the module reference, or the + // assertion below would hold for a file that reads nothing. + expect_that!( + decl_type(&ws, consumer_id, "holder"), + eq(&LuaType::ModuleRef(provider_id)) + ); + + let before = fingerprint_before_edit(&ws, provider_id, first, second); + ws.analysis + .update_file_text_only(&provider_uri, second.to_string()) + .expect("staged provider text"); + let dirty = ws.analysis.self_index_and_diff(vec![provider_id]); + let after = fingerprint_of(&ws, provider_id); + let diff = crate::diff_exports(&before, &after); + + // The export change is real, but names no file-level section, so the + // file-dependency expansion cannot be what dirties the holder. + expect_that!(dirty.changed_sources(), contains(eq(&provider_id))); + expect_that!( + diff.keys().any(|key| matches!( + key, + crate::ExportKey::ModuleExport + | crate::ExportKey::LoadEdges + | crate::ExportKey::Namespace + | crate::ExportKey::FileRealmMetadata + )), + is_false() + ); + // The holder never reads the changed member, so the inference-node + // lookup the changed key drives cannot be what dirties it either: + // without the seeded module identity nothing would. + let member_id = ws + .analysis + .compilation + .get_db() + .get_member_index() + .get_file_members(provider_id) + .iter() + .find(|member| member.get_key() == &crate::LuaMemberKey::Name("value".into())) + .map(|member| member.get_id()) + .expect("provider value member"); + let node = crate::LuaInferenceNodeId::TypeOwner(crate::LuaTypeOwner::Member(member_id)); + expect_that!( + ws.analysis + .compilation + .get_db() + .get_type_index() + .files_depending_on_inference_nodes(std::slice::from_ref(&node)) + .contains(&consumer_id), + is_false() + ); + expect_that!(dirty.files(), contains(eq(&consumer_id))); + } + + /// A ripple-moved inference still refreshes the file that only names it. + #[gtest] + fn ripple_moved_inference_refreshes_its_lazy_reader() { + // The provider's edit re-analyses the middleman, whose inferred global + // follows the provider's value; the middleman's own export diff then + // names the consumer that reads it. The consumer sits in neither dirty + // set, keeps its reference revision, and still comes back from the + // watched batch. + let mut ws = workspace_with(vec![]); + let provider_uri = ws.virtual_url_generator.new_uri("lua/chainprovider.lua"); + let provider_id = write(&mut ws, &provider_uri, "Chain = { Value = 1 }\n"); + let middle_uri = ws.virtual_url_generator.new_uri("lua/chainmiddle.lua"); + let middle_id = write(&mut ws, &middle_uri, "Binferred = Chain.Value\n"); + let consumer_uri = ws.virtual_url_generator.new_uri("lua/chainconsumer.lua"); + let consumer_id = write(&mut ws, &consumer_uri, "local got = Binferred\n"); + + // Staged: the middleman is change-driven dirty, the consumer is not. + ws.analysis + .update_file_text_only(&provider_uri, "Chain = { Value = \"text\" }\n".to_string()) + .expect("staged provider text"); + let dirty = ws.analysis.self_index_and_diff(vec![provider_id]); + expect_that!(dirty.files(), contains(eq(&middle_id))); + expect_that!(dirty.files(), not(contains(eq(&consumer_id)))); + ws.analysis.ripple(dirty); + + // Watched from clean: the consumer comes back with its revision put. + write(&mut ws, &provider_uri, "Chain = { Value = 1 }\n"); + let revision_before = revision_of(&ws, consumer_id); + let affected = ws.analysis.apply_file_system_changes(vec![( + provider_uri.clone(), + Some("Chain = { Value = \"text\" }\n".to_string()), + )]); + expect_that!(affected, contains(eq(&middle_id))); + expect_that!(affected, contains(eq(&consumer_id))); + expect_that!(revision_of(&ws, consumer_id), eq(revision_before)); + } + + /// A contributed-param change names the callee's owner, not the contributor's. + /// + /// The caller contributes argument types to the callee's signature, so the + /// `ContributedParam` key lives in the caller's export map while the + /// signature — and the function it belongs to — lives in the callee's + /// file. The owner resolves in the signature's own file (`Run` on the + /// mixin); the caller's own globals (`CallerMarker`) stay out of the + /// refresh set, which the old all-visible-names fallback got wrong. + #[gtest] + fn contributed_param_change_names_the_callee_owner() { + let caller_text = |argument: &str| { + format!( + r#" + CallerMarker = {{}} + 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 caller_uri = ws.virtual_url_generator.new_uri("lua/autorun/consumer.lua"); + let caller_id = write(&mut ws, &caller_uri, &caller_text("self")); + // The call has to actually contribute, or the diff below is vacuous. + expect_that!( + ws.analysis + .compilation + .get_db() + .get_call_site_param_index() + .iter_inferred_params() + .count(), + gt(0) + ); + + let before = + fingerprint_before_edit(&ws, caller_id, &caller_text("self"), &caller_text("OTHER")); + ws.analysis + .update_file_text_only(&caller_uri, caller_text("OTHER")) + .expect("staged caller text"); + let dirty = ws.analysis.self_index_and_diff(vec![caller_id]); + // The change is a contributed-param move in the caller's map, dirtying + // nothing, and the refresh names the callee owner without the + // contributor's own globals. + expect_that!(dirty.changed_sources(), contains(eq(&caller_id))); + expect_that!(dirty.dirty_len(), eq(0)); + let after = fingerprint_of(&ws, caller_id); + let diff = crate::diff_exports(&before, &after); + expect_that!( + diff.keys() + .any(|key| matches!(key, crate::ExportKey::ContributedParam(..))), + is_true() + ); + let mut names: Vec = dirty + .textual_refresh_names() + .iter() + .map(|name| name.as_str().to_string()) + .collect(); + names.sort_unstable(); + expect_that!(names, contains(eq(&"Run".to_string()))); + expect_that!(names, not(contains(eq(&"CallerMarker".to_string())))); + ws.analysis.ripple(dirty); + } + + /// An assignment-form signature owner still names its member. + /// + /// `M.Fn = function() end` stores the signature in the member's type + /// cache rather than sharing a property with it, so the property leg + /// finds nothing and only the type-cache scan names the owner. Distinct + /// from the function-stat (`function M.Fn() end`) property-leg test + /// above; the contributor's own globals stay out either way. + #[gtest] + fn contributed_param_change_names_the_assignment_form_owner() { + let caller_text = |argument: &str| { + format!( + r#" + CallerMarker = {{}} + local PANEL = {{}} + local OTHER = {{}} + function PANEL:ProvidedByReceiver() end + function OTHER:SomethingElse() end + function PANEL:Load() + self.Mixin = include("mixins/shared_assign.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_assign.lua", + r#" + local MIXIN = {} + MIXIN.Run = function(self) + self:ProvidedByReceiver() + end + return MIXIN + "#, + ); + let caller_uri = ws + .virtual_url_generator + .new_uri("lua/autorun/assignconsumer.lua"); + let caller_id = write(&mut ws, &caller_uri, &caller_text("self")); + expect_that!( + ws.analysis + .compilation + .get_db() + .get_call_site_param_index() + .iter_inferred_params() + .count(), + gt(0) + ); + + let before = + fingerprint_before_edit(&ws, caller_id, &caller_text("self"), &caller_text("OTHER")); + ws.analysis + .update_file_text_only(&caller_uri, caller_text("OTHER")) + .expect("staged caller text"); + let dirty = ws.analysis.self_index_and_diff(vec![caller_id]); + expect_that!(dirty.changed_sources(), contains(eq(&caller_id))); + expect_that!(dirty.dirty_len(), eq(0)); + let after = fingerprint_of(&ws, caller_id); + let diff = crate::diff_exports(&before, &after); + expect_that!( + diff.keys() + .any(|key| matches!(key, crate::ExportKey::ContributedParam(..))), + is_true() + ); + let mut names: Vec = dirty + .textual_refresh_names() + .iter() + .map(|name| name.as_str().to_string()) + .collect(); + names.sort_unstable(); + expect_that!(names, contains(eq(&"Run".to_string()))); + expect_that!(names, not(contains(eq(&"CallerMarker".to_string())))); + ws.analysis.ripple(dirty); + } + + /// A late callee move still refreshes its lazy reader. + /// + /// The contributor edit reindexes its callee late, after the outer ripple + /// already ran, changing a differently named export there (`Out`, not + /// `Run`). That reindexes the middleman (`MidLateInferred = + /// ParamLateHolder.Out`), whose own diff then names the lazy reader + /// (`local got = MidLateInferred`). Both the nested self-index names and + /// the nested ripple names bubble into the outer sideband; without that + /// the reader keeps a stale report. Diagnostic-only throughout: no extra + /// reindex, no revision move for the reader. + #[gtest] + fn contributed_param_late_callee_move_refreshes_lazy_reader() { + let caller_text = |argument: &str| { + format!( + r#" + local PANEL = {{}} + local OTHER = {{}} + function PANEL:ProvidedByReceiver() end + function OTHER:SomethingElse() end + function PANEL:Load() + self.Mixin = include("mixins/shared_late.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_late.lua", + r#" + local MIXIN = {} + ParamLateHolder = {} + function MIXIN.Run(self) + ParamLateHolder.Out = self + end + return MIXIN + "#, + ); + let caller_uri = ws + .virtual_url_generator + .new_uri("lua/autorun/lateconsumer.lua"); + let caller_id = write(&mut ws, &caller_uri, &caller_text("self")); + let callee_uri = ws + .virtual_url_generator + .new_uri("lua/mixins/shared_late.lua"); + let callee_id = ws.analysis.get_file_id(&callee_uri).expect("callee id"); + let middle_uri = ws.virtual_url_generator.new_uri("lua/latemiddle.lua"); + let middle_id = write( + &mut ws, + &middle_uri, + "MidLateInferred = ParamLateHolder.Out\n", + ); + let reader_uri = ws.virtual_url_generator.new_uri("lua/latereader.lua"); + let reader_id = write(&mut ws, &reader_uri, "local got = MidLateInferred\n"); + expect_that!( + ws.analysis + .compilation + .get_db() + .get_call_site_param_index() + .iter_inferred_params() + .count(), + gt(0) + ); + + // Staged: the contributor move is a contributed-param change that + // dirties nothing change-driven, while the callee owner is named. + ws.analysis + .update_file_text_only(&caller_uri, caller_text("OTHER")) + .expect("staged contributor text"); + let dirty = ws.analysis.self_index_and_diff(vec![caller_id]); + expect_that!(dirty.changed_sources(), contains(eq(&caller_id))); + expect_that!(dirty.dirty_len(), eq(0)); + expect_that!(dirty.files(), not(contains(eq(&reader_id)))); + ws.analysis.ripple(dirty); + + // Watched from clean: the callee and the middleman are reindexed, the + // lazy reader comes back un-reindexed via the bubbled sideband. + write(&mut ws, &caller_uri, &caller_text("self")); + let revision_before = revision_of(&ws, reader_id); + let affected = ws + .analysis + .apply_file_system_changes(vec![(caller_uri.clone(), Some(caller_text("OTHER")))]); + expect_that!(affected, contains(eq(&callee_id))); + expect_that!(affected, contains(eq(&middle_id))); + expect_that!(affected, contains(eq(&reader_id))); + expect_that!(revision_of(&ws, reader_id), eq(revision_before)); + let mut sorted = affected.clone(); + sorted.sort_unstable(); + sorted.dedup(); + expect_that!(affected, eq(&sorted)); + } + + /// A removed non-name member still names its owner. + /// + /// The dropped slot's integer key is no Lua name, but its owner — the + /// `Arr` global path — is, on both the pre and the post side. The owner's + /// referencers are refreshed without reindexing them. + #[gtest] + fn non_name_member_removal_refreshes_owner_referencers() { + let mut ws = workspace_with(vec![]); + let first = "Arr = {}\nArr[1] = \"a\"\nArr[2] = \"b\"\n"; + let second = "Arr = {}\nArr[1] = \"a\"\n"; + let provider_uri = ws.virtual_url_generator.new_uri("lua/arrprovider.lua"); + let provider_id = write(&mut ws, &provider_uri, first); + let consumer_uri = ws.virtual_url_generator.new_uri("lua/arrconsumer.lua"); + let consumer_id = write(&mut ws, &consumer_uri, "local observed = Arr\n"); + + // The dropped slot really is a non-name member on a named owner, or + // the refresh below would prove nothing. + { + let db = ws.analysis.compilation.get_db(); + let member_index = db.get_member_index(); + let dropped = member_index + .get_file_members(provider_id) + .iter() + .find(|member| member.get_key() == &crate::LuaMemberKey::Integer(2)) + .map(|member| member.get_id()) + .expect("integer slot under test"); + let owner = member_index + .get_member_owner(&dropped) + .map(|owner| format!("{owner:?}")); + expect_that!(owner, some(contains_substring("Arr"))); + } + + // Staged: the removal moves an export but dirties nothing, and the + // refresh carries the owner rather than the integer key. + ws.analysis + .update_file_text_only(&provider_uri, second.to_string()) + .expect("staged provider text"); + let dirty = ws.analysis.self_index_and_diff(vec![provider_id]); + expect_that!(dirty.changed_sources(), contains(eq(&provider_id))); + expect_that!(dirty.dirty_len(), eq(0)); + let mut names: Vec = dirty + .textual_refresh_names() + .iter() + .map(|name| name.as_str().to_string()) + .collect(); + names.sort_unstable(); + expect_that!(names, contains(eq(&"Arr".to_string()))); + ws.analysis.ripple(dirty); + + // Watched from clean: the owner referencer comes back un-reindexed. + write(&mut ws, &provider_uri, first); + let revision_before = revision_of(&ws, consumer_id); + let affected = ws + .analysis + .apply_file_system_changes(vec![(provider_uri.clone(), Some(second.to_string()))]); + expect_that!(affected, contains(eq(&consumer_id))); + expect_that!(revision_of(&ws, consumer_id), eq(revision_before)); + } + + /// A same-position global rename moves no export key, yet both the old and + /// the new name's referencers owe a diagnostic refresh. + /// + /// The rename check runs independently of the export diff, before its + /// early exit: `function OldName() end` to `function NewName() end` keeps + /// every position-derived key identical, so the dirty set stays empty + /// while the refresh names carry both sides. Both halves stay + /// diagnostic-only: no reindex, no revision move. + #[gtest] + fn global_function_rename_refreshes_old_and_new_referencers() { + let mut ws = workspace_with(vec![DiagnosticCode::UndefinedGlobal]); + let first = "function OldName() end\n"; + let second = "function NewName() end\n"; + let provider_uri = ws.virtual_url_generator.new_uri("lua/renamefunc.lua"); + let provider_id = write(&mut ws, &provider_uri, first); + + let old_consumer_uri = ws.virtual_url_generator.new_uri("lua/usesold.lua"); + let old_consumer_id = write(&mut ws, &old_consumer_uri, "OldName()\n"); + let new_consumer_uri = ws.virtual_url_generator.new_uri("lua/usesnew.lua"); + let new_consumer_id = write(&mut ws, &new_consumer_uri, "NewName()\n"); + expect_that!(codes_in(&ws, old_consumer_id), is_empty()); + expect_that!( + codes_in(&ws, new_consumer_id), + contains(eq(DiagnosticCode::UndefinedGlobal.get_name())) + ); + + // Staged: the rename moves no export, so nothing is dirty, but both + // names are named for refresh. + ws.analysis + .update_file_text_only(&provider_uri, second.to_string()) + .expect("staged provider text"); + let dirty = ws.analysis.self_index_and_diff(vec![provider_id]); + expect_that!(dirty.dirty_len(), eq(0)); + expect_true!(dirty.changed_sources().is_empty()); + let mut names: Vec = dirty + .textual_refresh_names() + .iter() + .map(|name| name.as_str().to_string()) + .collect(); + names.sort_unstable(); + expect_that!(names, contains(eq(&"OldName".to_string()))); + expect_that!(names, contains(eq(&"NewName".to_string()))); + ws.analysis.ripple(dirty); + + // Watched from clean: both referencers come back, un-reindexed, and + // their diagnostics flip sides. + write(&mut ws, &provider_uri, first); + let old_revision_before = revision_of(&ws, old_consumer_id); + let new_revision_before = revision_of(&ws, new_consumer_id); + let affected = ws + .analysis + .apply_file_system_changes(vec![(provider_uri.clone(), Some(second.to_string()))]); + expect_that!(affected, contains(eq(&old_consumer_id))); + expect_that!(affected, contains(eq(&new_consumer_id))); + expect_that!(revision_of(&ws, old_consumer_id), eq(old_revision_before)); + expect_that!(revision_of(&ws, new_consumer_id), eq(new_revision_before)); + expect_that!( + codes_in(&ws, old_consumer_id), + contains(eq(DiagnosticCode::UndefinedGlobal.get_name())) + ); + expect_that!(codes_in(&ws, new_consumer_id), is_empty()); + } +} 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..c3d90f2df 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 @@ -292,6 +292,7 @@ mod tests { let file_id = ws .analysis .update_file_by_uri(&uri, Some(source(""))) + .map(|(id, _)| id) .expect("raw receiver file"); assert_eq!( diagnostics_for(&mut ws, file_id, DiagnosticCode::InferUnknown).len(), @@ -302,6 +303,7 @@ mod tests { let annotated_file_id = ws .analysis .update_file_by_uri(&uri, Some(annotated.clone())) + .map(|(id, _)| id) .expect("annotated receiver file"); assert!( diagnostics_for(&mut ws, annotated_file_id, DiagnosticCode::InferUnknown).is_empty() @@ -310,6 +312,7 @@ mod tests { let raw_file_id = ws .analysis .update_file_by_uri(&uri, Some(source(""))) + .map(|(id, _)| id) .expect("raw receiver file after edit"); assert_eq!( diagnostics_for(&mut ws, raw_file_id, DiagnosticCode::InferUnknown).len(), @@ -318,10 +321,12 @@ mod tests { ws.analysis .update_file_by_uri(&uri, None) + .map(|(id, _)| id) .expect("receiver file removal"); let reopened_file_id = ws .analysis .update_file_by_uri(&uri, Some(annotated)) + .map(|(id, _)| id) .expect("reopened annotated receiver file"); assert!( diagnostics_for(&mut ws, reopened_file_id, DiagnosticCode::InferUnknown).is_empty() @@ -846,7 +851,8 @@ mod tests { ); ws.analysis - .update_file_by_uri(&provider_uri, Some(WITH_FLOAT_NETWORK_VAR.to_string())); + .update_file_by_uri(&provider_uri, Some(WITH_FLOAT_NETWORK_VAR.to_string())) + .map(|(id, _)| id); assert_eq!( ( local_type(&ws, consumer_file_id, "value"), @@ -856,17 +862,21 @@ mod tests { ); ws.analysis - .update_file_by_uri(&provider_uri, Some(WITH_BOOL_NETWORK_VAR.to_string())); + .update_file_by_uri(&provider_uri, Some(WITH_BOOL_NETWORK_VAR.to_string())) + .map(|(id, _)| id); assert_eq!(local_type(&ws, consumer_file_id, "value"), LuaType::Boolean); - ws.analysis.update_file_by_uri(&provider_uri, None); + ws.analysis + .update_file_by_uri(&provider_uri, None) + .map(|(id, _)| id); assert_eq!( diagnostics_for(&mut ws, consumer_file_id, DiagnosticCode::InferUnknown).len(), 1 ); ws.analysis - .update_file_by_uri(&provider_uri, Some(WITH_FLOAT_NETWORK_VAR.to_string())); + .update_file_by_uri(&provider_uri, Some(WITH_FLOAT_NETWORK_VAR.to_string())) + .map(|(id, _)| id); assert_eq!( ( local_type(&ws, consumer_file_id, "value"), @@ -1082,4 +1092,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() + ) + ); + } } diff --git a/crates/glua_code_analysis/src/diagnostic/test/legacy_module_test.rs b/crates/glua_code_analysis/src/diagnostic/test/legacy_module_test.rs index adbc56d31..329dd3a7e 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/legacy_module_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/legacy_module_test.rs @@ -335,16 +335,18 @@ mod test { let file_b = lsp_types::Uri::parse_from_file_path(&workspace_b.join("consumer.lua")).unwrap(); - analysis.update_file_by_uri( - &file_a, - Some( - r#" + analysis + .update_file_by_uri( + &file_a, + Some( + r#" module("class", package.seeall) function Create() end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); let file_id_b = analysis .update_file_by_uri( &file_b, @@ -355,6 +357,7 @@ mod test { .to_string(), ), ) + .map(|(id, _)| id) .expect("consumer file id"); analysis 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/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/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/diagnostic/test/need_check_nil_test.rs b/crates/glua_code_analysis/src/diagnostic/test/need_check_nil_test.rs index acedc73cb..4f19714a1 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 @@ -2074,10 +2074,11 @@ mod test { ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("dtree_node.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@class DListLayout local DListLayout = {} function DListLayout:Add() end @@ -2089,9 +2090,10 @@ mod test { ---@outparam self.ChildNodes DListLayout function DTree_Node:CreateChildNodes() end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); let diagnostics = diagnostics_for_code( &mut ws, @@ -6048,10 +6050,11 @@ mod test { ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("global.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@class Color ---@return Color @@ -6067,9 +6070,10 @@ mod test { ---@param l number function _G.HSLToColor(h, s, l) end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); ws.def_file( "lua/autorun/shared/sh_colors.lua", @@ -7911,10 +7915,11 @@ mod test { ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("isvalid.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@class Entity ---@field GetEditingData fun(self: Entity): table ---@class NULL : Entity @@ -7924,9 +7929,10 @@ mod test { ---@return_cast toBeValidated -NULL function _G.IsValid(toBeValidated) end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); assert!(ws.check_code_for( DiagnosticCode::NeedCheckNil, @@ -7955,10 +7961,11 @@ mod test { ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("global.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@class Entity ---@field GetEditingData fun(self: Entity): table ---@class NULL : Entity @@ -7969,9 +7976,10 @@ mod test { ---@return_cast value -NULL function _G.IsValid(value) end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); assert!(ws.check_code_for( DiagnosticCode::NeedCheckNil, @@ -8271,6 +8279,156 @@ mod test { )); } + #[gtest] + fn test_nullable_child_override_of_non_nullable_parent_member_is_flagged() { + // Issue 12: the first visible member hit (Child.value: string?) must + // decide nullability; the non-nullable Parent.value must not mask it. + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + + ws.def( + r#" + ---@class ParentNil12 + ---@field value string + + ---@class ChildNil12 : ParentNil12 + ---@field value string? + "#, + ); + + let flagged = ws.check_code_for( + DiagnosticCode::NeedCheckNil, + r#" + ---@param child ChildNil12 + local function test(child) + child.value:upper() + end + "#, + ); + assert_that!( + flagged, + eq(false), + "Expected NeedCheckNil for nullable Child.value override" + ); + } + + #[gtest] + fn test_nullable_child_override_of_non_nullable_parent_member_is_flagged_in_arithmetic() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + + ws.def( + r#" + ---@class ParentNum12 + ---@field amount number + + ---@class ChildNum12 : ParentNum12 + ---@field amount number? + "#, + ); + + let flagged = ws.check_code_for( + DiagnosticCode::NeedCheckNil, + r#" + ---@param child ChildNum12 + local function test(child) + local total = child.amount + 1 + end + "#, + ); + assert_that!( + flagged, + eq(false), + "Expected NeedCheckNil for nullable Child.amount override in arithmetic" + ); + } + + #[gtest] + fn test_non_nullable_child_override_of_nullable_parent_member_is_suppressed() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + + ws.def( + r#" + ---@class ParentOpt12 + ---@field value string? + + ---@class ChildConc12 : ParentOpt12 + ---@field value string + "#, + ); + + let flagged = ws.check_code_for( + DiagnosticCode::NeedCheckNil, + r#" + ---@param child ChildConc12 + local function test(child) + child.value:upper() + end + "#, + ); + assert_that!( + flagged, + eq(true), + "Child.value (string) override decides; no NeedCheckNil" + ); + } + + #[gtest] + fn test_parent_member_decides_when_child_has_no_override() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + + ws.def( + r#" + ---@class ParentStr12 + ---@field value string + + ---@class ChildNoDecl12 : ParentStr12 + "#, + ); + + let suppressed = ws.check_code_for( + DiagnosticCode::NeedCheckNil, + r#" + ---@param child ChildNoDecl12 + local function test(child) + child.value:upper() + end + "#, + ); + assert_that!( + suppressed, + eq(true), + "Parent.value (string) decides when child has no override" + ); + } + + #[gtest] + fn test_nullable_parent_member_still_flagged_when_child_has_no_override() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + + ws.def( + r#" + ---@class ParentOptOnly12 + ---@field value string? + + ---@class ChildNoDeclOpt12 : ParentOptOnly12 + "#, + ); + + let flagged = ws.check_code_for( + DiagnosticCode::NeedCheckNil, + r#" + ---@param child ChildNoDeclOpt12 + local function test(child) + child.value:upper() + end + "#, + ); + assert_that!( + flagged, + eq(false), + "Parent.value (string?) decides when child has no override" + ); + } + #[gtest] fn test_field_narrow_false_branch_no_nil() { // In the false branch (field doesn't exist), variable should retain original type @@ -8906,10 +9064,11 @@ mod test { ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("cl_lang.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" LANG = { Strings = {} } @@ -8936,9 +9095,10 @@ mod test { return LANG.Strings[lang_name] end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); let diagnostics = diagnostics_for_code( &mut ws, @@ -11492,16 +11652,18 @@ mod test { ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("globals.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@type SpawnMenu g_SpawnMenu = nil "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); // Main workspace file with nil + create lifecycle ws.def_file( @@ -11568,10 +11730,11 @@ mod test { ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("globals.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@meta ---@class MyType @@ -11580,9 +11743,10 @@ mod test { ---@type MyType g_AllNil = nil "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); ws.def_file( "main.lua", @@ -11681,10 +11845,11 @@ mod test { ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("toolobj.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@meta ---@class Weapon @@ -11696,9 +11861,10 @@ mod test { ---@class ToolObj : Tool ToolObj = ToolObj or {} "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); // --- Main workspace: shipped runtime pattern (garrysmod stool.lua) --- // Includes the `o.SWEP = nil` initializer in Create() and the @@ -11780,10 +11946,11 @@ mod test { ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("globals.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@meta ---@class MyType @@ -11792,9 +11959,10 @@ mod test { ---@type MyType g_Mixed = nil "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); ws.def_file( "main_nil.lua", @@ -11948,10 +12116,11 @@ mod test { ws.analysis.add_library_workspace(library_root.clone()); let tool_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("tool.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &tool_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &tool_uri, + Some( + r#" ---@meta ---@class Entity @@ -11979,9 +12148,10 @@ mod test { ---@return any function Tool:GetPos(id) end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); let custom_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("custom_classes.lua")).unwrap(); ws.analysis.update_file_by_uri( @@ -11997,7 +12167,7 @@ mod test { "# .to_string(), ), - ); + ).map(|(id, _)| id); ws.def_file( "gamemodes/sandbox/entities/weapons/gmod_tool/stool.lua", @@ -12844,6 +13014,44 @@ mod test { ); } + #[test] + fn test_truthiness_guard_does_not_suppress_repeated_call_expr() { + // The positive form of `test_negation_guard_does_not_suppress_repeated_call_expr`: + // `if maybeEnt() then` proves only that the call it made returned + // non-nil, so a second call inside the block is still unguarded. + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + + let diagnostics = diagnostics_for_code( + &mut ws, + DiagnosticCode::NeedCheckNil, + r#" + ---@class Entity + ---@field GetPos fun(self: Entity): Vector + + ---@return Entity? + local function maybeEnt() end + + local function useEntity() + if maybeEnt() then + maybeEnt():GetPos() + end + end + "#, + ); + + let call_warnings: Vec<_> = diagnostics + .iter() + .filter(|d| d.message.contains("may be nil")) + .collect(); + assert_that!( + call_warnings, + not(is_empty()), + "`if maybeEnt() then` should NOT suppress the nil diagnostic on the \ + second `maybeEnt()` call — each call may return a different value. \ + Diagnostics: {diagnostics:#?}" + ); + } + #[test] fn test_negation_guard_invalidated_by_indexed_key_reassignment() { let mut ws = VirtualWorkspace::new_with_init_std_lib(); @@ -14533,4 +14741,138 @@ 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()); + } + + /// 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)); + } + + /// 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:?}" + ); + } + } } 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..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 @@ -4527,4 +4527,96 @@ 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 + "#, + )); + } + /// 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)) + "#, + )); + } + + /// `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/diagnostic/test/redundant_parameter_test.rs b/crates/glua_code_analysis/src/diagnostic/test/redundant_parameter_test.rs index c3f567450..f49244ccb 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/redundant_parameter_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/redundant_parameter_test.rs @@ -73,16 +73,18 @@ mod test { &other_workspace.join("lua/foreign_arity_edge.lua"), ) .expect("other workspace uri"); - ws.analysis.update_file_by_uri( - &other_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &other_uri, + Some( + r#" ---@class ArityChild : ArityBase local ArityChild = {} "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); let file_id = ws.def_file( "lua/current_arity.lua", r#" 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/diagnostic/test/undefined_field_test.rs b/crates/glua_code_analysis/src/diagnostic/test/undefined_field_test.rs index 5975c8cb8..19ba65fe7 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 @@ -1410,6 +1410,31 @@ mod test { ); } + #[test] + fn test_many_same_key_guarded_bootstraps_collapse_to_first_table() { + 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 mut body = String::from("ns = ns or {}\n"); + for _ in 0..50 { + body.push_str("ns.slot = ns.slot or {}\n"); + } + body.push_str("local got = ns.slot\n"); + let file_id = ws.def_file("gamemodes/test/gamemode/shared.lua", &body); + + let ty = local_name_type(&mut ws, file_id, "got"); + let display = ws.humanize_type(ty.clone()); + + assert_eq!( + empty_table_bootstrap_branch_count(ws.analysis.compilation.get_db(), &ty), + 1, + "expected many same-key guarded bootstraps to collapse to one table branch, got {display}" + ); + } + #[test] fn test_repeated_initialized_index_prefixes_do_not_report_undefined_field() { let mut ws = VirtualWorkspace::new(); @@ -5306,10 +5331,11 @@ owner:CompletelyMadeUpMethod() ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("annotations.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@class VMatrix local VMatrix = {} function VMatrix:GetTranslation() end @@ -5331,9 +5357,10 @@ owner:CompletelyMadeUpMethod() ---@return_cast value -NULL function IsValid(value) end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); let file_id = ws.def_file( "gamemodes/sandbox/entities/weapons/gmod_tool/stools/finger.lua", @@ -5775,4 +5802,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:#?}"); + } } diff --git a/crates/glua_code_analysis/src/diagnostic/test/undefined_method_test.rs b/crates/glua_code_analysis/src/diagnostic/test/undefined_method_test.rs index 1f302e7a4..8f2aaf6ed 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/undefined_method_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/undefined_method_test.rs @@ -784,6 +784,67 @@ mod tests { assert_eq!(undefined_methods, ["Undefined method `Missing`. "]); } + #[test] + fn vgui_parent_call_through_member_receiver_is_collected_on_a_cold_build() { + 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 file_id = ws.def( + r#" + ---@class Panel + ---@field GetParent fun(self: Panel): Panel + ---@class DScrollPanel: Panel + local PANEL = {} + function PANEL:CardMethod() end + function PANEL:Init() + self.infoPanel = self:Add("DScrollPanel") + local row = self.infoPanel:Add("cityrpListRow") + end + vgui.Register("cityrp_character_card", PANEL, "Panel") + + local ROW = {} + function ROW:Think() + self:GetParent():GetParent():CardMethod() + self:GetParent():GetParent():Missing() + end + vgui.Register("cityrpListRow", ROW, "Panel") + "#, + ); + + let metadata = ws + .analysis + .compilation + .get_db() + .get_gmod_class_metadata_index(); + assert_eq!( + metadata.get_vgui_panel_parent_chain(&crate::LuaTypeDeclId::global("cityrpListRow")), + Some( + [ + crate::LuaTypeDeclId::global("DScrollPanel"), + crate::LuaTypeDeclId::global("cityrp_character_card"), + ] + .as_slice() + ) + ); + + let undefined_methods = ws + .analysis + .diagnose_file(file_id, CancellationToken::new()) + .unwrap_or_default() + .into_iter() + .filter(|diagnostic| { + diagnostic.code + == Some(NumberOrString::String( + DiagnosticCode::UndefinedMethod.get_name().to_string(), + )) + }) + .map(|diagnostic| diagnostic.message) + .collect::>(); + assert_eq!(undefined_methods, ["Undefined method `Missing`. "]); + } + #[test] fn test_vgui_focus_parent_chain_preserves_drag_base_methods_for_content_container() { let mut ws = VirtualWorkspace::new(); @@ -1039,6 +1100,7 @@ mod tests { let content_container_uri = ws.virtual_url_generator.new_uri(content_container_path); ws.analysis .update_file_by_uri(&content_container_uri, None) + .map(|(id, _)| id) .expect("forwarding helper file should exist"); let metadata = ws .analysis diff --git a/crates/glua_code_analysis/src/gamemode_base.rs b/crates/glua_code_analysis/src/gamemode_base.rs index 5bf02f46c..4d2463e08 100644 --- a/crates/glua_code_analysis/src/gamemode_base.rs +++ b/crates/glua_code_analysis/src/gamemode_base.rs @@ -17,7 +17,7 @@ //! The detector is intentionally tolerant: malformed KV files are skipped, //! cycles are broken, and nothing is added when no metadata is found. -use std::collections::HashSet; +use rustc_hash::FxHashSet; use std::path::{Path, PathBuf}; /// Validate that `name` is a plausible gamemode folder name. @@ -61,7 +61,7 @@ pub fn read_gamemode_base(txt_path: &Path) -> Option { /// * are deduplicated while preserving discovery order. pub fn detect_gamemode_base_libraries(workspace_root: &Path) -> Vec { let mut out: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); + let mut seen: FxHashSet = FxHashSet::default(); let workspace_root_canon = canonicalize_or(workspace_root); // Layout 2: workspace root *is* a gamemode folder. @@ -130,10 +130,10 @@ fn walk_chain( gamemodes_dir: &Path, workspace_root_canon: &Path, out: &mut Vec, - seen: &mut HashSet, + seen: &mut FxHashSet, ) { let mut current = start_folder.to_path_buf(); - let mut visited_names: HashSet = HashSet::new(); + let mut visited_names: FxHashSet = FxHashSet::default(); if let Some(name) = current.file_name().and_then(|s| s.to_str()) { visited_names.insert(name.to_string()); } diff --git a/crates/glua_code_analysis/src/inferred_guard.rs b/crates/glua_code_analysis/src/inferred_guard.rs new file mode 100644 index 000000000..30f6d2c17 --- /dev/null +++ b/crates/glua_code_analysis/src/inferred_guard.rs @@ -0,0 +1,649 @@ +//! Propagating inferred positive guards across an incremental re-analysis. +//! +//! A guard one file infers is evidence other files read, and an edit can add, +//! move or withdraw one. The snapshot taken before re-analysis is what the pass +//! afterwards diffs against to decide which readers have to run again. + +use glua_parser::{LuaAstNode, LuaCallExpr, LuaExpr, LuaIndexKey, LuaNameExpr, LuaParenExpr}; +use rustc_hash::{FxHashMap, FxHashSet}; +use smol_str::SmolStr; + +use crate::*; + +/// The cross-file facts an edit can invalidate, captured before +/// re-analysis. +#[derive(Clone, Debug, Default)] +pub(crate) struct InferredGuardSnapshot { + facts: FxHashMap, + consumers: FxHashMap>, + /// Parameter types inferred from the snapshotted files' call sites, keyed by + /// the callee signature they belong to. + inferred_params: FxHashMap<(LuaSignatureId, usize), LuaType>, + /// The files the snapshot was taken for, needed to recompute the same set. + snapshot_file_ids: FxHashSet, +} + +impl InferredGuardSnapshot { + /// Whether the snapshot recorded anything an edit could invalidate. An empty + /// one means the propagation pass has no work to do. + pub(crate) fn is_empty(&self) -> bool { + self.facts.is_empty() && self.inferred_params.is_empty() + } + + /// The files this snapshot was taken for, so the same set can be recomputed + /// after re-analysis and diffed against it. + pub(crate) fn snapshot_file_ids(&self) -> &FxHashSet { + &self.snapshot_file_ids + } + + /// Folds a later snapshot in without overwriting anything already held: + /// the oldest facts are the ones propagation has to diff against. + pub(crate) fn merge(&mut self, other: Self) { + for (owner, guard) in other.facts { + self.facts.entry(owner).or_insert(guard); + } + for (owner, consumers) in other.consumers { + self.consumers.entry(owner).or_insert(consumers); + } + for (key, typ) in other.inferred_params { + self.inferred_params.entry(key).or_insert(typ); + } + self.snapshot_file_ids.extend(other.snapshot_file_ids); + } +} + +#[derive(Default)] +struct InferredGuardReferenceFiles { + files: FxHashSet, + alias_calls: FxHashSet, +} + +#[cfg(test)] +#[derive(Debug, Default, Clone, Copy)] +pub(crate) struct InferredGuardPropagationStats { + pub changed_facts: usize, + pub reference_edges: usize, + pub frontiers: usize, + pub reindexed_files: usize, +} + +fn sort_inferred_guard_owners(owners: &mut [LuaInferredGuardOwner]) { + owners.sort_by(|left, right| { + (left.source_file_id(), left.source_position(), left.path()).cmp(&( + right.source_file_id(), + right.source_position(), + right.path(), + )) + }); +} + +fn global_path_for_expr(expr: &LuaExpr) -> Option> { + let mut path = match expr { + LuaExpr::NameExpr(name_expr) => { + Some(vec![name_expr.get_name_token()?.get_name_text().into()]) + } + LuaExpr::IndexExpr(index_expr) => { + if index_expr.get_index_token()?.is_colon() { + return None; + } + let mut path = global_path_for_expr(&index_expr.get_prefix_expr()?)?; + let member = match index_expr.get_index_key()? { + LuaIndexKey::Name(name) => name.get_name_text().into(), + LuaIndexKey::String(string) => string.get_value().into(), + _ => return None, + }; + path.push(member); + Some(path) + } + _ => None, + }?; + canonicalize_global_root_path(&mut path); + Some(path) +} + +fn immutable_local_alias_decl( + db: &DbIndex, + file_id: FileId, + alias_value: &LuaExpr, +) -> Option { + let alias_value = enclosing_parenthesized_expr(alias_value); + let local_stat = alias_value.get_parent::()?; + let local_name = local_stat.get_local_name_by_value(alias_value.clone())?; + let decl_id = LuaDeclId::new(file_id, local_name.get_position()); + let decl = db.get_decl_index().get_decl(&decl_id)?; + if !matches!(decl.extra, LuaDeclExtra::Local { .. }) + || decl.get_value_syntax_id() != Some(alias_value.get_syntax_id()) + || db + .get_reference_index() + .get_decl_references(&file_id, &decl_id) + .is_none_or(|references| references.mutable) + { + return None; + } + Some(decl_id) +} + +fn enclosing_parenthesized_expr(expr: &LuaExpr) -> LuaExpr { + let mut expr = expr.clone(); + while let Some(paren_expr) = expr.get_parent::() { + if paren_expr + .get_expr() + .is_none_or(|inner| inner.get_syntax_id() != expr.get_syntax_id()) + { + break; + } + expr = LuaExpr::ParenExpr(paren_expr); + } + expr +} + +fn is_call_prefix(expr: &LuaExpr) -> bool { + let expr = enclosing_parenthesized_expr(expr); + expr.get_parent::() + .and_then(|call| call.get_prefix_expr()) + .is_some_and(|prefix| prefix.get_syntax_id() == expr.get_syntax_id()) +} + +fn expr_resolves_to_inferred_guard_owner( + db: &DbIndex, + caches: &mut FxHashMap, + owner: &LuaInferredGuardOwner, + file_id: FileId, + expr: &LuaExpr, +) -> bool { + let cache = caches + .entry(file_id) + .or_insert_with(|| LuaInferCache::new(file_id, Default::default())); + semantic::infer_expr(db, cache, expr.clone()).ok() + == Some(LuaType::Signature(owner.signature_id())) +} + +fn call_resolves_to_inferred_guard_owner( + db: &DbIndex, + caches: &mut FxHashMap, + owner: &LuaInferredGuardOwner, + file_id: FileId, + prefix_expr: &LuaExpr, +) -> bool { + let prefix_expr = enclosing_parenthesized_expr(prefix_expr); + let Some(call) = prefix_expr.get_parent::() else { + return false; + }; + if call + .get_prefix_expr() + .is_none_or(|prefix| prefix.get_syntax_id() != prefix_expr.get_syntax_id()) + { + return false; + } + let cache = caches + .entry(file_id) + .or_insert_with(|| LuaInferCache::new(file_id, Default::default())); + semantic::get_prefix_expr_signature_id(db, cache, &call) == Some(owner.signature_id()) +} + +impl EmmyLuaAnalysis { + /// Returns every file this propagation re-analysed, so an edit can report + /// the full set it settled rather than only the files whose text changed. + pub(crate) fn reindex_changed_inferred_guard_references( + &mut self, + source_file_ids: &FxHashSet, + old_snapshot: &InferredGuardSnapshot, + already_reindexed: &[FileId], + incremental_source_file_ids: &FxHashSet, + ) -> FxHashSet { + let profile_enabled = std::env::var_os("GLUALS_PROFILE").is_some(); + let mut profile_changed_facts = 0usize; + let mut profile_reference_edges = 0usize; + let mut profile_waves = 0usize; + let mut profile_reindexed_files = 0usize; + let mut propagation_reindexed_files = source_file_ids + .iter() + .copied() + .chain(already_reindexed.iter().copied()) + .collect::>(); + let mut new_facts = self + .compilation + .get_db() + .get_signature_index() + .inferred_guard_facts_for_files(source_file_ids); + let equivalent_owners = self.reconcile_equivalent_inferred_guard_owners( + old_snapshot, + &new_facts, + &propagation_reindexed_files, + ); + let old_facts = &old_snapshot.facts; + let mut changed_owners = old_facts + .keys() + .chain(new_facts.keys()) + .filter(|owner| { + !equivalent_owners.contains(*owner) + && old_facts.get(*owner) != new_facts.get(*owner) + }) + .cloned() + .collect::>() + .into_iter() + .collect::>(); + if changed_owners.is_empty() { + #[cfg(test)] + { + self.inferred_guard_propagation_stats = InferredGuardPropagationStats::default(); + } + if profile_enabled { + eprintln!( + "[profile] inferred_guard_incremental changed_facts=0 reference_edges=0 waves=0 reindexed_files=0" + ); + } + return propagation_reindexed_files; + } + profile_changed_facts += changed_owners.len(); + sort_inferred_guard_owners(&mut changed_owners); + let mut frontier_old_facts = old_snapshot.facts.clone(); + let mut frontier_old_consumers = old_snapshot.consumers.clone(); + + let mut fuse = crate::compilation::analyzer::FixpointFuse::new("guard_frontier"); + while !changed_owners.is_empty() { + if fuse.trip() { + break; + } + let mut reference_files = FxHashSet::default(); + for owner in &changed_owners { + let newly_added = + !frontier_old_facts.contains_key(owner) && new_facts.contains_key(owner); + let old_consumers = frontier_old_consumers + .get(owner) + .into_iter() + .flatten() + .copied(); + let current_consumers = self + .compilation + .get_db() + .get_signature_index() + .inferred_guard_consumers(owner); + for file_id in old_consumers.chain(current_consumers) { + if !propagation_reindexed_files.contains(&file_id) { + profile_reference_edges += 1; + reference_files.insert(file_id); + } + } + if newly_added { + let allow_alias_retry = + 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(); + if !propagation_reindexed_files.contains(&file_id) || alias_retry { + profile_reference_edges += 1; + reference_files.insert(file_id); + } + } + } + } + if reference_files.is_empty() { + break; + } + + let mut reindex_file_ids = reference_files.into_iter().collect::>(); + reindex_file_ids.sort_unstable(); + let wave_file_ids = reindex_file_ids.iter().copied().collect::>(); + let old_wave_snapshot = self.inferred_guard_snapshot(&wave_file_ids); + self.compilation.remove_index(reindex_file_ids.clone()); + let update_file_ids = reindex_file_ids + .into_iter() + .filter(|file_id| { + self.compilation + .get_db() + .get_vfs() + .get_syntax_tree(file_id) + .is_some() + }) + .collect::>(); + if update_file_ids.is_empty() { + break; + } + profile_waves += 1; + profile_reindexed_files += update_file_ids.len(); + propagation_reindexed_files.extend(wave_file_ids.iter().copied()); + self.compilation.update_index(update_file_ids.clone()); + + new_facts = self + .compilation + .get_db() + .get_signature_index() + .inferred_guard_facts_for_files(&wave_file_ids); + let equivalent_owners = self.reconcile_equivalent_inferred_guard_owners( + &old_wave_snapshot, + &new_facts, + &propagation_reindexed_files, + ); + changed_owners = old_wave_snapshot + .facts + .keys() + .chain(new_facts.keys()) + .filter(|owner| { + !equivalent_owners.contains(*owner) + && old_wave_snapshot.facts.get(*owner) != new_facts.get(*owner) + }) + .cloned() + .collect::>() + .into_iter() + .collect(); + frontier_old_facts = old_wave_snapshot.facts; + frontier_old_consumers = old_wave_snapshot.consumers; + profile_changed_facts += changed_owners.len(); + sort_inferred_guard_owners(&mut changed_owners); + } + if profile_enabled { + eprintln!( + "[profile] inferred_guard_incremental changed_facts={} reference_edges={} waves={} reindexed_files={}", + profile_changed_facts, + profile_reference_edges, + profile_waves, + profile_reindexed_files + ); + } + #[cfg(test)] + { + self.inferred_guard_propagation_stats = InferredGuardPropagationStats { + changed_facts: profile_changed_facts, + reference_edges: profile_reference_edges, + frontiers: profile_waves, + reindexed_files: profile_reindexed_files, + }; + } + propagation_reindexed_files + } + + pub(crate) fn inferred_guard_snapshot( + &self, + file_ids: &FxHashSet, + ) -> InferredGuardSnapshot { + let signature_index = self.compilation.get_db().get_signature_index(); + let facts = signature_index.inferred_guard_facts_for_files(file_ids); + let consumers = facts + .keys() + .map(|owner| { + ( + owner.clone(), + signature_index.inferred_guard_consumers(owner).collect(), + ) + }) + .collect(); + let inferred_params = self + .compilation + .get_db() + .get_call_site_param_index() + .inferred_params_for_contributor_files(file_ids); + InferredGuardSnapshot { + facts, + consumers, + inferred_params, + snapshot_file_ids: file_ids.clone(), + } + } + + /// Re-analyses callee files whose call-site-inferred parameter types + /// changed. + /// Returns every file this pass re-analysed, including whatever its own + /// ripple reached. + pub(crate) fn reindex_changed_inferred_param_consumers( + &mut self, + old_snapshot: &InferredGuardSnapshot, + already_reindexed: &[FileId], + ) -> Vec { + self.reindex_changed_inferred_param_consumers_with_refresh_names( + old_snapshot, + already_reindexed, + ) + .0 + } + + /// [`reindex_changed_inferred_param_consumers`](Self::reindex_changed_inferred_param_consumers), + /// plus the sideband textual names its late reindex moved. + /// + /// The nested [`self_index_and_diff`](EmmyLuaAnalysis::self_index_and_diff) + /// names the callee exports the parameter change moved, and the nested + /// ripple names whatever those move in turn. Both are diagnostic-only: + /// they never enter `pending` or `dirty.files` and steer no reindex + /// decision; the caller merges them into its own sideband. + pub(crate) fn reindex_changed_inferred_param_consumers_with_refresh_names( + &mut self, + old_snapshot: &InferredGuardSnapshot, + already_reindexed: &[FileId], + ) -> (Vec, FxHashSet) { + let new_params = self + .compilation + .get_db() + .get_call_site_param_index() + .inferred_params_for_contributor_files(&old_snapshot.snapshot_file_ids); + let old_params = &old_snapshot.inferred_params; + if old_params.is_empty() && new_params.is_empty() { + return (Vec::new(), FxHashSet::default()); + } + + let already_reindexed = already_reindexed + .iter() + .copied() + .chain(old_snapshot.snapshot_file_ids.iter().copied()) + .collect::>(); + let mut changed_files = old_params + .keys() + .chain(new_params.keys()) + .filter(|key| old_params.get(*key) != new_params.get(*key)) + .map(|(signature_id, _)| signature_id.get_file_id()) + .filter(|file_id| !already_reindexed.contains(file_id)) + .collect::>(); + changed_files.sort_unstable(); + changed_files.dedup(); + if changed_files.is_empty() { + return (Vec::new(), FxHashSet::default()); + } + + let changed_files = changed_files + .into_iter() + .filter(|file_id| { + self.compilation + .get_db() + .get_vfs() + .get_syntax_tree(file_id) + .is_some() + }) + .collect::>(); + if changed_files.is_empty() { + return (Vec::new(), FxHashSet::default()); + } + // The parameter's consumers are the bodies that read it, which are the + // files owning these signatures. Whatever those bodies then derive + // differently is found by diffing their exports, the same way any + // other edit ripples; expanding to every dependency re-analysed the + // workspace for one parameter. The nested self-index names are kept + // and the nested ripple's own names are merged, so a late callee move + // still reaches lazy textual readers upstream. + let mut reanalyzed = changed_files.clone(); + let dirty = self.self_index_and_diff(changed_files); + let mut names = dirty.textual_refresh_names.clone(); + let (rippled, ripple_names) = self.ripple_with_refresh_names(dirty); + reanalyzed.extend(rippled); + names.extend(ripple_names); + reanalyzed.sort_unstable(); + reanalyzed.dedup(); + (reanalyzed, names) + } + + pub(crate) fn reconcile_equivalent_inferred_guard_owners( + &mut self, + old_snapshot: &InferredGuardSnapshot, + new_facts: &FxHashMap, + reindexed_file_ids: &FxHashSet, + ) -> FxHashSet { + let mut reconciled = FxHashSet::default(); + for owner in old_snapshot + .facts + .keys() + .filter(|owner| old_snapshot.facts.get(*owner) == new_facts.get(*owner)) + { + if let Some(consumers) = old_snapshot.consumers.get(owner) { + self.compilation + .get_db_mut() + .get_signature_index_mut() + .migrate_inferred_guard_consumers(owner.clone(), consumers, reindexed_file_ids); + } + reconciled.insert(owner.clone()); + } + + let mut old_owners = old_snapshot + .facts + .keys() + .filter(|owner| !new_facts.contains_key(*owner)) + .cloned() + .collect::>(); + let mut new_owners = new_facts + .keys() + .filter(|owner| !old_snapshot.facts.contains_key(*owner)) + .cloned() + .collect::>(); + sort_inferred_guard_owners(&mut old_owners); + sort_inferred_guard_owners(&mut new_owners); + + for old_owner in old_owners { + let Some(new_idx) = new_owners.iter().position(|new_owner| { + old_owner.source_file_id() == new_owner.source_file_id() + && old_owner.path() == new_owner.path() + && old_owner.state_mask() == new_owner.state_mask() + && old_snapshot.facts.get(&old_owner) == new_facts.get(new_owner) + }) else { + continue; + }; + let new_owner = new_owners.remove(new_idx); + if let Some(consumers) = old_snapshot.consumers.get(&old_owner) { + self.compilation + .get_db_mut() + .get_signature_index_mut() + .migrate_inferred_guard_consumers( + new_owner.clone(), + consumers, + reindexed_file_ids, + ); + } + reconciled.insert(old_owner); + reconciled.insert(new_owner); + } + reconciled + } + + fn resolve_inferred_guard_reference_files( + &self, + owner: &LuaInferredGuardOwner, + discover_aliases: bool, + ) -> InferredGuardReferenceFiles { + let Some(member_name) = owner.path().last() else { + return InferredGuardReferenceFiles::default(); + }; + let references = if owner.path().len() == 1 { + self.compilation + .get_db() + .get_reference_index() + .get_global_references(member_name) + } else { + self.compilation + .get_db() + .get_reference_index() + .get_index_references(&LuaMemberKey::Name(member_name.clone())) + }; + let Some(references) = references else { + return InferredGuardReferenceFiles::default(); + }; + + let db = self.compilation.get_db(); + let mut caches = FxHashMap::::default(); + let mut matching_references = references + .into_iter() + .filter_map(|reference| { + let root = db + .get_vfs() + .get_syntax_tree(&reference.file_id)? + .get_red_root(); + let expr = LuaExpr::cast(reference.value.to_node_from_root(&root)?)?; + (global_path_for_expr(&expr).as_deref() == Some(owner.path()) + && db.get_gmod_infer_index().are_offsets_compatible( + &reference.file_id, + expr.get_range().start(), + &owner.source_file_id(), + owner.signature_id().get_position(), + )) + .then_some((reference.file_id, expr)) + }) + .collect::>(); + matching_references.sort_by_key(|(file_id, expr)| (*file_id, expr.get_range().start())); + + let mut result = InferredGuardReferenceFiles::default(); + let mut alias_queue = VecDeque::new(); + let mut visited_aliases = FxHashSet::default(); + for (file_id, expr) in matching_references { + if call_resolves_to_inferred_guard_owner(db, &mut caches, owner, file_id, &expr) { + result.files.insert(file_id); + } + if discover_aliases + && expr_resolves_to_inferred_guard_owner(db, &mut caches, owner, file_id, &expr) + && let Some(decl_id) = immutable_local_alias_decl(db, file_id, &expr) + { + alias_queue.push_back(decl_id); + } + } + + while let Some(decl_id) = alias_queue.pop_front() { + if !visited_aliases.insert(decl_id) { + continue; + } + let Some(root) = db + .get_vfs() + .get_syntax_tree(&decl_id.file_id) + .map(|tree| tree.get_red_root()) + else { + continue; + }; + let Some(decl_references) = db + .get_reference_index() + .get_decl_references(&decl_id.file_id, &decl_id) + else { + continue; + }; + let mut cells = decl_references.cells.clone(); + cells.sort_by_key(|cell| cell.range.start()); + for cell in cells { + if cell.is_write { + continue; + } + let Some(name_expr) = root + .covering_element(cell.range) + .ancestors() + .find_map(LuaNameExpr::cast) + .filter(|name_expr| name_expr.get_range() == cell.range) + else { + continue; + }; + let expr = LuaExpr::NameExpr(name_expr); + if !db.get_gmod_infer_index().are_offsets_compatible( + &decl_id.file_id, + expr.get_range().start(), + &owner.source_file_id(), + owner.signature_id().get_position(), + ) { + continue; + } + if is_call_prefix(&expr) { + result.files.insert(decl_id.file_id); + result.alias_calls.insert(decl_id.file_id); + } + if let Some(next_decl_id) = immutable_local_alias_decl(db, decl_id.file_id, &expr) { + alias_queue.push_back(next_decl_id); + } + } + } + + result + } +} diff --git a/crates/glua_code_analysis/src/lib.rs b/crates/glua_code_analysis/src/lib.rs index 61408a8cb..982532815 100644 --- a/crates/glua_code_analysis/src/lib.rs +++ b/crates/glua_code_analysis/src/lib.rs @@ -14,6 +14,10 @@ mod config; mod db_index; mod diagnostic; mod gamemode_base; +mod inferred_guard; +#[cfg(test)] +pub(crate) use inferred_guard::InferredGuardPropagationStats; +pub(crate) use inferred_guard::InferredGuardSnapshot; mod library_collision; pub mod profile; pub mod progress; @@ -28,166 +32,64 @@ pub use db_index::*; 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, -}; +use glua_parser::{LineIndex, LuaCallExpr, LuaLocalStat, LuaParser, LuaSyntaxTree}; pub use library_collision::LibraryDefinitionCollision; use lsp_types::Uri; pub use profile::Profile; use resources::load_resource_std; +use rustc_hash::{FxHashMap, FxHashSet}; use schema_to_glua::SchemaConverter; pub use semantic::*; -use std::collections::{HashMap, VecDeque}; +use smol_str::SmolStr; +use std::collections::HashMap; +use std::collections::VecDeque; use std::path::{Component, Path}; use std::str::FromStr; -use std::{collections::HashSet, path::PathBuf, sync::Arc}; +use std::{path::PathBuf, sync::Arc}; pub use test_lib::{GMOD_CALL_ARG_BUILTINS_FIXTURE, VirtualWorkspace}; 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. -struct InferredGuardSnapshot { - facts: HashMap, - consumers: HashMap>, - /// Parameter types inferred from the snapshotted files' call sites, keyed by - /// the callee signature they belong to. - inferred_params: HashMap<(LuaSignatureId, usize), LuaType>, - /// The files the snapshot was taken for, needed to recompute the same set. - snapshot_file_ids: HashSet, -} - -#[derive(Default)] -struct InferredGuardReferenceFiles { - files: HashSet, - alias_calls: HashSet, -} - -#[cfg(test)] -#[derive(Debug, Default, Clone, Copy)] -pub(crate) struct InferredGuardPropagationStats { - pub changed_facts: usize, - pub reference_edges: usize, - pub frontiers: usize, - pub reindexed_files: usize, - pub broad_stabilizations: usize, -} - -fn sort_inferred_guard_owners(owners: &mut [LuaInferredGuardOwner]) { - owners.sort_by(|left, right| { - (left.source_file_id(), left.source_position(), left.path()).cmp(&( - right.source_file_id(), - right.source_position(), - right.path(), - )) - }); -} - -fn global_path_for_expr(expr: &LuaExpr) -> Option> { - let mut path = match expr { - LuaExpr::NameExpr(name_expr) => { - Some(vec![name_expr.get_name_token()?.get_name_text().into()]) - } - LuaExpr::IndexExpr(index_expr) => { - if index_expr.get_index_token()?.is_colon() { - return None; - } - let mut path = global_path_for_expr(&index_expr.get_prefix_expr()?)?; - let member = match index_expr.get_index_key()? { - LuaIndexKey::Name(name) => name.get_name_text().into(), - LuaIndexKey::String(string) => string.get_value().into(), - _ => return None, - }; - path.push(member); - Some(path) - } - _ => None, - }?; - canonicalize_global_root_path(&mut path); - Some(path) -} - -fn immutable_local_alias_decl( - db: &DbIndex, - file_id: FileId, - alias_value: &LuaExpr, -) -> Option { - let alias_value = enclosing_parenthesized_expr(alias_value); - let local_stat = alias_value.get_parent::()?; - let local_name = local_stat.get_local_name_by_value(alias_value.clone())?; - let decl_id = LuaDeclId::new(file_id, local_name.get_position()); - let decl = db.get_decl_index().get_decl(&decl_id)?; - if !matches!(decl.extra, LuaDeclExtra::Local { .. }) - || decl.get_value_syntax_id() != Some(alias_value.get_syntax_id()) - || db - .get_reference_index() - .get_decl_references(&file_id, &decl_id) - .is_none_or(|references| references.mutable) - { - return None; - } - Some(decl_id) -} - -fn enclosing_parenthesized_expr(expr: &LuaExpr) -> LuaExpr { - let mut expr = expr.clone(); - while let Some(paren_expr) = expr.get_parent::() { - if paren_expr - .get_expr() - .is_none_or(|inner| inner.get_syntax_id() != expr.get_syntax_id()) - { - break; - } - expr = LuaExpr::ParenExpr(paren_expr); - } - expr -} - -fn is_call_prefix(expr: &LuaExpr) -> bool { - let expr = enclosing_parenthesized_expr(expr); - expr.get_parent::() - .and_then(|call| call.get_prefix_expr()) - .is_some_and(|prefix| prefix.get_syntax_id() == expr.get_syntax_id()) +/// Ordering key for a file that has no id yet. +/// +/// The batch entry points sort their input before anything is added to the VFS, +/// so they cannot use [`Vfs::file_order_key`]; a URI that does not name a path +/// falls back to its own text. +fn uri_sort_key(uri: &Uri) -> String { + uri_to_file_path(uri) + .map(|path| crate::vfs::normalize_path_for_ordering(&path.to_string_lossy())) + .unwrap_or_else(|| uri.as_str().to_string()) } -fn expr_resolves_to_inferred_guard_owner( - db: &DbIndex, - caches: &mut HashMap, - owner: &LuaInferredGuardOwner, - file_id: FileId, - expr: &LuaExpr, -) -> bool { - let cache = caches - .entry(file_id) - .or_insert_with(|| LuaInferCache::new(file_id, Default::default())); - semantic::infer_expr(db, cache, expr.clone()).ok() - == Some(LuaType::Signature(owner.signature_id())) -} +/// Stack size for any thread that runs analysis. +/// +/// Inference and the type-graph walks recurse to the depth of the source, which +/// a generated or deeply nested GLua file can push past a default thread stack. +/// The binaries spawn their analysis thread with this; the worker pool uses it +/// too, so how deep a file may nest does not depend on which thread picked it +/// up. The reservation is virtual and committed lazily, so a worker that never +/// recurses costs nothing. +pub const ANALYSIS_STACK_SIZE: usize = 256 * 1024 * 1024; + +/// Minimum stack bytes that must remain to keep inferring. +/// +/// Source-controlled nesting (expressions, doc types) recurses per level and +/// can exhaust small worker stacks on generated files. Exhaustion bails to the +/// existing `RecursiveInfer` path (an `Unknown` fallback), the same graceful +/// failure cyclic inputs already produce, instead of aborting the process. +pub(crate) const ANALYSIS_STACK_RESERVE: usize = 256 * 1024; -fn call_resolves_to_inferred_guard_owner( - db: &DbIndex, - caches: &mut HashMap, - owner: &LuaInferredGuardOwner, - file_id: FileId, - prefix_expr: &LuaExpr, -) -> bool { - let prefix_expr = enclosing_parenthesized_expr(prefix_expr); - let Some(call) = prefix_expr.get_parent::() else { - return false; - }; - if call - .get_prefix_expr() - .is_none_or(|prefix| prefix.get_syntax_id() != prefix_expr.get_syntax_id()) - { - return false; +/// Fail-closed remaining-stack probe shared by expression and doc inference. +/// +/// `None` (unsupported target / OS query failure) counts as exhausted rather +/// than safe, mirroring how `stacker::maybe_grow` treats `None` as +/// insufficient. +pub(crate) fn analysis_stack_exhausted() -> bool { + match stacker::remaining_stack() { + Some(remaining) => remaining < ANALYSIS_STACK_RESERVE, + None => true, } - let cache = caches - .entry(file_id) - .or_insert_with(|| LuaInferCache::new(file_id, Default::default())); - 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 @@ -215,7 +117,7 @@ pub fn call_expr_is_net_op( } pub async fn fetch_schema_urls(urls: Vec) -> HashMap { - let mut url_contents = HashMap::new(); + let mut url_contents = HashMap::default(); for url in urls { if url.scheme() == "file" { if let Ok(path) = url.to_file_path() @@ -266,7 +168,7 @@ pub(crate) fn dependency_site_path_keys( return Vec::new(); } - let mut keys = HashSet::new(); + let mut keys = FxHashSet::default(); insert_dependency_path_key_variants(&mut keys, dependency_path.clone()); if let Some(source_parent) = db @@ -284,8 +186,371 @@ pub(crate) fn dependency_site_path_keys( keys } +fn select_cross_file_stabilization_dependents( + all_dependents: impl IntoIterator, + changed: &FxHashSet, +) -> Vec { + let mut dependents = all_dependents + .into_iter() + .filter(|file_id| !changed.contains(file_id)) + .collect::>(); + dependents.sort_unstable(); + dependents.dedup(); + dependents +} + +/// Pre-edit names for one changed file, so a removed export still contributes +/// the name another file would use to name it. +/// +/// Empirically, a removed [`LuaMemberId`]/[`LuaDeclId`] no longer resolves in +/// the post-edit index (`get_member`/`get_decl` return `None` once +/// `remove_index` has run). The snapshot therefore covers every member (owner +/// always, key name when the key is a [`LuaMemberKey::Name`]) and every decl +/// (name + locality): a non-name member such as an integer-indexed slot still +/// names its owner, and [`LuaTypeDeclId`] carries its name inside the +/// [`ExportKey`] itself and needs no snapshot. +struct PreEditNameSnapshot { + members: FxHashMap, Option)>, + decls: FxHashMap, +} + +fn snapshot_pre_edit_names(db: &DbIndex, file_id: FileId) -> PreEditNameSnapshot { + let mut members = FxHashMap::default(); + let mut decls = FxHashMap::default(); + for member in db.get_member_index().get_file_members(file_id) { + let name = match member.get_key() { + LuaMemberKey::Name(name) => Some(name.clone()), + LuaMemberKey::Integer(_) | LuaMemberKey::None | LuaMemberKey::ExprType(_) => None, + }; + let owner = db + .get_member_index() + .get_member_owner(&member.get_id()) + .cloned(); + members.insert(member.get_id(), (name, owner)); + } + if let Some(tree) = db.get_decl_index().get_decl_tree(&file_id) { + for (decl_id, decl) in tree.get_decls() { + let is_local = decl.is_local(); + decls.insert(*decl_id, (SmolStr::new(decl.get_name()), is_local)); + } + } + PreEditNameSnapshot { members, decls } +} + +fn insert_global_path_parts(out: &mut FxHashSet, path: &str) { + for part in path.split('.') { + if !part.is_empty() { + out.insert(SmolStr::new(part)); + } + } +} + +fn insert_type_name(out: &mut FxHashSet, type_id: &LuaTypeDeclId) { + out.insert(SmolStr::new(type_id.get_name())); + let simple = type_id.get_simple_name(); + if simple != type_id.get_name() { + out.insert(SmolStr::new(simple)); + } +} + +fn insert_member_owner_names(out: &mut FxHashSet, owner: &LuaMemberOwner) { + match owner { + LuaMemberOwner::GlobalPath(path) => insert_global_path_parts(out, path.get_name()), + LuaMemberOwner::Type(type_id) => insert_type_name(out, type_id), + LuaMemberOwner::Element(_) | LuaMemberOwner::LocalUnresolve => {} + } +} + +fn insert_member_id_names( + db: &DbIndex, + out: &mut FxHashSet, + member_id: &LuaMemberId, + pre: Option<&PreEditNameSnapshot>, +) { + // Emit both sides: a rename reuses the same position, so the post-edit + // entry exists but names the new key while the pre-edit snapshot still + // holds the old one. A removal only has the pre side; an addition only + // the post side. Either side also emits its owner's names, so a non-name + // member (integer slot, dynamic key) still names the global path or type + // another file uses to reach it. + if let Some(member) = db.get_member_index().get_member(member_id) { + if let LuaMemberKey::Name(name) = member.get_key() { + out.insert(name.clone()); + } + if let Some(owner) = db.get_member_index().get_member_owner(member_id) { + insert_member_owner_names(out, owner); + } + } + // Removed — or renamed away from this key — the post-edit index no longer + // holds the old name (`get_member` returns `None` after `remove_index`, + // verified empirically), so the pre-edit snapshot supplies it. + if let Some((name, owner)) = pre.and_then(|pre| pre.members.get(member_id)) { + if let Some(name) = name { + out.insert(name.clone()); + } + if let Some(owner) = owner { + insert_member_owner_names(out, owner); + } + } +} + +fn insert_decl_id_names( + db: &DbIndex, + out: &mut FxHashSet, + decl_id: &LuaDeclId, + pre: Option<&PreEditNameSnapshot>, +) { + if let Some(decl) = db.get_decl_index().get_decl(decl_id) { + if !decl.is_local() { + out.insert(SmolStr::new(decl.get_name())); + } + } + if let Some((name, is_local)) = pre.and_then(|pre| pre.decls.get(decl_id)) { + if !is_local { + out.insert(name.clone()); + } + } +} + +/// Names of the exported owner a changed signature belongs to. +/// +/// A [`LuaSignatureId`] names no Lua name itself; its owner does. The owner +/// lives in the signature's own file (`signature_id.get_file_id()`), which for +/// a [`ExportKey::ContributedParam`] is the callee's file rather than the +/// contributor's. Two legs cover the two shapes that export a function: +/// - a function stat (`function M.Fn() end`, `function G() end`) shares its +/// property with the signature, so the property index maps the member or +/// decl owner back to it; +/// - an assignment (`M.Fn = function() end`) stores the signature in the +/// member's or global's type cache, so scanning the file's type caches for +/// the signature finds it. +/// +/// Only exported owners count (non-local decls, members): an anonymous or +/// purely local closure contributes nothing, which is what keeps a local-only +/// signature change from widening diagnostics across the workspace. +fn insert_signature_owner_names( + db: &DbIndex, + out: &mut FxHashSet, + signature_id: &LuaSignatureId, + pre: &FxHashMap, +) { + let owner_file = signature_id.get_file_id(); + let file_pre = pre.get(&owner_file); + let property_index = db.get_property_index(); + for (owner_id, _) in property_index.properties_in_file(owner_file) { + if property_index.get_signature_owner(owner_id) != Some(*signature_id) { + continue; + } + match owner_id { + LuaSemanticDeclId::TypeDecl(type_id) => { + insert_type_name(out, type_id); + } + LuaSemanticDeclId::Member(member_id) => { + insert_member_id_names(db, out, member_id, file_pre); + } + LuaSemanticDeclId::LuaDecl(decl_id) => { + insert_decl_id_names(db, out, decl_id, file_pre); + } + LuaSemanticDeclId::Signature(_) => {} + } + } + let Some(owners) = db.get_type_index().file_type_owners(owner_file) else { + return; + }; + for owner in owners { + let exported = match owner { + LuaTypeOwner::Decl(decl_id) => db + .get_decl_index() + .get_decl(decl_id) + .is_some_and(|decl| !decl.is_local()), + LuaTypeOwner::Member(_) => true, + LuaTypeOwner::SyntaxId(_) => false, + }; + if !exported { + continue; + } + let Some(cache) = db.get_type_index().get_type_cache(owner) else { + continue; + }; + let mut found = false; + TypeVisitTrait::visit_type(cache.as_type(), &mut |inner| { + if let LuaType::Signature(id) = inner + && id == signature_id + { + found = true; + } + }); + if !found { + continue; + } + match owner { + LuaTypeOwner::Decl(decl_id) => { + insert_decl_id_names(db, out, decl_id, file_pre); + } + LuaTypeOwner::Member(member_id) => { + insert_member_id_names(db, out, member_id, file_pre); + } + LuaTypeOwner::SyntaxId(_) => {} + } + } +} + +/// Old and new names of non-local declarations the edit renamed without +/// moving any export. +/// +/// A same-position rename (`function Old() end` to `function New() end`) keeps +/// every position-derived [`ExportKey`] identical, so the export diff is empty +/// and this check runs independently of it, before the empty-diff early exit. +/// Pre-edit positions are mapped through the edit's [`PositionMap`]: a pre +/// decl whose image holds a post decl under a different name contributes both +/// names; pre decls lost inside the hunk paired with otherwise-unmatched post +/// decls are an in-hunk rename and contribute both sides as well. One-sided +/// leftovers are a pure addition or removal, which the export diff already +/// covers, and are skipped. Diagnostic-only: the names never enter +/// `dirty.files`. +fn decl_rename_refresh_names( + db: &DbIndex, + file_id: FileId, + map: &PositionMap, + pre: Option<&PreEditNameSnapshot>, +) -> FxHashSet { + let mut names = FxHashSet::default(); + let Some(pre) = pre else { + return names; + }; + let Some(tree) = db.get_decl_index().get_decl_tree(&file_id) else { + return names; + }; + let mut unmatched_post: FxHashMap = FxHashMap::default(); + for (decl_id, decl) in tree.get_decls() { + if decl_id.file_id != file_id || decl.is_local() { + continue; + } + unmatched_post.insert(decl_id.position, SmolStr::new(decl.get_name())); + } + let mut unmatched_pre: Vec = Vec::new(); + for (decl_id, (name, is_local)) in &pre.decls { + if *is_local || decl_id.file_id != file_id { + continue; + } + let Some(mapped) = map.map(decl_id.position) else { + // Inside the hunk: a rename candidate, or a deletion the diff + // covers when no post side is left unmatched. + unmatched_pre.push(name.clone()); + continue; + }; + // A mapped position with no post decl means the declaration is gone; + // the export diff reports that as removed keys, so nothing is added. + if let Some(post_name) = unmatched_post.remove(&mapped) + && post_name != *name + { + names.insert(name.clone()); + names.insert(post_name); + } + } + if !unmatched_pre.is_empty() && !unmatched_post.is_empty() { + names.extend(unmatched_pre); + names.extend(unmatched_post.into_values()); + } + names +} + +/// Names from one export diff that another file could reference textually. +/// +/// Covers every [`ExportKey`] variant that names a Lua name: +/// - `Member` / `TypeCache(Member)` / `Realm(Member)` / `Property(Member)`: +/// the member key plus its owner's global-path segments or type name, on +/// both the pre and the post side. +/// - `TypeDecl` / `Operator(Type)` / `Property(TypeDecl)`: the type name. +/// - `TypeCache(Decl)` / `Realm(Decl)` / `Property(Decl)`: the decl name. +/// - `Signature` / `ContributedParam` / `Property(Signature)`: the changed +/// signature's actual exported owner, resolved in the signature's own file +/// (for a contributed param that is the callee's file, so the contributor's +/// names stay out). An anonymous signature contributes nothing. +/// - `InferredGuard(GlobalPath)`: its path segments directly; a guard that +/// narrows differently without moving paths names nothing further. +/// - `NetFlow` (a string-literal message name), `Metatable` (a literal range), +/// `Operator(Table)` and `TypeCache(SyntaxId)` name no Lua reference and are +/// skipped; file-level `ModuleExport`/`LoadEdges`/`Namespace`/ +/// `FileRealmMetadata` already expand through file dependencies. +fn export_diff_changed_names( + db: &DbIndex, + file_id: FileId, + diff: &ChangedExports, + pre: &FxHashMap, +) -> FxHashSet { + let mut names = FxHashSet::default(); + let file_pre = pre.get(&file_id); + for key in diff.keys() { + match key { + ExportKey::Member(member_id) => { + insert_member_id_names(db, &mut names, member_id, file_pre); + } + ExportKey::TypeDecl(type_id) => { + insert_type_name(&mut names, type_id); + } + ExportKey::TypeCache(owner) => match owner { + LuaTypeOwner::Member(member_id) => { + insert_member_id_names(db, &mut names, member_id, file_pre); + } + LuaTypeOwner::Decl(decl_id) => { + insert_decl_id_names(db, &mut names, decl_id, file_pre); + } + LuaTypeOwner::SyntaxId(_) => {} + }, + ExportKey::Signature(signature_id) => { + insert_signature_owner_names(db, &mut names, signature_id, pre); + } + ExportKey::ContributedParam(signature_id, _) => { + insert_signature_owner_names(db, &mut names, signature_id, pre); + } + ExportKey::InferredGuard(owner) => { + for part in owner.path() { + if !part.is_empty() { + names.insert(part.clone()); + } + } + } + ExportKey::Property(semantic_id) => match semantic_id { + LuaSemanticDeclId::TypeDecl(type_id) => { + insert_type_name(&mut names, type_id); + } + LuaSemanticDeclId::Member(member_id) => { + insert_member_id_names(db, &mut names, member_id, file_pre); + } + LuaSemanticDeclId::LuaDecl(decl_id) => { + insert_decl_id_names(db, &mut names, decl_id, file_pre); + } + LuaSemanticDeclId::Signature(signature_id) => { + insert_signature_owner_names(db, &mut names, signature_id, pre); + } + }, + ExportKey::Operator(owner, _) => match owner { + LuaOperatorOwner::Table(_) => {} + LuaOperatorOwner::Type(type_id) => { + insert_type_name(&mut names, type_id); + } + }, + ExportKey::NetFlow(_, _) | ExportKey::Metatable(_) => {} + ExportKey::Realm(subject) => match subject { + RealmSubject::Decl(decl_id) => { + insert_decl_id_names(db, &mut names, decl_id, file_pre); + } + RealmSubject::Member(member_id) => { + insert_member_id_names(db, &mut names, member_id, file_pre); + } + }, + ExportKey::FileRealmMetadata + | ExportKey::ModuleExport + | ExportKey::LoadEdges + | ExportKey::Namespace => {} + } + } + names +} + fn dependency_path_keys_for_target(db: &DbIndex, target_path: &Path) -> Vec { - let mut keys = HashSet::new(); + let mut keys = FxHashSet::default(); let Some(target_path_text) = target_path.to_str() else { return Vec::new(); }; @@ -314,7 +579,7 @@ fn dependency_path_keys_for_target(db: &DbIndex, target_path: &Path) -> Vec, path: String) { +fn insert_dependency_path_key_variants(keys: &mut FxHashSet, path: String) { let normalized = normalize_dependency_path(&path); if normalized.is_empty() { return; @@ -377,8 +642,105 @@ pub struct EmmyLuaAnalysis { pub emmyrc: Arc, #[cfg(test)] pub(crate) inferred_guard_propagation_stats: InferredGuardPropagationStats, + /// The text each file's current index entries were built from, stashed by + /// the first write that does not re-index. + /// + /// Absent means the file's VFS content is still the text the index was + /// built from. The oldest stash is the one that matches the index, so a + /// burst of writes before an index costs one diff, not one per write. + pending_indexed_text: rustc_hash::FxHashMap>, +} + +/// The files an edit left needing re-analysis, and what the ripple has to know +/// about the edit that produced them. +#[derive(Debug, Default)] +pub struct DirtySet { + files: FxHashSet, + /// The files whose text this burst changed. Drives removal seeds, guard + /// propagation sources and `refresh_file_source_dependencies`. + sources: FxHashSet, + removed: FxHashSet, + guard_snapshot: InferredGuardSnapshot, + /// Whether guard facts or call-site-inferred params could have moved. + /// + /// Their consumers are reached through the propagation passes rather than + /// through the export graph, so a set with no dirty files can still owe + /// work. + guard_work: bool, + /// The edited files whose exports actually moved. + #[cfg(test)] + changed_sources: FxHashSet, + /// Names whose textual referencers owe a diagnostic refresh. + /// + /// Derived from the batch's export diff in [`EmmyLuaAnalysis::self_index_and_diff`]: + /// each changed/added/removed [`ExportKey`] contributes the Lua name(s) another + /// file would use to name it, resolved against the post-edit index with a + /// pre-edit snapshot as fallback for removed keys (whose index entries are + /// already gone). The ripple's own export diffs contribute a second sideband + /// set through `ripple_with_refresh_names`, merged with this one after the + /// ripple. Neither set drives reindexing: they stay out of `pending` and + /// `dirty.files` and only widen the watched-file method's returned + /// diagnostic list. They are deliberately ignored by [`DirtySet::is_empty`] and + /// [`DirtySet::dirty_len`]. + textual_refresh_names: FxHashSet, +} + +impl DirtySet { + /// Whether the ripple has nothing to do. The edited files themselves are + /// already settled by phase 1. + pub fn is_empty(&self) -> bool { + self.files.is_empty() && self.removed.is_empty() && !self.guard_work + } + + /// Unions another phase-1 result into this one, keeping the oldest guard + /// facts: a later batch sees facts an earlier self-index already overwrote. + pub fn extend(&mut self, other: DirtySet) { + self.files.extend(other.files); + self.sources.extend(other.sources); + self.removed.extend(other.removed); + self.guard_work |= other.guard_work; + self.textual_refresh_names + .extend(other.textual_refresh_names); + #[cfg(test)] + self.changed_sources.extend(other.changed_sources); + self.guard_snapshot.merge(other.guard_snapshot); + } + + /// How many files the ripple still owes. + /// + /// Textual refresh names never count: they owe diagnostics, not reanalysis. + pub fn dirty_len(&self) -> usize { + self.files.len() + } + + #[cfg(test)] + pub fn files(&self) -> &FxHashSet { + &self.files + } + #[cfg(test)] - cross_file_stabilization_invocations: usize, + pub fn changed_sources(&self) -> &FxHashSet { + &self.changed_sources + } + + #[cfg(test)] + pub fn textual_refresh_names(&self) -> &FxHashSet { + &self.textual_refresh_names + } + + /// Takes the sideband textual refresh names out of the set, so a caller + /// can schedule their diagnostics without holding on to the ripple's + /// re-index work. + pub fn take_textual_refresh_names(&mut self) -> std::collections::HashSet { + std::mem::take(&mut self.textual_refresh_names) + .into_iter() + .collect() + } + + /// How many sideband textual refresh names the set carries. + pub fn textual_refresh_names_len(&self) -> usize { + self.textual_refresh_names.len() + } } impl EmmyLuaAnalysis { @@ -390,8 +752,7 @@ impl EmmyLuaAnalysis { emmyrc, #[cfg(test)] inferred_guard_propagation_stats: InferredGuardPropagationStats::default(), - #[cfg(test)] - cross_file_stabilization_invocations: 0, + pending_indexed_text: rustc_hash::FxHashMap::default(), } } @@ -453,7 +814,11 @@ impl EmmyLuaAnalysis { module_index.add_workspace_root_with_kind(root, id, WorkspaceKind::Library); } - pub fn update_file_by_uri(&mut self, uri: &Uri, text: Option) -> Option { + pub fn update_file_by_uri( + &mut self, + uri: &Uri, + text: Option, + ) -> Option<(FileId, Vec)> { let existing_file_id = self.compilation.get_db().get_vfs().get_file_id(uri); if let Some(file_id) = existing_file_id { if let (Some(new_text), Some(old_text)) = ( @@ -476,13 +841,13 @@ impl EmmyLuaAnalysis { .get_module(file_id) .is_some() { - return Some(file_id); + return Some((file_id, Vec::new())); } // Index was cleared — fall through to rebuild it. self.compilation.remove_index(vec![file_id]); self.compilation.update_index(vec![file_id]); - return Some(file_id); + return Some((file_id, Vec::new())); } } else if text.is_none() { return None; @@ -510,29 +875,50 @@ impl EmmyLuaAnalysis { .get_db_mut() .get_vfs_mut() .set_file_content(uri, text); - return Some(file_id); + return Some((file_id, Vec::new())); } - // 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])) + // Two-phase incremental edit. Phase 1 re-indexes the edited file and + // re-homes every reference into it, so what the diff of its exports + // names is exactly the facts another file could be reading. Phase 2 + // re-analyses those readers, and nothing else: a body-local edit + // produces no key at all and costs one file's analysis. + // A file that was not there - never seen, or deleted and now + // reopened - is not reachable from any dependency edge: a read that + // failed to resolve it recorded nothing to find it by. + let was_absent = existing_file_id.is_none_or(|file_id| { + self.compilation + .get_db() + .get_vfs() + .get_syntax_tree(&file_id) + .is_none() }); - + let is_removed = text.is_none(); + if let Some(existing) = existing_file_id { + // Before the mutation: this is the text the index was built from. + // For a deletion it is also what says every old offset is gone. + self.stash_indexed_text(existing); + } 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])); - profile::phase("edit/reindex", || { - self.reindex_expanded_files(vec![file_id], expansion) + let mut dirty = profile::phase("edit/self-index", || { + self.self_index_and_diff(vec![file_id]) + }); + if was_absent || is_removed { + self.dirty_existence_dependents(&mut dirty, file_id); + } + // Phase 2 plus the textual widening: the caller gets every file whose + // diagnostics this edit may have moved, not only the edited file. + let widened = profile::phase("edit/ripple", || { + self.ripple_and_widen(dirty, vec![file_id]) }); profile::phase_report("update_file_by_uri"); - Some(file_id) + Some((file_id, widened)) } pub fn update_file_preparsed( @@ -543,7 +929,7 @@ impl EmmyLuaAnalysis { line_index: LineIndex, version: Option, trigger_reindex: bool, - ) -> Option { + ) -> Option<(FileId, Vec)> { let existing_file_id = self.compilation.get_db().get_vfs().get_file_id(&uri); if let Some(file_id) = existing_file_id { if let (Some(incoming_version), Some(current_version)) = ( @@ -577,82 +963,61 @@ impl EmmyLuaAnalysis { .get_db_mut() .get_vfs_mut() .update_file_version(&file_id, version); - return Some(file_id); + return Some((file_id, Vec::new())); } 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_indexed_text.remove(&file_id); } self.compilation .get_db_mut() .get_vfs_mut() .update_file_version(&file_id, version); - return Some(file_id); + return Some((file_id, Vec::new())); } } else if text.is_none() { return None; } + // The text the index was built from has to be read before the VFS + // mutation drops it. When this call also re-indexes, the diff below + // consumes it; otherwise it is left for whichever pass does index the + // file, because until then the index still describes the old text. + let was_absent = existing_file_id.is_none_or(|file_id| { + self.compilation + .get_db() + .get_vfs() + .get_syntax_tree(&file_id) + .is_none() + }); let is_removed = text.is_none(); - let (existing_reindex_file_ids, old_guard_facts) = if trigger_reindex { - let removed_file_ids = existing_file_id - .filter(|_| is_removed) - .into_iter() - .collect::>(); - let mut reindex_file_ids = - existing_file_id.map(|file_id| self.expand_reindex_file_ids(vec![file_id])); - if let Some(reindex_file_ids) = &mut reindex_file_ids { - self.add_vgui_forwarding_removal_seed(&removed_file_ids, reindex_file_ids); - } - let old_guard_fact_file_ids = reindex_file_ids - .iter() - .flatten() - .copied() - .collect::>(); - ( - reindex_file_ids, - self.inferred_guard_snapshot(&old_guard_fact_file_ids), - ) - } else { - (None, InferredGuardSnapshot::default()) - }; + if let Some(fid) = existing_file_id { + self.stash_indexed_text(fid); + } let file_id = self .compilation .get_db_mut() .get_vfs_mut() .set_file_content_preparsed(&uri, text, tree, line_index, version)?; - let incremental_source_file_ids = HashSet::from([file_id]); if trigger_reindex { - let reindex_file_ids = existing_reindex_file_ids - .unwrap_or_else(|| self.expand_reindex_file_ids(vec![file_id])); - self.compilation.remove_index(reindex_file_ids.clone()); - - let update_file_ids = reindex_file_ids - .iter() - .copied() - .filter(|id| !is_removed || *id != file_id) - .collect::>(); - if !update_file_ids.is_empty() { - self.compilation.update_index(update_file_ids); + let mut dirty = self.self_index_and_diff(vec![file_id]); + if was_absent || is_removed { + self.dirty_existence_dependents(&mut dirty, file_id); } - self.compilation - .get_db_mut() - .get_call_site_param_index_mut() - .refresh_file_source_dependencies(file_id); - self.reindex_changed_inferred_guard_references( - &reindex_file_ids.iter().copied().collect(), - &old_guard_facts, - &reindex_file_ids, - &incremental_source_file_ids, - ); - self.reindex_changed_inferred_param_consumers(&old_guard_facts, &reindex_file_ids); + let widened = self.ripple_and_widen(dirty, vec![file_id]); + return Some((file_id, widened)); } - Some(file_id) + Some((file_id, Vec::new())) } pub fn update_file_preparsed_deferred( @@ -695,6 +1060,10 @@ impl EmmyLuaAnalysis { return None; } + if let Some(fid) = existing_file_id { + self.stash_indexed_text(fid); + } + self.compilation .get_db_mut() .get_vfs_mut() @@ -718,6 +1087,7 @@ impl EmmyLuaAnalysis { return Some(file_id); } } + self.stash_indexed_text(file_id); } let file_id = self @@ -729,11 +1099,17 @@ 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. - 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); + /// Settles the index after these files' text changed under + /// [`update_file_text_only`](Self::update_file_text_only). + /// + /// Both phases of an edit: re-index the changed files and re-home the + /// references into them, then re-analyse whatever read a fact the edit + /// moved. Returns every live file whose diagnostics this settle may have + /// moved — the changed files, the files the ripple settled, and the + /// textual referencers of the moved names. + pub fn reindex_files(&mut self, file_ids: Vec) -> Vec { + let dirty = self.self_index_and_diff(file_ids.clone()); + self.ripple_and_widen(dirty, file_ids) } /// [`reindex_files`](Self::reindex_files) against an expansion that was @@ -748,7 +1124,24 @@ 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::>(); + self.reindex_expanded_files_inner(file_ids, expansion); + } + + /// Re-analyses `expansion` with `file_ids` as the files that changed. + /// + fn reindex_expanded_files_inner(&mut self, file_ids: Vec, expansion: Vec) { + let guard_fact_file_ids = expansion.iter().copied().collect::>(); + let old_guard_facts = self.inferred_guard_snapshot(&guard_fact_file_ids); + self.reindex_expanded_files_with_old_snapshot(file_ids, expansion, old_guard_facts); + } + + fn reindex_expanded_files_with_old_snapshot( + &mut self, + file_ids: Vec, + expansion: Vec, + old_guard_facts: InferredGuardSnapshot, + ) { + let incremental_source_file_ids = file_ids.iter().copied().collect::>(); let removed_file_ids = file_ids .iter() .copied() @@ -759,12 +1152,16 @@ impl EmmyLuaAnalysis { .get_syntax_tree(file_id) .is_none() }) - .collect::>(); + .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::>(); - let old_guard_facts = self.inferred_guard_snapshot(&guard_fact_file_ids); + let guard_fact_file_ids = file_ids.iter().copied().collect::>(); self.compilation.remove_index(file_ids.clone()); let update_file_ids = file_ids .iter() @@ -790,31 +1187,515 @@ impl EmmyLuaAnalysis { self.reindex_changed_inferred_param_consumers(&old_guard_facts, &file_ids); } - /// Rebuilds only these files' own index entries. + /// Records the text the file's current index entries were built from, + /// unless an earlier write 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. - pub fn self_index_files(&mut self, file_ids: Vec) { - self.compilation.remove_index(file_ids.clone()); - self.compilation.update_index(file_ids); - } - - /// 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()); - self.compilation.update_index(file_ids.clone()); - self.stabilize_cross_file_type_caches(&file_ids); + /// Must run before the VFS mutation. 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. + fn stash_indexed_text(&mut self, file_id: FileId) { + if self.pending_indexed_text.contains_key(&file_id) { + return; + } + let Some(text) = self + .compilation + .get_db() + .get_vfs() + .get_file_content(&file_id) + else { + return; + }; + let text: Arc = Arc::from(text.as_str()); + self.pending_indexed_text.insert(file_id, text); } - pub fn expand_reindex_file_ids(&self, file_ids: Vec) -> Vec { + /// Where the edit moved the file's offsets: from the stashed indexed text + /// to the text now in the VFS. + /// + /// No stash means nothing has written since the file was indexed, so + /// nothing moved. + fn take_position_map(&mut self, file_id: FileId) -> PositionMap { + let Some(old) = self.pending_indexed_text.remove(&file_id) else { + return PositionMap::identity(); + }; + match self + .compilation + .get_db() + .get_vfs() + .get_file_content(&file_id) + { + Some(new) => PositionMap::new(&old, new), + None => PositionMap::whole_file(old.len(), 0), + } + } + + /// Phase 1 of an edit: re-index the edited files themselves, re-home every + /// other file's references into them, and report which files the edit + /// actually invalidated. + /// + /// Preconditions: the new text is already in the VFS and + /// [`stash_indexed_text`](Self::stash_indexed_text) ran before each write. + /// + /// The edited files are settled against their own text when this returns, + /// which is all a request positioned inside one of them needs. The dirty + /// set is what the caller still owes [`ripple`](Self::ripple). + pub fn self_index_and_diff(&mut self, file_ids: Vec) -> DirtySet { + let mut dirty = DirtySet { + sources: file_ids.iter().copied().collect(), + ..DirtySet::default() + }; + dirty.removed = file_ids + .iter() + .copied() + .filter(|file_id| { + self.compilation + .get_db() + .get_vfs() + .get_syntax_tree(file_id) + .is_none() + }) + .collect(); + // Before any removal: a self-index overwrites the facts propagation + // has to diff against. + dirty.guard_snapshot = self.inferred_guard_snapshot(&dirty.sources); + dirty.guard_work = !dirty.guard_snapshot.is_empty(); + + // Taken before any removal: the seed is chosen from the metadata the + // removed file still holds, and a live file has to be re-analysed for + // the forwarding pass to observe the removal at all. + let mut seeded = dirty.removed.iter().copied().collect::>(); + seeded.sort_unstable(); + self.add_vgui_forwarding_removal_seed(&dirty.removed, &mut seeded); + dirty.files.extend(seeded); + + // Every old map first, then one batch re-index, then every new map. + // The batch matters: analyser phases run over a whole batch, so + // re-indexing the files one at a time would derive different facts + // than a cold build does for the same set. + let mut pre_edit = Vec::with_capacity(file_ids.len()); + // Pre-edit names for textual refresh: a removed export's index entries + // are gone after `remove_index`, so its old name only survives here. + let mut pre_names: FxHashMap = FxHashMap::default(); + for &file_id in &file_ids { + pre_names.insert( + file_id, + snapshot_pre_edit_names(self.compilation.get_db(), file_id), + ); + let map = self.take_position_map(file_id); + let removed = dirty.removed.contains(&file_id); + // Expressed in the new text's coordinates already, so it compares + // key for key against the map taken after the re-index. + let old = export_map( + self.compilation.get_db(), + file_id, + // A deleted file has no new coordinates to express its old + // exports in, and mapping them all onto nothing would drop + // every key instead of reporting it removed. + &if removed { + FileRemap::identity(file_id) + } else { + FileRemap::unvalidated(file_id, map) + }, + ); + pre_edit.push((file_id, map, old)); + } + + self.compilation.remove_index(file_ids.clone()); + let live = file_ids + .iter() + .copied() + .filter(|file_id| !dirty.removed.contains(file_id)) + .collect::>(); + if !live.is_empty() { + self.compilation.update_index(live); + } + + for (file_id, map, old) in pre_edit { + let removed = dirty.removed.contains(&file_id); + let remap = FileRemap::validated(self.compilation.get_db(), file_id, map); + let lost = remap_into_file(self.compilation.get_db_mut(), &remap); + dirty.files.extend(lost); + + let new = if removed { + ExportMap::default() + } else { + export_map( + self.compilation.get_db(), + file_id, + &FileRemap::identity(file_id), + ) + }; + let diff = diff_exports(&old, &new); + // Diagnostic-refresh-only renames, independent of the export diff: + // a same-position rename moves no key, so without this its names + // never reach the watched-file refresh. Runs before the early exit + // and never touches `dirty.files`. + { + let renamed = decl_rename_refresh_names( + self.compilation.get_db(), + file_id, + &map, + pre_names.get(&file_id), + ); + dirty.textual_refresh_names.extend(renamed); + } + if diff.is_empty() { + continue; + } + #[cfg(test)] + dirty.changed_sources.insert(file_id); + // Diagnostic-refresh-only: name the Lua names this diff moved so the + // watched-file path can refresh their textual referencers without + // reindexing them. Never touches `dirty.files`. + { + let changed = export_diff_changed_names( + self.compilation.get_db(), + file_id, + &diff, + &pre_names, + ); + dirty.textual_refresh_names.extend(changed); + } + dirty.guard_work |= diff.keys().any(|key| { + matches!( + key, + ExportKey::InferredGuard(_) | ExportKey::ContributedParam(..) + ) + }); + dirty + .files + .extend(dependents_of(self.compilation.get_db(), file_id, &diff)); + let evidence = self.evidence_dependents(file_id); + dirty.files.extend(evidence); + // A file whose `include` path this edit changed is reached through + // the dependency index; one whose path did not resolve before is + // not in it at all. + if diff.keys().any(|key| { + matches!( + key, + ExportKey::LoadEdges + | ExportKey::ModuleExport + | ExportKey::Namespace + | ExportKey::FileRealmMetadata + ) + }) { + let unresolved = + self.unresolved_path_dependency_dependents(&FxHashSet::from_iter([file_id])); + dirty.files.extend(unresolved); + } + } + + for file_id in dirty.sources.iter().chain(dirty.removed.iter()) { + dirty.files.remove(file_id); + } + dirty + } + + /// The files that read this file through evidence rather than through a + /// cached type. + /// + /// A call site contributes a parameter type, a callback source is matched + /// by path, and an inferred return reads source files. None is a type cache, + /// so none is reachable from the export diff. These edges are one hop, not + /// a transitive expansion, and are only paid when the file exported + /// something new. + /// + /// Inference support is deliberately absent: `dependents_of` already + /// queries it node by node for the nodes the diff names, and the + /// file-granular rollup would add every reader of every support node in + /// the file - including the ones this edit left byte for byte alone. + fn evidence_dependents(&self, file_id: FileId) -> Vec { + let files = FxHashSet::from_iter([file_id]); + let db = self.compilation.get_db(); + let call_site_index = db.get_call_site_param_index(); + let mut dependents = call_site_index.collect_source_dependents(&files); + dependents.extend(call_site_index.collect_contributor_files(&files)); + dependents.extend( + call_site_index.collect_source_path_dependents(db.get_vfs().get_file_path(&file_id)), + ); + dependents.extend( + db.get_signature_index() + .inferred_return_dependents_for_files(&files), + ); + dependents + } + + /// Phase 2 of an edit: re-analyse the files the edit invalidated, and + /// whatever their own facts then invalidate, until nothing changes. + /// + /// Each round re-analyses a batch and diffs each file's exports against + /// the map taken just before it, so a file that re-derives the same facts + /// adds nothing. A file is re-analysed at most once per ripple. + /// + /// Returns every live file whose diagnostics this edit may have moved: + /// the files re-analysed, propagation included, plus the readers the diff + /// named that needed no re-index of their own. A caller that publishes + /// diagnostics needs that set and not just the files whose text changed — + /// the point of the ripple is the files nobody edited. + pub fn ripple(&mut self, dirty: DirtySet) -> Vec { + self.ripple_with_refresh_names(dirty).0 + } + + /// [`ripple`](Self::ripple), plus the sideband textual names every ripple + /// export diff moved. + /// + /// Each batch is snapshotted before its re-index (a rippled file's removed + /// entries are gone afterwards, exactly like an edited file's), and the + /// names resolve after the ripple under a shared borrow. They never enter + /// `pending` or `dirty.files` and steer no reindex decision: which files + /// the ripple settled is decided by the change-driven pass alone. The + /// late parameter-consumer pass contributes its own sideband below, merged + /// the same way. + pub fn ripple_with_refresh_names( + &mut self, + mut dirty: DirtySet, + ) -> (Vec, std::collections::HashSet) { + if dirty.is_empty() { + return (Vec::new(), Default::default()); + } + self.compilation + .get_db_mut() + .get_call_site_param_index_mut() + .forget_removed_files(&dirty.removed); + + let mut settled = dirty.sources.clone(); + settled.extend(dirty.removed.iter().copied()); + let mut reanalyzed = dirty.sources.iter().copied().collect::>(); + reanalyzed.sort_unstable(); + let mut pending = std::mem::take(&mut dirty.files); + // Every file the diff named, whether or not it ends up re-indexed. A + // reader can need no new index entries and still diagnose differently, + // because it resolves the fact that moved when it is diagnosed rather + // than when it is indexed. Reporting only what was re-analysed leaves + // exactly those files holding a stale report. + let mut touched: FxHashSet = dirty.sources.iter().copied().collect(); + touched.extend(pending.iter().copied()); + let mut round = 0usize; + // Sideband textual names from every ripple export diff, resolved after + // the ripple. Diagnostic-only: never enters `pending` or `touched` + // below, and steers no reindex decision. + let mut sideband: FxHashSet = FxHashSet::default(); + + while !pending.is_empty() { + let mut batch = pending + .drain() + .filter(|file_id| !settled.contains(file_id)) + // A file with a stashed text owes its own self-index, which is + // the only pass that can re-home the references into it. + // Re-indexing it here would drop that stash and strand them. + .filter(|file_id| !self.pending_indexed_text.contains_key(file_id)) + .filter(|file_id| { + self.compilation + .get_db() + .get_vfs() + .get_syntax_tree(file_id) + .is_some() + }) + .collect::>(); + if batch.is_empty() { + break; + } + batch.sort_unstable(); + round += 1; + if round == 8 { + log::warn!( + "ripple has not converged after 8 rounds; {} file(s) still changing", + batch.len() + ); + } + + let before = batch + .iter() + .map(|file_id| { + ( + *file_id, + export_map( + self.compilation.get_db(), + *file_id, + &FileRemap::identity(*file_id), + ), + ) + }) + .collect::>(); + // Their pre-batch guard facts join the snapshot for the same + // reason the edited files' did: the re-index below overwrites them. + // The pre-batch name snapshots serve the sideband for the same + // reason: a rippled file's removed entries are gone afterwards. + let batch_set = batch.iter().copied().collect::>(); + dirty + .guard_snapshot + .merge(self.inferred_guard_snapshot(&batch_set)); + let mut batch_pres: FxHashMap = FxHashMap::default(); + for file_id in &batch { + batch_pres.insert( + *file_id, + snapshot_pre_edit_names(self.compilation.get_db(), *file_id), + ); + } + + let profile_enabled = std::env::var_os("GLUALS_PROFILE").is_some(); + if profile_enabled { + eprintln!("[profile] ripple round {round}: {} file(s)", batch.len()); + } + self.compilation.remove_index(batch.clone()); + self.compilation.update_index(batch.clone()); + reanalyzed.extend(batch.iter().copied()); + settled.extend(batch.iter().copied()); + + for file_id in &batch { + let after = export_map( + self.compilation.get_db(), + *file_id, + &FileRemap::identity(*file_id), + ); + let diff = diff_exports(&before[file_id], &after); + // Sideband before the early exit: a rippled file whose own + // exports moved names its textual referencers, which hold no + // cached edge the change-driven pass could follow. + { + let mut changed = export_diff_changed_names( + self.compilation.get_db(), + *file_id, + &diff, + &batch_pres, + ); + changed.extend(decl_rename_refresh_names( + self.compilation.get_db(), + *file_id, + &PositionMap::identity(), + batch_pres.get(file_id), + )); + sideband.extend(changed); + } + if diff.is_empty() { + continue; + } + if profile_enabled { + let keys = diff + .keys() + .take(6) + .map(|key| { + let text = format!("{key:?}"); + text.chars().take(110).collect::() + }) + .collect::>(); + eprintln!( + "[profile] ripple round {round}: {:?} changed {} export(s): {keys:?}", + self.compilation.get_db().get_vfs().get_file_path(file_id), + diff.keys().count() + ); + } + let evidence = self.evidence_dependents(*file_id); + for dependent in dependents_of(self.compilation.get_db(), *file_id, &diff) + .into_iter() + .chain(evidence) + { + touched.insert(dependent); + if !settled.contains(&dependent) { + pending.insert(dependent); + } + } + } + } + + for file_id in &dirty.sources { + self.compilation + .get_db_mut() + .get_call_site_param_index_mut() + .refresh_file_source_dependencies(*file_id); + } + let guard_fact_file_ids = dirty.guard_snapshot.snapshot_file_ids().clone(); + let guard_reindexed = self.reindex_changed_inferred_guard_references( + &guard_fact_file_ids, + &dirty.guard_snapshot, + &reanalyzed, + &dirty.sources, + ); + // The late callee reindex can move exports of its own (a + // contributed-parameter change altering a differently named export), + // and its own ripple can move more. Both name sets bubble here; + // diagnostic-only, like every other sideband merge. + let (param_reindexed, param_names) = self + .reindex_changed_inferred_param_consumers_with_refresh_names( + &dirty.guard_snapshot, + &reanalyzed, + ); + + reanalyzed.extend(guard_reindexed); + reanalyzed.extend(param_reindexed); + sideband.extend(param_names); + reanalyzed.extend(touched); + reanalyzed.retain(|file_id| { + self.compilation + .get_db() + .get_vfs() + .get_syntax_tree(file_id) + .is_some() + }); + reanalyzed.sort_unstable(); + reanalyzed.dedup(); + (reanalyzed, sideband.into_iter().collect()) + } + + /// 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) { + self.compilation.remove_index(file_ids.clone()); + self.compilation.update_index(file_ids); + } + + fn stabilize_cross_file_type_caches(&mut self, file_ids: &[FileId]) { + if file_ids.is_empty() { + return; + } + + let changed = file_ids.iter().copied().collect::>(); + let all_dependents = self + .compilation + .get_db() + .get_type_index() + .files_with_cross_file_type_caches_referencing_files(&changed); + let dependents = select_cross_file_stabilization_dependents(all_dependents, &changed) + .into_iter() + .filter(|file_id| { + self.compilation + .get_db() + .get_vfs() + .get_syntax_tree(file_id) + .is_some() + }) + .collect::>(); + if dependents.is_empty() { + return; + } + + self.compilation.remove_index(dependents.clone()); + self.compilation.update_index(dependents); + } + + /// Re-analyses exactly `file_ids`, skipping dependency expansion. + pub fn reindex_files_without_expansion(&mut self, file_ids: Vec) { + // Re-derived from the current text, so a stashed pre-index text now + // describes a state that no longer exists. + for file_id in &file_ids { + self.pending_indexed_text.remove(file_id); + } + self.compilation.remove_index(file_ids.clone()); + self.compilation.update_index(file_ids.clone()); + self.stabilize_cross_file_type_caches(&file_ids); + } + + pub fn expand_reindex_file_ids(&self, file_ids: Vec) -> Vec { let _p = Profile::new("expand_reindex_file_ids"); - let mut expanded = file_ids.into_iter().collect::>(); + let mut expanded = file_ids.into_iter().collect::>(); loop { // Include/require callers must be rebuilt with their changed target. // Traverse the indexed dependency graph; never rescan workspace ASTs. @@ -840,6 +1721,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)) @@ -849,6 +1735,16 @@ impl EmmyLuaAnalysis { .get_db() .get_call_site_param_index() .collect_source_path_dependents(callback_source_paths); + let inferred_return_dependents = self + .compilation + .get_db() + .get_signature_index() + .inferred_return_dependents_for_files(&expanded); + let settled_signature_dependents = self + .compilation + .get_db() + .get_signature_index() + .settled_read_dependents_for_files(&expanded); let mut added = false; for file_id in dependency_dependents .into_iter() @@ -856,540 +1752,172 @@ impl EmmyLuaAnalysis { .chain(dependent_files) .chain(inference_dependents) .chain(callback_dependents) + .chain(contributor_dependents) .chain(callback_path_dependents) + .chain(inferred_return_dependents) + .chain(settled_signature_dependents) { added |= expanded.insert(file_id); } if !added { - break; - } - } - - let mut expanded = expanded.into_iter().collect::>(); - expanded.sort_unstable(); - expanded - } - - fn add_vgui_forwarding_removal_seed( - &self, - removed_file_ids: &HashSet, - reindex_file_ids: &mut Vec, - ) { - if removed_file_ids.is_empty() { - return; - } - let db = self.compilation.get_db(); - let vfs = db.get_vfs(); - let module_index = db.get_module_index(); - let gmod_index = db.get_gmod_class_metadata_index(); - let affected_workspace_id = reindex_file_ids - .iter() - .filter(|file_id| removed_file_ids.contains(file_id)) - .find_map(|file_id| { - gmod_index - .has_annotated_vgui_parent_calls(*file_id) - .then(|| module_index.get_workspace_id(*file_id)) - .flatten() - }); - let Some(affected_workspace_id) = affected_workspace_id else { - return; - }; - if reindex_file_ids.iter().any(|file_id| { - !removed_file_ids.contains(file_id) && vfs.get_syntax_tree(file_id).is_some() - }) { - return; - } - - let all_file_ids = vfs.get_all_file_ids(); - let seed_file_id = all_file_ids - .iter() - .copied() - .filter(|file_id| { - !removed_file_ids.contains(file_id) && vfs.get_syntax_tree(file_id).is_some() - }) - .find(|file_id| module_index.get_workspace_id(*file_id) == Some(affected_workspace_id)) - .or_else(|| { - all_file_ids.iter().copied().find(|file_id| { - !removed_file_ids.contains(file_id) && vfs.get_syntax_tree(file_id).is_some() - }) - }); - let Some(seed_file_id) = seed_file_id else { - return; - }; - reindex_file_ids.push(seed_file_id); - reindex_file_ids.sort_unstable(); - reindex_file_ids.dedup(); - } - - fn unresolved_path_dependency_dependents(&self, file_ids: &HashSet) -> Vec { - let db = self.compilation.get_db(); - let target_path_keys = file_ids - .iter() - .filter_map(|file_id| db.get_vfs().get_file_path(file_id).cloned()) - .flat_map(|target_path| dependency_path_keys_for_target(db, &target_path)) - .collect::>(); - - db.get_file_dependencies_index() - .collect_unresolved_path_dependents(target_path_keys) - } - - fn reindex_changed_inferred_guard_references( - &mut self, - source_file_ids: &HashSet, - old_snapshot: &InferredGuardSnapshot, - already_reindexed: &[FileId], - incremental_source_file_ids: &HashSet, - ) { - #[cfg(test)] - let initial_stabilization_invocations = self.cross_file_stabilization_invocations; - let profile_enabled = std::env::var_os("GLUALS_PROFILE").is_some(); - let mut profile_changed_facts = 0usize; - let mut profile_reference_edges = 0usize; - let mut profile_waves = 0usize; - let mut profile_reindexed_files = 0usize; - let mut propagation_reindexed_files = source_file_ids - .iter() - .copied() - .chain(already_reindexed.iter().copied()) - .collect::>(); - let mut new_facts = self - .compilation - .get_db() - .get_signature_index() - .inferred_guard_facts_for_files(source_file_ids); - let equivalent_owners = self.reconcile_equivalent_inferred_guard_owners( - old_snapshot, - &new_facts, - &propagation_reindexed_files, - ); - let old_facts = &old_snapshot.facts; - let mut changed_owners = old_facts - .keys() - .chain(new_facts.keys()) - .filter(|owner| { - !equivalent_owners.contains(*owner) - && old_facts.get(*owner) != new_facts.get(*owner) - }) - .cloned() - .collect::>() - .into_iter() - .collect::>(); - if changed_owners.is_empty() { - #[cfg(test)] - { - self.inferred_guard_propagation_stats = InferredGuardPropagationStats::default(); - } - if profile_enabled { - eprintln!( - "[profile] inferred_guard_incremental changed_facts=0 reference_edges=0 waves=0 reindexed_files=0" - ); - } - return; - } - profile_changed_facts += changed_owners.len(); - sort_inferred_guard_owners(&mut changed_owners); - let mut frontier_old_facts = old_snapshot.facts.clone(); - let mut frontier_old_consumers = old_snapshot.consumers.clone(); - - while !changed_owners.is_empty() { - let mut reference_files = HashSet::new(); - for owner in &changed_owners { - let newly_added = - !frontier_old_facts.contains_key(owner) && new_facts.contains_key(owner); - let old_consumers = frontier_old_consumers - .get(owner) - .into_iter() - .flatten() - .copied(); - let current_consumers = self - .compilation - .get_db() - .get_signature_index() - .inferred_guard_consumers(owner); - for file_id in old_consumers.chain(current_consumers) { - if !propagation_reindexed_files.contains(&file_id) { - profile_reference_edges += 1; - reference_files.insert(file_id); - } - } - if newly_added { - let allow_alias_retry = - 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(); - if !propagation_reindexed_files.contains(&file_id) || alias_retry { - profile_reference_edges += 1; - reference_files.insert(file_id); - } - } - } - } - if reference_files.is_empty() { - break; - } - - let mut reindex_file_ids = reference_files.into_iter().collect::>(); - reindex_file_ids.sort_unstable(); - let wave_file_ids = reindex_file_ids.iter().copied().collect::>(); - let old_wave_snapshot = self.inferred_guard_snapshot(&wave_file_ids); - self.compilation.remove_index(reindex_file_ids.clone()); - let update_file_ids = reindex_file_ids - .into_iter() - .filter(|file_id| { - self.compilation - .get_db() - .get_vfs() - .get_syntax_tree(file_id) - .is_some() - }) - .collect::>(); - if update_file_ids.is_empty() { - break; - } - profile_waves += 1; - profile_reindexed_files += update_file_ids.len(); - propagation_reindexed_files.extend(wave_file_ids.iter().copied()); - self.compilation.update_index(update_file_ids.clone()); - - new_facts = self - .compilation - .get_db() - .get_signature_index() - .inferred_guard_facts_for_files(&wave_file_ids); - let equivalent_owners = self.reconcile_equivalent_inferred_guard_owners( - &old_wave_snapshot, - &new_facts, - &propagation_reindexed_files, - ); - changed_owners = old_wave_snapshot - .facts - .keys() - .chain(new_facts.keys()) - .filter(|owner| { - !equivalent_owners.contains(*owner) - && old_wave_snapshot.facts.get(*owner) != new_facts.get(*owner) - }) - .cloned() - .collect::>() - .into_iter() - .collect(); - frontier_old_facts = old_wave_snapshot.facts; - frontier_old_consumers = old_wave_snapshot.consumers; - profile_changed_facts += changed_owners.len(); - sort_inferred_guard_owners(&mut changed_owners); - } - if profile_enabled { - eprintln!( - "[profile] inferred_guard_incremental changed_facts={} reference_edges={} waves={} reindexed_files={}", - profile_changed_facts, - profile_reference_edges, - profile_waves, - profile_reindexed_files - ); - } - #[cfg(test)] - { - self.inferred_guard_propagation_stats = InferredGuardPropagationStats { - changed_facts: profile_changed_facts, - reference_edges: profile_reference_edges, - frontiers: profile_waves, - reindexed_files: profile_reindexed_files, - broad_stabilizations: self - .cross_file_stabilization_invocations - .saturating_sub(initial_stabilization_invocations), - }; - } - } - - fn inferred_guard_snapshot(&self, file_ids: &HashSet) -> InferredGuardSnapshot { - let signature_index = self.compilation.get_db().get_signature_index(); - let facts = signature_index.inferred_guard_facts_for_files(file_ids); - let consumers = facts - .keys() - .map(|owner| { - ( - owner.clone(), - signature_index.inferred_guard_consumers(owner).collect(), - ) - }) - .collect(); - let inferred_params = self - .compilation - .get_db() - .get_call_site_param_index() - .inferred_params_for_contributor_files(file_ids); - InferredGuardSnapshot { - facts, - consumers, - inferred_params, - snapshot_file_ids: file_ids.clone(), + break; + } } + + let mut expanded = expanded.into_iter().collect::>(); + expanded.sort_unstable(); + expanded } - /// Re-analyses callee files whose call-site-inferred parameter types - /// changed. - fn reindex_changed_inferred_param_consumers( - &mut self, - old_snapshot: &InferredGuardSnapshot, - already_reindexed: &[FileId], + fn add_vgui_forwarding_removal_seed( + &self, + removed_file_ids: &FxHashSet, + reindex_file_ids: &mut Vec, ) { - let new_params = self - .compilation - .get_db() - .get_call_site_param_index() - .inferred_params_for_contributor_files(&old_snapshot.snapshot_file_ids); - let old_params = &old_snapshot.inferred_params; - if old_params.is_empty() && new_params.is_empty() { + if removed_file_ids.is_empty() { return; } - - let already_reindexed = already_reindexed + let db = self.compilation.get_db(); + let vfs = db.get_vfs(); + let module_index = db.get_module_index(); + let gmod_index = db.get_gmod_class_metadata_index(); + let affected_workspace_id = reindex_file_ids .iter() - .copied() - .chain(old_snapshot.snapshot_file_ids.iter().copied()) - .collect::>(); - let mut changed_files = old_params - .keys() - .chain(new_params.keys()) - .filter(|key| old_params.get(*key) != new_params.get(*key)) - .map(|(signature_id, _)| signature_id.get_file_id()) - .filter(|file_id| !already_reindexed.contains(file_id)) - .collect::>(); - changed_files.sort_unstable(); - changed_files.dedup(); - if changed_files.is_empty() { + .filter(|file_id| removed_file_ids.contains(file_id)) + .find_map(|file_id| { + gmod_index + .has_annotated_vgui_parent_calls(*file_id) + .then(|| module_index.get_workspace_id(*file_id)) + .flatten() + }); + let Some(affected_workspace_id) = affected_workspace_id else { + return; + }; + if reindex_file_ids.iter().any(|file_id| { + !removed_file_ids.contains(file_id) && vfs.get_syntax_tree(file_id).is_some() + }) { return; } - let expanded = self.expand_reindex_file_ids(changed_files); - let expanded = expanded - .into_iter() + let all_file_ids = vfs.get_all_file_ids(); + let seed_file_id = all_file_ids + .iter() + .copied() .filter(|file_id| { - self.compilation - .get_db() - .get_vfs() - .get_syntax_tree(file_id) - .is_some() + !removed_file_ids.contains(file_id) && vfs.get_syntax_tree(file_id).is_some() }) - .collect::>(); - if expanded.is_empty() { + .find(|file_id| module_index.get_workspace_id(*file_id) == Some(affected_workspace_id)) + .or_else(|| { + all_file_ids.iter().copied().find(|file_id| { + !removed_file_ids.contains(file_id) && vfs.get_syntax_tree(file_id).is_some() + }) + }); + let Some(seed_file_id) = seed_file_id else { return; - } - self.compilation.remove_index(expanded.clone()); - self.compilation.update_index(expanded); + }; + reindex_file_ids.push(seed_file_id); + reindex_file_ids.sort_unstable(); + reindex_file_ids.dedup(); } - fn reconcile_equivalent_inferred_guard_owners( - &mut self, - old_snapshot: &InferredGuardSnapshot, - new_facts: &HashMap, - reindexed_file_ids: &HashSet, - ) -> HashSet { - let mut reconciled = HashSet::new(); - for owner in old_snapshot - .facts - .keys() - .filter(|owner| old_snapshot.facts.get(*owner) == new_facts.get(*owner)) - { - if let Some(consumers) = old_snapshot.consumers.get(owner) { - self.compilation - .get_db_mut() - .get_signature_index_mut() - .migrate_inferred_guard_consumers(owner.clone(), consumers, reindexed_file_ids); - } - reconciled.insert(owner.clone()); - } - - let mut old_owners = old_snapshot - .facts - .keys() - .filter(|owner| !new_facts.contains_key(*owner)) - .cloned() - .collect::>(); - let mut new_owners = new_facts - .keys() - .filter(|owner| !old_snapshot.facts.contains_key(*owner)) - .cloned() - .collect::>(); - sort_inferred_guard_owners(&mut old_owners); - sort_inferred_guard_owners(&mut new_owners); - - for old_owner in old_owners { - let Some(new_idx) = new_owners.iter().position(|new_owner| { - old_owner.source_file_id() == new_owner.source_file_id() - && old_owner.path() == new_owner.path() - && old_owner.state_mask() == new_owner.state_mask() - && old_snapshot.facts.get(&old_owner) == new_facts.get(new_owner) - }) else { - continue; - }; - let new_owner = new_owners.remove(new_idx); - if let Some(consumers) = old_snapshot.consumers.get(&old_owner) { - self.compilation - .get_db_mut() - .get_signature_index_mut() - .migrate_inferred_guard_consumers( - new_owner.clone(), - consumers, - reindexed_file_ids, - ); - } - reconciled.insert(old_owner); - reconciled.insert(new_owner); - } - reconciled + /// Marks the files that have to be re-analysed because `file_id` appeared or + /// vanished, rather than because its contents changed. + /// + /// Whether a file exists is not a fact that file exports, so the export diff + /// cannot see it. Every `include` of the path resolves differently now, both + /// the ones that resolved to it and the ones that failed to resolve at all. + /// The file itself is dropped from the set: the caller has already indexed + /// it, or it is gone. + fn dirty_existence_dependents(&self, dirty: &mut DirtySet, file_id: FileId) { + let dependents = self + .compilation + .get_db() + .get_file_dependencies_index() + .get_file_dependencies() + .collect_file_dependents(vec![file_id]); + dirty.files.extend(dependents); + let unresolved = + self.unresolved_path_dependency_dependents(&FxHashSet::from_iter([file_id])); + dirty.files.extend(unresolved); + dirty.files.remove(&file_id); } - fn resolve_inferred_guard_reference_files( - &self, - owner: &LuaInferredGuardOwner, - discover_aliases: bool, - ) -> InferredGuardReferenceFiles { - let Some(member_name) = owner.path().last() else { - return InferredGuardReferenceFiles::default(); - }; - let references = if owner.path().len() == 1 { - self.compilation - .get_db() - .get_reference_index() - .get_global_references(member_name) - } else { - self.compilation - .get_db() - .get_reference_index() - .get_index_references(&LuaMemberKey::Name(member_name.clone())) - }; - let Some(references) = references else { - return InferredGuardReferenceFiles::default(); - }; - + fn unresolved_path_dependency_dependents(&self, file_ids: &FxHashSet) -> Vec { let db = self.compilation.get_db(); - let mut caches = HashMap::::new(); - let mut matching_references = references - .into_iter() - .filter_map(|reference| { - let root = db - .get_vfs() - .get_syntax_tree(&reference.file_id)? - .get_red_root(); - let expr = LuaExpr::cast(reference.value.to_node_from_root(&root)?)?; - (global_path_for_expr(&expr).as_deref() == Some(owner.path()) - && db.get_gmod_infer_index().are_offsets_compatible( - &reference.file_id, - expr.get_range().start(), - &owner.source_file_id(), - owner.signature_id().get_position(), - )) - .then_some((reference.file_id, expr)) - }) - .collect::>(); - matching_references.sort_by_key(|(file_id, expr)| (*file_id, expr.get_range().start())); - - let mut result = InferredGuardReferenceFiles::default(); - let mut alias_queue = VecDeque::new(); - let mut visited_aliases = HashSet::new(); - for (file_id, expr) in matching_references { - if call_resolves_to_inferred_guard_owner(db, &mut caches, owner, file_id, &expr) { - result.files.insert(file_id); - } - if discover_aliases - && expr_resolves_to_inferred_guard_owner(db, &mut caches, owner, file_id, &expr) - && let Some(decl_id) = immutable_local_alias_decl(db, file_id, &expr) - { - alias_queue.push_back(decl_id); - } - } - - while let Some(decl_id) = alias_queue.pop_front() { - if !visited_aliases.insert(decl_id) { - continue; - } - let Some(root) = db - .get_vfs() - .get_syntax_tree(&decl_id.file_id) - .map(|tree| tree.get_red_root()) - else { - continue; - }; - let Some(decl_references) = db - .get_reference_index() - .get_decl_references(&decl_id.file_id, &decl_id) - else { - continue; - }; - let mut cells = decl_references.cells.clone(); - cells.sort_by_key(|cell| cell.range.start()); - for cell in cells { - if cell.is_write { - continue; - } - let Some(name_expr) = root - .covering_element(cell.range) - .ancestors() - .find_map(LuaNameExpr::cast) - .filter(|name_expr| name_expr.get_range() == cell.range) - else { - continue; - }; - let expr = LuaExpr::NameExpr(name_expr); - if !db.get_gmod_infer_index().are_offsets_compatible( - &decl_id.file_id, - expr.get_range().start(), - &owner.source_file_id(), - owner.signature_id().get_position(), - ) { - continue; - } - if is_call_prefix(&expr) { - result.files.insert(decl_id.file_id); - result.alias_calls.insert(decl_id.file_id); - } - if let Some(next_decl_id) = immutable_local_alias_decl(db, decl_id.file_id, &expr) { - alias_queue.push_back(next_decl_id); - } - } - } + let target_path_keys = file_ids + .iter() + .filter_map(|file_id| db.get_vfs().get_file_path(file_id).cloned()) + .flat_map(|target_path| dependency_path_keys_for_target(db, &target_path)) + .collect::>(); - result + db.get_file_dependencies_index() + .collect_unresolved_path_dependents(target_path_keys) } - fn stabilize_cross_file_type_caches(&mut self, file_ids: &[FileId]) { - #[cfg(test)] - { - self.cross_file_stabilization_invocations += 1; + /// Textual diagnostic-refresh candidates for `names`, without reindexing. + /// + /// Each name is looked up in the reference index (`files_referencing_name`, + /// which unions global and member-key references) and the union is filtered + /// to live diagnostic-eligible workspace files: a file must still have a + /// syntax tree, must belong to a `Main` module workspace, and must not + /// already be in `exclude` (the change-driven settle set). Takes `&self` + /// only, so diagnosing the result mutates nothing and no index snapshot + /// moves; in particular `file_reference_revision` is untouched. + /// + /// Eligibility decision (documented per review): `Main` only. `Library` + /// (shipped annotations and other library workspaces), `Std` and `Remote` + /// files are excluded: the language server only pulls/diagnoses main + /// workspace files (see `get_main_workspace_file_ids_for_diagnostics`), + /// and refreshing annotations would fan out over every consumer of a hub + /// name for files nobody diagnoses. The changed file's own workspace does + /// not matter: a library edit still refreshes its main-workspace + /// referencers; only candidates are filtered. + /// + /// No caps or prefilters: every referencer is reported, however large the + /// fanout, and per-name fanout is logged without truncation so a hub name + /// stays visible. + pub fn textual_refresh_candidates( + &self, + names: &std::collections::HashSet, + exclude: &std::collections::HashSet, + ) -> Vec { + if names.is_empty() { + return Vec::new(); } - if file_ids.is_empty() { - return; + let db = self.compilation.get_db(); + let reference_index = db.get_reference_index(); + let module_index = db.get_module_index(); + let vfs = db.get_vfs(); + let mut sorted_names: Vec<&SmolStr> = names.iter().collect(); + sorted_names.sort_unstable(); + let mut candidates = FxHashSet::default(); + for name in sorted_names { + let referencers = reference_index.files_referencing_name(name); + // No caps: report the full fanout even for hub names. + log::info!( + "textual refresh: name `{}` fans out to {} file(s)", + name.as_str(), + referencers.len() + ); + candidates.extend(referencers); } - - let changed = file_ids.iter().copied().collect::>(); - let all_dependents = self - .compilation - .get_db() - .get_type_index() - .files_with_cross_file_type_caches_referencing_files(&changed); - let dependents = select_cross_file_stabilization_dependents(all_dependents, &changed) + let mut filtered: Vec = candidates .into_iter() - .filter(|file_id| { - self.compilation - .get_db() - .get_vfs() - .get_syntax_tree(file_id) - .is_some() - }) - .collect::>(); - if dependents.is_empty() { - return; + .filter(|file_id| !exclude.contains(file_id)) + .filter(|file_id| vfs.get_syntax_tree(file_id).is_some()) + .filter(|file_id| module_index.is_main(file_id)) + .collect(); + filtered.sort_unstable(); + filtered.dedup(); + if !filtered.is_empty() { + log::info!( + "textual refresh: {} additional file(s) from {} name(s)", + filtered.len(), + names.len() + ); } - - self.compilation.remove_index(dependents.clone()); - self.compilation.update_index(dependents); + filtered } pub fn update_remote_file_by_uri(&mut self, uri: &Uri, text: Option) -> FileId { @@ -1403,7 +1931,7 @@ impl EmmyLuaAnalysis { let removed_file_ids = is_removed .then_some(fid) .into_iter() - .collect::>(); + .collect::>(); let mut reindex_file_ids = vec![fid]; self.add_vgui_forwarding_removal_seed(&removed_file_ids, &mut reindex_file_ids); self.compilation.remove_index(reindex_file_ids.clone()); @@ -1417,36 +1945,29 @@ impl EmmyLuaAnalysis { fid } - pub fn update_file_by_path(&mut self, path: &PathBuf, text: Option) -> Option { - let uri = file_path_to_uri(path)?; - self.update_file_by_uri(&uri, text) - } - pub fn update_files_by_uri(&mut self, files: Vec<(Uri, Option)>) -> Vec { - let mut removed_files = HashSet::new(); - let mut updated_files = HashSet::new(); + let mut removed_files = FxHashSet::default(); + let mut updated_files = FxHashSet::default(); let mut files = files; - files.sort_by_cached_key(|(uri, _)| { - uri_to_file_path(uri) - .map(|path| crate::vfs::normalize_path_for_ordering(&path.to_string_lossy())) - .unwrap_or_else(|| uri.as_str().to_string()) - }); + files.sort_by_cached_key(|(uri, _)| uri_sort_key(uri)); let old_source_file_ids = files .iter() .filter_map(|(uri, _)| self.compilation.get_db().get_vfs().get_file_id(uri)) - .collect::>(); + .collect::>(); let removed_source_file_ids = files .iter() .filter(|(_, text)| text.is_none()) .filter_map(|(uri, _)| self.compilation.get_db().get_vfs().get_file_id(uri)) - .collect::>(); + .collect::>(); 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( &removed_source_file_ids, &mut old_guard_fact_file_ids, ); - let old_guard_fact_file_ids = old_guard_fact_file_ids.into_iter().collect::>(); + let old_guard_fact_file_ids = old_guard_fact_file_ids + .into_iter() + .collect::>(); let old_guard_facts = self.inferred_guard_snapshot(&old_guard_fact_file_ids); // Separate files into: unchanged (skip), to-remove, and to-parse @@ -1498,54 +2019,27 @@ impl EmmyLuaAnalysis { .map(|(uri, _)| self.compilation.get_db_mut().get_vfs_mut().file_id(uri)) .collect(); - // Parse in parallel + // Parse in parallel on rayon's persistent pool. `collect` on an + // indexed parallel iterator preserves input order, so `parsed` + // stays aligned with `to_parse` and `file_ids`. + use rayon::prelude::*; let config = self.emmyrc.clone(); - let n_threads = std::thread::available_parallelism() - .map(|n| n.get()) - .unwrap_or(1) - .min(16); - let next_idx = std::sync::atomic::AtomicUsize::new(0); - - // Each slot stores the parsed result - let parsed: Vec>> = (0 - ..to_parse.len()) - .map(|_| std::sync::Mutex::new(None)) + crate::compilation::analyzer::parallel::init_pool(); + let parsed: Vec<(LuaSyntaxTree, LineIndex)> = to_parse + .par_iter() + .map_init(rowan::NodeCache::default, |node_cache, (_, text)| { + let parse_config = config.get_parse_config(node_cache); + let tree = LuaParser::parse(text, parse_config); + let line_index = LineIndex::parse(text); + (tree, line_index) + }) .collect(); - std::thread::scope(|s| { - for _ in 0..n_threads { - let next = &next_idx; - let files = &to_parse; - let results = &parsed; - let cfg = &config; - s.spawn(move || { - let mut node_cache = rowan::NodeCache::default(); - loop { - let idx = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if idx >= files.len() { - break; - } - let (_, text) = &files[idx]; - let parse_config = cfg.get_parse_config(&mut node_cache); - let tree = LuaParser::parse(text, parse_config); - let line_index = LineIndex::parse(text); - *results[idx].lock().expect("mutex poisoned") = - Some((tree, line_index)); - } - }); - } - }); - - // Insert pre-parsed results (sequential, fast HashMap inserts) + // Insert pre-parsed results (sequential, fast FxHashMap inserts) let vfs = self.compilation.get_db_mut().get_vfs_mut(); - for (i, ((_uri, text), file_id)) in - to_parse.into_iter().zip(file_ids.iter()).enumerate() + for (((_uri, text), file_id), (tree, line_index)) in + to_parse.into_iter().zip(file_ids.iter()).zip(parsed) { - let (tree, line_index) = parsed[i] - .lock() - .expect("mutex poisoned") - .take() - .expect("parsed result missing"); vfs.insert_preparsed(*file_id, text, tree, line_index); removed_files.insert(*file_id); updated_files.insert(*file_id); @@ -1570,7 +2064,7 @@ impl EmmyLuaAnalysis { let mut removed_files = self.expand_reindex_file_ids(removed_files.into_iter().collect()); self.add_vgui_forwarding_removal_seed(&removed_source_file_ids, &mut removed_files); - let guard_fact_file_ids = removed_files.iter().copied().collect::>(); + let guard_fact_file_ids = removed_files.iter().copied().collect::>(); self.compilation.remove_index(removed_files.clone()); updated_files.extend(removed_files.into_iter().filter(|file_id| { self.compilation @@ -1617,30 +2111,28 @@ impl EmmyLuaAnalysis { files: Vec<(Uri, Option)>, ) -> Vec { let mut files = files; - files.sort_by_cached_key(|(uri, _)| { - uri_to_file_path(uri) - .map(|path| crate::vfs::normalize_path_for_ordering(&path.to_string_lossy())) - .unwrap_or_else(|| uri.as_str().to_string()) - }); + files.sort_by_cached_key(|(uri, _)| uri_sort_key(uri)); let old_source_file_ids = files .iter() .filter_map(|(uri, _)| self.compilation.get_db().get_vfs().get_file_id(uri)) - .collect::>(); + .collect::>(); let removed_source_file_ids = files .iter() .filter(|(_, text)| text.is_none()) .filter_map(|(uri, _)| self.compilation.get_db().get_vfs().get_file_id(uri)) - .collect::>(); + .collect::>(); 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( &removed_source_file_ids, &mut old_guard_fact_file_ids, ); - let old_guard_fact_file_ids = old_guard_fact_file_ids.into_iter().collect::>(); + let old_guard_fact_file_ids = old_guard_fact_file_ids + .into_iter() + .collect::>(); let old_guard_facts = self.inferred_guard_snapshot(&old_guard_fact_file_ids); - let mut removed_files = HashSet::new(); - let mut updated_files = HashSet::new(); + let mut removed_files = FxHashSet::default(); + let mut updated_files = FxHashSet::default(); { let _p = Profile::new("update files"); for (uri, text) in files { @@ -1681,7 +2173,7 @@ impl EmmyLuaAnalysis { let mut removed_files = self.expand_reindex_file_ids(removed_files.into_iter().collect()); self.add_vgui_forwarding_removal_seed(&removed_source_file_ids, &mut removed_files); - let guard_fact_file_ids = removed_files.iter().copied().collect::>(); + let guard_fact_file_ids = removed_files.iter().copied().collect::>(); self.compilation.remove_index(removed_files.clone()); updated_files.extend(removed_files.into_iter().filter(|file_id| { self.compilation @@ -1710,53 +2202,185 @@ impl EmmyLuaAnalysis { updated_files } - pub fn remove_file_by_uri(&mut self, uri: &Uri) -> Option { - if let Some(file_id) = self.compilation.get_db().get_vfs().get_file_id(uri) { - let mut reindex_file_ids = self.expand_reindex_file_ids(vec![file_id]); - reindex_file_ids.extend( + /// Removes a file the way the filesystem did. + /// + /// One deletion is a one-item batch. The whole-file purge the removal used + /// to do by hand — a `PositionMap::whole_file` remap, so members other files + /// own on this file's table literals lose every offset — is what + /// `take_position_map` already returns once the VFS holds no text for the + /// file, and the batch feeds the owners it reports back into the dirty set + /// instead of discarding them. The contribution-signature seed the expansion + /// needed has no counterpart here because a contributed parameter is an + /// exported fact: the diff against an emptied export map names its readers. + /// Removes a file the way the filesystem did, and reports what to re-diagnose. + pub fn remove_file_by_uri(&mut self, uri: &Uri) -> (Option, Vec) { + let Some(file_id) = self.compilation.get_db().get_vfs().get_file_id(uri) else { + return (None, Vec::new()); + }; + log::info!( + "remove_file_by_uri: uri={} file_id={:?}", + uri.as_str(), + file_id + ); + let affected = self.apply_file_system_changes(vec![(uri.clone(), None)]); + (Some(file_id), affected) + } + + /// Phase 2 of an edit plus the textual widening: re-analyse what `dirty` + /// invalidated, then resolve the sideband refresh names to + /// diagnostic-refresh-only candidates and append them. + /// + /// `changed` seeds the returned set (the edited batch); the files the + /// ripple settled and the textual referencers of the moved names join it. + /// Returns every live file whose diagnostics this edit may have moved, + /// sorted and deduped. Unlike + /// [`apply_file_system_changes`](Self::apply_file_system_changes), no + /// detached-file filtering happens here: a caller that detaches URIs + /// retains the live files itself afterwards. + fn ripple_and_widen(&mut self, dirty: DirtySet, changed: Vec) -> Vec { + let refresh_names: std::collections::HashSet = + dirty.textual_refresh_names.iter().cloned().collect(); + let mut affected = changed; + let (rippled, ripple_names) = self.ripple_with_refresh_names(dirty); + affected.extend(rippled); + // Diagnostic-refresh-only, after the change-driven settle: batch and + // ripple names merged, then resolved to textual candidates via + // `files_referencing_name`, filtered to live `Main` files. No reindex, + // no `dirty_len`. + let mut all_names = refresh_names; + all_names.extend(ripple_names); + let exclude: std::collections::HashSet = affected.iter().copied().collect(); + let textual = self.textual_refresh_candidates(&all_names, &exclude); + affected.extend(textual); + affected.sort_unstable(); + affected.dedup(); + affected + } + + /// Applies a batch of filesystem changes — the creates, changes and deletes + /// a watched-file notification delivers together — and returns every live + /// file whose diagnostics the batch may have moved. + /// + /// One [`self_index_and_diff`](Self::self_index_and_diff) over the whole + /// batch and one [`ripple`](Self::ripple), so a branch switch costs the + /// files the change reached rather than the union of their dependency + /// expansions. The batch is one call rather than one per file because the + /// analyser derives its facts over a whole batch; settling the files one at + /// a time does not land where a cold build of the same set does. + /// + /// The returned set is what a caller has to re-diagnose. It is deliberately + /// not just the files whose text changed: the files the ripple settled are + /// the ones nobody edited and nobody would otherwise refresh. Past the + /// change-driven settle, textual referencers of the changed names — from + /// both the batch and the ripple diffs — are appended as + /// diagnostic-refresh-only candidates: they are collected via + /// `files_referencing_name` after the ripple, under a shared borrow, so + /// they raise no `DirtySet::dirty_len` and move no index snapshot. + /// + /// `None` means the file is gone from disk. Unlike + /// [`update_file_by_uri`](Self::update_file_by_uri), which leaves a + /// tombstone, the URI is detached once the settle is done so a later file at + /// the same path is a new [`FileId`]. The detach has to come last: until + /// then the path is what finds the files whose reads of it have to be + /// re-derived. + pub fn apply_file_system_changes(&mut self, files: Vec<(Uri, Option)>) -> Vec { + let mut files = files; + files.sort_by_cached_key(|(uri, _)| uri_sort_key(uri)); + // One URI can arrive several times in a burst; only the last event is + // the state on disk. + let mut latest: Vec<(Uri, Option)> = Vec::with_capacity(files.len()); + for (uri, text) in files { + match latest.last_mut() { + Some(last) if last.0 == uri => last.1 = text, + _ => latest.push((uri, text)), + } + } + + let mut changed = Vec::new(); + let mut existence_changed = Vec::new(); + let mut removed_uris = Vec::new(); + for (uri, text) in latest { + let existing = self.compilation.get_db().get_vfs().get_file_id(&uri); + if let Some(file_id) = existing + && let (Some(new_text), Some(old_text)) = ( + text.as_deref(), + self.compilation + .get_db() + .get_vfs() + .get_file_content(&file_id) + .map(String::as_str), + ) + && old_text == new_text + { + // Same bytes. Already indexed means nothing to do; an index that + // was cleared still owes its rebuild, and with no stash the + // remap is the identity, which is what an unchanged file wants. + if self + .compilation + .get_db() + .get_module_index() + .get_module(file_id) + .is_none() + { + changed.push(file_id); + } + continue; + } + if existing.is_none() && text.is_none() { + continue; + } + + let was_absent = existing.is_none_or(|file_id| { self.compilation .get_db() - .get_call_site_param_index() - .collect_contribution_signature_files(&HashSet::from([file_id])), - ); - reindex_file_ids.sort_unstable(); - reindex_file_ids.dedup(); - let removed_file_ids = HashSet::from([file_id]); - 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); - self.compilation + .get_vfs() + .get_syntax_tree(&file_id) + .is_none() + }); + let is_removed = text.is_none(); + if let Some(file_id) = existing { + self.stash_indexed_text(file_id); + } + let file_id = self + .compilation .get_db_mut() .get_vfs_mut() - .remove_file(uri)?; - log::info!( - "remove_file_by_uri: uri={} file_id={:?}", - uri.as_str(), - file_id - ); - self.compilation.remove_index(reindex_file_ids.clone()); - let update_file_ids = reindex_file_ids - .iter() - .copied() - .filter(|id| *id != file_id) - .collect::>(); - if !update_file_ids.is_empty() { - self.compilation.update_index(update_file_ids); + .set_file_content(&uri, text); + if was_absent || is_removed { + existence_changed.push(file_id); } - 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, - &reindex_file_ids, - &HashSet::new(), - ); - return Some(file_id); + if is_removed { + removed_uris.push(uri); + } + changed.push(file_id); + } + + if changed.is_empty() { + return Vec::new(); + } + changed.sort_unstable(); + changed.dedup(); + + let mut dirty = self.self_index_and_diff(changed.clone()); + for file_id in existence_changed { + self.dirty_existence_dependents(&mut dirty, file_id); } + let mut affected = self.ripple_and_widen(dirty, changed); - None + for uri in &removed_uris { + self.compilation.get_db_mut().get_vfs_mut().remove_file(uri); + } + + affected.retain(|file_id| { + self.compilation + .get_db() + .get_vfs() + .get_syntax_tree(file_id) + .is_some() + }); + affected.sort_unstable(); + affected.dedup(); + affected } pub fn update_files_by_path(&mut self, files: Vec<(PathBuf, Option)>) -> Vec { @@ -1784,7 +2408,7 @@ impl EmmyLuaAnalysis { pub fn set_workspace_diagnostic_configs( &mut self, - configs: HashMap>, + configs: std::collections::HashMap>, ) { self.diagnostic.set_workspace_configs(configs); } @@ -1955,19 +2579,6 @@ impl EmmyLuaAnalysis { } } -fn select_cross_file_stabilization_dependents( - all_dependents: impl IntoIterator, - changed: &HashSet, -) -> Vec { - let mut dependents = all_dependents - .into_iter() - .filter(|file_id| !changed.contains(file_id)) - .collect::>(); - dependents.sort_unstable(); - dependents.dedup(); - dependents -} - impl Default for EmmyLuaAnalysis { fn default() -> Self { Self::new() @@ -2013,6 +2624,38 @@ mod tests { ); } + #[test] + fn reindex_expansion_includes_inferred_return_producers() { + let workspace = std::env::temp_dir().join("gmod_glua_ls_inferred_return_dependencies"); + let producer_uri = Uri::parse_from_file_path(&workspace.join("producer.lua")) + .expect("producer URI should parse"); + let returner_uri = Uri::parse_from_file_path(&workspace.join("returner.lua")) + .expect("returner URI should parse"); + let mut analysis = EmmyLuaAnalysis::new(); + analysis.add_main_workspace(workspace); + let producer = analysis + .update_file_by_uri( + &producer_uri, + Some("State = {}\nState.value = 1".to_string()), + ) + .map(|(id, _)| id) + .expect("producer should be indexed"); + let returner = analysis + .update_file_by_uri( + &returner_uri, + Some("function GetStateValue() return State.value end".to_string()), + ) + .map(|(id, _)| id) + .expect("returner should be indexed"); + + assert!( + analysis + .expand_reindex_file_ids(vec![producer]) + .contains(&returner), + "a producer edit must reindex functions whose inferred return reads it" + ); + } + #[test] fn reindex_expansion_includes_unresolved_path_dependents_for_reopened_file() { let workspace = std::env::temp_dir().join("gmod_glua_ls_reopen_dependency_workspace"); @@ -2025,16 +2668,19 @@ mod tests { analysis.add_main_workspace(workspace); let old_target = analysis .update_file_by_uri(&target_uri, Some("return {}".to_string())) + .map(|(id, _)| id) .expect("target should be created"); let caller = analysis .update_file_by_uri( &caller_uri, Some(r#"local reopened = include("mixins/reopened.lua")"#.to_string()), ) + .map(|(id, _)| id) .expect("caller should be created"); analysis .remove_file_by_uri(&target_uri) + .0 .expect("target should be removed"); assert_ne!( analysis @@ -2076,16 +2722,19 @@ mod tests { analysis.add_main_workspace(workspace); analysis .update_file_by_uri(&target_uri, Some("return {}".to_string())) + .map(|(id, _)| id) .expect("target should be created"); let caller = analysis .update_file_by_uri( &caller_uri, Some(format!("local reopened = {dependency_expr}")), ) + .map(|(id, _)| id) .expect("caller should be created"); analysis .remove_file_by_uri(&target_uri) + .0 .expect("target should be removed"); let reopened_target = analysis .compilation @@ -2144,7 +2793,7 @@ mod tests { let changed_a = FileId { id: 1 }; let changed_b = FileId { id: 2 }; let unchanged_dependent = FileId { id: 3 }; - let changed = HashSet::from([changed_a, changed_b]); + let changed = HashSet::from_iter([changed_a, changed_b]); assert_eq!( select_cross_file_stabilization_dependents( @@ -2216,6 +2865,7 @@ mod tests { let content = "local IsValid = IsValid"; let file_id = analysis .update_file_by_uri(&uri, Some(content.to_string())) + .map(|(id, _)| id) .expect("file id should exist"); analysis.compilation.clear_index(); @@ -2228,7 +2878,9 @@ mod tests { .is_none() ); - analysis.update_file_by_uri(&uri, Some(content.to_string())); + analysis + .update_file_by_uri(&uri, Some(content.to_string())) + .map(|(id, _)| id); assert!( analysis .compilation @@ -2248,6 +2900,7 @@ mod tests { let content = "local IsValid = IsValid"; let file_id = analysis .update_file_by_uri(&uri, Some(content.to_string())) + .map(|(id, _)| id) .expect("file id should exist"); analysis.compilation.clear_index(); @@ -2330,9 +2983,11 @@ mod tests { analysis.add_library_workspace(library_workspace); let main_file_id = analysis .update_file_by_uri(&main_uri, Some("return true".to_string())) + .map(|(id, _)| id) .expect("main file should be indexed"); let helper_file_id = analysis .update_file_by_uri(&helper_uri, Some("return true".to_string())) + .map(|(id, _)| id) .expect("helper file should be indexed"); let helper_syntax_id = LuaSyntaxId::from_node( &analysis @@ -2359,7 +3014,7 @@ mod tests { }, ); - let removed_file_ids = HashSet::from([helper_file_id]); + let removed_file_ids = HashSet::from_iter([helper_file_id]); let mut reindex_file_ids = vec![helper_file_id]; analysis.add_vgui_forwarding_removal_seed(&removed_file_ids, &mut reindex_file_ids); diff --git a/crates/glua_code_analysis/src/library_collision.rs b/crates/glua_code_analysis/src/library_collision.rs index 9c8a27a7f..bf148ddff 100644 --- a/crates/glua_code_analysis/src/library_collision.rs +++ b/crates/glua_code_analysis/src/library_collision.rs @@ -1,7 +1,5 @@ -use std::{ - collections::{BTreeSet, HashMap}, - path::PathBuf, -}; +use rustc_hash::FxHashMap as HashMap; +use std::{collections::BTreeSet, path::PathBuf}; use crate::{ EmmyLuaAnalysis, FileId, LuaMember, LuaMemberKey, LuaMemberOwner, LuaTypeFlag, WorkspaceId, @@ -63,7 +61,7 @@ impl EmmyLuaAnalysis { pub fn library_definition_collisions(&self) -> Vec { let db = self.compilation.get_db(); let module_index = db.get_module_index(); - let mut collisions = HashMap::<(WorkspaceId, WorkspaceId), CollisionAccumulator>::new(); + let mut collisions = HashMap::<(WorkspaceId, WorkspaceId), CollisionAccumulator>::default(); for type_decl in db.get_type_index().get_all_types() { if !type_decl.get_id().is_global() { @@ -148,7 +146,7 @@ fn collect_library_locations( module_index: &crate::LuaModuleIndex, file_ids: impl Iterator, ) -> Vec { - let mut by_workspace = HashMap::::new(); + let mut by_workspace = HashMap::::default(); for file_id in file_ids { let Some(workspace_id) = module_index.get_workspace_id(file_id) else { continue; 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/src/semantic/cache/mod.rs b/crates/glua_code_analysis/src/semantic/cache/mod.rs index 6c3a34e69..fae76369d 100644 --- a/crates/glua_code_analysis/src/semantic/cache/mod.rs +++ b/crates/glua_code_analysis/src/semantic/cache/mod.rs @@ -9,14 +9,28 @@ use smol_str::SmolStr; use std::{collections::HashSet, sync::Arc}; use crate::{ - DbIndex, FileId, FlowId, GmodRealm, LuaDeclId, LuaFunctionType, LuaInferredGuardOwner, - LuaMemberId, LuaMemberKey, LuaSemanticDeclId, VarRefId, VarRefRootId, + DbIndex, FileId, FlowId, GmodRealm, InFiled, LuaDeclId, LuaFunctionType, LuaInferredGuardOwner, + LuaMemberId, LuaMemberKey, LuaSemanticDeclId, LuaSignatureId, VarRefId, VarRefRootId, db_index::{LuaType, LuaTypeDeclId}, - semantic::infer::{InferFailReason, ParamInferenceSource}, + semantic::{ + generic::FuncGenericBinding, + infer::{InferFailReason, ParamInferenceSource}, + }, }; pub type FlowCacheInnerKey = (FlowId, GmodRealm, FlowOrigin); +/// The syntax-derived facts about one dynamic-field definition site. +/// +/// Both are a pure function of the definition's file tree, so they are read +/// once per definition instead of re-walking from the root on every access +/// position that consults it. +#[derive(Debug, Clone, Copy, Default)] +pub struct DynamicFieldDefinitionSyntax { + pub member_id: Option, + pub enclosing_assign_range: Option, +} + #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Default)] pub enum FlowOrigin { #[default] @@ -77,6 +91,25 @@ pub struct PendingStrTplTypeDecl { pub super_type: LuaType, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GenericCallBindings { + Known(Vec), + Ambiguous, +} + +impl GenericCallBindings { + pub fn record(&mut self, new_bindings: Vec) { + match self { + GenericCallBindings::Known(existing) => { + if existing != &new_bindings { + *self = GenericCallBindings::Ambiguous; + } + } + GenericCallBindings::Ambiguous => {} + } + } +} + #[derive(Debug, Clone)] pub struct LuaInferCache { file_id: FileId, @@ -86,6 +119,7 @@ pub struct LuaInferCache { FxHashMap<(LuaSyntaxId, Option, LuaType), CacheEntry>>, pub call_arg_types_cache: FxHashMap<(LuaSyntaxId, Option), Arc>>, + pub generic_call_bindings: FxHashMap, pub flow_node_cache: FxHashMap>>, pub flow_query_realm: Option, @@ -123,8 +157,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>, - pub local_reassignment_positions_cache: FxHashMap>, - pub local_reassignments_indexed: bool, + /// 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 dynamic_field_scope_metatable_cache: FxHashMap>>, pub dynamic_field_resolution_cache: FxHashMap< @@ -134,8 +172,17 @@ pub struct LuaInferCache { pub local_class_table_member_ids_cache: FxHashMap<(LuaTypeDeclId, LuaMemberKey), Arc>>, pub dynamic_field_type_cache: FxHashMap>, + pub dynamic_field_definition_syntax_cache: + FxHashMap, DynamicFieldDefinitionSyntax>, 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>>, @@ -144,7 +191,23 @@ pub struct LuaInferCache { /// the call targets through the reference, property, member and signature /// indexes. pub call_returns_never_cache: FxHashMap, - inferred_guard_dependencies: HashSet, + /// Whether a call site's signature is generic, keyed by the call + /// expression. Consulted per call-site during inference, so re-inferring + /// the call prefix each time is avoided. + pub signature_is_generic_cache: FxHashMap>, + /// How many computed-key reads were answered by merging the sibling + /// members of a table. Each of those answers is a snapshot of whichever + /// writers the batch had indexed, so a value bound from one is re-derived + /// once the index settles. See `settled_sibling_merge_read_candidates`. + pub sibling_merge_reads: u32, + inferred_guard_dependencies: FxHashSet, + inferred_return_reads: + FxHashMap, + /// Member reads that found no member. A read that fails records no type + /// cache and so no dependency edge, yet the file's answer changes the + /// moment another file defines the member; these are what the analyzer + /// files as that edge. See `LuaMemberIndex::set_missed_member_reads`. + missed_member_reads: FxHashSet<(crate::LuaMemberOwner, LuaMemberKey)>, } impl LuaInferCache { @@ -164,24 +227,30 @@ impl LuaInferCache { param_type_cache: FxHashMap::default(), param_type_source_cache: FxHashMap::default(), expr_var_ref_id_cache: FxHashMap::default(), - narrow_by_literal_stop_position_cache: HashSet::new(), + narrow_by_literal_stop_position_cache: HashSet::default(), scripted_global_singleton_type_cache: None, pending_str_tpl_type_decls: Vec::new(), self_type_cache: FxHashMap::default(), self_base_seed: None, decl_cache: FxHashMap::default(), for_range_iter_var_type_cache: FxHashMap::default(), - local_reassignment_positions_cache: FxHashMap::default(), - local_reassignments_indexed: false, + in_place_ipairs_transform_cache: FxHashMap::default(), dynamic_field_scope_metatable_cache: FxHashMap::default(), dynamic_field_resolution_cache: FxHashMap::default(), local_class_table_member_ids_cache: FxHashMap::default(), dynamic_field_type_cache: FxHashMap::default(), - dynamic_field_resolving: HashSet::new(), + dynamic_field_definition_syntax_cache: FxHashMap::default(), + dynamic_field_resolving: HashSet::default(), 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(), + signature_is_generic_cache: FxHashMap::default(), + generic_call_bindings: FxHashMap::default(), + sibling_merge_reads: 0, + inferred_guard_dependencies: FxHashSet::default(), + inferred_return_reads: FxHashMap::default(), + missed_member_reads: FxHashSet::default(), } } @@ -201,6 +270,31 @@ impl LuaInferCache { self.config.analysis_phase = phase; } + pub fn record_generic_call_bindings( + &mut self, + syntax_id: LuaSyntaxId, + bindings: Vec, + ) { + match self.generic_call_bindings.entry(syntax_id) { + std::collections::hash_map::Entry::Occupied(mut entry) => { + entry.get_mut().record(bindings); + } + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(GenericCallBindings::Known(bindings)); + } + } + } + + pub fn get_generic_call_bindings( + &self, + syntax_id: &LuaSyntaxId, + ) -> Option<&[FuncGenericBinding]> { + match self.generic_call_bindings.get(syntax_id) { + Some(GenericCallBindings::Known(bindings)) => Some(bindings), + _ => None, + } + } + pub fn add_pending_str_tpl_type_decl( &mut self, source_range: TextRange, @@ -231,10 +325,33 @@ impl LuaInferCache { self.inferred_guard_dependencies.insert(owner); } - pub fn take_inferred_guard_dependencies(&mut self) -> HashSet { + pub fn take_inferred_guard_dependencies(&mut self) -> FxHashSet { std::mem::take(&mut self.inferred_guard_dependencies) } + pub(crate) fn record_inferred_return_reads( + &mut self, + signature_id: LuaSignatureId, + record: crate::db_index::read_set::InferredReturnReadRecord, + ) { + self.inferred_return_reads.insert(signature_id, record); + } + + pub(crate) fn take_inferred_return_reads( + &mut self, + ) -> FxHashMap { + std::mem::take(&mut self.inferred_return_reads) + } + + pub fn record_missed_member_read(&mut self, owner: crate::LuaMemberOwner, key: LuaMemberKey) { + crate::db_index::read_set::record_missing_member_slot(&owner, &key); + self.missed_member_reads.insert((owner, key)); + } + + pub fn take_missed_member_reads(&mut self) -> FxHashSet<(crate::LuaMemberOwner, LuaMemberKey)> { + std::mem::take(&mut self.missed_member_reads) + } + pub fn clear(&mut self) { self.expr_cache.clear(); self.call_cache.clear(); @@ -253,15 +370,62 @@ impl LuaInferCache { self.self_base_seed = None; self.decl_cache.clear(); self.for_range_iter_var_type_cache.clear(); - self.local_reassignment_positions_cache.clear(); - self.local_reassignments_indexed = false; + self.in_place_ipairs_transform_cache.clear(); self.dynamic_field_scope_metatable_cache.clear(); self.dynamic_field_resolution_cache.clear(); self.local_class_table_member_ids_cache.clear(); self.dynamic_field_type_cache.clear(); + self.dynamic_field_definition_syntax_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(); + self.signature_is_generic_cache.clear(); + self.generic_call_bindings.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(); + } + + /// 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) { + self.clear_deferred_inference_results(); + self.for_range_iter_var_type_cache.clear(); } /// Discards the inference a wave of deferred resolution can have @@ -273,6 +437,8 @@ 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.signature_is_generic_cache.clear(); + self.generic_call_bindings.clear(); self.flow_node_cache.retain(|_, inner| { inner.retain(|_, entry| !matches!(entry, CacheEntry::Error(_))); !inner.is_empty() @@ -283,6 +449,9 @@ impl LuaInferCache { .retain(|_, entry| !matches!(entry, CacheEntry::Error(_))); self.for_range_iter_var_type_cache .retain(|_, entry| !matches!(entry, CacheEntry::Error(_))); + // The transform's element type comes from inferring the loop body's + // right-hand side, which a resolved signature return can change. + self.in_place_ipairs_transform_cache.clear(); } /// Clears inference results that can become stale as deferred declarations, @@ -299,6 +468,8 @@ impl LuaInferCache { self.param_type_cache.clear(); self.param_type_source_cache.clear(); self.call_returns_never_cache.clear(); + self.signature_is_generic_cache.clear(); + self.generic_call_bindings.clear(); // Local reference identities come directly from immutable reference // indexes and are safe to retain. Global/member/self roots can be // selected through types and overloads that unresolve is about to @@ -311,12 +482,15 @@ impl LuaInferCache { self.self_base_seed = None; self.decl_cache.clear(); self.for_range_iter_var_type_cache.clear(); + self.in_place_ipairs_transform_cache.clear(); self.dynamic_field_scope_metatable_cache.clear(); self.dynamic_field_resolution_cache.clear(); self.local_class_table_member_ids_cache.clear(); self.dynamic_field_type_cache.clear(); + self.dynamic_field_definition_syntax_cache.clear(); self.dynamic_field_resolving.clear(); self.vgui_parent_fallback_calls.clear(); + self.vgui_parent_chain_calls.clear(); } pub fn get_flow_cache( @@ -491,10 +665,6 @@ mod tests { cache .narrow_by_literal_stop_position_cache .insert(syntax_id); - cache - .local_reassignment_positions_cache - .insert(decl_id, vec![TextSize::from(9)]); - cache.local_reassignments_indexed = true; cache.clear_for_unresolve(&DbIndex::new()); @@ -505,11 +675,6 @@ mod tests { Some(&GmodRealm::Server) ); assert!(cache.narrow_by_literal_stop_position_cache.is_empty()); - assert_eq!( - cache.local_reassignment_positions_cache.get(&decl_id), - Some(&vec![TextSize::from(9)]) - ); - assert!(cache.local_reassignments_indexed); } #[test] diff --git a/crates/glua_code_analysis/src/semantic/decl/mod.rs b/crates/glua_code_analysis/src/semantic/decl/mod.rs index ba5c70d56..cfb39ea36 100644 --- a/crates/glua_code_analysis/src/semantic/decl/mod.rs +++ b/crates/glua_code_analysis/src/semantic/decl/mod.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use rustc_hash::FxHashSet; use glua_parser::{LuaAstNode, LuaCallExpr, LuaIndexExpr, LuaSyntaxKind}; use rowan::NodeOrToken; @@ -45,13 +45,13 @@ pub fn enum_variable_is_param( #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeclGuard { - decl_set: HashSet, + decl_set: FxHashSet, } impl DeclGuard { pub fn new() -> Self { Self { - decl_set: HashSet::new(), + decl_set: FxHashSet::default(), } } diff --git a/crates/glua_code_analysis/src/semantic/generic/call_constraint.rs b/crates/glua_code_analysis/src/semantic/generic/call_constraint.rs index fd33b66b2..085a8ed75 100644 --- a/crates/glua_code_analysis/src/semantic/generic/call_constraint.rs +++ b/crates/glua_code_analysis/src/semantic/generic/call_constraint.rs @@ -318,18 +318,18 @@ fn get_union_constraint_type( union_type: &LuaUnionType, depth: usize, ) -> Option { - match union_type { - LuaUnionType::Nullable(typ) => { + match union_type.nullable_inner() { + Some(typ) => { let constraint_type = get_constraint_type(semantic_model, typ, depth + 1)?; Some(TypeOps::Union.apply(semantic_model.get_db(), &constraint_type, &LuaType::Nil)) } - LuaUnionType::Multi(types) => { + None => { let mut constraint_types = None; - for (idx, typ) in types.iter().enumerate() { + for (idx, typ) in union_type.types().enumerate() { if let Some(constraint_type) = get_constraint_type(semantic_model, typ, depth + 1) { let constraint_types = constraint_types.get_or_insert_with(|| { - let mut mapped = Vec::with_capacity(types.len()); - mapped.extend(types[..idx].iter().cloned()); + let mut mapped = Vec::with_capacity(union_type.len()); + mapped.extend(union_type.types().take(idx).cloned()); mapped }); constraint_types.push(constraint_type); diff --git a/crates/glua_code_analysis/src/semantic/generic/instantiate_type/instantiate_func_generic.rs b/crates/glua_code_analysis/src/semantic/generic/instantiate_type/instantiate_func_generic.rs index bf5ec50ab..01abcc33e 100644 --- a/crates/glua_code_analysis/src/semantic/generic/instantiate_type/instantiate_func_generic.rs +++ b/crates/glua_code_analysis/src/semantic/generic/instantiate_type/instantiate_func_generic.rs @@ -1,4 +1,5 @@ -use std::{collections::HashSet, ops::Deref, sync::Arc}; +use rustc_hash::FxHashSet as HashSet; +use std::{ops::Deref, sync::Arc}; use glua_parser::{LuaAstNode, LuaCallExpr, LuaChunk, LuaDocTypeList, LuaExpr, LuaNameExpr}; use internment::ArcIntern; @@ -31,7 +32,45 @@ use crate::{ }; use crate::{LuaMemberOwner, SemanticDeclLevel, infer_node_semantic_decl}; -use super::TypeSubstitutor; +use super::{SubstitutorValue, TypeSubstitutor}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GenericFunctionInstantiation { + pub function: LuaFunctionType, + pub bindings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FuncGenericBinding { + pub id: GenericTplId, + pub state: FuncGenericBindingState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FuncGenericBindingState { + Bound, + Unbound { fallback: LuaType }, +} + +fn find_generic_constraint(func: &LuaFunctionType, id: GenericTplId) -> Option { + let mut constraint = None; + func.visit_type(&mut |t| match t { + LuaType::TplRef(generic_tpl) | LuaType::ConstTplRef(generic_tpl) + if generic_tpl.get_tpl_id() == id => + { + if constraint.is_none() { + constraint = generic_tpl.get_constraint().cloned(); + } + } + LuaType::StrTplRef(str_tpl) if str_tpl.get_tpl_id() == id => { + if constraint.is_none() { + constraint = str_tpl.get_constraint().cloned(); + } + } + _ => {} + }); + constraint +} /// Resolve a flow-valid inferred string default for a call argument expression. /// @@ -315,14 +354,14 @@ fn resolve_vgui_panel_ref_from_arg( )) } -pub fn instantiate_func_generic( +pub fn instantiate_func_generic_with_bindings( db: &DbIndex, cache: &mut LuaInferCache, func: &LuaFunctionType, call_expr: LuaCallExpr, -) -> Result { +) -> Result { let file_id = cache.get_file_id().clone(); - let mut generic_tpls = HashSet::new(); + let mut generic_tpls = HashSet::default(); let mut contain_self = false; func.visit_type(&mut |t| match t { LuaType::TplRef(generic_tpl) | LuaType::ConstTplRef(generic_tpl) => { @@ -359,6 +398,21 @@ pub fn instantiate_func_generic( call_expr: Some(call_expr.clone()), source_range: call_expr.get_range(), }; + + let callee_generic_ids: Vec = { + let mut ids: Vec<_> = generic_tpls.iter().copied().collect(); + ids.sort_by_key(|id| { + ( + match id { + GenericTplId::Func(_) => 0, + GenericTplId::Type(_) => 1, + }, + id.get_idx(), + ) + }); + ids + }; + if !generic_tpls.is_empty() { context.substitutor.add_need_infer_tpls(generic_tpls); @@ -382,11 +436,44 @@ pub fn instantiate_func_generic( substitutor.add_self_type(self_type); } - if let LuaType::DocFunction(f) = instantiate_doc_function(db, func, &substitutor) { - Ok(f.deref().clone()) - } else { - Ok(func.clone()) + let mut bindings = Vec::with_capacity(callee_generic_ids.len()); + for id in callee_generic_ids { + let state = match substitutor.get(id) { + Some(SubstitutorValue::None) => { + let fallback = find_generic_constraint(func, id).unwrap_or(LuaType::Any); + FuncGenericBindingState::Unbound { fallback } + } + Some( + SubstitutorValue::Type(_) + | SubstitutorValue::Params(_) + | SubstitutorValue::MultiTypes(_) + | SubstitutorValue::MultiBase(_), + ) => FuncGenericBindingState::Bound, + None => { + let fallback = find_generic_constraint(func, id).unwrap_or(LuaType::Any); + FuncGenericBindingState::Unbound { fallback } + } + }; + bindings.push(FuncGenericBinding { id, state }); } + + let function = if let LuaType::DocFunction(f) = instantiate_doc_function(db, func, &substitutor) + { + f.deref().clone() + } else { + func.clone() + }; + + Ok(GenericFunctionInstantiation { function, bindings }) +} + +pub fn instantiate_func_generic( + db: &DbIndex, + cache: &mut LuaInferCache, + func: &LuaFunctionType, + call_expr: LuaCallExpr, +) -> Result { + instantiate_func_generic_with_bindings(db, cache, func, call_expr).map(|res| res.function) } fn apply_call_generic_type_list( @@ -451,6 +538,7 @@ fn infer_generic_types_from_call( if !func_param_type.contain_tpl() { continue; } + context.source_range = call_arg_expr.get_range(); if !func_param_type.is_variadic() && check_expr_can_later_infer(context, func_param_type, call_arg_expr)? @@ -528,6 +616,7 @@ fn infer_generic_types_from_call( if !context.substitutor.is_infer_all_tpl() { for (func_param_type, call_arg_expr) in unresolve_tpls { + context.source_range = call_arg_expr.get_range(); let closure_type = infer_expr(db, context.cache, call_arg_expr)?; tpl_pattern_match(context, &func_param_type, &closure_type)?; diff --git a/crates/glua_code_analysis/src/semantic/generic/instantiate_type/mod.rs b/crates/glua_code_analysis/src/semantic/generic/instantiate_type/mod.rs index 263c61bf6..5861ccdc6 100644 --- a/crates/glua_code_analysis/src/semantic/generic/instantiate_type/mod.rs +++ b/crates/glua_code_analysis/src/semantic/generic/instantiate_type/mod.rs @@ -21,7 +21,10 @@ use super::type_substitutor::{SubstitutorValue, TypeSubstitutor}; use crate::TypeVisitTrait; use crate::semantic::member::find_members_with_key; pub(crate) use instantiate_func_generic::check_vgui_panel_ref_role; -pub use instantiate_func_generic::{build_self_type, infer_self_type, instantiate_func_generic}; +pub use instantiate_func_generic::{ + FuncGenericBinding, FuncGenericBindingState, GenericFunctionInstantiation, build_self_type, + infer_self_type, instantiate_func_generic, instantiate_func_generic_with_bindings, +}; pub use instantiate_special_generic::get_keyof_members; pub use instantiate_special_generic::instantiate_alias_call; diff --git a/crates/glua_code_analysis/src/semantic/generic/test.rs b/crates/glua_code_analysis/src/semantic/generic/test.rs index 17780cd3f..d1a4f2dad 100644 --- a/crates/glua_code_analysis/src/semantic/generic/test.rs +++ b/crates/glua_code_analysis/src/semantic/generic/test.rs @@ -4,10 +4,182 @@ mod test { use smol_str::SmolStr; use crate::{ - DiagnosticCode, GenericTpl, GenericTplId, LuaMergedTableType, LuaType, RenderLevel, + DiagnosticCode, FileId, GenericTpl, GenericTplId, LuaMergedTableType, LuaType, RenderLevel, TypeSubstitutor, VirtualWorkspace, humanize_type, instantiate_type_generic, }; + fn local_type(ws: &VirtualWorkspace, file_id: FileId, name: &str) -> LuaType { + let db = ws.analysis.compilation.get_db(); + let decl_id = db + .get_decl_index() + .get_decl_tree(&file_id) + .and_then(|tree| { + tree.get_decls() + .values() + .find(|decl| decl.get_name() == name) + .map(|decl| decl.get_id()) + }) + .unwrap_or_else(|| panic!("no declaration named {name}")); + db.get_type_index() + .get_type_cache(&decl_id.into()) + .unwrap_or_else(|| panic!("no cached type for {name}")) + .as_type() + .clone() + } + + /// `pairs` is declared `---@generic K, V, I` over + /// `table | V[] | {[K]: V}` (`resources/std/global.lua:244-247`), so + /// `for k, v in pairs(t)` binds `k` and `v` through the iterator's `K` and + /// `V`. When `t`'s members are known they bind to real types. + /// + /// When they are not — an empty literal, or the `x or {}` fallback that is + /// idiomatic Lua — there is nothing to bind them to, and what must not be + /// left behind is `pairs`'s own declared type parameter. A `TplRef` is an + /// internal placeholder, and the cache it lands in is the one hover and + /// completion read, so it escapes to the user as `K` and `V`. + /// + /// Normal inference retains the unbound template as a retry marker for + /// unresolve. Once settled, loop-variable finalization consumes recorded + /// call-binding provenance to replace proven unbound callee generics with + /// their constraint or fallback, avoiding leaks without disturbing legitimate + /// enclosing caller generics. + #[test] + fn pairs_over_a_shapeless_table_does_not_leak_its_type_parameters() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let file_id = ws.def( + r#" + local known = { alpha = 1 } + for knownKey, knownValue in pairs(known) do end + + local empty = {} + for emptyKey, emptyValue in pairs(empty) do end + + local function opaque() end + local fallback = opaque() or {} + for fallbackKey, fallbackValue in pairs(fallback) do end + "#, + ); + + // Control: a table whose members are known still binds them. This is the + // behaviour a fix must not trade away. + assert!( + matches!(local_type(&ws, file_id, "knownKey"), LuaType::StringConst(ref key) if key.as_str() == "alpha"), + "knownKey was {:?}", + local_type(&ws, file_id, "knownKey") + ); + assert!( + matches!( + local_type(&ws, file_id, "knownValue"), + LuaType::IntegerConst(1) + ), + "knownValue was {:?}", + local_type(&ws, file_id, "knownValue") + ); + + for name in ["emptyKey", "emptyValue", "fallbackKey", "fallbackValue"] { + let ty = local_type(&ws, file_id, name); + assert!( + !matches!(ty, LuaType::TplRef(_)), + "{name} kept pairs's own declared type parameter: {ty:?}" + ); + // `Any` is this analyzer's conservative "no information available". + // A `TplRef` only looks more structured; it carries nothing. + assert_eq!(ty, LuaType::Any, "{name} settled on an unexpected type"); + } + } + + #[test] + fn pairs_over_shapeless_table_inside_generic_function_does_not_leak_or_collide() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let file_id = ws.def( + r#" + ---@generic K, V + local function shapelessInsideGeneric() + local empty = {} + for k, v in pairs(empty) do end + end + "#, + ); + + let k_ty = local_type(&ws, file_id, "k"); + let v_ty = local_type(&ws, file_id, "v"); + assert_eq!(k_ty, LuaType::Any, "k should settle to Any"); + assert_eq!(v_ty, LuaType::Any, "v should settle to Any"); + } + + #[test] + fn pairs_over_legitimate_caller_generic_table() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let file_id = ws.def( + r#" + ---@generic K, V + ---@param t table + local function genericTable(t) + for k, v in pairs(t) do end + end + "#, + ); + + let k_ty = local_type(&ws, file_id, "k"); + let v_ty = local_type(&ws, file_id, "v"); + assert_ne!(k_ty, LuaType::Any, "k should not be Any"); + assert_ne!(v_ty, LuaType::Any, "v should not be Any"); + assert!( + matches!(k_ty, LuaType::TplRef(_)), + "k should be enclosing TplRef, got {:?}", + k_ty + ); + assert!( + matches!(v_ty, LuaType::TplRef(_)), + "v should be enclosing TplRef, got {:?}", + v_ty + ); + let k_debug = format!("{k_ty:?}"); + let v_debug = format!("{v_ty:?}"); + assert!( + k_debug.contains("\"K\""), + "k TplRef should be K, got {k_debug}" + ); + assert!( + v_debug.contains("\"V\""), + "v TplRef should be V, got {v_debug}" + ); + } + + #[test] + fn pairs_over_caller_bound_composite_generic_table() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let file_id = ws.def( + r#" + ---@generic K, V + ---@param t table + local function genericTableComposite(t) + for k, v in pairs(t) do end + end + "#, + ); + + let k_ty = local_type(&ws, file_id, "k"); + let v_ty = local_type(&ws, file_id, "v"); + assert_ne!(k_ty, LuaType::Any, "k should not be Any"); + assert_ne!(v_ty, LuaType::Any, "v should not be Any"); + assert!( + matches!(k_ty, LuaType::TplRef(_)), + "k should be enclosing TplRef, got {:?}", + k_ty + ); + let k_debug = format!("{k_ty:?}"); + assert!( + k_debug.contains("\"K\""), + "k TplRef should be K, got {k_debug}" + ); + let v_debug = format!("{v_ty:?}"); + assert!( + matches!(v_ty, LuaType::Array(_)) && v_debug.contains("\"V\""), + "v should be Array of enclosing V, got {v_debug}" + ); + } + #[test] fn test_variadic_func() { let mut ws = crate::VirtualWorkspace::new(); diff --git a/crates/glua_code_analysis/src/semantic/generic/tpl_pattern/mod.rs b/crates/glua_code_analysis/src/semantic/generic/tpl_pattern/mod.rs index 221521aec..d2e766951 100644 --- a/crates/glua_code_analysis/src/semantic/generic/tpl_pattern/mod.rs +++ b/crates/glua_code_analysis/src/semantic/generic/tpl_pattern/mod.rs @@ -24,7 +24,7 @@ use crate::{ }; use super::type_substitutor::TypeSubstitutor; -use std::collections::HashMap; +use rustc_hash::FxHashMap; type TplPatternMatchResult = Result<(), InferFailReason>; @@ -217,6 +217,19 @@ pub fn tpl_pattern_match( return Ok(()); } + // A merged table is one table seen through the several literals that wrote + // it, so a parameter binds off it exactly as it binds off any one of them. + // Every arm below matches a single table shape, so without this the merge + // falls through unmatched and the parameter is left as a raw template ref — + // and whether a slot holds the merge or one literal is decided by how far + // the batch had run when the read was taken. + if let LuaType::MergedTable(merged) = &target { + for constituent in merged.get_types() { + tpl_pattern_match(context, pattern, constituent)?; + } + return Ok(()); + } + match pattern { LuaType::TplRef(tpl) => { if tpl.get_tpl_id().is_func() { @@ -1106,7 +1119,7 @@ fn is_pairs_call(context: &mut TplContext) -> Option { fn try_handle_pairs_metamethod( context: &mut TplContext, table_generic_params: &[LuaType], - members: &HashMap>, + members: &FxHashMap>, ) -> TplPatternMatchResult { let pairs_member = members .get(&LuaMemberKey::Name("__pairs".into())) diff --git a/crates/glua_code_analysis/src/semantic/generic/type_substitutor.rs b/crates/glua_code_analysis/src/semantic/generic/type_substitutor.rs index a5bd0f7ea..ff6412dda 100644 --- a/crates/glua_code_analysis/src/semantic/generic/type_substitutor.rs +++ b/crates/glua_code_analysis/src/semantic/generic/type_substitutor.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use super::tpl_pattern::constant_decay; use crate::{GenericTplId, LuaType, LuaTypeDeclId}; @@ -19,14 +19,14 @@ impl Default for TypeSubstitutor { impl TypeSubstitutor { pub fn new() -> Self { Self { - tpl_replace_map: HashMap::new(), - alias_type_ids: HashSet::new(), + tpl_replace_map: HashMap::default(), + alias_type_ids: HashSet::default(), self_type: None, } } pub fn from_type_array(type_array: Vec) -> Self { - let mut tpl_replace_map = HashMap::new(); + let mut tpl_replace_map = HashMap::default(); for (i, ty) in type_array.into_iter().enumerate() { tpl_replace_map.insert( GenericTplId::Type(i as u32), @@ -35,7 +35,7 @@ impl TypeSubstitutor { } Self { tpl_replace_map, - alias_type_ids: HashSet::new(), + alias_type_ids: HashSet::default(), self_type: None, } } @@ -49,7 +49,7 @@ impl TypeSubstitutor { alias_type_id: LuaTypeDeclId, parent: Option<&TypeSubstitutor>, ) -> Self { - let mut tpl_replace_map = HashMap::new(); + let mut tpl_replace_map = HashMap::default(); for (i, ty) in type_array.into_iter().enumerate() { tpl_replace_map.insert( GenericTplId::Type(i as u32), diff --git a/crates/glua_code_analysis/src/semantic/infer/infer_binary/infer_binary_or.rs b/crates/glua_code_analysis/src/semantic/infer/infer_binary/infer_binary_or.rs index 7bdd20a73..f5c34b1cf 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_binary/infer_binary_or.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_binary/infer_binary_or.rs @@ -1,7 +1,7 @@ -use glua_parser::{LuaAssignStat, LuaAstNode, LuaBinaryExpr, LuaExpr, LuaSyntaxNode}; +use glua_parser::{LuaAssignStat, LuaAstNode, LuaBinaryExpr, LuaExpr, LuaNameExpr, LuaSyntaxNode}; use crate::{ - DbIndex, LuaInferCache, LuaType, LuaUnionType, TypeOps, check_type_compact, + DbIndex, LuaInferCache, LuaType, TypeOps, check_type_compact, db_index::{LuaMemberOwner, LuaTypeCache, LuaTypeDeclId}, semantic::{ infer::{InferResult, narrow::remove_false_or_nil}, @@ -43,15 +43,13 @@ fn can_empty_table_satisfy_type(db: &DbIndex, ty: &LuaType) -> bool { // For unions, at least ONE type must be satisfiable by {} LuaType::Union(union_type) => { - match union_type.as_ref() { - LuaUnionType::Nullable(inner) => { - // For Type?, check the inner type (nil is already removed) - can_empty_table_satisfy_type(db, inner) - } - LuaUnionType::Multi(types) => { - // At least one type in union must be satisfiable - types.iter().any(|t| can_empty_table_satisfy_type(db, t)) - } + match union_type.nullable_inner() { + // For Type?, check the inner type (nil is already removed) + Some(inner) => can_empty_table_satisfy_type(db, inner), + // At least one type in union must be satisfiable + None => union_type + .types() + .any(|t| can_empty_table_satisfy_type(db, t)), } } @@ -100,7 +98,12 @@ pub fn try_bootstrap_or( return None; } - if !matches!(left, LuaExpr::NameExpr(_) | LuaExpr::IndexExpr(_)) { + // Only the self-bootstrap `X = X or {}` is answered before its left read + // resolves: that read is the slot being defined, so waiting on it would + // wait forever. Any other `X or {}` is a read of `X`, and answering with + // the fresh table alone commits the state of the index at the moment the + // file was walked, so it waits for the read like any other operand. + if !is_self_referential_bootstrap(db, cache, left) { return None; } @@ -150,7 +153,7 @@ pub fn special_or_rule( // A self-read cannot inform its own definition, so the fold must // not consult it: `X = X or {}` always yields the fresh table, // whatever the left read happens to resolve to right now. - if is_self_referential_bootstrap(&left_expr) { + if is_self_referential_bootstrap(db, cache, &left_expr) { return Some(right_type.clone()); } @@ -229,11 +232,16 @@ pub fn infer_binary_expr_or(db: &DbIndex, left: LuaType, right: LuaType) -> Infe } /// True when `left_expr` is a field read that is also the target of the assignment -/// it feeds, e.g. `X.a[k] = X.a[k] or {}`. Name targets are excluded: for a local or -/// parameter `x = x or {}` is a narrowing idiom over a declared type rather than a -/// definition of the table, and unresolved globals are already handled below. -fn is_self_referential_bootstrap(left_expr: &LuaExpr) -> bool { - if !matches!(left_expr, LuaExpr::IndexExpr(_)) { +/// it feeds, e.g. `X.a[k] = X.a[k] or {}`. For a name target, only an unresolved +/// name qualifies: a resolved local, parameter, or declared global makes +/// `x = x or {}` a narrowing idiom over a declared type rather than a definition +/// of the table (unresolved globals qualify, matching the handling below). +pub(crate) fn is_self_referential_bootstrap( + db: &DbIndex, + cache: &mut LuaInferCache, + left_expr: &LuaExpr, +) -> bool { + if !matches!(left_expr, LuaExpr::NameExpr(_) | LuaExpr::IndexExpr(_)) { return false; } @@ -255,7 +263,47 @@ fn is_self_referential_bootstrap(left_expr: &LuaExpr) -> bool { return false; }; - significant_tokens(var.syntax()) == significant_tokens(left_expr.syntax()) + if significant_tokens(var.syntax()) != significant_tokens(left_expr.syntax()) { + return false; + } + + match left_expr { + LuaExpr::IndexExpr(_) => true, + LuaExpr::NameExpr(name_expr) => !is_resolved_name_expr(db, cache, name_expr), + _ => false, + } +} + +/// True when the name has a local/param declaration or a declared global, i.e. +/// anything that makes the name a resolved read rather than an unresolved +/// bootstrap target. Mirrors `is_unresolved_global_unknown_name_expr`. +fn is_resolved_name_expr(db: &DbIndex, cache: &mut LuaInferCache, name_expr: &LuaNameExpr) -> bool { + let file_id = cache.get_file_id(); + if db + .get_reference_index() + .get_local_reference(&file_id) + .and_then(|file_ref| file_ref.get_decl_id(&name_expr.get_range())) + .is_some() + { + return true; + } + + let Some(name_text) = name_expr.get_name_text() else { + return false; + }; + + if db + .get_decl_index() + .get_decl_tree(&file_id) + .is_some_and(|tree| { + tree.find_local_decl(name_text.as_str(), name_expr.get_position()) + .is_some() + }) + { + return true; + } + + resolve_global_decl_id(db, cache, name_text.as_str(), Some(name_expr)).is_some() } fn significant_tokens(node: &LuaSyntaxNode) -> Vec { diff --git a/crates/glua_code_analysis/src/semantic/infer/infer_binary/mod.rs b/crates/glua_code_analysis/src/semantic/infer/infer_binary/mod.rs index 99961eb25..7423d3354 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_binary/mod.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_binary/mod.rs @@ -1,7 +1,7 @@ mod infer_binary_and; mod infer_binary_or; -use glua_parser::{BinaryOperator, LuaBinaryExpr}; +use glua_parser::{BinaryOperator, LuaBinaryExpr, LuaExpr, LuaIndexKey}; use infer_binary_and::{infer_binary_expr_and, special_and_rule}; use infer_binary_or::{infer_binary_expr_or, special_or_rule}; use smol_str::SmolStr; @@ -22,6 +22,43 @@ pub fn infer_binary_expr( let op = expr.get_op_token().ok_or(InferFailReason::None)?.get_op(); let (left, right) = expr.get_exprs().ok_or(InferFailReason::None)?; + // A field self-bootstrap `X.y = X.y or {}` always answers with the fresh + // table: the left read is the slot being defined, so it cannot inform its + // own definition (see `special_or_rule`, which discards it unconditionally + // for this shape). Inferring it first would merge every writer the slot + // holds so far — and walk the flow chain behind it — on every write, only + // to throw the answer away, so N same-slot bootstraps cost O(N^2). The + // table literal alone is the same answer `special_or_rule` returns below. + // + // Bare names are excluded: `is_self_referential_bootstrap` now resolves name + // targets (local/param/declared-global gate), so the unresolved-global arms + // ahead of the bootstrap check keep precedence for them and the narrow + // idiom keeps its cheap left read. An index read is never an unresolved + // global, so those checks are provably inert for it and skipping them + // changes nothing. + // + // Computed keys are excluded too: only an `Expr`-keyed member read bumps + // the sibling-merge counter the walk uses to queue settled re-derivation, + // so inferring one has an observable side effect this must preserve. + if op == BinaryOperator::OpOr + && matches!( + &left, + LuaExpr::IndexExpr(index) if matches!( + index.get_index_key(), + Some( + LuaIndexKey::Name(_) + | LuaIndexKey::String(_) + | LuaIndexKey::Integer(_) + | LuaIndexKey::Idx(_) + ) + ) + ) + && matches!(&right, LuaExpr::TableExpr(table) if table.is_empty()) + && infer_binary_or::is_self_referential_bootstrap(db, cache, &left) + { + return infer_expr(db, cache, right); + } + let left_type = match infer_expr(db, cache, left.clone()) { Ok(ty) => ty, Err(err) => { diff --git a/crates/glua_code_analysis/src/semantic/infer/infer_call/infer_setmetatable.rs b/crates/glua_code_analysis/src/semantic/infer/infer_call/infer_setmetatable.rs index 7c7079dea..d8045d5ec 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_call/infer_setmetatable.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_call/infer_setmetatable.rs @@ -5,7 +5,7 @@ use glua_parser::{ use crate::{ DbIndex, InFiled, InferFailReason, LuaDeclExtra, LuaInferCache, LuaInstanceType, LuaMemberKey, - LuaMemberOwner, LuaType, LuaUnionType, SemanticDeclLevel, infer_expr, + LuaMemberOwner, LuaType, SemanticDeclLevel, infer_expr, semantic::{ SemanticDeclGuard, get_member_value_expr, infer::InferResult, infer_expr_semantic_decl, member::find_members_with_key, @@ -221,7 +221,7 @@ fn infer_metatable_index_type( ReceiverOwnerType::Rejected => return Ok(MetatableIndex::NoIndex(LuaType::Unknown)), ReceiverOwnerType::NotReceiver => infer_expr(db, cache, metatable)?, }; - match exact_table_index_type(db, cache, &meta_type) { + match exact_table_index_type(db, cache, &meta_type, metatable_expr.get_position()) { ExactMetatableIndexType::Exact(index_type) => { return Ok(MetatableIndex::Index(index_type)); } @@ -229,8 +229,9 @@ fn infer_metatable_index_type( ExactMetatableIndexType::None => {} } + // Every `__index` writer, so writers that disagree are seen to disagree. if let Some(meta_members) = - find_members_with_key(db, &meta_type, LuaMemberKey::Name("__index".into()), false) + find_members_with_key(db, &meta_type, LuaMemberKey::Name("__index".into()), true) { let mut index_types = Vec::with_capacity(meta_members.len()); for meta_member in meta_members { @@ -411,7 +412,7 @@ enum ExactMetatableIndexType { Exact(LuaType), } -enum MetatableIndexCandidate { +pub(crate) enum MetatableIndexCandidate { Unsupported, Supported(LuaType), } @@ -420,6 +421,7 @@ fn exact_table_index_type( db: &DbIndex, cache: &mut LuaInferCache, meta_type: &LuaType, + caller_position: rowan::TextSize, ) -> ExactMetatableIndexType { let table_range = match meta_type { LuaType::TableConst(range) => range, @@ -428,12 +430,16 @@ fn exact_table_index_type( }; let owner = LuaMemberOwner::Element(table_range.clone()); let key = LuaMemberKey::Name("__index".into()); - let mut member_ids = db - .get_member_index() - .get_members_for_owner_key(&owner, &key) - .into_iter() - .map(|member| member.get_id()) - .collect::>(); + // The writers a reader at the call sees: a later plain write in the same + // file has replaced the earlier ones by then. + let mut member_ids = crate::LuaMemberIndexItem::Many( + db.get_member_index() + .get_members_for_owner_key(&owner, &key) + .into_iter() + .map(|member| member.get_id()) + .collect(), + ) + .visible_member_ids_with_realm_at_offset(db, &cache.get_file_id(), caller_position); member_ids.sort_by_key(|id| (id.file_id.id, u32::from(id.get_position()))); if member_ids.is_empty() { let Some(index_type) = last_table_literal_index_type(db, cache, table_range) else { @@ -549,13 +555,13 @@ fn last_table_literal_index_value(table: &LuaTableExpr) -> Option { }) } -fn classify_metatable_index_candidate(typ: &LuaType) -> MetatableIndexCandidate { +pub(crate) fn classify_metatable_index_candidate(typ: &LuaType) -> MetatableIndexCandidate { match typ { - LuaType::Union(union) => match union.as_ref() { - LuaUnionType::Nullable(inner) => classify_metatable_index_candidate(inner), - LuaUnionType::Multi(types) => { + LuaType::Union(union) => match union.nullable_inner() { + Some(inner) => classify_metatable_index_candidate(inner), + None => { let mut supported_types = Vec::new(); - for typ in types.iter().filter(|typ| !typ.is_nil()) { + for typ in union.types().filter(|typ| !typ.is_nil()) { match classify_metatable_index_candidate(typ) { MetatableIndexCandidate::Supported(typ) => supported_types.push(typ), MetatableIndexCandidate::Unsupported => { 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..7ac0863e4 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,11 +8,12 @@ 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, LuaIntersectionType, LuaOperatorMetaMethod, LuaOperatorOwner, LuaSignature, LuaSignatureId, - LuaTupleType, LuaType, LuaTypeDeclId, LuaUnionType, ReturnTypeKind, VariadicType, + LuaTupleType, LuaType, LuaTypeDeclId, LuaTypeFlag, LuaUnionType, ReturnTypeKind, VariadicType, }; use crate::{GMOD_DOMAIN_CONVAR, GMOD_ROLE_REFERENCE}; use crate::{GmodConVarKind, GmodLoadEdgeKind, GmodStateMask}; @@ -30,10 +31,11 @@ use crate::{ }; use crate::{ SemanticDeclGuard, SemanticDeclLevel, build_self_type, infer_self_type, - instantiate_func_generic, semantic::infer_expr, + instantiate_func_generic, instantiate_func_generic_with_bindings, semantic::infer_expr, }; use infer_require::infer_require_call; use infer_setmetatable::infer_setmetatable_call; +pub(crate) use infer_setmetatable::{MetatableIndexCandidate, classify_metatable_index_candidate}; mod infer_require; mod infer_setmetatable; @@ -132,6 +134,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 { @@ -233,12 +253,22 @@ fn refine_known_vgui_panel_return( let Some(type_id) = single_non_nil_instance_type_id(&return_type) else { return return_type; }; - if !type_decl_is_vgui_panel(db, &type_id, 0) - && db - .get_gmod_class_metadata_index() - .get_vgui_panel_base(type_id.get_name()) - .is_none() - { + let is_registered = db + .get_gmod_class_metadata_index() + .get_vgui_panel_base(type_id.get_name()) + .is_some(); + // String-template inference also creates auto-generated placeholders; those + // do not prove that the engine can construct the named panel. + let is_placeholder = db + .get_type_index() + .get_type_decl(&type_id) + .is_some_and(|decl| { + decl.get_locations() + .iter() + .all(|location| location.flag.contains(LuaTypeFlag::AutoGenerated)) + }) + && !is_registered; + if is_placeholder || (!type_decl_is_vgui_panel(db, &type_id, 0) && !is_registered) { return return_type; } @@ -350,14 +380,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) + } + None => { + if is_broad_panel_type(&return_type) { + cache + .vgui_parent_fallback_calls + .insert(call_expr.get_syntax_id()); + } + return_type } - return_type - }) + } } fn is_broad_panel_type(typ: &LuaType) -> bool { @@ -643,8 +683,10 @@ fn infer_doc_function( prefix_signature_id: Option, ) -> InferCallFuncResult { if func.contain_tpl() { - let result = instantiate_func_generic(db, cache, func, call_expr.clone())?; - return Ok(Arc::new(result)); + let instantiation = + instantiate_func_generic_with_bindings(db, cache, func, call_expr.clone())?; + cache.record_generic_call_bindings(call_expr.get_syntax_id(), instantiation.bindings); + return Ok(Arc::new(instantiation.function)); } // Handle self-type substitution for functions with SelfInfer in return type @@ -922,8 +964,14 @@ fn infer_signature_doc_function( ) .with_optional_params(signature.get_param_optional_flags()); if is_generic { - fake_doc_function = - instantiate_func_generic(db, cache, &fake_doc_function, call_expr.clone())?; + let instantiation = instantiate_func_generic_with_bindings( + db, + cache, + &fake_doc_function, + call_expr.clone(), + )?; + cache.record_generic_call_bindings(call_expr.get_syntax_id(), instantiation.bindings); + fake_doc_function = instantiation.function; } let fake_doc_function = @@ -1102,13 +1150,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( @@ -1768,7 +1889,12 @@ pub(crate) fn unwrapp_return_type( value: call_expr.get_range(), }; - return Ok(materialize_instance_return(return_type.clone(), id)); + let base = if matches!(inst.get_base(), LuaType::Instance(_)) { + inst.get_base().clone() + } else { + return_type.clone() + }; + return Ok(materialize_instance_return(base, id)); } return Ok(return_type); @@ -1899,7 +2025,7 @@ pub fn infer_call_expr( db, cache, call_expr.clone(), - prefix_type, + prefix_type.clone(), &InferGuard::new(), None, )? @@ -1951,15 +2077,25 @@ fn signature_is_generic( if signature.is_generic() { return Some(true); } - let LuaExpr::IndexExpr(index_expr) = call_expr.get_prefix_expr()? else { - return None; - }; - let prefix_type = infer_expr(db, cache, index_expr.get_prefix_expr()?).ok()?; - match prefix_type { - // 对于 Generic 直接认为是泛型 - LuaType::Generic(_) => Some(true), - _ => Some(prefix_type.contain_tpl()), + let call_syntax_id = call_expr.get_syntax_id(); + if let Some(cached) = cache.signature_is_generic_cache.get(&call_syntax_id) { + return *cached; } + let result = (|| -> Option { + let LuaExpr::IndexExpr(index_expr) = call_expr.get_prefix_expr()? else { + return None; + }; + let prefix_type = infer_expr(db, cache, index_expr.get_prefix_expr()?).ok()?; + match prefix_type { + // 对于 Generic 直接认为是泛型 + LuaType::Generic(_) => Some(true), + _ => Some(prefix_type.contain_tpl()), + } + })(); + cache + .signature_is_generic_cache + .insert(call_syntax_id, result); + result } fn apply_signature_return_kinds_to_function( @@ -2102,11 +2238,41 @@ fn apply_definition_return_type(return_type: LuaType) -> LuaType { #[cfg(test)] mod tests { use crate::{ - InferFailReason, InferGuard, LuaSignatureId, LuaType, LuaUnionType, SignatureReturnStatus, - VirtualWorkspace, semantic::infer_call_expr_func, + FileId, InFiled, InferFailReason, InferGuard, LuaInstanceType, LuaSignatureId, LuaType, + LuaUnionType, SignatureReturnStatus, VirtualWorkspace, semantic::infer_call_expr_func, }; use glua_parser::LuaAstNode; + #[test] + fn rematerializing_instance_return_replaces_the_call_site_wrapper() { + let mut ws = VirtualWorkspace::new(); + let file_id = ws.def("source()"); + let call_expr = ws.get_node::(file_id); + let table_range = InFiled::new(FileId::new(2), rowan::TextRange::new(0.into(), 2.into())); + let previous_call = InFiled::new(FileId::new(3), rowan::TextRange::new(4.into(), 8.into())); + let source_instance = LuaType::Instance( + LuaInstanceType::new(LuaType::TableConst(table_range.clone()), previous_call).into(), + ); + let prior_materialization = + InFiled::new(FileId::new(4), rowan::TextRange::new(10.into(), 14.into())); + let return_type = LuaType::Instance( + LuaInstanceType::new(source_instance.clone(), prior_materialization).into(), + ); + let semantic_model = ws.analysis.compilation.get_semantic_model(file_id).unwrap(); + let db = semantic_model.get_db(); + let mut cache = semantic_model.get_cache().borrow_mut(); + + let result = super::unwrapp_return_type(db, &mut cache, return_type, call_expr.clone()) + .expect("call return should materialize"); + let LuaType::Instance(instance) = result else { + panic!("expected an instance return"); + }; + + assert_eq!(instance.get_base(), &source_instance); + assert_eq!(instance.get_range().file_id, file_id); + assert_eq!(instance.get_range().value, call_expr.get_range()); + } + #[test] fn test_call_cache_non_callable_not_sticky() { let mut ws = VirtualWorkspace::new(); diff --git a/crates/glua_code_analysis/src/semantic/infer/infer_doc_type.rs b/crates/glua_code_analysis/src/semantic/infer/infer_doc_type.rs index 31e95a413..dec2172ff 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_doc_type.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_doc_type.rs @@ -13,7 +13,7 @@ use crate::{ AsyncState, DbIndex, FileId, InFiled, LuaAliasCallKind, LuaAliasCallType, LuaArrayLen, LuaArrayType, LuaAttributeType, LuaFunctionType, LuaGenericType, LuaIndexAccessKey, LuaIntersectionType, LuaMultiLineUnion, LuaObjectType, LuaStringTplType, LuaTupleStatus, - LuaTupleType, LuaType, LuaTypeDeclId, TypeOps, VariadicType, + LuaTupleType, LuaType, LuaTypeDeclId, TypeOps, VariadicType, analysis_stack_exhausted, }; #[derive(Clone, Copy)] @@ -29,6 +29,9 @@ impl<'a> DocTypeInferContext<'a> { } pub fn infer_doc_type(ctx: DocTypeInferContext<'_>, node: &LuaDocType) -> LuaType { + if analysis_stack_exhausted() { + return LuaType::Unknown; + } match node { LuaDocType::Name(name_type) => { if let Some(name) = name_type.get_name_text() { 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..ef25aa3f9 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 @@ -7,16 +7,19 @@ use glua_parser::{ }; use internment::ArcIntern; use rowan::{TextRange, TextSize}; +use rustc_hash::FxHashSet; use smol_str::SmolStr; use std::borrow::Cow; -use std::collections::HashSet; use crate::{ CacheEntry, FileId, GenericTpl, GlobalId, InFiled, InferGuardRef, LuaAliasCallKind, LuaDeclId, LuaInferCache, LuaInstanceType, LuaMemberOwner, LuaOperatorOwner, TypeOps, compilation::{ - analyzer::dominating_guarded_table_bootstrap_range, get_scripted_class_info_for_file, - get_scripted_class_type_decl_id, + analyzer::{ + dominating_guarded_table_bootstrap_range, for_range_pairs_source_for_var, + is_guarded_table_definition_site, + }, + get_scripted_class_info_for_file, get_scripted_class_type_decl_id, }, db_index::{ DbIndex, LuaGenericType, LuaIntersectionType, LuaMember, LuaMemberIndexItem, LuaMemberKey, @@ -48,6 +51,7 @@ use crate::{ member::merge_open_table_types, member::resolve_dynamic_field_member, member::resolve_member_item_with_realm, + member::source_survives_table_generation_cutoff, type_check::{self, check_type_compact}, visible_super_types_in_workspace_for_file_at_offset, }, @@ -58,7 +62,7 @@ use super::{ type_decl_is_vgui_panel, }; -type TableMemberLookupGuard = HashSet>; +type TableMemberLookupGuard = FxHashSet>; fn visible_super_types_for_index( db: &DbIndex, @@ -559,17 +563,32 @@ fn infer_table_member_owner( table_member_lookup_guard: &mut TableMemberLookupGuard, ) -> InferResult { let owner = LuaMemberOwner::Element(inst.clone()); + let member_index = db.get_member_index(); + let owner_is_definition_site = member_index.canonical_owner(owner.clone()) != owner; + let source_min_position = + owner_is_definition_site.then(|| InFiled::new(inst.file_id, inst.value.start())); let index_key = index_expr.get_index_key().ok_or(InferFailReason::None)?; let key = match LuaMemberKey::from_index_key_or_unknown(db, cache, &index_key) { Ok(key) => key, Err(err) - if is_unknown_dynamic_key_without_table_data(db, &owner, &inst, &index_key, &err) => + if is_unknown_dynamic_key_without_table_data( + db, + &owner, + &inst, + &index_key, + &err, + source_min_position.as_ref(), + ) => { if is_dynamic_index_proven_in_range(db, cache, &index_expr, &index_key) { // Presence is proven, the element type is not. return Ok(LuaType::Unknown); } - return Ok(nullable_any_type()); + return Ok(empty_table_dynamic_access_type( + db, + &inst, + source_min_position.as_ref(), + )); } Err(err) => return Err(err), }; @@ -582,17 +601,52 @@ fn infer_table_member_owner( &key, cache.get_file_id(), index_expr.get_position(), + source_min_position.as_ref(), ) { return Ok(member_type); } - if let Some(member_item) = db.get_member_index().get_member_item(&owner, &key) { - let member_type = member_item.resolve_type_with_realm_at_offset( + // A computed-key write from another file is evidence that the table is a + // registry, not an answer for this access: the cross-file expression-key + // handler below is what reads it, and it applies the realm and + // finite-domain filters that evidence needs. Short-circuiting here would + // bypass them. Only a same-file write states the value at this branch -- + // which is all this lookup could ever find before one owner per global + // path brought every file's writes into one bucket. + let exact_item = member_index.get_member_item(&owner, &key).and_then(|item| { + let mut member_ids = item.visible_member_ids_with_realm_at_offset( db, &cache.get_file_id(), index_expr.get_position(), - )?; + ); + if let Some(source_min_position) = &source_min_position { + // Definition sites share a global-path bucket; a fresh table cannot + // inherit members written to an earlier same-file table generation. + member_ids.retain(|member_id| { + source_survives_table_generation_cutoff( + db, + member_id.file_id, + member_id.get_position(), + source_min_position, + ) + }); + } + if matches!(key, LuaMemberKey::ExprType(_)) + && !member_ids + .iter() + .any(|member_id| member_id.file_id == cache.get_file_id()) + { + return None; + } + match member_ids.as_slice() { + [] => None, + [member_id] => Some(LuaMemberIndexItem::One(*member_id)), + _ => Some(LuaMemberIndexItem::Many(member_ids)), + } + }); + if let Some(member_item) = exact_item { + let member_type = member_item.resolve_type(db)?; if is_literal_table_field_access(&index_key) && owner_has_finite_named_dynamic_assignment(db, &owner) && let Some(dynamic_field) = resolve_dynamic_field_member( @@ -617,6 +671,7 @@ fn infer_table_member_owner( &key, cache.get_file_id(), Some(index_expr.get_position()), + source_min_position.as_ref(), true, ) && !type_is_uninformative(&dynamic_member_type) { @@ -632,8 +687,13 @@ fn infer_table_member_owner( // shaped table-of-table literals keep their per-row element type under a // non-constant index. if dynamic_numeric_index_key(&key) - && let Some(base) = - resolve_table_const_array_base(db, cache, &owner, index_expr.get_position())? + && let Some(base) = resolve_table_const_array_base( + db, + cache, + &owner, + index_expr.get_position(), + source_min_position.as_ref(), + )? { return Ok(base); } @@ -646,21 +706,12 @@ fn infer_table_member_owner( &key, cache.get_file_id(), index_expr.get_position(), + source_min_position.as_ref(), ) { return Ok(member_type); } - if let Some(member_type) = infer_cross_file_matching_expr_key_member_type( - db, - &owner, - &key, - cache.get_file_id(), - index_expr.get_position(), - ) { - return Ok(member_type); - } - if db.get_emmyrc().gmod.enabled && db.get_emmyrc().gmod.infer_dynamic_fields && is_literal_table_field_access(&index_key) @@ -679,17 +730,27 @@ 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); } } - match infer_owner_raw_member_type_with_realm( + let raw_member_type = infer_owner_raw_member_type_with_realm( db, owner.clone(), &key, cache.get_file_id(), Some(index_expr.get_position()), - ) { + source_min_position.clone(), + ); + match raw_member_type { Ok(typ) => { if type_is_uninformative(&typ) && let Some(dynamic_member_type) = infer_table_dynamic_key_member_type( @@ -698,6 +759,7 @@ fn infer_table_member_owner( &key, cache.get_file_id(), Some(index_expr.get_position()), + source_min_position.as_ref(), true, ) && !type_is_uninformative(&dynamic_member_type) @@ -705,6 +767,25 @@ fn infer_table_member_owner( return Ok(dynamic_member_type); } + // An uninformative answer is not an answer. Fall back to the same + // last-resort value evidence the not-found tail uses, so a read of + // a registry does not lose its nil just because the key-specific + // lookup resolved to `unknown`. + if type_is_uninformative(&typ) + && let Some(member_type) = infer_cross_file_matching_expr_key_member_type( + db, + &owner, + &key, + cache.get_file_id(), + index_expr.get_position(), + ) + { + return Ok(nullable_if_needed(db, member_type)); + } + + if matches!(key, LuaMemberKey::ExprType(_)) { + cache.sibling_merge_reads += 1; + } Ok(typ) } Err(InferFailReason::FieldNotFound) => { @@ -727,6 +808,7 @@ fn infer_table_member_owner( &key, cache.get_file_id(), Some(index_expr.get_position()), + source_min_position.as_ref(), true, ) && !type_is_uninformative(&dynamic_member_type) { @@ -741,6 +823,7 @@ fn infer_table_member_owner( &key, cache.get_file_id(), Some(index_expr.get_position()), + source_min_position.as_ref(), false, ) && !type_is_uninformative(&dynamic_member_type) { @@ -758,20 +841,57 @@ fn infer_table_member_owner( return Ok(metatable_type); } - if is_dynamic_expr_key_without_table_data(db, &owner, &inst, &key) { + // Consulted last, after every key-specific source above: a + // computed write in another file is evidence about the *values* a + // table holds, never about this key being one of them. So it only + // answers when nothing better did, and it answers `V?`. + if let Some(member_type) = infer_cross_file_matching_expr_key_member_type( + db, + &owner, + &key, + cache.get_file_id(), + index_expr.get_position(), + ) { + return Ok(nullable_if_needed(db, member_type)); + } + + // Ahead of the no-table-data fallback below: a write through the + // path this element is reached by -- including the wildcard segment + // a `for _, v in pairs(REG)` write files under -- states the value, + // and giving up with `any?` while that member exists would be the + // walk's answer rather than the program's. + if let Ok(global_path_type) = infer_global_path_member( + db, + cache, + index_expr.clone(), + Some(key.clone()), + source_min_position.clone(), + ) { + return Ok(global_path_type); + } + + if is_dynamic_expr_key_without_table_data( + db, + &owner, + &inst, + &key, + source_min_position.as_ref(), + ) { if is_dynamic_index_proven_in_range(db, cache, &index_expr, &index_key) { // Presence is proven, the element type is not. return Ok(LuaType::Unknown); } - return Ok(nullable_any_type()); + return Ok(empty_table_dynamic_access_type( + db, + &inst, + source_min_position.as_ref(), + )); } - if let Ok(global_path_type) = - infer_global_path_member(db, cache, index_expr.clone(), Some(key.clone())) - { - Ok(global_path_type) - } else if is_table_const_from_doc_tag(db, &inst) { + + if is_table_const_from_doc_tag(db, &inst) { Ok(nullable_any_type()) } else { + cache.record_missed_member_read(owner, key); Err(InferFailReason::FieldNotFound) } } @@ -794,13 +914,19 @@ fn infer_table_metatable_member( let meta_owner = LuaMemberOwner::Element(metatable.clone()); let index_member_key = LuaMemberKey::Name("__index".into()); + // Every `__index` writer is visible; a slot whose writers disagree (a + // table in one place, a function or another table elsewhere) names no + // single lookup table, so nothing is resolved through it. if let Ok(index_type) = infer_owner_raw_member_type_with_realm( db, meta_owner.clone(), &index_member_key, cache.get_file_id(), Some(index_expr.get_position()), - ) && !metatable_index_type_points_to_table(&index_type, table_range) + None, + ) && let super::infer_call::MetatableIndexCandidate::Supported(index_type) = + super::infer_call::classify_metatable_index_candidate(&index_type) + && !metatable_index_type_points_to_table(&index_type, table_range) && let Ok(typ) = infer_member_by_member_key_with_table_guard( db, cache, @@ -854,6 +980,7 @@ fn infer_table_dynamic_key_member_type( key: &LuaMemberKey, caller_file_id: FileId, caller_position: Option, + source_min_position: Option<&InFiled>, allow_unobserved_named_access: bool, ) -> Option { // Apply a table's dynamic-key value type to named fields only after the @@ -873,7 +1000,13 @@ fn infer_table_dynamic_key_member_type( let table_type = LuaType::TableConst(inst.clone()); if find_members_with_key(db, &table_type, key.clone(), true) .is_none_or(|members| members.is_empty()) - && !owner_has_precise_dynamic_value(db, owner, caller_file_id, caller_position) + && !owner_has_precise_dynamic_value( + db, + owner, + caller_file_id, + caller_position, + source_min_position, + ) { return None; } @@ -901,9 +1034,13 @@ fn infer_table_dynamic_key_member_type( } let member_item = LuaMemberIndexItem::One(member.get_id()); - if let Ok(member_type) = - resolve_member_item_with_realm(db, &member_item, caller_file_id, caller_position) - { + if let Ok(member_type) = resolve_member_item_with_realm( + db, + &member_item, + caller_file_id, + caller_position, + source_min_position, + ) { result_type = TypeOps::Union.apply(db, &result_type, &member_type); } } @@ -916,6 +1053,7 @@ fn owner_has_precise_dynamic_value( owner: &LuaMemberOwner, caller_file_id: FileId, caller_position: Option, + source_min_position: Option<&InFiled>, ) -> bool { let Some(members) = db.get_member_index().get_expr_key_members(owner) else { return false; @@ -923,8 +1061,14 @@ fn owner_has_precise_dynamic_value( members.iter().any(|member| { let member_item = LuaMemberIndexItem::One(member.get_id()); - resolve_member_item_with_realm(db, &member_item, caller_file_id, caller_position) - .is_ok_and(|typ| is_precise_unknown_wildcard_value_type(&typ)) + resolve_member_item_with_realm( + db, + &member_item, + caller_file_id, + caller_position, + source_min_position, + ) + .is_ok_and(|typ| is_precise_unknown_wildcard_value_type(&typ)) }) } @@ -951,6 +1095,7 @@ fn infer_gmod_same_file_expr_key_member_type( key: &LuaMemberKey, access_file_id: FileId, access_position: TextSize, + source_min_position: Option<&InFiled>, ) -> Option { if !db.get_emmyrc().gmod.enabled || !db.get_emmyrc().gmod.infer_dynamic_fields { return None; @@ -988,9 +1133,13 @@ fn infer_gmod_same_file_expr_key_member_type( } let member_item = crate::db_index::LuaMemberIndexItem::One(member.get_id()); - let Ok(member_type) = - member_item.resolve_type_with_realm_at_offset(db, &access_file_id, access_position) - else { + let Ok(member_type) = resolve_member_item_with_realm( + db, + &member_item, + access_file_id, + Some(access_position), + source_min_position, + ) else { continue; }; @@ -1276,14 +1425,6 @@ fn is_literal_member_key(key: &LuaMemberKey) -> bool { matches!(key, LuaMemberKey::Name(_) | LuaMemberKey::Integer(_)) } -fn dynamic_field_owner(owner: &LuaMemberOwner) -> Option { - match owner { - LuaMemberOwner::Type(type_id) => Some(crate::DynamicFieldOwner::Type(type_id.clone())), - LuaMemberOwner::Element(range) => Some(crate::DynamicFieldOwner::Table(range.clone())), - _ => None, - } -} - fn member_is_finite_named_dynamic_assignment( db: &DbIndex, owner: &LuaMemberOwner, @@ -1293,7 +1434,7 @@ fn member_is_finite_named_dynamic_assignment( return false; } - dynamic_field_owner(owner).is_some_and(|dynamic_owner| { + crate::dynamic_field_owner_of(db, owner).is_some_and(|dynamic_owner| { db.get_dynamic_field_index() .member_has_finite_named_definition(&dynamic_owner, member.get_id()) }) @@ -1304,7 +1445,7 @@ fn owner_has_finite_named_dynamic_assignment(db: &DbIndex, owner: &LuaMemberOwne return false; } - dynamic_field_owner(owner).is_some_and(|dynamic_owner| { + crate::dynamic_field_owner_of(db, owner).is_some_and(|dynamic_owner| { db.get_dynamic_field_index() .owner_has_finite_named_members(&dynamic_owner) }) @@ -1319,7 +1460,7 @@ fn member_key_is_unknown_expr(key: &LuaMemberKey) -> bool { } fn owner_wildcard_covers_literal_key(db: &DbIndex, owner: &LuaMemberOwner) -> bool { - let Some(dynamic_owner) = dynamic_field_owner(owner) else { + let Some(dynamic_owner) = crate::dynamic_field_owner_of(db, owner) else { return false; }; @@ -1381,15 +1522,28 @@ fn nullable_any_type() -> LuaType { LuaType::Union(LuaUnionType::from_vec(vec![LuaType::Any, LuaType::Nil]).into()) } +fn empty_table_dynamic_access_type( + db: &DbIndex, + inst: &InFiled, + source_min_position: Option<&InFiled>, +) -> LuaType { + if source_min_position.is_some() && !is_guarded_table_definition_site(db, inst) { + LuaType::Nil + } else { + nullable_any_type() + } +} + fn is_unknown_dynamic_key_without_table_data( db: &DbIndex, owner: &LuaMemberOwner, inst: &InFiled, index_key: &LuaIndexKey, err: &InferFailReason, + source_min_position: Option<&InFiled>, ) -> bool { matches!(index_key, LuaIndexKey::Expr(_)) - && table_const_has_no_specific_data(db, owner, inst) + && table_const_has_no_specific_data(db, owner, inst, source_min_position) && matches!( err, InferFailReason::None @@ -1403,8 +1557,10 @@ fn is_dynamic_expr_key_without_table_data( owner: &LuaMemberOwner, inst: &InFiled, key: &LuaMemberKey, + source_min_position: Option<&InFiled>, ) -> bool { - matches!(key, LuaMemberKey::ExprType(_)) && table_const_has_no_specific_data(db, owner, inst) + matches!(key, LuaMemberKey::ExprType(_)) + && table_const_has_no_specific_data(db, owner, inst, source_min_position) } fn is_dynamic_index_proven_in_range( @@ -1421,12 +1577,38 @@ fn is_dynamic_index_proven_in_range( && check_index_in_range(db, cache, index_expr) } +/// Whether nothing is known about this table beyond that it is one. +/// +/// An expression-keyed member does not count: it says the table is filled +/// under computed keys, which is the opposite of knowing a key. Only a member +/// under a name or an integer states something specific about what the table +/// holds. The distinction used to be free -- a registry's computed write and +/// its `X.k = X.k or {}` bootstrap were two different literals, so the +/// bootstrap's bucket really was empty -- and it has to be made explicitly now +/// that both belong to the one owner the path has. fn table_const_has_no_specific_data( db: &DbIndex, owner: &LuaMemberOwner, inst: &InFiled, + source_min_position: Option<&InFiled>, ) -> bool { - !db.get_member_index().has_live_member(owner) && db.get_metatable_index().get(inst).is_none() + let has_specific_member = db + .get_member_index() + .get_members(owner) + .is_some_and(|members| { + members.iter().any(|member| { + is_literal_member_key(member.get_key()) + && source_min_position.is_none_or(|source_min_position| { + source_survives_table_generation_cutoff( + db, + member.get_file_id(), + member.get_id().get_position(), + source_min_position, + ) + }) + }) + }); + !has_specific_member && db.get_metatable_index().get(inst).is_none() } fn infer_plain_table_member( @@ -1438,7 +1620,9 @@ fn infer_plain_table_member( return Ok(member_type); } - if let Ok(global_path_type) = infer_global_path_member(db, cache, index_expr.clone(), None) { + if let Ok(global_path_type) = + infer_global_path_member(db, cache, index_expr.clone(), None, None) + { return Ok(global_path_type); } @@ -1889,6 +2073,7 @@ fn infer_custom_type_member( } } + cache.record_missed_member_read(owner, key); Err(InferFailReason::FieldNotFound) } @@ -1970,7 +2155,7 @@ fn get_expr_key_members( fn get_all_member_key(db: &DbIndex, origin_type: &LuaType) -> Option> { let mut result = Vec::new(); let mut stack = vec![origin_type.clone()]; // 堆栈用于迭代处理 - let mut visited = HashSet::new(); + let mut visited = FxHashSet::default(); while let Some(current_type) = stack.pop() { if visited.contains(¤t_type) { @@ -2217,7 +2402,9 @@ fn finish_union_member_inference( if missing_arm { member_types.push(LuaType::Nil); } - Ok(LuaType::from_vec(member_types)) + Ok(normalize_uninformative_member_union(LuaType::from_vec( + member_types, + ))) } fn infer_merged_table_member( @@ -2249,13 +2436,21 @@ fn infer_merged_table_member( } if member_types.is_empty() { - if let Ok(global_path_type) = infer_global_path_member(db, cache, index_expr.clone(), None) + if let Ok(global_path_type) = + infer_global_path_member(db, cache, index_expr.clone(), None, None) { return Ok(global_path_type); } return Err(last_resolve_reason); } + // Components that are one slot (two literals of one global path) answer + // identically; merging that answer with itself would fold a union of + // rival tables into one table. + member_types.dedup(); + if let [member_type] = member_types.as_slice() { + return Ok(member_type.clone()); + } Ok(merge_open_table_types(db, member_types)) } @@ -2526,7 +2721,8 @@ fn infer_member_by_index_merged_table( } if member_types.is_empty() { - if let Ok(global_path_type) = infer_global_path_member(db, cache, index_expr.clone(), None) + if let Ok(global_path_type) = + infer_global_path_member(db, cache, index_expr.clone(), None, None) { return Ok(global_path_type); } @@ -2541,6 +2737,7 @@ fn infer_global_path_member( cache: &mut LuaInferCache, index_expr: LuaIndexMemberExpr, resolved_key: Option, + source_min_position: Option>, ) -> InferResult { let Some(prefix_expr) = index_expr.get_prefix_expr() else { return Err(InferFailReason::FieldNotFound); @@ -2562,11 +2759,30 @@ fn infer_global_path_member( }; let access_position = index_expr.get_position(); - let resolved = - member_item.resolve_type_with_realm_at_offset(db, &cache.get_file_id(), access_position); + let mut visible_member_ids = member_item.visible_member_ids_with_realm_at_offset( + db, + &cache.get_file_id(), + access_position, + ); + if let Some(source_min_position) = source_min_position { + visible_member_ids.retain(|member_id| { + source_survives_table_generation_cutoff( + db, + member_id.file_id, + member_id.get_position(), + &source_min_position, + ) + }); + } + let visible_item = match visible_member_ids.as_slice() { + [] => return Err(InferFailReason::FieldNotFound), + [member_id] => LuaMemberIndexItem::One(*member_id), + _ => LuaMemberIndexItem::Many(visible_member_ids), + }; + let resolved = visible_item.resolve_type(db); let decl_backed_type = resolve_decl_backed_global_path_member_type( db, - member_item, + &visible_item, &cache.get_file_id(), member_key.clone(), Some(access_position), @@ -2589,18 +2805,57 @@ 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 { + global_rooted_access_path(db, file_id, expr) + .or_else(|| pairs_iter_value_registry_path(db, file_id, expr)) +} + +fn global_rooted_access_path(db: &DbIndex, file_id: FileId, expr: &LuaExpr) -> Option { if !expr_root_is_global(db, file_id, expr) { return None; } 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, } } +/// The registry path a `for _, v in pairs(REG)` value variable stands for. +/// +/// Such a variable names *some* value of `REG`, never a particular one, which +/// is what the wildcard segment `REG.[]` means -- the bucket a `REG[k].field` +/// write already files under. Resolving it from the syntax keeps the answer +/// independent of how far inference had run, which a prefix-type lookup cannot +/// promise. +/// +/// The source must be a name or index path rooted in a global. +/// `get_owner_access_path` walks through calls, so `pairs(player.GetAll())` +/// would otherwise file `Player` members onto `player.GetAll.[]`. +pub(crate) fn pairs_iter_value_registry_path( + db: &DbIndex, + file_id: FileId, + expr: &LuaExpr, +) -> Option { + let LuaExpr::NameExpr(name_expr) = expr else { + return None; + }; + let source = for_range_pairs_source_for_var(db, file_id, name_expr, 1)?; + if !matches!( + source.source_expr, + LuaExpr::NameExpr(_) | LuaExpr::IndexExpr(_) + ) { + return None; + } + let path = global_rooted_access_path(db, file_id, &source.source_expr)?; + Some(format!("{path}.[]")) +} + fn expr_root_is_global(db: &DbIndex, file_id: FileId, expr: &LuaExpr) -> bool { let Some(root_name) = expr_root_name(expr) else { return false; @@ -2666,6 +2921,8 @@ fn infer_member_by_index_table( let owner = LuaMemberOwner::Element(table_range.clone()); let access_key = LuaMemberKey::from_index_key_or_unknown(db, cache, &index_key).ok(); let member_index = db.get_member_index(); + let source_min_position = (member_index.canonical_owner(owner.clone()) != owner) + .then(|| InFiled::new(table_range.file_id, table_range.value.start())); // A literal key matches a literal member key only when the two are // equal, so the candidates are that one key plus the // expression-keyed members. @@ -2693,27 +2950,28 @@ fn infer_member_by_index_table( // (MergedTable/Union components), mirroring `infer_table_member`. let mut saw_match = false; for member in members { - if member_key_matches_type(db, &key_type, member.get_key()) { - saw_match = true; - matched_inferred_index_key |= - is_inferred_index_member_key(member.get_key()); - // Resolve the member type instead of reading the raw - // cache, which may still be Unknown mid-analysis. - let member_type = db - .get_member_index() - .get_member_item(&owner, member.get_key()) - .and_then(|item| { - item.resolve_type_with_realm_at_offset( - db, - &cache.get_file_id(), - index_expr.get_position(), - ) - .ok() - }) - .unwrap_or(LuaType::Unknown); - - result_type = TypeOps::Union.apply(db, &result_type, &member_type); + if !member_key_matches_type(db, &key_type, member.get_key()) { + continue; } + let Some(member_type) = db + .get_member_index() + .get_member_item(&owner, member.get_key()) + .and_then(|item| { + resolve_member_item_with_realm( + db, + item, + cache.get_file_id(), + Some(index_expr.get_position()), + source_min_position.as_ref(), + ) + .ok() + }) + else { + continue; + }; + saw_match = true; + matched_inferred_index_key |= is_inferred_index_member_key(member.get_key()); + result_type = TypeOps::Union.apply(db, &result_type, &member_type); } if saw_match { @@ -2782,6 +3040,7 @@ fn resolve_table_const_array_base( cache: &LuaInferCache, owner: &LuaMemberOwner, position: TextSize, + source_min_position: Option<&InFiled>, ) -> Result, InferFailReason> { // Only true shaped sequential table literals should receive array-like // treatment. Mixed/object literals with integer-keyed members must not @@ -2801,16 +3060,24 @@ fn resolve_table_const_array_base( if !matches!(member.get_key(), LuaMemberKey::Integer(_)) { continue; } - saw_integer_member = true; let member_type = match db .get_member_index() .get_member_item(owner, member.get_key()) { - Some(item) => { - item.resolve_type_with_realm_at_offset(db, &cache.get_file_id(), position)? - } + Some(item) => match resolve_member_item_with_realm( + db, + item, + cache.get_file_id(), + Some(position), + source_min_position, + ) { + Ok(member_type) => member_type, + Err(InferFailReason::FieldNotFound) => continue, + Err(reason) => return Err(reason), + }, None => LuaType::Unknown, }; + saw_integer_member = true; let widened = match &member_type { LuaType::IntegerConst(int) => LuaType::DocIntegerConst(*int), LuaType::FloatConst(_) => LuaType::Number, @@ -2965,7 +3232,31 @@ fn infer_member_by_index_union( return Err(InferFailReason::FieldNotFound); } - Ok(member_type) + Ok(normalize_uninformative_member_union(member_type)) +} + +fn normalize_uninformative_member_union(typ: LuaType) -> LuaType { + let LuaType::Union(union) = &typ else { + return typ; + }; + let mut has_any = false; + let mut has_nil = false; + for member in union.types() { + match member { + LuaType::Any => has_any = true, + LuaType::Nil => has_nil = true, + LuaType::Unknown => {} + _ => return typ, + } + } + if !has_any { + return typ; + } + if has_nil { + nullable_any_type() + } else { + LuaType::Any + } } fn infer_member_by_index_intersection( @@ -3171,9 +3462,9 @@ fn get_expr_member_key( expr: &LuaExpr, ) -> Option> { let expr_type = infer_expr(db, cache, expr.clone()).ok()?; - let mut keys: HashSet = HashSet::new(); + let mut keys: FxHashSet = FxHashSet::default(); let mut stack = vec![expr_type.clone()]; - let mut visited = HashSet::new(); + let mut visited = FxHashSet::default(); while let Some(current_type) = stack.pop() { if !visited.insert(current_type.clone()) { @@ -3273,9 +3564,33 @@ mod union_member_tests { use glua_parser::{LuaAstNode, LuaExpr, LuaIndexExpr}; use rowan::TextSize; - use super::finish_union_member_inference; + use super::{finish_union_member_inference, normalize_uninformative_member_union}; use crate::{Emmyrc, FileId, InferFailReason, LuaDeclId, LuaType, VirtualWorkspace}; + #[test] + fn inferred_member_union_collapses_only_uninformative_arms() { + let nullable_any = + LuaType::from_vec_structural(vec![LuaType::Nil, LuaType::Unknown, LuaType::Any]); + assert_eq!( + normalize_uninformative_member_union(nullable_any), + LuaType::from_vec(vec![LuaType::Any, LuaType::Nil]) + ); + + let any = LuaType::from_vec_structural(vec![LuaType::Unknown, LuaType::Any]); + assert_eq!(normalize_uninformative_member_union(any), LuaType::Any); + + let informative = LuaType::from_vec_structural(vec![LuaType::String, LuaType::Any]); + assert_eq!( + normalize_uninformative_member_union(informative.clone()), + informative + ); + + assert_eq!( + finish_union_member_inference(vec![LuaType::Unknown, LuaType::Any], true, None,), + Ok(LuaType::from_vec(vec![LuaType::Any, LuaType::Nil])) + ); + } + #[test] fn successful_union_arm_does_not_materialize_deferred_failure_as_nil() { let result = finish_union_member_inference( 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..fff56d59a 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, + 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, @@ -127,31 +128,12 @@ pub fn infer_name_expr( LuaExpr::NameExpr(name_expr.clone()), var_ref_id, ); - if decl_id.is_none() { - if let Ok(ref typ) = narrow_res { - let should_fallback = - typ.is_nil() || matches!(typ, LuaType::TableConst(_)); - if should_fallback { - if let Ok(global_type) = infer_global_type( - db, - Some(file_id), - Some(name_expr.get_position()), - name, - ) { - if global_type.is_custom_type() - || (!global_type.is_nil() - && !global_type.is_nullable() - && !global_type.is_unknown() - && !matches!( - global_type, - LuaType::Any | LuaType::Never - )) - { - return Ok(global_type); - } - } - } - } + if decl_id.is_none() + && let Ok(typ) = &narrow_res + && let Some(global_type) = + positional_global_table_fallback(db, file_id, &name_expr, typ) + { + return Ok(global_type); } narrow_res.or_else(|_| { infer_global_type(db, Some(file_id), Some(name_expr.get_position()), name) @@ -241,27 +223,11 @@ fn infer_local_decl_name_type( .get_decl(&decl_id) .is_some_and(|decl| decl.is_global()); - if is_global { - if let Ok(ref typ) = result { - let should_fallback = typ.is_nil() || matches!(typ, LuaType::TableConst(_)); - if should_fallback { - if let Some(name_token) = name_expr.get_name_token() { - let name = name_token.get_name_text(); - if let Ok(global_type) = - infer_global_type(db, Some(file_id), Some(name_expr.get_position()), name) - { - if global_type.is_custom_type() - || (!global_type.is_nil() - && !global_type.is_nullable() - && !global_type.is_unknown() - && !matches!(global_type, LuaType::Any | LuaType::Never)) - { - return Ok(global_type); - } - } - } - } - } + if is_global + && let Ok(typ) = &result + && let Some(global_type) = positional_global_table_fallback(db, file_id, name_expr, typ) + { + return Ok(global_type); } if let Ok(typ) = &result @@ -273,12 +239,270 @@ fn infer_local_decl_name_type( name_expr.get_position(), ) { + if is_global + && let Some(global_type) = + positional_global_table_fallback(db, file_id, name_expr, &initializer_type) + { + return Ok(global_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 } +fn positional_global_table_fallback( + db: &DbIndex, + file_id: FileId, + name_expr: &LuaNameExpr, + typ: &LuaType, +) -> Option { + if !typ.is_nil() && !matches!(typ, LuaType::TableConst(_) | LuaType::MergedTable(_)) { + return None; + } + let name_token = name_expr.get_name_token()?; + let name = name_token.get_name_text(); + let global_type = + infer_global_type(db, Some(file_id), Some(name_expr.get_position()), name).ok()?; + (global_type.is_custom_type() + || (!global_type.is_nil() + && !global_type.is_nullable() + && !global_type.is_unknown() + && !matches!(global_type, LuaType::Any | LuaType::Never))) + .then_some(global_type) +} + +/// 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 and the body itself must have no +/// control flow that skips or exits elements, 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 !is_global_ipairs_call(db, cache, file_id, iter_call) { + return None; + } + + let body = for_range.get_block()?; + if loop_body_has_partial_coverage_risk(&body) { + 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 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) +} + +/// Matches a bare `ipairs(...)` call that really resolves to the global builtin: +/// an `_G.ipairs(...)` index call, a local/param shadow, or a user-workspace +/// global redefinition of `ipairs` all fail, so their loops never get the +/// transform. The global must resolve to a declaration in the std or library +/// workspace; unresolved names and main/remote definitions are rejected. +fn is_global_ipairs_call( + db: &DbIndex, + cache: &mut LuaInferCache, + file_id: FileId, + call: &LuaCallExpr, +) -> bool { + let Some(LuaExpr::NameExpr(name_expr)) = call.get_prefix_expr() else { + return false; + }; + if name_expr.get_name_text().as_deref() != Some("ipairs") { + return false; + } + + if db + .get_reference_index() + .get_local_reference(&file_id) + .and_then(|file_ref| file_ref.get_decl_id(&name_expr.get_range())) + .is_some() + { + return false; + } + + match resolve_global_decl_id(db, cache, "ipairs", Some(&name_expr)) { + None => false, + Some(decl_id) => db + .get_module_index() + .get_workspace_id(decl_id.file_id) + .is_some_and(|workspace_id| { + let module_index = db.get_module_index(); + module_index.is_std_workspace_id(workspace_id) + || module_index.is_library_workspace_id(workspace_id) + }), + } +} + +/// Whether the loop body contains control flow that can skip or exit elements, +/// which would leave parts of the array unrewritten by the transform's +/// unconditional element write. Mirrors the rejected-control-flow scan in the +/// numeric range population analyzer: nested closures are transparent, since +/// their control flow never escapes the enclosing loop. +fn loop_body_has_partial_coverage_risk(body: &LuaBlock) -> bool { + for stat in body.syntax().descendants().filter_map(LuaStat::cast) { + if is_node_in_nested_closure(stat.syntax(), body.syntax()) { + continue; + } + match stat { + LuaStat::BreakStat(_) + | LuaStat::ReturnStat(_) + | LuaStat::GotoStat(_) + | LuaStat::LabelStat(_) + | LuaStat::IfStat(_) + | LuaStat::WhileStat(_) + | LuaStat::RepeatStat(_) + | LuaStat::ForStat(_) + | LuaStat::ForRangeStat(_) => return true, + _ => {} + } + } + false +} + +fn is_node_in_nested_closure(node: &LuaSyntaxNode, boundary: &LuaSyntaxNode) -> bool { + node.ancestors() + .take_while(|ancestor| ancestor != boundary) + .any(|ancestor| LuaClosureExpr::can_cast(ancestor.kind().into())) +} + +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, @@ -423,72 +647,9 @@ fn has_local_reassignment_between( return false; } - if !cache.local_reassignments_indexed { - collect_local_reassignment_positions(db, cache); - } - - cache - .local_reassignment_positions_cache - .get(&decl_id) - .and_then(|positions| positions.first()) - .is_some_and(|position| *position < query_position) -} - -fn collect_local_reassignment_positions(db: &DbIndex, cache: &mut LuaInferCache) { - cache.local_reassignments_indexed = true; - let file_id = cache.get_file_id(); - let Some(root) = db - .get_vfs() - .get_syntax_tree(&file_id) - .map(|tree| tree.get_red_root()) - else { - return; - }; - - let references = db.get_reference_index().get_local_reference(&file_id); - let decl_tree = db.get_decl_index().get_decl_tree(&file_id); - for assign_stat in root.descendants().filter_map(LuaAssignStat::cast) { - let position = assign_stat.get_position(); - - let (vars, _) = assign_stat.get_var_and_expr_list(); - for var in vars { - let LuaVarExpr::NameExpr(name_expr) = var else { - continue; - }; - - let assigned_decl_id = references - .and_then(|refs| refs.get_decl_id(&name_expr.get_range())) - .or_else(|| assignment_name_decl_id(decl_tree, &name_expr)); - let Some(assigned_decl_id) = assigned_decl_id else { - continue; - }; - if assigned_decl_id.file_id != file_id || position <= assigned_decl_id.position { - continue; - } - - cache - .local_reassignment_positions_cache - .entry(assigned_decl_id) - .or_default() - .push(position); - } - } - - for positions in cache.local_reassignment_positions_cache.values_mut() { - positions.sort_unstable(); - positions.dedup(); - } -} - -fn assignment_name_decl_id( - decl_tree: Option<&crate::LuaDeclarationTree>, - name_expr: &LuaNameExpr, -) -> Option { - let name = name_expr.get_name_text()?; - - decl_tree - .and_then(|tree| tree.find_local_decl(&name, name_expr.get_position())) - .map(|decl| decl.get_id()) + db.get_reference_index() + .first_local_reassignment(cache.get_file_id(), &decl_id) + .is_some_and(|position| position < query_position) } fn try_infer_local_initializer_type( @@ -551,9 +712,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 @@ -568,6 +738,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, @@ -2819,11 +2995,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_global_type, 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, @@ -2890,6 +3066,72 @@ mod test { .expect("expected closure") } + #[test] + fn merged_global_table_falls_back_to_the_reader_realm() { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + let file_ids = ws.def_files(vec![ + ( + "lua/autorun/client/dispatch_writer.lua", + "areas = { client = true }", + ), + ( + "lua/autorun/server/dispatch_writer.lua", + "areas = { server = true }", + ), + ( + "lua/autorun/client/dispatch_reader.lua", + "local AllAreas = areas", + ), + ]); + let file_id = |suffix: &str| { + file_ids + .iter() + .copied() + .find(|file_id| { + ws.analysis + .compilation + .get_db() + .get_vfs() + .get_file_path(file_id) + .is_some_and(|path| path.ends_with(suffix)) + }) + .expect("fixture file") + }; + let client_file_id = file_id("lua/autorun/client/dispatch_writer.lua"); + let reader_file_id = file_id("lua/autorun/client/dispatch_reader.lua"); + let model = ws + .analysis + .compilation + .get_semantic_model(reader_file_id) + .expect("reader semantic model"); + let areas = model + .get_root() + .descendants::() + .find(|expr| expr.get_name_text().as_deref() == Some("areas")) + .expect("areas read"); + let mut cache = LuaInferCache::new(reader_file_id, Default::default()); + + let direct = infer_global_type( + model.get_db(), + Some(reader_file_id), + Some(areas.get_position()), + "areas", + ) + .expect("positional global type"); + assert!( + matches!(&direct, LuaType::TableConst(table) if table.file_id == client_file_id), + "expected positional lookup to return the client table, got {direct:?}" + ); + + match infer_name_expr(model.get_db(), &mut cache, areas).expect("areas type") { + LuaType::TableConst(table) => assert_eq!(table.file_id, client_file_id), + other => panic!("expected the client table, got {other:?}"), + } + } + fn find_closure_signature_ids( ws: &VirtualWorkspace, file_id: crate::FileId, @@ -2947,6 +3189,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(); @@ -3308,6 +3647,7 @@ mod test { .to_string(), ), ) + .map(|(id, _)| id) .expect("file id must be present"); let initial_signature_id = find_first_closure_signature_id(&ws, file_id); @@ -3319,18 +3659,20 @@ mod test { .local_func_decl_for(&initial_signature_id) .expect("expected initial local function decl mapping"); - ws.analysis.update_file_by_uri( - &uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &uri, + Some( + r#" -- shift signature position to verify stale map cleanup local function replacement(value) return value end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); let updated_signature_id = find_first_closure_signature_id(&ws, file_id); let db = ws.analysis.compilation.get_db(); diff --git a/crates/glua_code_analysis/src/semantic/infer/mod.rs b/crates/glua_code_analysis/src/semantic/infer/mod.rs index 436dd66f7..9323d4846 100644 --- a/crates/glua_code_analysis/src/semantic/infer/mod.rs +++ b/crates/glua_code_analysis/src/semantic/infer/mod.rs @@ -23,8 +23,10 @@ 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::pairs_iter_value_registry_path; pub(crate) use infer_index::resolve_decl_backed_global_path_member_type; pub(crate) use infer_name::find_self_ref_id; pub(crate) use infer_name::infer_authoritative_method_self_type; @@ -45,7 +47,7 @@ use rowan::TextRange; use smol_str::SmolStr; use crate::{ - InFiled, InferGuard, LuaMemberKey, VariadicType, + InFiled, InferGuard, LuaMemberKey, VariadicType, analysis_stack_exhausted, db_index::{DbIndex, LuaOperator, LuaOperatorMetaMethod, LuaSignatureId, LuaType}, }; @@ -97,6 +99,12 @@ pub fn infer_expr(db: &DbIndex, cache: &mut LuaInferCache, expr: LuaExpr) -> Inf return Ok(bind_type_cache.as_type().clone()); } + // The reserve check sits below the cache fast paths: cache hits return + // without recursing, so they need no stack. + if analysis_stack_exhausted() { + return Err(InferFailReason::RecursiveInfer); + } + cache.expr_cache.insert(key, CacheEntry::Ready); let result_type = match expr { @@ -346,6 +354,51 @@ where break; } + // 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(_))) => + { + let arms = union + .types() + .filter_map(|typ| match typ { + LuaType::Variadic(variadic) => Some(variadic.clone()), + _ => 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(_) if var_count.is_some() => usize::MAX, + VariadicType::Base(_) => 1, + }) + .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()) 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( diff --git a/crates/glua_code_analysis/src/semantic/infer/narrow/condition_flow/index_flow.rs b/crates/glua_code_analysis/src/semantic/infer/narrow/condition_flow/index_flow.rs index c728260e1..e34b88338 100644 --- a/crates/glua_code_analysis/src/semantic/infer/narrow/condition_flow/index_flow.rs +++ b/crates/glua_code_analysis/src/semantic/infer/narrow/condition_flow/index_flow.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use rustc_hash::FxHashSet; use glua_parser::{LuaAstNode, LuaChunk, LuaExpr, LuaIndexExpr, LuaIndexMemberExpr}; @@ -274,7 +274,7 @@ fn collect_field_exist_narrow_candidates( // open bases must not fan out into every scripted subclass. let reverse_owners = collect_reverse_member_owner_types(db, member_key); let mut candidates = Vec::new(); - let mut seen = HashSet::new(); + let mut seen = FxHashSet::default(); for owner in reverse_owners { if !owner_fits_antecedent(db, &owner, &antecedent_arms) { @@ -345,7 +345,7 @@ fn collect_field_exist_narrow_candidates( fn collect_antecedent_nominal_arms(left_type: &LuaType) -> Vec { let mut arms = Vec::new(); - let mut seen = HashSet::new(); + let mut seen = FxHashSet::default(); collect_antecedent_nominal_arms_into(left_type, &mut arms, &mut seen); arms } @@ -353,7 +353,7 @@ fn collect_antecedent_nominal_arms(left_type: &LuaType) -> Vec { fn collect_antecedent_nominal_arms_into( left_type: &LuaType, arms: &mut Vec, - seen: &mut HashSet, + seen: &mut FxHashSet, ) { match left_type { LuaType::Union(union_type) => { @@ -375,7 +375,7 @@ fn collect_antecedent_nominal_arms_into( fn collect_reverse_member_owner_types(db: &DbIndex, member_key: &LuaMemberKey) -> Vec { let mut owners = Vec::new(); - let mut seen = HashSet::new(); + let mut seen = FxHashSet::default(); for member in db .get_member_index() .get_current_members_for_key(member_key) @@ -482,7 +482,7 @@ fn expand_surviving_subtypes_for_falsy_overrides( } let mut expanded = Vec::new(); - let mut seen = HashSet::new(); + let mut seen = FxHashSet::default(); for candidate in &candidates { if falsy_override_parents.iter().any(|p| p == candidate) { continue; diff --git a/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs b/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs index 470b7c117..bde0d8e95 100644 --- a/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs +++ b/crates/glua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs @@ -1883,12 +1883,24 @@ fn get_type_at_assign_stat( } if maybe_ref_id != *var_ref_id { - if var_ref_id.start_with(&maybe_ref_id) - && let Some(expr_type) = infer_expr_list_value_type_at(db, cache, &exprs, i)? - && let Some(member_type) = - assigned_prefix_member_type(db, cache, var_ref_id, &maybe_ref_id, &expr_type)? - { - return Ok(ResultTypeOrContinue::Result(member_type)); + if var_ref_id.start_with(&maybe_ref_id) { + let expr_type = infer_expr_list_value_type_at(db, cache, &exprs, i)?; + if let Some(expr_type) = expr_type + && let Some(member_type) = assigned_prefix_member_type( + db, + cache, + var_ref_id, + &maybe_ref_id, + &expr_type, + )? + { + return Ok(ResultTypeOrContinue::Result(member_type)); + } + if exprs.get(i).is_some_and(expr_is_table_constructor) { + return Ok(ResultTypeOrContinue::Result(get_var_ref_type( + db, cache, var_ref_id, + )?)); + } } continue; @@ -2728,13 +2740,13 @@ fn numeric_table_index_query_from_decl_initializer( if initializer.get_ret_idx() != 0 { return None; } - let expr = initializer - .get_expr_syntax_id() - .to_node_from_root(root.syntax()) - .and_then(LuaExpr::cast)?; - let LuaExpr::IndexExpr(index_expr) = expr else { + let expr_id = initializer.get_expr_syntax_id(); + if !LuaIndexExpr::can_cast(expr_id.get_kind()) { return None; - }; + } + let index_expr = expr_id + .to_node_from_root(root.syntax()) + .and_then(LuaIndexExpr::cast)?; let access_index = index_expr_numeric_key_value(db, cache, &index_expr)?; let prefix_expr = index_expr.get_prefix_expr()?; let query_root = index_expr_root_id(db, cache, prefix_expr)?; @@ -2903,13 +2915,13 @@ fn numeric_table_index_query_key_name_from_initializer( if initializer.get_ret_idx() != 0 { return None; } - let expr = initializer - .get_expr_syntax_id() - .to_node_from_root(root.syntax()) - .and_then(LuaExpr::cast)?; - let LuaExpr::IndexExpr(index_expr) = expr else { + let expr_id = initializer.get_expr_syntax_id(); + if !LuaIndexExpr::can_cast(expr_id.get_kind()) { return None; - }; + } + let index_expr = expr_id + .to_node_from_root(root.syntax()) + .and_then(LuaIndexExpr::cast)?; let LuaIndexKey::Expr(LuaExpr::NameExpr(name_expr)) = index_expr.get_index_key()? else { return None; }; @@ -3300,10 +3312,14 @@ fn assignment_flow_info_cannot_match( return false; } - !info - .index_paths - .iter() - .any(|path| path.deref().as_str() == query_path.deref().as_str()) + !info.index_paths.iter().any(|path| { + let path = path.deref().as_str(); + let query_path = query_path.deref().as_str(); + query_path == path + || query_path + .strip_prefix(path) + .is_some_and(|rest| rest.starts_with('.')) + }) } fn maybe_get_collection_append_assignment_type( @@ -4057,7 +4073,7 @@ mod tests { let root = parser.get_chunk_node(); let db = DbIndex::new(); let flow_tree = FlowTree::new( - HashMap::new(), + HashMap::default(), vec![ FlowNode { id: FlowId(0), @@ -4076,8 +4092,8 @@ mod tests { }, ], vec![vec![FlowId(0), FlowId(1)]], - HashMap::new(), - HashMap::new(), + HashMap::default(), + HashMap::default(), vec![AssignmentFlowInfo::default(); 3], FileNarrowingCapability::default(), ); @@ -4158,7 +4174,7 @@ mod tests { .expect("condition name expression"); let db = DbIndex::new(); let flow_tree = FlowTree::new( - HashMap::new(), + HashMap::default(), vec![ FlowNode { id: FlowId(0), @@ -4172,8 +4188,8 @@ mod tests { }, ], Vec::new(), - HashMap::new(), - HashMap::new(), + HashMap::default(), + HashMap::default(), vec![AssignmentFlowInfo::default(); 2], FileNarrowingCapability::default(), ); diff --git a/crates/glua_code_analysis/src/semantic/infer/test.rs b/crates/glua_code_analysis/src/semantic/infer/test.rs index 90d5ff817..4eb85e185 100644 --- a/crates/glua_code_analysis/src/semantic/infer/test.rs +++ b/crates/glua_code_analysis/src/semantic/infer/test.rs @@ -159,10 +159,11 @@ mod test { ws.analysis.add_library_workspace(library_root.clone()); let library_uri = lsp_types::Uri::parse_from_file_path(&library_root.join("isvalid.lua")).unwrap(); - ws.analysis.update_file_by_uri( - &library_uri, - Some( - r#" + ws.analysis + .update_file_by_uri( + &library_uri, + Some( + r#" ---@class Entity ---@field health integer @@ -170,9 +171,10 @@ mod test { ---@return TypeGuard function _G.IsValid(obj) end "# - .to_string(), - ), - ); + .to_string(), + ), + ) + .map(|(id, _)| id); // Cached aliases of the global helper should still narrow. assert!(ws.check_code_for( @@ -836,7 +838,7 @@ mod test { "value", ); - assert_eq!(ty, ws.ty("boolean|unknown")); + assert_eq!(ty, ws.ty("boolean|any")); } #[test] @@ -1311,6 +1313,7 @@ mod test { &missing_key, file_id, None, + None, ); assert!( matches!(missing_lookup, Err(InferFailReason::FieldNotFound)), @@ -1324,6 +1327,7 @@ mod test { &existing_key, file_id, None, + None, ); assert!( matches!(existing_lookup, Ok(LuaType::Signature(_))), @@ -1591,4 +1595,123 @@ mod test { assert_eq!(ws.expr_ty("cycleValue.missingField"), LuaType::Nil); } + + #[test] + fn test_dynamic_read_unions_exact_dynamic_writer_with_literal_keyed_siblings() { + let mut ws = VirtualWorkspace::new(); + ws.def_file( + "shared/sh_property.lua", + r#" + property = property or {} + property.owner = property.owner or {} + "#, + ); + ws.def_file( + "shared/map_a.lua", + r#" + property.owner[1] = "Public Area" + property.owner[2] = "Owned by the Government" + "#, + ); + ws.def_file( + "server/sv_buy.lua", + r#" + local function buy(propertynumber) + local uniqueID = 1234 + property.owner[propertynumber] = uniqueID + end + "#, + ); + let reader = ws.def_file( + "shared/sh_reader.lua", + r#" + local function sync(propertynumber) + local owner = property.owner[propertynumber] + end + "#, + ); + + let ty = + infer_index_expr_type_by_text_in_file(&ws, reader, "property.owner[propertynumber]"); + let LuaType::Union(union) = &ty else { + panic!("expected a union of every writer, got {ty:?}"); + }; + let arms = union.types().cloned().collect::>(); + assert!( + arms.iter() + .any(|arm| matches!(arm, LuaType::String | LuaType::StringConst(_))), + "missing the literal-keyed string writers: {ty:?}" + ); + assert!( + arms.iter() + .any(|arm| matches!(arm, LuaType::Integer | LuaType::IntegerConst(_))), + "missing the dynamic-keyed integer writer: {ty:?}" + ); + } + + #[test] + fn test_member_bound_from_sibling_merge_takes_the_settled_writer_set() { + let mut ws = VirtualWorkspace::new(); + let file_ids = ws.def_files(vec![ + ( + "shared/sh_property.lua", + r#" + property = property or {} + property.owner = property.owner or {} + "#, + ), + ( + "shared/map_a.lua", + r#" + property.owner[1] = "Public Area" + "#, + ), + ( + "server/sv_buy.lua", + r#" + local function buy(propertynumber) + local uniqueID = 1234 + property.owner[propertynumber] = uniqueID + end + "#, + ), + ( + "shared/sh_reader.lua", + r#" + local function sync(propertynumber) + local access = { owner = property.owner[propertynumber] } + end + "#, + ), + ]); + let reader = *file_ids.last().expect("reader file"); + + let db = ws.analysis.compilation.get_db(); + let member = db + .get_member_index() + .get_file_members(reader) + .into_iter() + .find(|member| member.get_key() == &LuaMemberKey::Name("owner".into())) + .expect("the table field member"); + let cached = db + .get_type_index() + .get_type_cache(&member.get_id().into()) + .expect("the table field cache") + .as_type() + .clone(); + let LuaType::Union(union) = &cached else { + panic!("expected every writer in the settled cache, got {cached:?}"); + }; + let arms = union.types().cloned().collect::>(); + assert!( + arms.iter() + .any(|arm| matches!(arm, LuaType::String | LuaType::StringConst(_))), + "missing the literal-keyed string writer: {cached:?}" + ); + assert!( + arms.iter() + .any(|arm| matches!(arm, LuaType::Integer | LuaType::IntegerConst(_))), + "missing the computed-key integer writer attached after the walk: {cached:?}" + ); + } } diff --git a/crates/glua_code_analysis/src/semantic/member/find_index.rs b/crates/glua_code_analysis/src/semantic/member/find_index.rs index e90c0c45b..005e0226f 100644 --- a/crates/glua_code_analysis/src/semantic/member/find_index.rs +++ b/crates/glua_code_analysis/src/semantic/member/find_index.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use crate::{ DbIndex, InFiled, InferGuardRef, LuaGenericType, LuaIntersectionType, LuaMemberKey, @@ -270,7 +270,7 @@ fn find_index_intersection( infer_guard: &InferGuardRef, ) -> FindMembersResult { let mut order: Vec = Vec::new(); - let mut members: HashMap = HashMap::new(); + let mut members: HashMap = HashMap::default(); for member in intersection.get_types() { let Some(sub_members) = find_index_operations_guard(db, member, infer_guard) else { @@ -278,7 +278,7 @@ fn find_index_intersection( }; // Within a single component type, treat duplicate keys as overrides (first wins). - let mut component_seen: HashSet = HashSet::new(); + let mut component_seen: HashSet = HashSet::default(); for member in sub_members { if !component_seen.insert(member.key.clone()) { continue; 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..f537a191f 100644 --- a/crates/glua_code_analysis/src/semantic/member/find_members.rs +++ b/crates/glua_code_analysis/src/semantic/member/find_members.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use rowan::TextSize; use smol_str::SmolStr; @@ -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( @@ -523,7 +548,7 @@ fn super_types_for_context<'a>( ) }, ); - let mut seen_super_types = HashSet::new(); + let mut seen_super_types = HashSet::default(); visible.retain(|(_, _, _, _, super_type)| seen_super_types.insert(*super_type)); Some( @@ -602,7 +627,7 @@ fn find_workspace_scoped_owner_members( match filter { FindMemberFilter::All => { let owner_members = member_index.get_members(owner)?; - let mut seen = HashSet::new(); + let mut seen = HashSet::default(); let mut members = Vec::new(); for member in owner_members { @@ -775,7 +800,7 @@ fn find_intersection_members( filter: &FindMemberFilter, ) -> FindMembersResult { let mut order: Vec = Vec::new(); - let mut members: HashMap = HashMap::new(); + let mut members: HashMap = HashMap::default(); for typ in intersection_type.get_types().iter() { let instantiated_type = ctx.instantiate_type(db, typ); @@ -786,7 +811,7 @@ fn find_intersection_members( }; // Within a single component type, treat duplicate keys as overrides (first wins). - let mut component_seen: HashSet = HashSet::new(); + let mut component_seen: HashSet = HashSet::default(); for member in sub_members { if !component_seen.insert(member.key.clone()) { continue; @@ -846,7 +871,7 @@ fn find_merged_table_members( filter: &FindMemberFilter, ) -> FindMembersResult { let mut order: Vec = Vec::new(); - let mut members: HashMap = HashMap::new(); + let mut members: HashMap = HashMap::default(); for typ in merged_table.get_types().iter() { let instantiated_type = ctx.instantiate_type(db, typ); @@ -862,7 +887,7 @@ fn find_merged_table_members( // table then disagrees with the union every other reader of that slot // gets from `resolve_member_item_type`. Union within the component, // then merge components as table fragments below. - let mut component_members: HashMap = HashMap::new(); + let mut component_members: HashMap = HashMap::default(); let mut component_order: Vec = Vec::new(); for member in sub_members { match component_members.entry(member.key.clone()) { @@ -888,9 +913,14 @@ fn find_merged_table_members( entry.insert(member); } std::collections::hash_map::Entry::Occupied(mut entry) => { - let merged_type = - merge_open_table_types(db, vec![entry.get().typ.clone(), member.typ]); - entry.get_mut().typ = merged_type; + // Components that are one slot answer identically; merging + // that answer with itself would fold a union of rival + // tables into one table. + if entry.get().typ != member.typ { + let merged_type = + merge_open_table_types(db, vec![entry.get().typ.clone(), member.typ]); + entry.get_mut().typ = merged_type; + } } } } @@ -1127,9 +1157,10 @@ 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); + let member_key = LuaMemberKey::Name(field_name.clone()); if !should_include_member(&member_key, filter) { continue; } @@ -1138,6 +1169,12 @@ fn append_dynamic_fields_for_type( continue; } + if let Some(site) = &access_site + && !dynamic_field_visible_at_offset(db, &owner, &field_name, site) + { + continue; + } + let resolved = ctx.file_id().and_then(|file_id| { resolve_dynamic_field_member_for_file(db, file_id, &prefix_type, &member_key) }); @@ -1172,7 +1209,10 @@ fn append_dynamic_fields_for_table( return false; } - let owner = crate::DynamicFieldOwner::Table(table_range.clone()); + let owner = crate::canonical_dynamic_field_owner( + db, + crate::DynamicFieldOwner::Table(table_range.clone()), + ); let prefix_type = LuaType::TableConst(table_range.clone()); if let Some(should_stop) = append_keyed_dynamic_field( db, @@ -1206,9 +1246,10 @@ 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); + let member_key = LuaMemberKey::Name(field_name.clone()); if !should_include_member(&member_key, filter) { continue; } @@ -1217,6 +1258,12 @@ fn append_dynamic_fields_for_table( continue; } + if let Some(site) = &access_site + && !dynamic_field_visible_at_offset(db, &owner, &field_name, site) + { + 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 +1286,50 @@ fn append_dynamic_fields_for_table( false } +/// 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. +/// +/// 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, + site: &DynamicFieldAccessSite, +) -> bool { + let definitions = db + .get_dynamic_field_index() + .field_definitions(owner, field_name); + if definitions.is_empty() { + return true; + } + + let member_index = db.get_member_index(); + definitions.iter().any(|definition| { + if definition.file_id != site.file_id || definition.value.start() <= site.position { + return true; + } + // 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 + // 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() + }) +} + fn append_keyed_dynamic_field( db: &DbIndex, ctx: &FindMembersContext, @@ -1270,6 +1361,11 @@ fn append_keyed_dynamic_field( if !is_visible { return Some(false); } + if let Some(site) = ctx.dynamic_field_access_site(db) + && !dynamic_field_visible_at_offset(db, owner, field_name, &site) + { + 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/member/get_member_map.rs b/crates/glua_code_analysis/src/semantic/member/get_member_map.rs index 095061796..dd03c37ea 100644 --- a/crates/glua_code_analysis/src/semantic/member/get_member_map.rs +++ b/crates/glua_code_analysis/src/semantic/member/get_member_map.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use rustc_hash::FxHashMap; use crate::{DbIndex, FileId, LuaMemberKey, LuaType, WorkspaceId}; @@ -10,7 +10,7 @@ use super::{ pub fn get_member_map( db: &DbIndex, prefix_type: &LuaType, -) -> Option>> { +) -> Option>> { let members = find_members::find_members(db, prefix_type)?; build_member_map(members) } @@ -20,7 +20,7 @@ pub fn get_member_map_in_workspace_for_file( prefix_type: &LuaType, workspace_id: WorkspaceId, file_id: FileId, -) -> Option>> { +) -> Option>> { let members = find_members::find_members_in_workspace_for_file(db, prefix_type, workspace_id, file_id)?; build_member_map(members) @@ -32,7 +32,7 @@ pub fn get_member_map_in_workspace_for_file_at_offset( workspace_id: WorkspaceId, file_id: FileId, caller_position: rowan::TextSize, -) -> Option>> { +) -> Option>> { let members = find_members::find_members_in_workspace_for_file_at_offset( db, prefix_type, @@ -45,8 +45,8 @@ pub fn get_member_map_in_workspace_for_file_at_offset( fn build_member_map( members: Vec, -) -> Option>> { - let mut member_map = HashMap::new(); +) -> Option>> { + let mut member_map = FxHashMap::default(); for member in members { let key = member.key.clone(); let typ = &member.typ; diff --git a/crates/glua_code_analysis/src/semantic/member/infer_raw_member.rs b/crates/glua_code_analysis/src/semantic/member/infer_raw_member.rs index 05f30bb33..020e249be 100644 --- a/crates/glua_code_analysis/src/semantic/member/infer_raw_member.rs +++ b/crates/glua_code_analysis/src/semantic/member/infer_raw_member.rs @@ -4,7 +4,7 @@ use rowan::TextSize; use smol_str::SmolStr; use crate::{ - DbIndex, FileId, GlobalId, InferFailReason, InferGuard, InferGuardRef, LuaGenericType, + DbIndex, FileId, GlobalId, InFiled, InferFailReason, InferGuard, InferGuardRef, LuaGenericType, LuaMemberIndexItem, LuaMemberKey, LuaMemberOwner, LuaMergedTableType, LuaObjectType, LuaTupleType, LuaType, LuaTypeDeclId, TypeOps, check_type_compact, semantic::{ @@ -214,12 +214,51 @@ pub(crate) fn infer_owner_raw_member_type_with_realm( member_key: &LuaMemberKey, caller_file_id: FileId, caller_position: Option, + source_min_position: Option>, ) -> RawGetMemberTypeResult { - if let Some(member_item) = db - .get_member_index() - .get_member_item(&member_owner, member_key) - { - return resolve_member_item_with_realm(db, member_item, caller_file_id, caller_position); + let member_index = db.get_member_index(); + if let Some(member_item) = member_index.get_member_item(&member_owner, member_key) { + let exact_type = resolve_member_item_with_realm( + db, + member_item, + caller_file_id, + caller_position, + source_min_position.as_ref(), + )?; + // A dynamic write answers a dynamic read, but it is not evidence that + // the integer entries beside it are absent: `t[n]` on a table filled + // by both `t[1] = "a"` and `t[k] = 5` holds either, since a literal + // index and a computed one address the same entries. Named fields do + // not join: beside a computed write they are the table's own schema, + // and a key computed at runtime is the registry entry, not a field. + let LuaMemberKey::ExprType(access_key_type) = member_key else { + return Ok(exact_type); + }; + let mut integer_keys = member_index + .get_member_keys(&member_owner) + .filter(|key| { + matches!(key, LuaMemberKey::Integer(_)) + && member_key_matches_type(db, access_key_type, key) + }) + .collect::>(); + integer_keys.sort(); + let result_type = integer_keys + .into_iter() + .filter_map(|key| member_index.get_member_item(&member_owner, key)) + .filter_map(|item| { + resolve_member_item_with_realm( + db, + item, + caller_file_id, + caller_position, + source_min_position.as_ref(), + ) + .ok() + }) + .fold(exact_type, |acc, member_type| { + TypeOps::Union.apply(db, &acc, &member_type) + }); + return Ok(result_type); } let Some(access_key_type) = member_key_as_type(member_key) else { @@ -229,7 +268,6 @@ pub(crate) fn infer_owner_raw_member_type_with_realm( // Two literal keys match only when they are equal, which the exact-key // lookup above already covered, so a literal access that reaches here can // only be answered by an expression-keyed member. - let member_index = db.get_member_index(); let owner_members = if matches!(member_key, LuaMemberKey::Name(_) | LuaMemberKey::Integer(_)) { member_index.get_expr_key_members(&member_owner) } else { @@ -249,9 +287,13 @@ pub(crate) fn infer_owner_raw_member_type_with_realm( } let member_item = LuaMemberIndexItem::One(member.get_id()); - if let Ok(member_type) = - resolve_member_item_with_realm(db, &member_item, caller_file_id, caller_position) - { + if let Ok(member_type) = resolve_member_item_with_realm( + db, + &member_item, + caller_file_id, + caller_position, + source_min_position.as_ref(), + ) { saw_match = true; result_type = TypeOps::Union.apply(db, &result_type, &member_type); } @@ -276,7 +318,32 @@ pub(crate) fn resolve_member_item_with_realm( member_item: &LuaMemberIndexItem, caller_file_id: FileId, caller_position: Option, + source_min_position: Option<&InFiled>, ) -> RawGetMemberTypeResult { + if let Some(source_min_position) = source_min_position { + let mut member_ids = if let Some(pos) = caller_position { + member_item.visible_member_ids_with_realm_at_offset(db, &caller_file_id, pos) + } else { + member_item.visible_member_ids_with_realm(db, &caller_file_id) + }; + member_ids.retain(|member_id| { + source_survives_table_generation_cutoff( + db, + member_id.file_id, + member_id.get_position(), + source_min_position, + ) + }); + if member_ids.is_empty() { + return Err(InferFailReason::FieldNotFound); + } + let item = match member_ids.as_slice() { + [member_id] => LuaMemberIndexItem::One(*member_id), + _ => LuaMemberIndexItem::Many(member_ids), + }; + return item.resolve_type(db); + } + if let Some(pos) = caller_position { member_item.resolve_type_with_realm_at_offset(db, &caller_file_id, pos) } else { @@ -284,6 +351,25 @@ pub(crate) fn resolve_member_item_with_realm( } } +pub(crate) fn source_survives_table_generation_cutoff( + db: &DbIndex, + source_file_id: FileId, + source_position: TextSize, + source_min_position: &InFiled, +) -> bool { + if source_file_id != source_min_position.file_id || source_position >= source_min_position.value + { + return true; + } + + let member_index = db.get_member_index(); + let source_function = + member_index.enclosing_function_scope_range(source_file_id, source_position); + let generation_function = member_index + .enclosing_function_scope_range(source_min_position.file_id, source_min_position.value); + source_function.is_some() && source_function != generation_function +} + fn infer_custom_type_raw_member_type( db: &DbIndex, mut cache: Option<&mut LuaInferCache>, diff --git a/crates/glua_code_analysis/src/semantic/member/mod.rs b/crates/glua_code_analysis/src/semantic/member/mod.rs index 72964b82b..0a7788744 100644 --- a/crates/glua_code_analysis/src/semantic/member/mod.rs +++ b/crates/glua_code_analysis/src/semantic/member/mod.rs @@ -3,8 +3,6 @@ mod find_members; mod get_member_map; mod infer_raw_member; -use std::collections::HashSet; - use rustc_hash::FxHashSet; use crate::{ @@ -25,18 +23,19 @@ pub use get_member_map::{ get_member_map_in_workspace_for_file_at_offset, }; use glua_parser::{ - LuaAssignStat, LuaExpr, LuaFuncStat, LuaSyntaxKind, LuaTableExpr, LuaTableField, + LuaAssignStat, LuaExpr, LuaFuncStat, LuaSyntaxKind, LuaSyntaxNode, LuaTableExpr, LuaTableField, }; use glua_parser::{LuaAstNode, LuaIndexExpr}; pub(crate) use infer_raw_member::{ infer_owner_raw_member_type_with_realm, resolve_member_item_with_realm, + source_survives_table_generation_cutoff, }; pub use infer_raw_member::{infer_raw_member_type, infer_raw_member_type_with_cache}; use rowan::{TextRange, TextSize}; use super::{ - InferFailReason, LuaInferCache, SemanticDeclLevel, infer_expr, infer_expr_list_value_type_at, - infer_node_semantic_decl, infer_table_should_be, + DynamicFieldDefinitionSyntax, InferFailReason, LuaInferCache, SemanticDeclLevel, infer_expr, + infer_expr_list_value_type_at, infer_node_semantic_decl, infer_table_should_be, }; pub fn get_buildin_type_map_type_id(type_: &LuaType) -> Option { @@ -73,7 +72,7 @@ pub(crate) fn intersect_member_types(db: &DbIndex, left: LuaType, right: LuaType pub(crate) fn merge_open_table_types(db: &DbIndex, types: Vec) -> LuaType { let mut table_components = Vec::new(); let mut other_types = Vec::new(); - let mut seen = HashSet::new(); + let mut seen = FxHashSet::default(); for typ in types { if typ.is_never() { @@ -81,19 +80,9 @@ pub(crate) fn merge_open_table_types(db: &DbIndex, types: Vec) -> LuaTy } if let LuaType::Union(union) = &typ { - let mut nested_components = Vec::new(); - let mut all_components_are_tables = true; - for component in union.into_vec() { - if is_open_table_merge_component(&component) { - nested_components.push(component); - } else { - all_components_are_tables = false; - break; - } - } - - if all_components_are_tables { - for component in nested_components { + let components = union.into_vec(); + if components.iter().all(is_open_table_merge_component) { + for component in components { if seen.insert(component.clone()) { table_components.push(component); } @@ -158,6 +147,13 @@ pub(crate) fn local_class_table_member_ids( let Some(decl_tree) = db.get_decl_index().get_decl_tree(&location.file_id) else { continue; }; + let Some(root) = db + .get_vfs() + .get_syntax_tree(&location.file_id) + .map(|tree| tree.get_red_root()) + else { + continue; + }; for decl in decl_tree.get_decls().values() { // `local_table_decl_member_owner` opens with these two conditions // and they are field reads, so they run before the index lookups. @@ -171,7 +167,7 @@ pub(crate) fn local_class_table_member_ids( if !decl_binds_type(db, decl, type_id) { continue; } - let Some(owner) = local_table_decl_member_owner(db, decl) else { + let Some(owner) = local_table_decl_member_owner(decl, &root) else { continue; }; let Some(member_item) = member_index.get_member_item(&owner, member_key) else { @@ -230,18 +226,17 @@ fn decl_binds_type(db: &DbIndex, decl: &LuaDecl, type_id: &LuaTypeDeclId) -> boo }) } -fn local_table_decl_member_owner(db: &DbIndex, decl: &LuaDecl) -> Option { +fn local_table_decl_member_owner(decl: &LuaDecl, root: &LuaSyntaxNode) -> Option { let initializer = decl.get_initializer()?; if initializer.get_ret_idx() != 0 { return None; } - let root = db - .get_vfs() - .get_syntax_tree(&decl.get_id().file_id)? - .get_red_root(); - let node = initializer.get_expr_syntax_id().to_node_from_root(&root)?; - let table_expr = LuaTableExpr::cast(node)?; + let expr_id = initializer.get_expr_syntax_id(); + if !LuaTableExpr::can_cast(expr_id.get_kind()) { + return None; + } + let table_expr = LuaTableExpr::cast(expr_id.to_node_from_root(root)?)?; Some(LuaMemberOwner::Element(InFiled::new( decl.get_id().file_id, table_expr.get_range(), @@ -298,6 +293,21 @@ mod tests { assert_eq!(merged_table.get_types(), &[table_of, object]); } + + #[test] + fn merge_open_table_types_keeps_mixed_union_alternatives_distinct() { + let db = DbIndex::new(); + let mut left_fields = BTreeMap::new(); + left_fields.insert(LuaMemberKey::Name("left".into()), LuaType::Integer); + let left = LuaType::Object(LuaObjectType::new_with_fields(left_fields, Vec::new()).into()); + let mut right_fields = BTreeMap::new(); + right_fields.insert(LuaMemberKey::Name("right".into()), LuaType::String); + let right = + LuaType::Object(LuaObjectType::new_with_fields(right_fields, Vec::new()).into()); + let mixed = LuaType::from_vec_structural(vec![left, right, LuaType::BooleanConst(false)]); + + assert_eq!(merge_open_table_types(&db, vec![mixed.clone()]), mixed); + } } #[derive(Debug, Clone)] @@ -313,7 +323,7 @@ enum DynamicFieldDefinitionVisibility { } struct VisibleDynamicFieldDefinition { - location: InFiled, + member_id: Option, visibility: DynamicFieldDefinitionVisibility, } @@ -341,9 +351,11 @@ pub(crate) fn resolve_dynamic_field_member( let Some(field_name) = member_key.get_name() else { return Ok(None); }; + let caller_file_id = cache.get_file_id(); let definitions = dynamic_field_definitions( db, - cache.get_file_id(), + cache, + caller_file_id, prefix_type, field_name, access_position, @@ -366,8 +378,7 @@ pub(crate) fn resolve_dynamic_field_member( let mut semantic_decl = None; let mut has_runtime_type = false; for definition in definitions { - let location = definition.location; - let Some(member_id) = dynamic_field_member_id(db, location.file_id, location.value) else { + let Some(member_id) = definition.member_id else { continue; }; if semantic_decl.is_none() { @@ -481,6 +492,7 @@ fn dynamic_field_definition_cache(cache: &LuaInferCache, file_id: FileId) -> Lua fn dynamic_field_definitions( db: &DbIndex, + cache: &mut LuaInferCache, caller_file_id: FileId, prefix_type: &LuaType, field_name: &str, @@ -489,20 +501,31 @@ fn dynamic_field_definitions( match prefix_type { LuaType::Ref(type_id) | LuaType::Def(type_id) => dynamic_field_definitions_for_owner( db, + cache, caller_file_id, &crate::DynamicFieldOwner::Type(type_id.clone()), field_name, access_position, + None, ), - LuaType::TableConst(table_range) => dynamic_field_definitions_for_owner( - db, - caller_file_id, - &crate::DynamicFieldOwner::Table(table_range.clone()), - field_name, - access_position, - ), + LuaType::TableConst(table_range) => { + let table_owner = crate::DynamicFieldOwner::Table(table_range.clone()); + let owner = crate::canonical_dynamic_field_owner(db, table_owner.clone()); + let source_min_position = (owner != table_owner) + .then(|| InFiled::new(table_range.file_id, table_range.value.start())); + dynamic_field_definitions_for_owner( + db, + cache, + caller_file_id, + &owner, + field_name, + access_position, + source_min_position.as_ref(), + ) + } LuaType::Instance(instance) => dynamic_field_definitions( db, + cache, caller_file_id, instance.get_base(), field_name, @@ -514,10 +537,12 @@ fn dynamic_field_definitions( fn dynamic_field_definitions_for_owner( db: &DbIndex, + cache: &mut LuaInferCache, caller_file_id: FileId, owner: &crate::DynamicFieldOwner, field_name: &str, access_position: Option, + source_min_position: Option<&InFiled>, ) -> Vec { let dynamic_fields_global = db.get_emmyrc().gmod.dynamic_fields_global; let caller_mask = effective_dynamic_field_state_mask(db, caller_file_id, access_position); @@ -525,31 +550,52 @@ fn dynamic_field_definitions_for_owner( db.get_member_index() .enclosing_function_scope_range(caller_file_id, position) }); - db.get_dynamic_field_index() - .get_field_definitions(owner, field_name) - .into_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( + let mut visible = Vec::new(); + // Consulted in the index's canonical order, unfiltered and unsorted: the + // elected arm and the elected semantic decl both follow it. + for definition in db + .get_dynamic_field_index() + .field_definitions(owner, field_name) + { + if source_min_position.is_some_and(|source_min_position| { + !source_survives_table_generation_cutoff( db, - caller_file_id, - &definition, - access_position, - access_function, + definition.file_id, + definition.value.start(), + source_min_position, ) - .map(|visibility| VisibleDynamicFieldDefinition { - location: definition, + }) { + continue; + } + if !(dynamic_fields_global || definition.file_id == caller_file_id) { + continue; + } + if !is_dynamic_field_realm_compatible(db, caller_mask, definition) { + continue; + } + let syntax = dynamic_field_definition_syntax(db, cache, definition); + if let Some(visibility) = dynamic_field_definition_visibility_at( + db, + caller_file_id, + definition, + syntax.enclosing_assign_range, + access_position, + access_function, + ) { + visible.push(VisibleDynamicFieldDefinition { + member_id: syntax.member_id, visibility, - }) - }) - .collect() + }); + } + } + visible } fn dynamic_field_definition_visibility_at( db: &DbIndex, caller_file_id: FileId, definition: &crate::InFiled, + enclosing_assign_range: Option, access_position: Option, access_function: Option, ) -> Option { @@ -559,7 +605,9 @@ fn dynamic_field_definition_visibility_at( if definition.file_id != caller_file_id { return Some(DynamicFieldDefinitionVisibility::Runtime); } - if definition_enclosing_assignment_contains(db, definition, access_position) { + if enclosing_assign_range + .is_some_and(|range| range.contains(access_position) && range != definition.value) + { return None; } @@ -570,6 +618,16 @@ fn dynamic_field_definition_visibility_at( if definition_function != access_function && (definition_function.is_some() || access_function.is_some()) { + // A write in a function that encloses the reading one runs before the + // inner function can be called, so it is at least as good evidence as + // the cross-file write trusted unconditionally above. + if let (Some(definition_function), Some(access_function)) = + (definition_function, access_function) + && definition_function.contains_range(access_function) + && definition.value.start() <= access_position + { + return Some(DynamicFieldDefinitionVisibility::Runtime); + } return Some(DynamicFieldDefinitionVisibility::ShapeOnly); } @@ -577,28 +635,73 @@ fn dynamic_field_definition_visibility_at( .then_some(DynamicFieldDefinitionVisibility::Runtime) } -fn definition_enclosing_assignment_contains( +/// The definition site's member id and enclosing assignment range. +/// +/// Both are read off the definition file's tree alone, so they are memoised +/// per definition rather than re-walked from the root for every access +/// position that consults the same definition. +fn dynamic_field_definition_syntax( db: &DbIndex, + cache: &mut LuaInferCache, definition: &crate::InFiled, - access_position: TextSize, -) -> bool { - let Some(root) = db.get_vfs().get_syntax_tree(&definition.file_id) else { - return false; +) -> DynamicFieldDefinitionSyntax { + if let Some(cached) = cache.dynamic_field_definition_syntax_cache.get(definition) { + return *cached; + } + let syntax = dynamic_field_definition_syntax_uncached(db, definition); + cache + .dynamic_field_definition_syntax_cache + .insert(definition.clone(), syntax); + syntax +} + +fn dynamic_field_definition_syntax_uncached( + db: &DbIndex, + definition: &crate::InFiled, +) -> DynamicFieldDefinitionSyntax { + let mut syntax = DynamicFieldDefinitionSyntax::default(); + let file_id = definition.file_id; + let range = definition.value; + let Some(tree) = db.get_vfs().get_syntax_tree(&file_id) else { + return syntax; }; - let root = root.get_red_root(); - let Some(token) = root - .token_at_offset(definition.value.start()) - .right_biased() - else { - return false; + let root = tree.get_red_root(); + let Some(token) = root.token_at_offset(range.start()).right_biased() else { + return syntax; }; - token - .parent_ancestors() - .find_map(LuaAssignStat::cast) - .is_some_and(|assign_stat| { - let range = assign_stat.get_range(); - range.contains(access_position) && range != definition.value - }) + + let mut current = token.parent(); + while let Some(node) = current { + if syntax.member_id.is_none() { + if let Some(index_expr) = LuaIndexExpr::cast(node.clone()) { + // Legacy: range matches the full index expression. New: range + // matches the index key (field name) within it. + if index_expr.get_range() == range + || index_expr.get_index_key().and_then(|key| key.get_range()) == Some(range) + { + syntax.member_id = Some(LuaMemberId::new(index_expr.get_syntax_id(), file_id)); + } + } + if syntax.member_id.is_none() + && let Some(table_field) = LuaTableField::cast(node.clone()) + && (table_field.get_range() == range + || table_field.get_field_key().and_then(|key| key.get_range()) == Some(range)) + { + syntax.member_id = Some(LuaMemberId::new(table_field.get_syntax_id(), file_id)); + } + } + if syntax.enclosing_assign_range.is_none() + && let Some(assign_stat) = LuaAssignStat::cast(node.clone()) + { + syntax.enclosing_assign_range = Some(assign_stat.get_range()); + } + if syntax.member_id.is_some() && syntax.enclosing_assign_range.is_some() { + break; + } + current = node.parent(); + } + + syntax } fn effective_dynamic_field_state_mask( @@ -637,39 +740,6 @@ fn is_dynamic_field_realm_compatible( )) } -fn dynamic_field_member_id(db: &DbIndex, file_id: FileId, range: TextRange) -> Option { - let root = db.get_vfs().get_syntax_tree(&file_id)?.get_red_root(); - let token = root.token_at_offset(range.start()).right_biased()?; - let mut current = token.parent(); - while let Some(node) = current { - if let Some(index_expr) = LuaIndexExpr::cast(node.clone()) { - // Legacy: range matches the full index expression - if index_expr.get_range() == range { - return Some(LuaMemberId::new(index_expr.get_syntax_id(), file_id)); - } - // New: range matches the index key (field name) within this expression - if let Some(key) = index_expr.get_index_key() { - if key.get_range() == Some(range) { - return Some(LuaMemberId::new(index_expr.get_syntax_id(), file_id)); - } - } - } - if let Some(table_field) = LuaTableField::cast(node.clone()) { - if table_field.get_range() == range { - return Some(LuaMemberId::new(table_field.get_syntax_id(), file_id)); - } - if let Some(key) = table_field.get_field_key() - && key.get_range() == Some(range) - { - return Some(LuaMemberId::new(table_field.get_syntax_id(), file_id)); - } - } - current = node.parent(); - } - - None -} - pub(crate) fn member_key_as_type(key: &LuaMemberKey) -> Option { match key { LuaMemberKey::None => None, @@ -784,7 +854,7 @@ fn find_member_origin_owner_inner( member_id: LuaMemberId, caller_position: Option, ) -> Option { - let mut visited_members = HashSet::new(); + let mut visited_members = FxHashSet::default(); let mut current_owner = resolve_member_owner(db, infer_config, &member_id, caller_position); let mut final_owner = current_owner.clone(); diff --git a/crates/glua_code_analysis/src/semantic/mod.rs b/crates/glua_code_analysis/src/semantic/mod.rs index cb549d54e..cf32c9e0e 100644 --- a/crates/glua_code_analysis/src/semantic/mod.rs +++ b/crates/glua_code_analysis/src/semantic/mod.rs @@ -12,7 +12,7 @@ mod semantic_info; mod type_check; mod visibility; -use std::collections::HashMap; +use rustc_hash::FxHashMap; use std::sync::{Arc, Mutex, MutexGuard}; /// Test-only work counter, re-exported so scaling guards can assert on the @@ -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, DynamicFieldDefinitionSyntax, 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, @@ -39,6 +42,7 @@ pub(crate) use infer::narrow::{InferConditionFlow, cast_type}; pub use infer::narrow::{ explicit_param_string_default_reaches_flow, inferred_string_default_reaches_flow, }; +pub(crate) use infer::pairs_iter_value_registry_path; pub(crate) use infer::resolve_decl_backed_global_path_member_type; use infer::{infer_call_arg_expr_list_types, infer_expr_list_types, infer_expr_list_value_type_at}; pub use infer::{infer_table_field_value_should_be, infer_table_should_be}; @@ -213,17 +217,32 @@ fn declared_call_contract_authority( let Some(member_owner) = db.get_member_index().get_member_owner(&member_id) else { return DeclaredCallContractAuthority::Independent; }; - let expected_receiver_type = match member_owner { - LuaMemberOwner::Type(owner_id) => LuaType::Ref(owner_id.clone()), - LuaMemberOwner::Element(range) => LuaType::TableConst(range.clone()), - _ => return DeclaredCallContractAuthority::Independent, + let member_owner = member_owner.clone(); + let expected_receiver_type = match &member_owner { + LuaMemberOwner::Type(owner_id) => Some(LuaType::Ref(owner_id.clone())), + LuaMemberOwner::Element(range) => Some(LuaType::TableConst(range.clone())), + // A global path's table has no type of its own; the receiver is + // compared to it as an owner below. + LuaMemberOwner::GlobalPath(_) => None, + LuaMemberOwner::LocalUnresolve => return DeclaredCallContractAuthority::Independent, }; let Some(receiver) = index.get_prefix_expr() else { return DeclaredCallContractAuthority::Independent; }; let receiver_fact = infer_expr_fact_with_cache(db, cache, receiver); + let receiver_owner = match receiver_fact.typ() { + LuaType::TableConst(range) => Some(range.clone()), + LuaType::Instance(instance) => Some(instance.get_range().clone()), + _ => None, + } + .map(|range| { + db.get_member_index() + .canonical_owner(LuaMemberOwner::Element(range)) + }); if receiver_fact.confidence() >= LuaInferenceConfidence::Certain - && check_type_compact(db, receiver_fact.typ(), &expected_receiver_type).is_ok() + && (expected_receiver_type.is_some_and(|expected_receiver_type| { + check_type_compact(db, receiver_fact.typ(), &expected_receiver_type).is_ok() + }) || receiver_owner == Some(member_owner)) { DeclaredCallContractAuthority::Independent } else { @@ -440,7 +459,7 @@ impl<'a> SemanticModel<'a> { pub fn get_member_info_map( &self, prefix_type: &LuaType, - ) -> Option>> { + ) -> Option>> { let module_index = self.db.get_module_index(); if let Some(workspace_id) = module_index.get_workspace_id(self.file_id) { return member::get_member_map_in_workspace_for_file( @@ -458,7 +477,7 @@ impl<'a> SemanticModel<'a> { &self, prefix_type: &LuaType, position_offset: rowan::TextSize, - ) -> Option>> { + ) -> Option>> { let module_index = self.db.get_module_index(); if let Some(workspace_id) = module_index.get_workspace_id(self.file_id) { return member::get_member_map_in_workspace_for_file_at_offset( @@ -496,7 +515,7 @@ impl<'a> SemanticModel<'a> { compact_type: &LuaType, compact_expr: &LuaExpr, ) -> TypeCheckResult { - let mut member_facts = HashMap::new(); + let mut member_facts = FxHashMap::default(); self.collect_table_expr_member_facts(compact_expr, &mut member_facts); let runtime_compact_type = self.infer_closure_body_type_for_check(compact_expr); check_type_compact_detail_with_member_facts( @@ -519,7 +538,7 @@ impl<'a> SemanticModel<'a> { fn collect_table_expr_member_facts( &self, expr: &LuaExpr, - member_facts: &mut HashMap, + member_facts: &mut FxHashMap, ) { let Some(table_expr) = LuaTableExpr::cast(expr.syntax().clone()) else { return; 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..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 @@ -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,53 @@ 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 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, + LuaType::Table + | LuaType::TableConst(_) + | LuaType::Unknown + | LuaType::Any + | LuaType::Nil + | LuaType::Global ) } 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); }; diff --git a/crates/glua_code_analysis/src/semantic/type_check/complex_type/object_type_check.rs b/crates/glua_code_analysis/src/semantic/type_check/complex_type/object_type_check.rs index 538886d1a..5cb5c0add 100644 --- a/crates/glua_code_analysis/src/semantic/type_check/complex_type/object_type_check.rs +++ b/crates/glua_code_analysis/src/semantic/type_check/complex_type/object_type_check.rs @@ -1,4 +1,5 @@ -use std::collections::{HashMap, hash_map::Entry}; +use rustc_hash::FxHashMap as HashMap; +use std::collections::hash_map::Entry; use crate::{ LuaMemberKey, LuaMemberOwner, LuaObjectType, LuaTupleType, LuaType, RenderLevel, @@ -213,7 +214,7 @@ fn collect_type_members( // Build a merged view of class members (including supertypes). When the same key appears // multiple times (override), keep the first one (subclass wins). - let mut map: HashMap = HashMap::new(); + let mut map: HashMap = HashMap::default(); let mut index_keys: Vec = Vec::new(); if collect_index_keys { index_keys.reserve(type_members.len()); 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..0c196f71f 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,8 @@ 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_structural_method_member, member_is_required, + type_check_fail_reason::TypeCheckFailReason, type_check_guard::TypeCheckGuard, }; pub fn check_generic_type_compact( @@ -204,12 +203,11 @@ fn check_generic_type_compact_table( )); } } - None if !source_member_type.is_optional() - && !member_has_documented_default( - context.db, - property_owner_id.as_ref(), - Some(&source_member_type), - ) => + None if member_is_required( + context.db, + property_owner_id.as_ref(), + &source_member_type, + ) => { if !context.detail { return Err(TypeCheckFailReason::TypeNotMatch); 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..38b77ff98 100644 --- a/crates/glua_code_analysis/src/semantic/type_check/mod.rs +++ b/crates/glua_code_analysis/src/semantic/type_check/mod.rs @@ -10,7 +10,7 @@ mod type_check_context; mod type_check_fail_reason; mod type_check_guard; -use std::{collections::HashMap, ops::Deref}; +use rustc_hash::FxHashMap as HashMap; use complex_type::check_complex_type_compact; use func_type::{check_doc_func_type_compact, check_sig_type_compact}; @@ -35,6 +35,32 @@ 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) +} + +/// Whether a literal that omits this member fails to satisfy the type. +/// +/// Three things excuse an omission, and they are all exemptions from the same +/// rule, so they are asked together: the member is declared optional, it was +/// added by a runtime write rather than declared, or it documents a default. +pub(super) fn member_is_required( + db: &DbIndex, + property_owner_id: Option<&LuaSemanticDeclId>, + member_type: &LuaType, +) -> bool { + !member_type.is_optional() + && !is_assignment_added_member(db, property_owner_id) + && !member_has_documented_default(db, property_owner_id, Some(member_type)) +} + // A documented default makes a member optional for presence only. fn member_has_documented_default( db: &DbIndex, @@ -109,6 +135,25 @@ 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. + // + // 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(()); + } + if fast_eq_check(source, compact_type) { return Ok(()); } @@ -135,8 +180,8 @@ fn check_general_type_compact( .allow_nullable_as_non_nullable && let LuaType::Union(union_type) = compact_type { - match union_type.deref() { - LuaUnionType::Nullable(non_nullable_type) => { + match union_type.nullable_inner() { + Some(non_nullable_type) => { return check_general_type_compact( context, source, @@ -144,9 +189,9 @@ fn check_general_type_compact( check_guard.next_level()?, ); } - LuaUnionType::Multi(types) if types.contains(&LuaType::Nil) => { - let non_nil: Vec = types - .iter() + None if union_type.types().any(|t| matches!(t, LuaType::Nil)) => { + let non_nil: Vec = union_type + .types() .filter(|t| !matches!(t, LuaType::Nil)) .cloned() .collect(); @@ -156,7 +201,7 @@ fn check_general_type_compact( .next() .expect("non_nil has exactly 1 element") } else { - LuaType::Union(LuaUnionType::Multi(non_nil).into()) + LuaType::Union(LuaUnionType::from_multi_unchecked(non_nil).into()) }; return check_general_type_compact( context, @@ -312,7 +357,7 @@ fn fast_eq_check(a: &LuaType, b: &LuaType) -> bool { | (LuaType::Any, LuaType::Any) => true, (LuaType::Ref(type_id_left), LuaType::Ref(type_id_right)) => type_id_left == type_id_right, (LuaType::Union(u), LuaType::Ref(type_id_right)) => { - if let LuaUnionType::Nullable(LuaType::Ref(type_id_left)) = u.deref() { + if let Some(LuaType::Ref(type_id_left)) = u.nullable_inner() { return type_id_left == type_id_right; } false 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..4eef52e63 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 @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use crate::{ LuaMemberKey, LuaMemberOwner, LuaObjectType, LuaSemanticDeclId, LuaTupleType, LuaType, @@ -13,8 +13,8 @@ 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, + member_is_required, sub_type::get_base_type_id, type_check_fail_reason::TypeCheckFailReason, + type_check_guard::TypeCheckGuard, }; const GMOD_NULL_TYPE_NAME: &str = "NULL"; @@ -336,7 +336,7 @@ fn check_ref_type_compact_table( return Ok(()); // empty member donot need check }; - let mut checked_keys = HashSet::new(); + let mut checked_keys = HashSet::default(); for source_member in source_type_members { let key = source_member.key; @@ -396,12 +396,11 @@ fn check_ref_type_compact_table( )); } } - None if !source_member_type.is_optional() - && !member_has_documented_default( - context.db, - source_member.property_owner_id.as_ref(), - Some(&source_member_type), - ) => + None if member_is_required( + context.db, + source_member.property_owner_id.as_ref(), + &source_member_type, + ) => { if !context.detail { return Err(TypeCheckFailReason::TypeNotMatch); @@ -430,7 +429,7 @@ fn check_ref_type_compact_object( return Ok(()); }; - let mut checked_keys = HashSet::new(); + let mut checked_keys = HashSet::default(); for source_member in source_type_members { let key = source_member.key; if !checked_keys.insert(key.clone()) { @@ -467,12 +466,11 @@ fn check_ref_type_compact_object( )); } } - None if !source_member_type.is_optional() - && !member_has_documented_default( - context.db, - property_owner_id.as_ref(), - Some(&source_member_type), - ) => + None if member_is_required( + context.db, + property_owner_id.as_ref(), + &source_member_type, + ) => { if !context.detail { return Err(TypeCheckFailReason::TypeNotMatch); @@ -516,7 +514,7 @@ fn check_ref_type_compact_tuple( }; let tuple_types = tuple_type.get_types(); - let mut checked_keys = HashSet::new(); + let mut checked_keys = HashSet::default(); for member in source_type_members { let key = member.key; if !checked_keys.insert(key.clone()) { diff --git a/crates/glua_code_analysis/src/semantic/type_check/sub_type.rs b/crates/glua_code_analysis/src/semantic/type_check/sub_type.rs index 1b0459d13..bee84afac 100644 --- a/crates/glua_code_analysis/src/semantic/type_check/sub_type.rs +++ b/crates/glua_code_analysis/src/semantic/type_check/sub_type.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use rustc_hash::FxHashSet; use crate::{DbIndex, LuaType, LuaTypeDeclId}; @@ -24,7 +24,7 @@ fn check_sub_type_of_iterative( let type_index = db.get_type_index(); let mut stack = Vec::with_capacity(4); - let mut visited = HashSet::with_capacity(4); + let mut visited = FxHashSet::with_capacity_and_hasher(4, Default::default()); stack.push(sub_type_ref_id); while let Some(current_id) = stack.pop() { diff --git a/crates/glua_code_analysis/src/semantic/type_check/type_check_context.rs b/crates/glua_code_analysis/src/semantic/type_check/type_check_context.rs index aded27685..fd16fc084 100644 --- a/crates/glua_code_analysis/src/semantic/type_check/type_check_context.rs +++ b/crates/glua_code_analysis/src/semantic/type_check/type_check_context.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use rustc_hash::FxHashMap; use crate::{DbIndex, InferFailReason, LuaMemberId, LuaMemberIndexItem, LuaType, LuaTypeFact}; @@ -13,7 +13,7 @@ pub struct TypeCheckContext<'db> { pub detail: bool, pub db: &'db DbIndex, pub level: TypeCheckCheckLevel, - member_facts: HashMap, + member_facts: FxHashMap, } impl<'db> TypeCheckContext<'db> { @@ -22,11 +22,11 @@ impl<'db> TypeCheckContext<'db> { detail, db, level, - member_facts: HashMap::new(), + member_facts: FxHashMap::default(), } } - pub fn with_member_facts(mut self, member_facts: HashMap) -> Self { + pub fn with_member_facts(mut self, member_facts: FxHashMap) -> Self { self.member_facts = member_facts; self } 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 { diff --git a/crates/glua_code_analysis/src/semantic/visibility/mod.rs b/crates/glua_code_analysis/src/semantic/visibility/mod.rs index 1184a5750..38aedd854 100644 --- a/crates/glua_code_analysis/src/semantic/visibility/mod.rs +++ b/crates/glua_code_analysis/src/semantic/visibility/mod.rs @@ -146,8 +146,12 @@ fn check_block_visibility( (LuaType::Def(left), LuaMemberOwner::Type(right)) => { return Some(left == *right); } - (LuaType::TableConst(left), LuaMemberOwner::Element(right)) => { - return Some(left == *right); + (LuaType::TableConst(left), _) => { + return Some( + db.get_member_index() + .canonical_owner(LuaMemberOwner::Element(left)) + == *member_owner, + ); } _ => {} } @@ -183,7 +187,11 @@ fn check_def_visibility( }, VisibilityKind::Private => match (typ, member_owner) { (LuaType::Def(left), LuaMemberOwner::Type(right)) => Some(left == *right), - (LuaType::TableConst(left), LuaMemberOwner::Element(right)) => Some(left == *right), + (LuaType::TableConst(left), _) => Some( + db.get_member_index() + .canonical_owner(LuaMemberOwner::Element(left)) + == *member_owner, + ), _ => Some(false), }, _ => None, diff --git a/crates/glua_code_analysis/src/test_lib/mod.rs b/crates/glua_code_analysis/src/test_lib/mod.rs index 756cd9cd6..c6a07d032 100644 --- a/crates/glua_code_analysis/src/test_lib/mod.rs +++ b/crates/glua_code_analysis/src/test_lib/mod.rs @@ -552,6 +552,7 @@ impl VirtualWorkspace { self.analysis .update_file_by_uri(&uri, Some(content.to_string())) + .map(|(id, _)| id) .expect("File ID must be present") } @@ -638,6 +639,7 @@ impl VirtualWorkspace { self.analysis .update_file_by_uri(&uri, Some(content.to_string())) + .map(|(id, _)| id) .expect("File ID must be present") } diff --git a/crates/glua_code_analysis/src/vfs/mod.rs b/crates/glua_code_analysis/src/vfs/mod.rs index 8a83037c7..15e0f3dfa 100644 --- a/crates/glua_code_analysis/src/vfs/mod.rs +++ b/crates/glua_code_analysis/src/vfs/mod.rs @@ -14,7 +14,7 @@ pub(crate) use loader::normalize_path_for_ordering; pub use loader::{LuaFileInfo, load_workspace_files, read_file_with_encoding}; use lsp_types::Uri; use rowan::NodeCache; -use std::collections::HashMap; +use rustc_hash::FxHashMap; use std::path::PathBuf; use std::sync::Arc; pub use virtual_url::VirtualUrlGenerator; @@ -23,12 +23,12 @@ use crate::Emmyrc; #[derive(Debug)] pub struct Vfs { - file_id_map: HashMap, - file_path_map: HashMap, - remote_file_id_map: HashMap, + file_id_map: FxHashMap, + file_path_map: FxHashMap, + remote_file_id_map: FxHashMap, file_data: Vec>, - line_index_map: HashMap, - tree_map: HashMap, + line_index_map: FxHashMap, + tree_map: FxHashMap, emmyrc: Option>, node_cache: NodeCache, /// Monotonic counter bumped whenever file *content* (or existence) changes. @@ -96,12 +96,12 @@ impl Default for Vfs { impl Vfs { pub fn new() -> Self { Vfs { - file_id_map: HashMap::new(), - file_path_map: HashMap::new(), - remote_file_id_map: HashMap::new(), + file_id_map: FxHashMap::default(), + file_path_map: FxHashMap::default(), + remote_file_id_map: FxHashMap::default(), file_data: Vec::new(), - line_index_map: HashMap::new(), - tree_map: HashMap::new(), + line_index_map: FxHashMap::default(), + tree_map: FxHashMap::default(), emmyrc: None, node_cache: NodeCache::default(), content_revision: 0, @@ -159,6 +159,19 @@ impl Vfs { self.file_path_map.get(&id.id) } + /// The file's place in a stable cross-file ordering. + /// + /// File ids are handed out in whatever order a batch reached the files, so + /// they are not the same between a cold build and an incremental session. + /// Anything that has to elect one file's write over another's — merged + /// documentation, table writers, load sites — orders on this instead. A file + /// the VFS does not know sorts last. + pub fn file_order_key(&self, id: &FileId) -> Arc { + self.get_file_path(id) + .map(|path| Arc::from(normalize_path_for_ordering(&path.to_string_lossy()))) + .unwrap_or_else(|| Arc::from("")) + } + /// Whether `data` parses to the same significant token stream (kind, /// range and text) as the tree currently stored for `file_id`. Comments /// count as significant — annotations live in them — so only pure @@ -605,4 +618,166 @@ mod tests { // Same byte length, but the blank line splits the doc block in two. assert!(!vfs.content_semantically_matches(file_id, "--- a\n\n--- b\nlocal c = 1")); } + + /// VFS ownership drops a guard-tripped tree safely on a production-sized + /// (2 MB) thread. Drives the real replace/remove/stale/deferred entries — + /// not a synthetic drop call — so the LSP/VFS boundary teardown is what is + /// exercised. Rowan frees green nodes iteratively, so every ordinary drop + /// below is O(1) stack. + #[test] + fn deep_tree_vfs_replace_and_remove_drop_safely_on_small_stack() { + fn deep_blocks(depth: usize) -> String { + let mut body = String::new(); + for _ in 0..depth { + body.push_str("do "); + } + body.push('x'); + for _ in 0..depth { + body.push_str(" end"); + } + body.push('\n'); + body + } + + let deep = deep_blocks(20000); + std::thread::Builder::new() + .stack_size(2 * 1024 * 1024) + .spawn(move || { + let mut vfs = new_vfs(); + let uri = file_uri(); + + let (tree, line_index) = parse_lua(&deep); + assert!( + tree.get_errors() + .iter() + .any(|error| error.message.contains("too deeply nested")), + "deep blocks must trip the stack reserve guard" + ); + vfs.set_file_content_preparsed(&uri, Some(deep.clone()), tree, line_index, Some(1)) + .expect("initial deep insert must be accepted"); + + let (shallow_tree, shallow_index) = parse_lua("local x = 1"); + vfs.set_file_content_preparsed( + &uri, + Some("local x = 1".to_string()), + shallow_tree, + shallow_index, + Some(2), + ) + .expect("replacement must be accepted"); + + let (tree, line_index) = parse_lua(&deep); + vfs.set_file_content_preparsed(&uri, Some(deep.clone()), tree, line_index, Some(3)) + .expect("deep re-insert must be accepted"); + vfs.remove_file(&uri).expect("remove must succeed"); + + let (tree, line_index) = parse_lua("local y = 2"); + vfs.set_file_content_preparsed( + &uri, + Some("local y = 2".to_string()), + tree, + line_index, + Some(5), + ) + .expect("fresh insert after remove must be accepted"); + let (stale_tree, stale_index) = parse_lua(&deep); + assert!( + vfs.set_file_content_preparsed( + &uri, + Some(deep.clone()), + stale_tree, + stale_index, + Some(4), + ) + .is_none(), + "stale preparsed update must be rejected" + ); + + let (tree, line_index) = parse_lua(&deep); + let (_, deferred) = vfs + .set_file_content_preparsed_deferred( + &uri, + Some(deep.clone()), + tree, + line_index, + Some(6), + ) + .expect("deferred deep insert must be accepted"); + drop(deferred); + + let (tree, line_index) = parse_lua("local z = 3"); + let (_, deferred) = vfs + .set_file_content_preparsed_deferred( + &uri, + Some("local z = 3".to_string()), + tree, + line_index, + Some(7), + ) + .expect("deferred replacement must be accepted"); + drop(deferred); + }) + .expect("worker thread should spawn") + .join() + .expect("production-sized worker must not overflow when VFS drops deep trees"); + } + + /// VFS `set_file_content` (real parse through the shared `NodeCache`) + /// followed by `clear` and by VFS destruction must drop safely on a + /// production-sized (2 MB) thread — including a red root retained past + /// `clear`, whose final release previously aborted in recursive `GreenNode` + /// destruction. Rowan now frees green nodes iteratively, so the retained + /// red, the cleared trees, the dropped `NodeCache`, and the destroyed VFS + /// below are all safe on this 2 MB stack; join-based like the existing + /// 2 MB test. + #[test] + fn deep_tree_vfs_clear_and_destroy_drop_safely_on_small_stack() { + fn deep_blocks(depth: usize) -> String { + let mut body = String::new(); + for _ in 0..depth { + body.push_str("do "); + } + body.push('x'); + for _ in 0..depth { + body.push_str(" end"); + } + body.push('\n'); + body + } + + let deep = deep_blocks(20000); + std::thread::Builder::new() + .stack_size(2 * 1024 * 1024) + .spawn(move || { + let mut vfs = new_vfs(); + let uri = file_uri(); + let file_id = vfs.set_file_content(&uri, Some(deep.clone())); + assert!( + vfs.get_file_parse_error(&file_id) + .unwrap_or_default() + .iter() + .any(|error| error.message.contains("too deeply nested")), + "deep blocks must trip the stack reserve guard" + ); + // Retain a red root past `clear`: the trees are gone but the + // green stays pinned until this last red dies below. + let red = vfs + .get_syntax_tree(&file_id) + .expect("deep file must have a tree") + .get_red_root(); + vfs.clear(); + drop(red); + + // And the destruction path: `Vfs` has no `Drop` impl, so every + // tree and the whole `NodeCache` release inline here on 2 MB. + let mut vfs = new_vfs(); + vfs.set_file_content(&uri, Some(deep.clone())); + drop(vfs); + }) + .expect("worker thread should spawn") + .join() + .expect( + "production-sized worker must not overflow when VFS clears or drops deep trees", + ); + } } 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/Cargo.toml b/crates/glua_ls/Cargo.toml index 23290cef2..4062e7073 100644 --- a/crates/glua_ls/Cargo.toml +++ b/crates/glua_ls/Cargo.toml @@ -39,6 +39,7 @@ dirs.workspace = true wax.workspace = true internment.workspace = true smol_str.workspace = true +rustc-hash.workspace = true [dependencies.clap] workspace = true diff --git a/crates/glua_ls/src/context/debounced_analysis.rs b/crates/glua_ls/src/context/debounced_analysis.rs index f8ee1b501..d0dfca045 100644 --- a/crates/glua_ls/src/context/debounced_analysis.rs +++ b/crates/glua_ls/src/context/debounced_analysis.rs @@ -1,5 +1,6 @@ -use glua_code_analysis::{EmmyLuaAnalysis, FileId}; +use glua_code_analysis::{DirtySet, EmmyLuaAnalysis, FileId}; use lsp_types::Uri; +use smol_str::SmolStr; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering}; @@ -7,7 +8,7 @@ use std::time::{Duration, Instant}; use tokio::sync::{Mutex, Notify, RwLock}; use tokio_util::sync::CancellationToken; -use super::{ClientProxy, file_diagnostic::SharedDiagnosticDataCache}; +use super::{ClientProxy, FileDiagnostic, file_diagnostic::SharedDiagnosticDataCache}; const FRESHNESS_STUCK_WARN_AFTER: Duration = Duration::from_secs(5); @@ -36,6 +37,33 @@ const MAX_RIPPLE_DEFERRAL: Duration = Duration::from_secs(5); /// ripple proceeds and the stragglers wait it out as they did before. const READER_HANDOFF_GRACE: Duration = Duration::from_millis(250); +/// Breakdown of a freshness wait by observed state, so one summary line +/// can report where the wait went without per-iteration logging. +/// +/// `in_flight` covers text applied by no task yet (`in_flight_changes > 0`); +/// `blocked` covers the self-index not having rebuilt this document's own +/// entries yet (or, for workspace waits, the ripple owed). `total` is the +/// whole wait. +#[derive(Default, Clone, Copy)] +pub struct FreshWaitBreakdown { + pub total: Duration, + pub in_flight: Duration, + pub blocked: Duration, +} + +/// Outcome of a freshness wait. +/// +/// `Fresh` means the analysis caught up with the client's text. `Cancelled` +/// means the request's token fired first. `LoopDead` means the debounce +/// supervisor gave up ([`Self::note_debounce_loop_dead`]), so freshness can +/// never arrive and the caller must not diagnose stale data. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Freshness { + Fresh, + Cancelled, + LoopDead, +} + /// Debounced analysis: accumulates file IDs from rapid edits and runs `reindex_files` once the user pauses typing. pub struct DebouncedAnalysis { pending_files: Mutex>, @@ -56,6 +84,11 @@ pub struct DebouncedAnalysis { /// notification handler, before the didChange task is spawned) so that any /// request handler dispatched afterwards sees the flag immediately. has_pending_changes: AtomicBool, + /// False once the supervisor has given up restarting the debounce loop + /// (see `LS_DEBOUNCE_LOOP_DEAD`). Freshness can then never arrive, so + /// waiters fail fast instead of parking until their request is cancelled. + /// Set once, never re-armed: a dead loop stays dead for process lifetime. + debounce_loop_alive: AtomicBool, in_flight_changes: AtomicUsize, /// Requests aimed at one document that are waiting for, or reading against, /// that document's own index entries. @@ -71,6 +104,9 @@ pub struct DebouncedAnalysis { reindex_notify: Notify, analysis: Arc>, shared_diagnostic_data_cache: SharedDiagnosticDataCache, + /// Used to schedule diagnostic-only refreshes for textual refresh names: + /// referencers of a name an edit moved that need no re-index of their own. + file_diagnostic: Arc, debounce_duration: Duration, shutdown: CancellationToken, client: Arc, @@ -88,6 +124,7 @@ impl DebouncedAnalysis { debounce_ms: u64, shutdown: CancellationToken, client: Arc, + file_diagnostic: Arc, shared_diagnostic_data_cache: SharedDiagnosticDataCache, workspace_diagnostic_level: Arc, lsp_features: Arc, @@ -97,6 +134,7 @@ impl DebouncedAnalysis { reindexing_files: Mutex::new(HashSet::new()), blocked_documents: Mutex::new(HashMap::new()), has_pending_changes: AtomicBool::new(false), + debounce_loop_alive: AtomicBool::new(true), in_flight_changes: AtomicUsize::new(0), pending_readers: AtomicUsize::new(0), readers_idle_notify: Notify::new(), @@ -104,6 +142,7 @@ impl DebouncedAnalysis { reindex_notify: Notify::new(), analysis, shared_diagnostic_data_cache, + file_diagnostic, debounce_duration: Duration::from_millis(debounce_ms), shutdown, client, @@ -141,6 +180,21 @@ impl DebouncedAnalysis { InFlightChangeGuard::new(self.clone(), 1) } + /// Record that the supervising loop has given up restarting the debounce + /// loop (`LS_DEBOUNCE_LOOP_DEAD`). Freshness can never arrive afterwards, + /// so [`Self::wait_until_fresh_for`] and [`Self::wait_until_file_fresh_for`] + /// fail fast instead of parking until the request is cancelled. Wakes + /// parked waiters: a waiter past its stuck-warning timer has no active + /// sleep left and would otherwise never re-check the flag. + pub(crate) fn note_debounce_loop_dead(&self) { + self.debounce_loop_alive.store(false, Ordering::Release); + self.reindex_notify.notify_waiters(); + } + + fn debounce_loop_alive(&self) -> bool { + self.debounce_loop_alive.load(Ordering::Acquire) + } + pub async fn finish_in_flight_changes(&self, count: usize) { if count == 0 { return; @@ -212,7 +266,27 @@ impl DebouncedAnalysis { /// 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) { + async fn await_reader_handoff( + &self, + timing: Option<&mut crate::util::ls_profile::HandoffTiming>, + ) { + let profile = timing.is_some(); + let start = profile.then(Instant::now); + // Whether any reader was ever observed: distinguishes `idle` (none at + // entry) from `released` (drained within the grace). + let mut saw_reader = false; + let outcome = self.await_reader_handoff_inner(&mut saw_reader).await; + if let (Some(timing), Some(start)) = (timing, start) { + timing.wait = start.elapsed(); + timing.outcome = Some(outcome); + } + } + + async fn await_reader_handoff_inner( + &self, + saw_reader: &mut bool, + ) -> crate::util::ls_profile::HandoffOutcome { + use crate::util::ls_profile::HandoffOutcome; let deadline = Instant::now() + READER_HANDOFF_GRACE; loop { @@ -222,32 +296,44 @@ impl DebouncedAnalysis { idle.as_mut().enable(); if self.pending_readers.load(Ordering::Acquire) == 0 { - return; + return if *saw_reader { + HandoffOutcome::Released + } else { + HandoffOutcome::Idle + }; } + *saw_reader = true; let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { - return; + return HandoffOutcome::Timeout; } tokio::select! { _ = idle => {} - _ = tokio::time::sleep(remaining) => return, - _ = self.shutdown.cancelled() => return, + _ = tokio::time::sleep(remaining) => return HandoffOutcome::Timeout, + _ = self.shutdown.cancelled() => { + return if self.pending_readers.load(Ordering::Acquire) == 0 { + if *saw_reader { HandoffOutcome::Released } else { HandoffOutcome::Idle } + } else { + HandoffOutcome::Timeout + }; + } } } } /// 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 + /// Returns `Fresh` when the analysis is current, `Cancelled` if the cancel + /// token fired first, and `LoopDead` if the debounce loop is dead. + /// 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, request_method: &'static str, - ) -> bool { + ) -> Freshness { #[cfg(test)] self.freshness_waits.fetch_add(1, Ordering::AcqRel); @@ -262,14 +348,18 @@ impl DebouncedAnalysis { notified.as_mut().enable(); if !self.has_pending_changes.load(Ordering::Acquire) { - return true; + return Freshness::Fresh; + } + + if !self.debounce_loop_alive() { + return Freshness::LoopDead; } let remaining = FRESHNESS_STUCK_WARN_AFTER.saturating_sub(started_at.elapsed()); tokio::select! { _ = notified => {} // re-check - _ = cancel_token.cancelled() => return false, + _ = cancel_token.cancelled() => return Freshness::Cancelled, _ = tokio::time::sleep(remaining), if !warned_stuck => { self.log_freshness_stuck(request_method, started_at).await; warned_stuck = true; @@ -296,7 +386,7 @@ impl DebouncedAnalysis { cancel_token: &CancellationToken, request_method: &'static str, uri: &Uri, - ) -> bool { + ) -> Freshness { #[cfg(test)] self.freshness_waits.fetch_add(1, Ordering::AcqRel); @@ -309,18 +399,171 @@ impl DebouncedAnalysis { notified.as_mut().enable(); if self.file_is_answerable(uri).await { - return true; + return Freshness::Fresh; + } + + if !self.debounce_loop_alive() { + return Freshness::LoopDead; } let remaining = FRESHNESS_STUCK_WARN_AFTER.saturating_sub(started_at.elapsed()); tokio::select! { _ = notified => {} - _ = cancel_token.cancelled() => return false, + _ = cancel_token.cancelled() => return Freshness::Cancelled, + _ = tokio::time::sleep(remaining), if !warned_stuck => { + self.log_freshness_stuck(request_method, started_at).await; + warned_stuck = true; + } + } + } + } + + /// Profiled form of [`Self::wait_until_fresh_for`]: identical wake-up + /// behavior, plus time accumulated by observed state. + pub async fn wait_until_fresh_for_profiled( + &self, + cancel_token: &CancellationToken, + request_method: &'static str, + ) -> (Freshness, FreshWaitBreakdown) { + #[cfg(test)] + self.freshness_waits.fetch_add(1, Ordering::AcqRel); + + let started_at = Instant::now(); + let mut warned_stuck = false; + let mut in_flight = Duration::ZERO; + let mut blocked = Duration::ZERO; + + loop { + let notified = self.reindex_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + + if !self.has_pending_changes.load(Ordering::Acquire) { + return ( + Freshness::Fresh, + FreshWaitBreakdown { + total: started_at.elapsed(), + in_flight, + blocked, + }, + ); + } + + if !self.debounce_loop_alive() { + return ( + Freshness::LoopDead, + FreshWaitBreakdown { + total: started_at.elapsed(), + in_flight, + blocked, + }, + ); + } + + let iter_start = Instant::now(); + let observed_in_flight = self.in_flight_changes.load(Ordering::Acquire) > 0; + let remaining = FRESHNESS_STUCK_WARN_AFTER.saturating_sub(started_at.elapsed()); + + let cancelled = tokio::select! { + _ = notified => false, + _ = cancel_token.cancelled() => true, + _ = tokio::time::sleep(remaining), if !warned_stuck => { + self.log_freshness_stuck(request_method, started_at).await; + warned_stuck = true; + false + } + }; + let elapsed = iter_start.elapsed(); + if observed_in_flight { + in_flight += elapsed; + } else { + blocked += elapsed; + } + if cancelled { + return ( + Freshness::Cancelled, + FreshWaitBreakdown { + total: started_at.elapsed(), + in_flight, + blocked, + }, + ); + } + } + } + + /// Profiled form of [`Self::wait_until_file_fresh_for`]: identical + /// wake-up behavior, plus time accumulated by observed state. + pub async fn wait_until_file_fresh_for_profiled( + &self, + cancel_token: &CancellationToken, + request_method: &'static str, + uri: &Uri, + ) -> (Freshness, FreshWaitBreakdown) { + #[cfg(test)] + self.freshness_waits.fetch_add(1, Ordering::AcqRel); + + let started_at = Instant::now(); + let mut warned_stuck = false; + let mut in_flight = Duration::ZERO; + let mut blocked = Duration::ZERO; + + loop { + let notified = self.reindex_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + + if self.file_is_answerable(uri).await { + return ( + Freshness::Fresh, + FreshWaitBreakdown { + total: started_at.elapsed(), + in_flight, + blocked, + }, + ); + } + + if !self.debounce_loop_alive() { + return ( + Freshness::LoopDead, + FreshWaitBreakdown { + total: started_at.elapsed(), + in_flight, + blocked, + }, + ); + } + + let iter_start = Instant::now(); + let observed_in_flight = self.in_flight_changes.load(Ordering::Acquire) > 0; + let remaining = FRESHNESS_STUCK_WARN_AFTER.saturating_sub(started_at.elapsed()); + + let cancelled = tokio::select! { + _ = notified => false, + _ = cancel_token.cancelled() => true, _ = tokio::time::sleep(remaining), if !warned_stuck => { self.log_freshness_stuck(request_method, started_at).await; warned_stuck = true; + false } + }; + let elapsed = iter_start.elapsed(); + if observed_in_flight { + in_flight += elapsed; + } else { + blocked += elapsed; + } + if cancelled { + return ( + Freshness::Cancelled, + FreshWaitBreakdown { + total: started_at.elapsed(), + in_flight, + blocked, + }, + ); } } } @@ -364,8 +607,14 @@ 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) { + /// Wait until the given file is no longer pending reindex. Returns + /// `LoopDead` without diagnosing when the debounce loop is dead (freshness + /// can never arrive); callers must abort rather than diagnose stale data. + pub async fn wait_for_reindex( + &self, + file_id: FileId, + cancel_token: CancellationToken, + ) -> Freshness { loop { let notified = self.reindex_notify.notified(); tokio::pin!(notified); @@ -377,39 +626,56 @@ impl DebouncedAnalysis { pending.contains(&file_id) || reindexing.contains(&file_id) }; if !is_pending { - return; + return Freshness::Fresh; + } + if !self.debounce_loop_alive() { + return Freshness::LoopDead; } tokio::select! { _ = notified => {} - _ = cancel_token.cancelled() => return, + _ = cancel_token.cancelled() => return Freshness::Cancelled, } } } - /// 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. + /// Re-index the edited files' own entries, and report which other files + /// the edit invalidated. /// /// 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, + timing: Option<&mut crate::util::ls_profile::SelfWriteTimings>, + ) -> Option { + let profile = timing.is_some(); let analysis = self.analysis.clone(); let cache = self.shared_diagnostic_data_cache.clone(); tokio::select! { _ = self.shutdown.cancelled() => None, result = tokio::task::spawn_blocking(move || { + let wait_start = profile.then(Instant::now); let mut guard = analysis.blocking_write(); - let expansion = guard.expand_reindex_file_ids(file_ids.clone()); - guard.self_index_files(file_ids); + let wait = wait_start.map(|start| start.elapsed()).unwrap_or_default(); + let hold_start = profile.then(Instant::now); + // Change-aware: the dirty set holds only the files that read a + // fact this edit actually moved. Most keystrokes (typing inside + // a function, trailing comment, local rename) export nothing + // new and leave it empty. + let dirty = guard.self_index_and_diff(file_ids); cache.invalidate(); - expansion + let hold = hold_start.map(|start| start.elapsed()).unwrap_or_default(); + (dirty, wait, hold) }) => match result { - Ok(expansion) => Some(expansion), + Ok((result, wait, hold)) => { + if let Some(timing) = timing { + timing.wait = wait; + timing.hold = hold; + } + Some(result) + } Err(err) => { log::error!("self-index task failed: {}", err); None @@ -418,32 +684,128 @@ impl DebouncedAnalysis { } } - async fn reindex_files_without_queuing( + async fn ripple_without_queuing( &self, - file_ids: Vec, - expansion: Vec, - ) -> bool { + dirty: DirtySet, + timing: Option<&mut crate::util::ls_profile::RippleWriteTimings>, + ) -> (bool, HashSet, Vec) { + let profile = timing.is_some(); 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, + _ = self.shutdown.cancelled() => (false, HashSet::default(), Vec::new()), result = tokio::task::spawn_blocking(move || { + let wait_start = profile.then(Instant::now); let mut guard = analysis.blocking_write(); - guard.reindex_expanded_files(file_ids, expansion); + let wait = wait_start.map(|start| start.elapsed()).unwrap_or_default(); + let hold_start = profile.then(Instant::now); + let (rippled, refresh_names) = guard.ripple_with_refresh_names(dirty); // Invalidate under the write lock so no reader can observe the // fresh index next to the stale shared diagnostic data. cache.invalidate(); + let hold = hold_start.map(|start| start.elapsed()).unwrap_or_default(); + (rippled, refresh_names, wait, hold) }) => { - if let Err(err) = result { - log::error!("reindex task failed: {}", err); + match result { + Ok((rippled, refresh_names, wait, hold)) => { + if let Some(timing) = timing { + timing.wait = wait; + timing.hold = hold; + } + (true, refresh_names, rippled) + } + Err(err) => { + log::error!("reindex task failed: {}", err); + (false, HashSet::default(), Vec::new()) + } + } + } + } + } + + /// Resolve owed textual refresh names to their main-workspace referencers + /// and schedule diagnostic-only tasks for them. + /// + /// Never reindexes: the candidates go to + /// [`FileDiagnostic::add_files_diagnostic_task`], the same path a + /// watched-file change uses. `exclude` carries the files the edit path + /// already settled (the edited batch, the files owed a ripple, and the + /// files the ripple re-analysed), which the candidate filter drops. + async fn schedule_textual_refresh( + &self, + names: HashSet, + exclude: &HashSet, + ) -> bool { + if names.is_empty() { + return false; + } + let analysis = self.analysis.clone(); + let exclude = exclude.clone(); + let resolved = tokio::task::spawn_blocking(move || { + let guard = analysis.blocking_read(); + let interval = guard + .get_emmyrc() + .diagnostics + .diagnostic_interval + .unwrap_or(500); + let candidates = guard.textual_refresh_candidates(&names, &exclude); + (candidates, interval) + }) + .await; + match resolved { + Ok((candidates, interval)) => { + if candidates.is_empty() { return false; } + self.file_diagnostic + .add_files_diagnostic_task(candidates, interval, None) + .await; true } + Err(err) => { + log::error!("textual refresh resolve task failed: {}", err); + false + } + } + } + + /// Arm an idle workspace diagnostic refresh so closed files hit by + /// cross-file changes get re-diagnosed once typing pauses. + /// + /// Cancels the previously armed timer, if any. Clones the arcs it needs and + /// takes no lock: the caller may still hold one. + fn arm_idle_workspace_diagnostic(&self, token_slot: &mut Option) { + if let Some(token) = token_slot.take() { + token.cancel(); } + let cancel_token = CancellationToken::new(); + *token_slot = Some(cancel_token.clone()); + + let client = self.client.clone(); + let status = self.workspace_diagnostic_level.clone(); + let lsp_features = self.lsp_features.clone(); + let shutdown = self.shutdown.clone(); + tokio::spawn(async move { + tokio::select! { + _ = tokio::time::sleep(IDLE_WORKSPACE_DIAGNOSTIC_DELAY) => { + if !cancel_token.is_cancelled() && !shutdown.is_cancelled() { + // Raise, never lower: don't drop a pending Slow sweep. + status.fetch_max( + crate::context::WorkspaceDiagnosticLevel::Fast.to_u8(), + Ordering::AcqRel, + ); + if lsp_features.supports_refresh_diagnostic() { + client.refresh_workspace_diagnostics(); + } + } + } + _ = cancel_token.cancelled() => {} + _ = shutdown.cancelled() => {} + } + }); } /// Hold the owed ripple until typing has stopped for [`RIPPLE_QUIET`]. @@ -451,23 +813,74 @@ impl DebouncedAnalysis { /// 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 { + async fn ripple_quiet_elapsed( + &self, + burst_started_at: Instant, + timing: Option<&mut crate::util::ls_profile::QuietTiming>, + ) -> bool { + use crate::util::ls_profile::QuietOutcome; + let profile = timing.is_some(); + let start = profile.then(Instant::now); let extra = RIPPLE_QUIET.saturating_sub(self.debounce_duration); let deferral_left = MAX_RIPPLE_DEFERRAL.saturating_sub(burst_started_at.elapsed()); + // Capped by the deferral budget rather than by quiet: sustained typing + // must not hold diagnostics off indefinitely. + let capped_by_deferral = deferral_left <= extra; if extra.is_zero() || deferral_left.is_zero() { + if let (Some(timing), Some(start)) = (timing, start) { + timing.wait = start.elapsed(); + timing.outcome = Some(if deferral_left.is_zero() && !extra.is_zero() { + QuietOutcome::MaxDeferral + } else if extra.is_zero() && deferral_left.is_zero() { + // Both exhausted: the deferral cap is what forces the run. + QuietOutcome::MaxDeferral + } else { + QuietOutcome::Timer + }); + } return true; } - tokio::select! { + enum QuietWait { + Shutdown, + Edit, + Timer, + } + let wait_outcome = tokio::select! { biased; - _ = self.shutdown.cancelled() => return true, - _ = self.notify.notified() => return false, - _ = tokio::time::sleep(extra.min(deferral_left)) => {} + _ = self.shutdown.cancelled() => QuietWait::Shutdown, + _ = self.notify.notified() => QuietWait::Edit, + _ = tokio::time::sleep(extra.min(deferral_left)) => QuietWait::Timer, + }; + let run_now = match wait_outcome { + QuietWait::Edit => false, + QuietWait::Shutdown => true, + QuietWait::Timer => { + // A notify landing before the select registered would be lost, so the + // timer expiring is not on its own proof that nothing arrived. + self.pending_files.lock().await.is_empty() + } + }; + if let (Some(timing), Some(start)) = (timing, start) { + timing.wait = start.elapsed(); + timing.outcome = Some(match wait_outcome { + QuietWait::Edit => QuietOutcome::Edit, + QuietWait::Shutdown => QuietOutcome::Timer, + QuietWait::Timer => { + if run_now { + if capped_by_deferral { + QuietOutcome::MaxDeferral + } else { + QuietOutcome::Timer + } + } else { + // Timer fired but an edit landed just before the check. + QuietOutcome::Edit + } + } + }); } - - // A notify landing before the select registered would be lost, so the - // timer expiring is not on its own proof that nothing arrived. - self.pending_files.lock().await.is_empty() + run_now } /// Background loop: waits for events, debounces, then runs reindex. @@ -475,9 +888,16 @@ impl DebouncedAnalysis { 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, - // and the union of the expansions each of them captured. + // and the union of the dirty sets each of them reported. let mut owed_files: HashSet = HashSet::new(); - let mut owed_expansion: HashSet = HashSet::new(); + let mut owed_ripple: Option = None; + // Textual refresh names owed a diagnostic-only refresh, merged from + // every self-index diff and ripple sideband of this burst. They steer + // no reindex: they are drained whenever no ripple is owed. + let mut owed_refresh_names: HashSet = HashSet::default(); + // Files the edit path already settled (edited batch, ripple-settled), + // excluded from the textual refresh resolution. + let mut refresh_exclude: HashSet = HashSet::default(); let mut burst_started_at: Option = None; loop { // Register before testing the condition: `notify_waiters()` stores @@ -488,7 +908,8 @@ impl DebouncedAnalysis { let needs_work = !self.pending_files.lock().await.is_empty() || self.has_pending_changes.load(Ordering::Acquire) - || !owed_files.is_empty(); + || !owed_files.is_empty() + || !owed_refresh_names.is_empty(); if !needs_work { tokio::select! { _ = notified => {} @@ -497,6 +918,9 @@ impl DebouncedAnalysis { } // Debounce: keep resetting the timer while new events arrive. + let profile_cycle = crate::util::ls_profile::ls_profile_enabled(); + let cycle_id = profile_cycle.then(crate::util::ls_profile::next_reindex_cycle); + let debounce_start = crate::util::ls_profile::profile_instant(); loop { tokio::select! { biased; @@ -505,6 +929,13 @@ impl DebouncedAnalysis { _ = tokio::time::sleep(self.debounce_duration) => break, } } + let debounce_elapsed = debounce_start + .map(|start| start.elapsed()) + .unwrap_or_default(); + let mut self_timings = crate::util::ls_profile::SelfWriteTimings::default(); + let mut handoff_timing = crate::util::ls_profile::HandoffTiming::default(); + let mut quiet_timing = crate::util::ls_profile::QuietTiming::default(); + let mut ripple_timings = crate::util::ls_profile::RippleWriteTimings::default(); // Timer expired — drain pending files and reindex let file_ids: Vec = { @@ -531,7 +962,10 @@ 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 self_timing = profile_cycle.then_some(&mut self_timings); + let Some(mut dirty) = self + .self_index_without_queuing(file_ids.clone(), self_timing) + .await else { if self.shutdown.is_cancelled() { return; @@ -549,6 +983,21 @@ impl DebouncedAnalysis { drop(reindexing); self.refresh_dirty_state().await; self.reindex_notify.notify_waiters(); + if let Some(cycle) = cycle_id { + use crate::util::ls_profile::{HandoffOutcome, QuietOutcome}; + crate::util::ls_profile::emit_ls_reindex( + cycle, + debounce_elapsed, + self_timings.wait, + self_timings.hold, + std::time::Duration::ZERO, + HandoffOutcome::Idle, + std::time::Duration::ZERO, + QuietOutcome::Timer, + std::time::Duration::ZERO, + std::time::Duration::ZERO, + ); + } continue; }; @@ -563,24 +1012,102 @@ impl DebouncedAnalysis { // 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; - - owed_files.extend(file_ids.iter().copied()); - owed_expansion.extend(expansion); - burst_started_at.get_or_insert_with(Instant::now); + let handoff_opt = profile_cycle.then_some(&mut handoff_timing); + self.await_reader_handoff(handoff_opt).await; + + // Refresh names ride along whichever path the dirty set takes: + // they are taken before the emptiness check, which ignores + // them by design. + owed_refresh_names.extend(dirty.take_textual_refresh_names()); + refresh_exclude.extend(file_ids.iter().copied()); + + if dirty.is_empty() { + // Nothing else reads a fact this edit moved - the + // self-index already makes these files answerable. 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(); + // Refresh names still owed: fall through so the gate below + // drains them without a ripple. + if owed_ripple.is_none() && owed_refresh_names.is_empty() { + if let Some(cycle) = cycle_id { + use crate::util::ls_profile::{HandoffOutcome, QuietOutcome}; + crate::util::ls_profile::emit_ls_reindex( + cycle, + debounce_elapsed, + self_timings.wait, + self_timings.hold, + handoff_timing.wait, + handoff_timing.outcome.unwrap_or(HandoffOutcome::Idle), + std::time::Duration::ZERO, + QuietOutcome::Timer, + std::time::Duration::ZERO, + std::time::Duration::ZERO, + ); + } + continue; + } + } else { + owed_files.extend(file_ids.iter().copied()); + match &mut owed_ripple { + Some(owed) => owed.extend(dirty), + none => *none = Some(dirty), + } + burst_started_at.get_or_insert_with(Instant::now); + } } - if owed_files.is_empty() { + let Some(owed) = owed_ripple.take() else { + if owed_refresh_names.is_empty() { + self.refresh_dirty_state().await; + self.reindex_notify.notify_waiters(); + continue; + } + // No ripple is owed, but textual refresh names are: resolve + // them and schedule diagnostics, then settle the burst. + let names = std::mem::take(&mut owed_refresh_names); + let exclude = std::mem::take(&mut refresh_exclude); + let scheduled = self.schedule_textual_refresh(names, &exclude).await; self.refresh_dirty_state().await; self.reindex_notify.notify_waiters(); + if scheduled { + self.arm_idle_workspace_diagnostic(&mut idle_workspace_diagnostic_token); + } continue; - } + }; // 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 { + let quiet_opt = profile_cycle.then_some(&mut quiet_timing); + if !self + .ripple_quiet_elapsed(burst_started_at_instant, quiet_opt) + .await + { + owed_ripple = Some(owed); + if let Some(cycle) = cycle_id { + use crate::util::ls_profile::{HandoffOutcome, QuietOutcome}; + crate::util::ls_profile::emit_ls_reindex( + cycle, + debounce_elapsed, + self_timings.wait, + self_timings.hold, + handoff_timing.wait, + handoff_timing.outcome.unwrap_or(HandoffOutcome::Idle), + quiet_timing.wait, + quiet_timing.outcome.unwrap_or(QuietOutcome::Edit), + std::time::Duration::ZERO, + std::time::Duration::ZERO, + ); + } continue; } @@ -590,21 +1117,20 @@ impl DebouncedAnalysis { ids.sort(); ids }; - let ripple_expansion: Vec = { - let mut ids: Vec = owed_expansion.iter().copied().collect(); - ids.sort(); - ids - }; log::info!( - "ripple: {} edited file(s) over {} file(s) after {}ms quiet", + "ripple: {} edited file(s) over {} dirty file(s) after {}ms quiet", ripple_files.len(), - ripple_expansion.len(), + owed.dirty_len(), RIPPLE_QUIET.as_millis() ); - let reindex_completed = self - .reindex_files_without_queuing(ripple_files.clone(), ripple_expansion) - .await; + let ripple_opt = profile_cycle.then_some(&mut ripple_timings); + let (reindex_completed, ripple_names, rippled) = + self.ripple_without_queuing(owed, ripple_opt).await; + // The ripple's own sideband joins the names already owed; they + // drain through the gate on the next iteration. + owed_refresh_names.extend(ripple_names); + refresh_exclude.extend(rippled.iter().copied()); { let mut reindexing = self.reindexing_files.lock().await; @@ -613,10 +1139,24 @@ impl DebouncedAnalysis { } } owed_files.clear(); - owed_expansion.clear(); burst_started_at = None; self.reindex_notify.notify_waiters(); + if let Some(cycle) = cycle_id { + use crate::util::ls_profile::{HandoffOutcome, QuietOutcome}; + crate::util::ls_profile::emit_ls_reindex( + cycle, + debounce_elapsed, + self_timings.wait, + self_timings.hold, + handoff_timing.wait, + handoff_timing.outcome.unwrap_or(HandoffOutcome::Idle), + quiet_timing.wait, + quiet_timing.outcome.unwrap_or(QuietOutcome::Timer), + ripple_timings.wait, + ripple_timings.hold, + ); + } if !reindex_completed { // Only shutdown stops the loop; a panicked reindex must // fall through so `refresh_dirty_state()` releases waiters. @@ -638,34 +1178,7 @@ impl DebouncedAnalysis { // Arm an idle workspace diagnostic refresh so closed files hit // by cross-file changes get re-diagnosed once typing pauses. - if let Some(token) = idle_workspace_diagnostic_token.take() { - token.cancel(); - } - let cancel_token = CancellationToken::new(); - idle_workspace_diagnostic_token = Some(cancel_token.clone()); - - let client = self.client.clone(); - let status = self.workspace_diagnostic_level.clone(); - let lsp_features = self.lsp_features.clone(); - let shutdown = self.shutdown.clone(); - tokio::spawn(async move { - tokio::select! { - _ = tokio::time::sleep(IDLE_WORKSPACE_DIAGNOSTIC_DELAY) => { - if !cancel_token.is_cancelled() && !shutdown.is_cancelled() { - // Raise, never lower: don't drop a pending Slow sweep. - status.fetch_max( - crate::context::WorkspaceDiagnosticLevel::Fast.to_u8(), - Ordering::AcqRel, - ); - if lsp_features.supports_refresh_diagnostic() { - client.refresh_workspace_diagnostics(); - } - } - } - _ = cancel_token.cancelled() => {} - _ = shutdown.cancelled() => {} - } - }); + self.arm_idle_workspace_diagnostic(&mut idle_workspace_diagnostic_token); } self.refresh_dirty_state().await; @@ -765,16 +1278,16 @@ impl Drop for InFlightChangeGuard { #[cfg(test)] mod tests { use std::sync::Arc; - use std::sync::atomic::AtomicU8; + use std::sync::atomic::{AtomicU8, Ordering}; use std::time::{Duration, Instant}; - use super::{MAX_RIPPLE_DEFERRAL, READER_HANDOFF_GRACE}; + use super::{Freshness, MAX_RIPPLE_DEFERRAL, READER_HANDOFF_GRACE}; use glua_code_analysis::{DiagnosticCode, EmmyLuaAnalysis, FileId, file_path_to_uri}; use googletest::prelude::*; use lsp_server::Connection; use lsp_types::Uri; - use lsp_types::{ClientCapabilities, Diagnostic, NumberOrString}; + use lsp_types::{ClientCapabilities, Diagnostic, NumberOrString, PublishDiagnosticsParams}; use std::str::FromStr; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; @@ -792,13 +1305,19 @@ mod tests { let (connection, _peer) = Connection::memory(); let client = Arc::new(ClientProxy::new(connection)); let status_bar = Arc::new(StatusBar::new(client.clone(), true)); - let file_diagnostic = FileDiagnostic::new(analysis.clone(), status_bar, client.clone()); + let file_diagnostic = Arc::new(FileDiagnostic::new( + analysis.clone(), + status_bar, + client.clone(), + )); + let shared_diagnostic_data_cache = file_diagnostic.shared_diagnostic_data_cache(); Arc::new(DebouncedAnalysis::new( analysis, 0, CancellationToken::new(), client, - file_diagnostic.shared_diagnostic_data_cache(), + file_diagnostic, + shared_diagnostic_data_cache, Arc::new(AtomicU8::new(0)), test_lsp_features(), )) @@ -856,6 +1375,114 @@ mod tests { }) } + /// Once the supervisor has given up on the loop, freshness can never + /// arrive: waiters must fail fast instead of parking until cancelled. + #[gtest] + fn dead_loop_fails_freshness_wait_fast() -> Result<()> { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build"); + runtime.block_on(async { + let debounced_analysis = test_debounced_analysis(); + debounced_analysis + .has_pending_changes + .store(true, Ordering::Release); + debounced_analysis.note_debounce_loop_dead(); + let token = CancellationToken::new(); + let fresh = tokio::time::timeout( + Duration::from_secs(10), + debounced_analysis.wait_until_fresh_for(&token, "test"), + ) + .await + .expect("freshness wait must fail fast once the loop is dead"); + verify_that!(fresh, eq(Freshness::LoopDead))?; + Ok(()) + }) + } + + /// A waiter already parked when the loop dies must also be released: past + /// the stuck warning it has no active timer left, so only a wakeup lets + /// it re-check the alive flag. + #[gtest] + fn dead_loop_releases_already_parked_waiter() -> Result<()> { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build"); + runtime.block_on(async { + let debounced_analysis = test_debounced_analysis(); + debounced_analysis + .has_pending_changes + .store(true, Ordering::Release); + let token = CancellationToken::new(); + let waiter = tokio::spawn({ + let debounced_analysis = debounced_analysis.clone(); + async move { + debounced_analysis + .wait_until_fresh_for(&token, "test") + .await + } + }); + tokio::time::timeout(Duration::from_secs(10), async { + while debounced_analysis.freshness_wait_count() == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("waiter should reach the freshness wait promptly"); + // Let the waiter complete a full park iteration first, so this + // exercises release-from-parked rather than fail-fast-on-entry + // (without the wakeup it would sit out the 5s stuck timer here). + let started = Instant::now(); + tokio::time::sleep(Duration::from_millis(200)).await; + debounced_analysis.note_debounce_loop_dead(); + let fresh = tokio::time::timeout(Duration::from_secs(10), waiter) + .await + .expect("parked waiter must be released promptly") + .expect("waiter task must not panic"); + verify_that!(fresh, eq(Freshness::LoopDead))?; + assert!( + started.elapsed() < Duration::from_secs(5), + "release took {:?}, expected prompt wakeup not the stuck timer", + started.elapsed() + ); + Ok(()) + }) + } + + /// `wait_for_reindex` parks diagnostic tasks, not requests, so it has no + /// cancel token of its own to rely on: without the alive check a dead + /// loop parks it forever on a file that will never reindex. + #[gtest] + fn dead_loop_releases_reindex_waiter() -> Result<()> { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build"); + runtime.block_on(async { + let debounced_analysis = test_debounced_analysis(); + debounced_analysis + .pending_files + .lock() + .await + .insert(FileId::new(7)); + let waiter = tokio::spawn({ + let debounced_analysis = debounced_analysis.clone(); + async move { + debounced_analysis + .wait_for_reindex(FileId::new(7), CancellationToken::new()) + .await + } + }); + let started = Instant::now(); + tokio::time::sleep(Duration::from_millis(200)).await; + debounced_analysis.note_debounce_loop_dead(); + let reindexed = tokio::time::timeout(Duration::from_secs(10), waiter) + .await + .expect("parked reindex waiter must be released promptly") + .expect("waiter task must not panic"); + verify_that!(reindexed, eq(Freshness::LoopDead))?; + assert!( + started.elapsed() < Duration::from_secs(5), + "release took {:?}, expected prompt wakeup not the stuck timer", + started.elapsed() + ); + Ok(()) + }) + } + /// Typing must send the ripple back rather than let it start: while it /// runs, no keystroke can even be applied. #[gtest] @@ -873,7 +1500,7 @@ mod tests { let run_the_ripple = tokio::time::timeout( Duration::from_millis(500), - debounced_analysis.ripple_quiet_elapsed(Instant::now()), + debounced_analysis.ripple_quiet_elapsed(Instant::now(), None), ) .await .expect("an edit should send the ripple back well inside the quiet window"); @@ -898,7 +1525,7 @@ mod tests { let run_the_ripple = tokio::time::timeout( Duration::from_millis(100), - debounced_analysis.ripple_quiet_elapsed(started_at), + debounced_analysis.ripple_quiet_elapsed(started_at, None), ) .await .expect("the cap should release the ripple immediately"); @@ -919,7 +1546,7 @@ mod tests { // Nothing outstanding: the ripple must not pay the grace. tokio::time::timeout( Duration::from_millis(50), - debounced_analysis.await_reader_handoff(), + debounced_analysis.await_reader_handoff(None), ) .await .expect("no readers should let the ripple straight through"); @@ -927,7 +1554,7 @@ mod tests { let handoff = debounced_analysis.begin_reader_handoff(); let held = tokio::time::timeout( Duration::from_millis(50), - debounced_analysis.await_reader_handoff(), + debounced_analysis.await_reader_handoff(None), ) .await; verify_that!(held.is_err(), eq(true))?; @@ -935,7 +1562,7 @@ mod tests { drop(handoff); tokio::time::timeout( Duration::from_millis(250), - debounced_analysis.await_reader_handoff(), + debounced_analysis.await_reader_handoff(None), ) .await .expect("dropping the last handoff should release the ripple"); @@ -953,7 +1580,7 @@ mod tests { let _never_dropped = debounced_analysis.begin_reader_handoff(); let started_at = Instant::now(); - debounced_analysis.await_reader_handoff().await; + debounced_analysis.await_reader_handoff(None).await; verify_that!(started_at.elapsed() >= READER_HANDOFF_GRACE, eq(true))?; verify_that!(started_at.elapsed() < READER_HANDOFF_GRACE * 4, eq(true))?; @@ -987,7 +1614,10 @@ mod tests { ), ) .await; - verify_that!(untouched_answered.unwrap_or(false), eq(true))?; + verify_that!( + untouched_answered.unwrap_or(Freshness::Cancelled), + eq(Freshness::Fresh) + )?; let edited_answered = tokio::time::timeout( Duration::from_millis(250), @@ -1031,21 +1661,28 @@ mod tests { let api_uri = file_path_to_uri(&workspace.join("lua/autorun/shared/api.lua")) .expect("API URI should parse"); - analysis.update_file_by_uri( - &api_uri, - Some("function NeedsUse() return true end".to_string()), - ); + analysis + .update_file_by_uri( + &api_uri, + Some("function NeedsUse() return true end".to_string()), + ) + .map(|(id, _)| id); let user_uri = file_path_to_uri(&workspace.join("lua/autorun/shared/user.lua")) .expect("user URI should parse"); - analysis.update_file_by_uri(&user_uri, Some("NeedsUse()".to_string())); + analysis + .update_file_by_uri(&user_uri, Some("NeedsUse()".to_string())) + .map(|(id, _)| id); let (connection, _peer) = Connection::memory(); let client = Arc::new(ClientProxy::new(connection)); let status_bar = Arc::new(StatusBar::new(client.clone(), true)); let analysis = Arc::new(RwLock::new(analysis)); - let file_diagnostic = - FileDiagnostic::new(analysis.clone(), status_bar.clone(), client.clone()); + let file_diagnostic = Arc::new(FileDiagnostic::new( + analysis.clone(), + status_bar.clone(), + client.clone(), + )); let initial_diagnostics = file_diagnostic .pull_file_diagnostics(user_uri.clone(), CancellationToken::new()) @@ -1070,21 +1707,24 @@ mod tests { .expect("API file should still exist") }; + let shared_diagnostic_data_cache = file_diagnostic.shared_diagnostic_data_cache(); let debounced_analysis = DebouncedAnalysis::new( analysis.clone(), 0, CancellationToken::new(), client, - file_diagnostic.shared_diagnostic_data_cache(), + file_diagnostic.clone(), + shared_diagnostic_data_cache, Arc::new(AtomicU8::new(0)), test_lsp_features(), ); - verify_that!( - debounced_analysis - .reindex_files_without_queuing(vec![api_file_id], vec![api_file_id]) - .await, - eq(true) - )?; + let dirty = debounced_analysis + .self_index_without_queuing(vec![api_file_id], None) + .await + .expect("self-index should complete"); + let (completed, _refresh_names, _rippled) = + debounced_analysis.ripple_without_queuing(dirty, None).await; + verify_that!(completed, eq(true))?; let updated_diagnostics = file_diagnostic .pull_file_diagnostics(user_uri, CancellationToken::new()) @@ -1098,4 +1738,235 @@ mod tests { Ok(()) }) } + + fn publish_params(message: &lsp_server::Message) -> Option { + let lsp_server::Message::Notification(notification) = message else { + return None; + }; + if notification.method != "textDocument/publishDiagnostics" { + return None; + } + serde_json::from_value(notification.params.clone()).ok() + } + + /// A test bed wired so the debounce loop's diagnostic scheduling can be + /// observed on the client channel: `peer` receives everything + /// `FileDiagnostic` publishes. + fn loop_test_debounced_analysis( + analysis: Arc>, + ) -> (Arc, lsp_server::Connection) { + let (connection, peer) = Connection::memory(); + let client = Arc::new(ClientProxy::new(connection)); + let status_bar = Arc::new(StatusBar::new(client.clone(), true)); + let file_diagnostic = Arc::new(FileDiagnostic::new( + analysis.clone(), + status_bar, + client.clone(), + )); + let shared_diagnostic_data_cache = file_diagnostic.shared_diagnostic_data_cache(); + let debounced_analysis = Arc::new(DebouncedAnalysis::new( + analysis, + 0, + CancellationToken::new(), + client, + file_diagnostic, + shared_diagnostic_data_cache, + Arc::new(AtomicU8::new(0)), + test_lsp_features(), + )); + (debounced_analysis, peer) + } + + async fn reference_revision(analysis: &RwLock, file_id: FileId) -> u64 { + analysis + .read() + .await + .compilation + .get_db() + .get_reference_index() + .file_reference_revision(file_id) + } + + /// A rename that dirties nothing must still reach its readers on the + /// editor path: the debounced loop drains the self-index's textual refresh + /// names, schedules diagnostic-only tasks for their referencers, and + /// leaves those referencers un-reindexed (mirrors + /// `global_function_rename_refreshes_old_and_new_referencers`). + #[gtest] + fn a_rename_edit_refreshes_its_referencers_without_reindexing() -> Result<()> { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build"); + runtime.block_on(async { + let mut analysis = EmmyLuaAnalysis::new(); + let workspace = std::env::temp_dir().join("gmod_glua_ls_debounced_rename_refresh"); + analysis.add_main_workspace(workspace.clone()); + analysis + .diagnostic + .enable_only(DiagnosticCode::UndefinedGlobal); + + let provider_uri = file_path_to_uri(&workspace.join("lua/rename/provider.lua")) + .expect("provider URI should parse"); + let consumer_uri = file_path_to_uri(&workspace.join("lua/rename/consumer.lua")) + .expect("consumer URI should parse"); + analysis + .update_file_by_uri(&provider_uri, Some("function OldName() end\n".to_string())) + .map(|(id, _)| id); + analysis + .update_file_by_uri(&consumer_uri, Some("OldName()\n".to_string())) + .map(|(id, _)| id); + let consumer_id = analysis + .compilation + .get_db() + .get_vfs() + .get_file_id(&consumer_uri) + .expect("consumer file id"); + + let analysis = Arc::new(RwLock::new(analysis)); + let (debounced_analysis, peer) = loop_test_debounced_analysis(analysis.clone()); + let revision_before = reference_revision(&analysis, consumer_id).await; + + let loop_task = tokio::spawn({ + let debounced_analysis = debounced_analysis.clone(); + async move { debounced_analysis.run().await } + }); + + // didChange equivalent: stage the text, then schedule the file. + let provider_id = { + let mut guard = analysis.write().await; + guard + .update_file_text_only(&provider_uri, "function NewName() end\n".to_string()) + .expect("provider file should still exist") + }; + debounced_analysis + .schedule(provider_id, provider_uri.clone()) + .await; + + // Nothing else publishes for the consumer in this test, so its + // first publish can only come from the names-only refresh task. + let published = tokio::time::timeout(Duration::from_secs(10), async { + tokio::task::spawn_blocking(move || { + loop { + let message = peer.receiver.recv().expect("peer channel should stay open"); + if let Some(params) = publish_params(&message) + && params.uri == consumer_uri + { + return params; + } + } + }) + .await + .expect("the publish reader task must not panic") + }) + .await + .expect("the consumer should be re-published by the textual refresh"); + let undefined_global = DiagnosticCode::UndefinedGlobal.get_name().to_string(); + verify_that!( + published + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code + == Some(NumberOrString::String(undefined_global.clone()))), + eq(true) + )?; + + debounced_analysis.shutdown.cancel(); + tokio::time::timeout(Duration::from_secs(5), loop_task) + .await + .expect("the loop should stop on shutdown") + .expect("the loop task must not panic"); + + // The rename dirties nothing: the referencer must not have been + // reindexed on the way to its refresh. + let revision_after = reference_revision(&analysis, consumer_id).await; + verify_that!(revision_after, eq(revision_before))?; + Ok(()) + }) + } + + /// Names carried by a ripple's own export diffs must survive the ripple + /// path: a file that only names a rippled inference comes back + /// diagnostic-only, un-reindexed (mirrors + /// `ripple_moved_inference_refreshes_its_lazy_reader`). + #[gtest] + fn a_ripple_and_its_sideband_names_both_reach_their_readers() -> Result<()> { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build"); + runtime.block_on(async { + let mut analysis = EmmyLuaAnalysis::new(); + let workspace = std::env::temp_dir().join("gmod_glua_ls_debounced_ripple_sideband"); + analysis.add_main_workspace(workspace.clone()); + + let provider_uri = file_path_to_uri(&workspace.join("lua/chain/provider.lua")) + .expect("provider URI should parse"); + let middle_uri = file_path_to_uri(&workspace.join("lua/chain/middle.lua")) + .expect("middle URI should parse"); + let consumer_uri = file_path_to_uri(&workspace.join("lua/chain/consumer.lua")) + .expect("consumer URI should parse"); + analysis + .update_file_by_uri(&provider_uri, Some("Chain = { Value = 1 }\n".to_string())) + .map(|(id, _)| id); + analysis + .update_file_by_uri(&middle_uri, Some("Binferred = Chain.Value\n".to_string())) + .map(|(id, _)| id); + analysis + .update_file_by_uri(&consumer_uri, Some("local got = Binferred\n".to_string())) + .map(|(id, _)| id); + let consumer_id = analysis + .compilation + .get_db() + .get_vfs() + .get_file_id(&consumer_uri) + .expect("consumer file id"); + + let analysis = Arc::new(RwLock::new(analysis)); + let (debounced_analysis, peer) = loop_test_debounced_analysis(analysis.clone()); + let revision_before = reference_revision(&analysis, consumer_id).await; + + let loop_task = tokio::spawn({ + let debounced_analysis = debounced_analysis.clone(); + async move { debounced_analysis.run().await } + }); + + let provider_id = { + let mut guard = analysis.write().await; + guard + .update_file_text_only( + &provider_uri, + "Chain = { Value = \"text\" }\n".to_string(), + ) + .expect("provider file should still exist") + }; + debounced_analysis + .schedule(provider_id, provider_uri.clone()) + .await; + + // The consumer only names `Binferred`, an export of the rippled + // middleman: its publish can only come from the sideband names the + // ripple reported, drained after the ripple ran. + tokio::time::timeout(Duration::from_secs(15), async { + tokio::task::spawn_blocking(move || { + loop { + let message = peer.receiver.recv().expect("peer channel should stay open"); + if let Some(params) = publish_params(&message) + && params.uri == consumer_uri + { + return; + } + } + }) + .await + .expect("the publish reader task must not panic") + }) + .await + .expect("the consumer should be re-published by the ripple sideband"); + + debounced_analysis.shutdown.cancel(); + tokio::time::timeout(Duration::from_secs(5), loop_task) + .await + .expect("the loop should stop on shutdown") + .expect("the loop task must not panic"); + + let revision_after = reference_revision(&analysis, consumer_id).await; + verify_that!(revision_after, eq(revision_before))?; + Ok(()) + }) + } } diff --git a/crates/glua_ls/src/context/did_change_coalescer.rs b/crates/glua_ls/src/context/did_change_coalescer.rs index 29e261c5f..2ec1544f6 100644 --- a/crates/glua_ls/src/context/did_change_coalescer.rs +++ b/crates/glua_ls/src/context/did_change_coalescer.rs @@ -8,6 +8,7 @@ use crate::handlers::on_did_change_text_document; struct QueuedDidChange { params: DidChangeTextDocumentParams, in_flight: InFlightChangeGuard, + enqueue_profile: Option, } /// Coalesces rapid `textDocument/didChange` notifications. @@ -37,8 +38,17 @@ impl DidChangeCoalescer { /// Enqueue a didChange notification. /// If the worker is busy, the params accumulate in the channel and /// the worker drains + deduplicates them in the next batch. - pub fn enqueue(&self, params: DidChangeTextDocumentParams, in_flight: InFlightChangeGuard) { - if let Err(err) = self.tx.send(QueuedDidChange { params, in_flight }) { + pub fn enqueue( + &self, + params: DidChangeTextDocumentParams, + in_flight: InFlightChangeGuard, + enqueue_profile: Option, + ) { + if let Err(err) = self.tx.send(QueuedDidChange { + params, + in_flight, + enqueue_profile, + }) { log::error!( "LS_COALESCER_SEND_FAILED didChange worker channel is closed; settling dropped change" ); @@ -77,10 +87,43 @@ impl DidChangeCoalescer { // Process only the latest version for each URI. for (uri, queued) in latest { + if !crate::util::ls_profile::ls_profile_enabled() + || queued.enqueue_profile.is_none() + { + let task_context = context.clone(); + let handle = tokio::spawn(async move { + on_did_change_text_document(task_context, queued.params).await; + }); + if let Err(err) = handle.await { + log::error!( + "LS_COALESCER_ITEM_PANIC uri={:?} didChange handler failed: {}", + uri, + err + ); + } + queued.in_flight.finish().await; + continue; + } + + // Profiled path: scope one accumulator so preparse, write + // wait/hold, and schedule timings observed inside + // `on_did_change_text_document` accumulate into a single + // `[profile] ls_did_change` line. Superseded (coalesced away) + // versions never reach here and emit nothing. + let profile = queued.enqueue_profile.expect("profile checked above"); + let accum = std::sync::Arc::new(crate::util::ls_profile::DidChangeAccum::new()); + let dequeue_at = std::time::Instant::now(); + let coalescer_wait = dequeue_at.saturating_duration_since(profile.enqueue_at); let task_context = context.clone(); - let handle = tokio::spawn(async move { - on_did_change_text_document(task_context, queued.params).await; - }); + let accum_scope = accum.clone(); + let params = queued.params; + let in_flight = queued.in_flight; + let handle = tokio::spawn(crate::util::ls_profile::LS_DID_CHANGE_ACCUM.scope( + accum_scope, + async move { + on_did_change_text_document(task_context, params).await; + }, + )); if let Err(err) = handle.await { log::error!( "LS_COALESCER_ITEM_PANIC uri={:?} didChange handler failed: {}", @@ -88,7 +131,19 @@ impl DidChangeCoalescer { err ); } - queued.in_flight.finish().await; + in_flight.finish().await; + let timings = accum.snapshot(); + let total = profile.enqueue_at.elapsed(); + crate::util::ls_profile::emit_ls_did_change( + profile.seq, + profile.version, + coalescer_wait, + timings.preparse, + timings.write_wait, + timings.write_hold, + timings.notify_to_schedule, + total, + ); } } } diff --git a/crates/glua_ls/src/context/file_diagnostic.rs b/crates/glua_ls/src/context/file_diagnostic.rs index 3859fa008..f6c84619a 100644 --- a/crates/glua_ls/src/context/file_diagnostic.rs +++ b/crates/glua_ls/src/context/file_diagnostic.rs @@ -15,7 +15,7 @@ use tokio_util::sync::CancellationToken; use crate::util::{LongRunningWatchdogStatus, spawn_long_running_watchdog}; -use super::{ClientProxy, ProgressTask, StatusBar}; +use super::{ClientProxy, Freshness, ProgressTask, StatusBar}; #[derive(Clone, Default)] pub(crate) struct SharedDiagnosticDataCache { @@ -227,7 +227,11 @@ impl FileDiagnostic { tokio::select! { _ = tokio::time::sleep(Duration::from_millis(interval)) => { if let Some(da) = debounced_analysis { - da.wait_for_reindex(file_id_clone, cancel_token.clone()).await; + if da.wait_for_reindex(file_id_clone, cancel_token.clone()).await + != Freshness::Fresh + { + return; + } } if cancel_token.is_cancelled() { return; @@ -1071,15 +1075,18 @@ mod tests { let server_uri = file_path_to_uri(&workspace.join("lua/autorun/server/sv_api.lua")) .expect("server URI should parse"); - analysis.update_file_by_uri( - &server_uri, - Some("function ServerOnlyApi() return true end".to_string()), - ); + analysis + .update_file_by_uri( + &server_uri, + Some("function ServerOnlyApi() return true end".to_string()), + ) + .map(|(id, _)| id); let client_uri = file_path_to_uri(&workspace.join("lua/autorun/client/cl_user.lua")) .expect("client URI should parse"); let client_file = analysis .update_file_by_uri(&client_uri, Some("ServerOnlyApi()".to_string())) + .map(|(id, _)| id) .expect("client file should be indexed"); let (connection, _peer) = Connection::memory(); diff --git a/crates/glua_ls/src/context/mod.rs b/crates/glua_ls/src/context/mod.rs index fb01c820f..e80b4a3e3 100644 --- a/crates/glua_ls/src/context/mod.rs +++ b/crates/glua_ls/src/context/mod.rs @@ -10,7 +10,7 @@ mod workspace_manager; pub use client::ClientProxy; pub use client_id::{ClientId, get_client_id}; -pub use debounced_analysis::{DebouncedAnalysis, InFlightChangeGuard}; +pub use debounced_analysis::{DebouncedAnalysis, Freshness, InFlightChangeGuard}; pub use did_change_coalescer::DidChangeCoalescer; pub use file_diagnostic::FileDiagnostic; use glua_code_analysis::EmmyLuaAnalysis; @@ -165,13 +165,15 @@ impl ServerContext { 200, debounced_shutdown.clone(), client.clone(), + file_diagnostic.clone(), file_diagnostic.shared_diagnostic_data_cache(), workspace_diagnostic_level, lsp_features.clone(), )); // Supervise the debounce loop: freshness waiters park on it with no - // deadline, so if it dies the whole server silently goes quiet. + // deadline, so if it dies the waiters fail fast via the alive flag + // instead of the whole server silently going quiet. { let da = debounced_analysis.clone(); let shutdown = debounced_shutdown.clone(); @@ -189,10 +191,11 @@ impl ServerContext { restarts += 1; if restarts > DEBOUNCE_RESTART_LIMIT { log::error!( - "LS_DEBOUNCE_LOOP_DEAD debounced analysis loop panicked {} times; giving up, so edits stop being re-indexed and freshness waits park until their request is cancelled: {}", + "LS_DEBOUNCE_LOOP_DEAD debounced analysis loop panicked {} times; giving up, so edits stop being re-indexed and freshness waits fail fast: {}", restarts, err ); + da.note_debounce_loop_dead(); return; } log::error!( diff --git a/crates/glua_ls/src/context/snapshot.rs b/crates/glua_ls/src/context/snapshot.rs index 502eb5a10..4a94e2a89 100644 --- a/crates/glua_ls/src/context/snapshot.rs +++ b/crates/glua_ls/src/context/snapshot.rs @@ -180,10 +180,29 @@ impl ServerContextSnapshot { &self, cancel_token: &CancellationToken, ) -> Option> { - tokio::select! { + if !crate::util::ls_profile::ls_profile_enabled() { + return tokio::select! { + guard = self.analysis().read() => Some(guard), + _ = cancel_token.cancelled() => None, + }; + } + + // Profiled path: time the lock wait and attribute it to the request + // accumulator scoped in the fresh-index dispatch arm, so the per + // request summary line can report `analysis_read_wait_ms` without + // changing handler signatures. Callers outside that scope (other + // dispatch arms) simply have nowhere to record and drop the sample. + let start = std::time::Instant::now(); + let guard = tokio::select! { guard = self.analysis().read() => Some(guard), _ = cancel_token.cancelled() => None, + }; + if guard.is_some() { + let elapsed = start.elapsed(); + let _ = crate::util::ls_profile::LS_REQUEST_ACCUM + .try_with(|accum| accum.add_read_wait(elapsed)); } + guard } /// Acquire a read lock on the workspace manager, racing against a diff --git a/crates/glua_ls/src/handlers/code_lens/mod.rs b/crates/glua_ls/src/handlers/code_lens/mod.rs index 05aa50e5d..e110b5f70 100644 --- a/crates/glua_ls/src/handlers/code_lens/mod.rs +++ b/crates/glua_ls/src/handlers/code_lens/mod.rs @@ -10,7 +10,7 @@ use resolve_code_lens::resolve_code_lens; use serde::{Deserialize, Serialize}; use tokio_util::sync::CancellationToken; -use crate::context::ServerContextSnapshot; +use crate::context::{Freshness, ServerContextSnapshot}; use super::RegisterCapabilities; @@ -34,10 +34,11 @@ pub async fn on_code_lens_handler( // Wait for pending reindex work so VS Code keeps the current lenses visible // instead of clearing them during the dirty window, which causes layout flicker. - if !context + if context .debounced_analysis() .wait_until_fresh_for(&cancel_token, "textDocument/codeLens") .await + != Freshness::Fresh { return None; } diff --git a/crates/glua_ls/src/handlers/completion/add_completions/completion_item_info.rs b/crates/glua_ls/src/handlers/completion/add_completions/completion_item_info.rs index b26b5a580..23b5bc490 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/completion_item_info.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/completion_item_info.rs @@ -1,4 +1,4 @@ -use glua_code_analysis::{LuaType, LuaUnionType}; +use glua_code_analysis::LuaType; use glua_parser::{LuaExpr, LuaLiteralToken, UnaryOperator}; use crate::handlers::{ @@ -114,9 +114,9 @@ pub(super) fn is_gmod_literal_constructor_type(typ: &LuaType) -> bool { is_gmod_literal_constructor_name(&id.get_simple_name()) } LuaType::Instance(instance) => is_gmod_literal_constructor_type(instance.get_base()), - LuaType::Union(union) => match union.as_ref() { - LuaUnionType::Nullable(typ) => is_gmod_literal_constructor_type(typ), - LuaUnionType::Multi(types) => types.iter().any(is_gmod_literal_constructor_type), + LuaType::Union(union) => match union.nullable_inner() { + Some(typ) => is_gmod_literal_constructor_type(typ), + None => union.types().any(is_gmod_literal_constructor_type), }, LuaType::Intersection(intersection) => intersection .get_types() @@ -130,9 +130,9 @@ pub(crate) fn is_color_type(typ: &LuaType) -> bool { match typ { LuaType::Ref(id) | LuaType::Def(id) => id.get_simple_name() == "Color", LuaType::Instance(instance) => is_color_type(instance.get_base()), - LuaType::Union(union) => match union.as_ref() { - LuaUnionType::Nullable(typ) => is_color_type(typ), - LuaUnionType::Multi(types) => types.iter().any(is_color_type), + LuaType::Union(union) => match union.nullable_inner() { + Some(typ) => is_color_type(typ), + None => union.types().any(is_color_type), }, LuaType::Intersection(intersection) => intersection.get_types().iter().any(is_color_type), _ => false, diff --git a/crates/glua_ls/src/handlers/completion/add_completions/mod.rs b/crates/glua_ls/src/handlers/completion/add_completions/mod.rs index 6337bd6ac..3b9870662 100644 --- a/crates/glua_ls/src/handlers/completion/add_completions/mod.rs +++ b/crates/glua_ls/src/handlers/completion/add_completions/mod.rs @@ -135,10 +135,10 @@ pub fn is_table_namespace_type(typ: &LuaType) -> bool { | LuaType::TableOf(_) | LuaType::Object(_) | LuaType::Global => true, - LuaType::Union(union) => match union.as_ref() { - LuaUnionType::Nullable(typ) => is_table_namespace_type(typ), - LuaUnionType::Multi(types) => { - let mut non_nil_types = types.iter().filter(|typ| !matches!(typ, LuaType::Nil)); + LuaType::Union(union) => match union.nullable_inner() { + Some(typ) => is_table_namespace_type(typ), + None => { + let mut non_nil_types = union.types().filter(|typ| !matches!(typ, LuaType::Nil)); non_nil_types.next().is_some_and(is_table_namespace_type) && non_nil_types.all(is_table_namespace_type) } @@ -178,10 +178,10 @@ pub fn get_completion_tags( } fn get_union_completion_kind(union: &LuaUnionType) -> CompletionItemKind { - let kinds = match union { - LuaUnionType::Nullable(typ) => return get_completion_kind(typ), - LuaUnionType::Multi(types) => types - .iter() + let kinds = match union.nullable_inner() { + Some(typ) => return get_completion_kind(typ), + None => union + .types() .filter(|typ| !matches!(typ, LuaType::Nil)) .map(get_completion_kind) .collect::>(), diff --git a/crates/glua_ls/src/handlers/completion/mod.rs b/crates/glua_ls/src/handlers/completion/mod.rs index 5a66871c2..7d80d1ab6 100644 --- a/crates/glua_ls/src/handlers/completion/mod.rs +++ b/crates/glua_ls/src/handlers/completion/mod.rs @@ -32,6 +32,11 @@ pub async fn on_completion_handler( params: CompletionParams, cancel_token: CancellationToken, ) -> Option { + // Profiling: the fresh-index dispatch arm scopes a request accumulator; + // this guard records `handler_ms` into it on every return path so the + // single `[profile] ls_request` line stays accurate. `analysis_read_wait` + // is recorded inside `read_analysis` via the same scope. + let _handler_timer = crate::util::ls_profile::HandlerTimer::scoped(); if cancel_token.is_cancelled() { return None; } 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..475207e8f 100644 --- a/crates/glua_ls/src/handlers/completion/providers/member_provider.rs +++ b/crates/glua_ls/src/handlers/completion/providers/member_provider.rs @@ -1,14 +1,15 @@ 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, 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, LuaFuncStat, LuaIndexExpr, LuaLocalFuncStat, LuaNameExpr, LuaStringToken, PathTrait, }; use rowan::TextSize; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use smol_str::SmolStr; -use std::collections::{HashMap, HashSet}; use crate::handlers::completion::{ add_completions::{CompletionTriggerStatus, add_member_completion_with_description_hint}, @@ -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,79 @@ fn extend_gmod_hook_fallback_members( } } +/// 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(); + for infos in members.values_mut() { + resolve_slot_writes(db, infos); + let mut seen = HashSet::default(); + infos.retain(|info| seen.insert(MemberInfoIdentity::from(info))); + } +} + +/// 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, +} + +/// 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 writes = infos + .iter() + .map(|info| classify_slot_write(db, info)) + .collect::>(); + if !writes.contains(&SlotWrite::Unconditional) { + return; + } + + let mut writes = writes.into_iter(); + infos.retain(|_| writes.next() != Some(SlotWrite::Branch)); +} + +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 glua_code_analysis::is_member_assignment_in_conditional_branch(db, *member_id) { + SlotWrite::Branch + } else { + SlotWrite::Unconditional + } +} + type MemberIdentityMap = HashMap>; #[derive(Clone, Debug, Eq, Hash, PartialEq)] @@ -200,7 +280,7 @@ impl From<&LuaMemberInfo> for MemberInfoIdentity { fn collect_member_identities( members: &HashMap>, ) -> MemberIdentityMap { - let mut existing: MemberIdentityMap = HashMap::new(); + let mut existing: MemberIdentityMap = HashMap::default(); for (key, infos) in members { let entry = existing.entry(key.clone()).or_default(); entry.extend(infos.iter().map(MemberInfoIdentity::from)); @@ -570,9 +650,9 @@ fn is_gmod_hook_member_info(db: &DbIndex, info: &LuaMemberInfo) -> bool { /// `---@realm` annotations are the same for every member declared in it. Both /// used to be re-derived per member, which meant walking the declaring file's /// entire syntax tree once per candidate — 98ms of a 200ms completion on a -/// gamemode workspace. The analyzer already indexes those ranges, so prefer its -/// binary search and fall back to one cached walk per file, exactly as the -/// realm-misuse checker does. +/// gamemode workspace. The analyzer indexes those ranges for every file it +/// analyses, so this reads its binary search; the cached walk below only covers +/// a file that never reached the analyzer at all. struct RealmFilter { enabled: bool, call_mask: GmodStateMask, @@ -597,7 +677,7 @@ impl RealmFilter { Self { enabled, call_mask, - walked: HashMap::new(), + walked: HashMap::default(), } } @@ -748,7 +828,7 @@ mod tests { #[gtest] fn push_unique_member_info_keeps_distinct_overload_indices() -> Result<()> { let key = LuaMemberKey::Name("lookup".into()); - let mut members = HashMap::new(); + let mut members = HashMap::default(); let mut existing = collect_member_identities(&members); push_unique_member_info( diff --git a/crates/glua_ls/src/handlers/definition/mod.rs b/crates/glua_ls/src/handlers/definition/mod.rs index f4c41b064..422441866 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 @@ -317,11 +317,14 @@ fn collect_dynamic_field_locations( } } LuaType::TableConst(table_range) => { - let owner = glua_code_analysis::DynamicFieldOwner::Table(table_range.clone()); + let owner = glua_code_analysis::canonical_dynamic_field_owner( + semantic_model.get_db(), + glua_code_analysis::DynamicFieldOwner::Table(table_range.clone()), + ); 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 diff --git a/crates/glua_ls/src/handlers/diagnostic/document_diagnostic.rs b/crates/glua_ls/src/handlers/diagnostic/document_diagnostic.rs index 7b7c6843f..0c63c9f2e 100644 --- a/crates/glua_ls/src/handlers/diagnostic/document_diagnostic.rs +++ b/crates/glua_ls/src/handlers/diagnostic/document_diagnostic.rs @@ -6,7 +6,7 @@ use lsp_types::{ use tokio_util::sync::CancellationToken; use super::diagnostic_result_id; -use crate::context::ServerContextSnapshot; +use crate::context::{Freshness, ServerContextSnapshot}; fn full_report( result_id: Option, @@ -57,10 +57,11 @@ pub async fn on_pull_document_diagnostic( // Correctness, not latency: the index stays stale between didChange and // the debounced reindex, and diagnostics computed then are wrong. - if !context + if context .debounced_analysis() .wait_until_fresh_for(&token, "textDocument/diagnostic") .await + != Freshness::Fresh { return keep_client_state(&context, &uri, previous_result_id).await; } diff --git a/crates/glua_ls/src/handlers/diagnostic/workspace_diagnostic.rs b/crates/glua_ls/src/handlers/diagnostic/workspace_diagnostic.rs index 171b6bf8a..8a84f9903 100644 --- a/crates/glua_ls/src/handlers/diagnostic/workspace_diagnostic.rs +++ b/crates/glua_ls/src/handlers/diagnostic/workspace_diagnostic.rs @@ -9,32 +9,46 @@ use lsp_types::{ use tokio_util::sync::CancellationToken; use super::diagnostic_result_id; -use crate::context::{ServerContextSnapshot, WorkspaceDiagnosticLevel}; +use crate::context::{Freshness, ServerContextSnapshot, WorkspaceDiagnosticLevel}; pub async fn on_pull_workspace_diagnostic( context: ServerContextSnapshot, params: WorkspaceDiagnosticParams, token: CancellationToken, -) -> WorkspaceDiagnosticReport { - // Wait for any pending/in-flight document changes to finish before diagnosing. - if !context +) -> Option { + match context .debounced_analysis() .wait_until_fresh_for(&token, "workspace/diagnostic") .await { - // Cancellation — return empty items rather than stale data, - // since workspace diagnostics replace per-URI state and - // returning stale could mask real issues. The client will - // re-pull after the next refresh signal. - return WorkspaceDiagnosticReport { items: vec![] }; + Freshness::Fresh => {} + Freshness::Cancelled => { + // Cancellation — return empty items rather than stale data, + // since workspace diagnostics replace per-URI state and + // returning stale could mask real issues. The client will + // re-pull after the next refresh signal. + return Some(WorkspaceDiagnosticReport { items: vec![] }); + } + Freshness::LoopDead => { + // Freshness can never arrive, so a sweep would diagnose the whole + // workspace against stale data. Fail as cancelled (the token makes + // the task answer with the cancel error, whose `retriggerRequest` + // data re-pulls when the client next refreshes) instead of + // reporting a success. The level is left unclaimed. + log::error!( + "LS_WORKSPACE_DIAGNOSTIC_LOOP_DEAD workspace/diagnostic abandoned: debounce loop is dead" + ); + token.cancel(); + return None; + } } let Some(workspace_manager) = context.read_workspace_manager(&token).await else { - return WorkspaceDiagnosticReport { items: vec![] }; + return Some(WorkspaceDiagnosticReport { items: vec![] }); }; let status = workspace_manager.claim_workspace_diagnostic_level(); if status == WorkspaceDiagnosticLevel::None { - return WorkspaceDiagnosticReport { items: vec![] }; + return Some(WorkspaceDiagnosticReport { items: vec![] }); } let client_id = workspace_manager.client_config.client_id; let open_files = workspace_manager.current_open_files.clone(); @@ -70,15 +84,14 @@ pub async fn on_pull_workspace_diagnostic( let analysis = context.analysis().read().await; let vfs = analysis.compilation.get_db().get_vfs(); - build_report( + Some(build_report( file_diagnostics, params.previous_result_ids, &open_files, |uri| vfs.get_file_id(uri), |file_id| vfs.get_file_version(&file_id), - ) + )) } - /// Builds the report, matching client-supplied URIs against the server's own by /// `FileId` rather than by URI. /// diff --git a/crates/glua_ls/src/handlers/document_color/build_color.rs b/crates/glua_ls/src/handlers/document_color/build_color.rs index 94860d172..ecc0ffdd2 100644 --- a/crates/glua_ls/src/handlers/document_color/build_color.rs +++ b/crates/glua_ls/src/handlers/document_color/build_color.rs @@ -531,7 +531,7 @@ mod tests { ---@param b number ---@param a? number function surface.SetDrawColor(r, g, b, a) end - + surface.SetDrawColor(255, 0, 0) surface.SetDrawColor(255, 0, 0, 255) "#, @@ -632,7 +632,7 @@ mod tests { ---@param b number ---@param a? number function Color(r, g, b, a) end - + local c = Color(255, 0, 0) "#, ); @@ -688,7 +688,7 @@ mod tests { ---@param y number ---@param z number function SetPos(x, y, z) end - + SetPos(255, 0, 0) "#, ); diff --git a/crates/glua_ls/src/handlers/document_color/mod.rs b/crates/glua_ls/src/handlers/document_color/mod.rs index 8ea49801f..b964c86dc 100644 --- a/crates/glua_ls/src/handlers/document_color/mod.rs +++ b/crates/glua_ls/src/handlers/document_color/mod.rs @@ -352,7 +352,7 @@ mod tests { ---@param b number ---@param a? number function surface.SetDrawColor(r, g, b, a) end - + surface.SetDrawColor(255, 0, 0) "#, ); @@ -366,7 +366,7 @@ mod tests { ---@param g number ---@param b number function surface.SetDrawColor(r, g, b) end - + surface.SetDrawColor(255, 0, 0) "#, ); diff --git a/crates/glua_ls/src/handlers/emmy_annotator/mod.rs b/crates/glua_ls/src/handlers/emmy_annotator/mod.rs index 79f267dbd..5d4da30c8 100644 --- a/crates/glua_ls/src/handlers/emmy_annotator/mod.rs +++ b/crates/glua_ls/src/handlers/emmy_annotator/mod.rs @@ -8,7 +8,7 @@ pub use emmy_annotator_request::*; use lsp_types::Uri; use tokio_util::sync::CancellationToken; -use crate::context::ServerContextSnapshot; +use crate::context::{Freshness, ServerContextSnapshot}; pub async fn on_emmy_annotator_handler( context: ServerContextSnapshot, @@ -24,10 +24,11 @@ pub async fn on_emmy_annotator_handler( // Wait for any pending reindex to finish so we compute against // consistent tree + index data. Cancel-aware: bails out when a // new didChange fires cancel_all_requests(). - if !context + if context .debounced_analysis() .wait_until_fresh_for(&cancel_token, "gluals/emmyAnnotator") .await + != Freshness::Fresh { return None; } diff --git a/crates/glua_ls/src/handlers/hover/find_origin.rs b/crates/glua_ls/src/handlers/hover/find_origin.rs index df75fc530..83b418a07 100644 --- a/crates/glua_ls/src/handlers/hover/find_origin.rs +++ b/crates/glua_ls/src/handlers/hover/find_origin.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use glua_code_analysis::{ LuaCompilation, LuaDeclExtra, LuaDeclId, LuaMemberId, LuaMemberIndexItem, LuaMemberOwner, - LuaSemanticDeclId, LuaType, LuaTypeDeclId, LuaUnionType, SemanticDeclLevel, SemanticModel, + LuaSemanticDeclId, LuaType, LuaTypeDeclId, SemanticDeclLevel, SemanticModel, }; use glua_parser::{LuaAssignStat, LuaAstNode, LuaSyntaxKind, LuaTableExpr, LuaTableField}; use rowan::TextSize; @@ -411,9 +411,9 @@ fn table_is_class(table_type: &LuaType, depth: usize) -> bool { } match table_type { LuaType::Ref(_) | LuaType::Def(_) | LuaType::Generic(_) => true, - LuaType::Union(union) => match union.as_ref() { - LuaUnionType::Nullable(t) => table_is_class(t, depth + 1), - LuaUnionType::Multi(ts) => ts.iter().any(|t| table_is_class(t, depth + 1)), + LuaType::Union(union) => match union.nullable_inner() { + Some(t) => table_is_class(t, depth + 1), + None => union.types().any(|t| table_is_class(t, depth + 1)), }, _ => false, } diff --git a/crates/glua_ls/src/handlers/hover/function/mod.rs b/crates/glua_ls/src/handlers/hover/function/mod.rs index 36ac85bb1..a418947d4 100644 --- a/crates/glua_ls/src/handlers/hover/function/mod.rs +++ b/crates/glua_ls/src/handlers/hover/function/mod.rs @@ -566,6 +566,13 @@ fn hover_doc_function_type( push_typed_owner_prefix(&prefix, LuaType::Ref(type_decl_id.clone())); } } + LuaMemberOwner::GlobalPath(path) => { + name.push_str(path.get_name()); + if is_method { + type_label = "(method) "; + } + name.push(if is_method { ':' } else { '.' }); + } LuaMemberOwner::Element(element_id) => { if let Some(LuaType::Ref(type_decl_id) | LuaType::Def(type_decl_id)) = extract_parent_type_from_element(builder.semantic_model, element_id) diff --git a/crates/glua_ls/src/handlers/inlay_hint/mod.rs b/crates/glua_ls/src/handlers/inlay_hint/mod.rs index 413f92aea..5bae746a8 100644 --- a/crates/glua_ls/src/handlers/inlay_hint/mod.rs +++ b/crates/glua_ls/src/handlers/inlay_hint/mod.rs @@ -2,7 +2,7 @@ mod build_function_hint; mod build_inlay_hint; use super::RegisterCapabilities; -use crate::context::{ClientId, ServerContextSnapshot}; +use crate::context::{ClientId, Freshness, ServerContextSnapshot}; use build_inlay_hint::build_inlay_hints; pub use build_inlay_hint::{get_override_lsp_location, get_super_member_id}; use glua_code_analysis::{EmmyLuaAnalysis, FileId}; @@ -32,10 +32,11 @@ pub async fn on_inlay_hint_handler( // Wait for any pending reindex to finish so we serve fresh hints // computed against consistent tree + index data. - if !context + if context .debounced_analysis() .wait_until_fresh_for(&cancel_token, "textDocument/inlayHint") .await + != Freshness::Fresh { return None; } diff --git a/crates/glua_ls/src/handlers/notification_handler.rs b/crates/glua_ls/src/handlers/notification_handler.rs index e37b7a1d8..356e8ddd8 100644 --- a/crates/glua_ls/src/handlers/notification_handler.rs +++ b/crates/glua_ls/src/handlers/notification_handler.rs @@ -86,9 +86,15 @@ pub async fn on_notification_handler( // Mark analysis dirty BEFORE handing the update to the coalescer so // follow-up requests see the stale state immediately. let in_flight = snapshot.debounced_analysis_arc().begin_in_flight_change(); + // Profiling: capture the notification receipt (version + seq + + // clock) so the coalescer worker can report `coalescer_wait_ms` + // and `total_ms` spanning notification through schedule. `None` + // when unset: zero clock reads, zero output. + let enqueue_profile = + crate::util::ls_profile::DidChangeEnqueue::capture(params.text_document.version); server_context .did_change_coalescer() - .enqueue(params, in_flight); + .enqueue(params, in_flight, enqueue_profile); } return Ok(()); } diff --git a/crates/glua_ls/src/handlers/request_handler.rs b/crates/glua_ls/src/handlers/request_handler.rs index 08c1396c7..3bb888344 100644 --- a/crates/glua_ls/src/handlers/request_handler.rs +++ b/crates/glua_ls/src/handlers/request_handler.rs @@ -18,7 +18,7 @@ use lsp_types::request::{ use serde::Serialize; use crate::{ - context::{RequestTaskMetadata, ServerContext}, + context::{Freshness, RequestTaskMetadata, ServerContext}, handlers::{ diagnostic::{on_pull_document_diagnostic, on_pull_workspace_diagnostic}, document_type_format::on_type_formatting_handler, @@ -133,45 +133,141 @@ macro_rules! dispatch_request { let task_metadata = request_task_metadata(<$fresh_req_type>::METHOD, ¶ms); let target_uri = task_metadata.uri.clone(); $context.task(id.clone(), task_metadata, |cancel_token| async move { - // Symbol resolution against a stale index silently - // returns empty; wait for the reindex. A request - // aimed at one file only needs that file's own - // entries to match its text, so it waits for those - // rather than for the edit's whole dependency - // ripple — seconds apart on a large gamemode. - // The handoff is held across the handler so the - // ripple waits for this request to take its read - // lock rather than putting it behind the ripple it - // was just released from. - let (fresh, _handoff) = match target_uri.as_ref() { - Some(uri) => { - let debounced = snapshot.debounced_analysis_arc(); - let handoff = debounced.begin_reader_handoff(); - let fresh = debounced - .wait_until_file_fresh_for( - &cancel_token, - <$fresh_req_type>::METHOD, - uri, - ) - .await; - (fresh, Some(handoff)) - } - None => { - let fresh = snapshot - .debounced_analysis() - .wait_until_fresh_for( - &cancel_token, - <$fresh_req_type>::METHOD, - ) - .await; - (fresh, None) + if !crate::util::ls_profile::ls_profile_enabled() { + // Symbol resolution against a stale index silently + // returns empty; wait for the reindex. A request + // aimed at one file only needs that file's own + // entries to match its text, so it waits for those + // rather than for the edit's whole dependency + // ripple — seconds apart on a large gamemode. + // The handoff is held across the handler so the + // ripple waits for this request to take its read + // lock rather than putting it behind the ripple it + // was just released from. + let (freshness, _handoff) = match target_uri.as_ref() { + Some(uri) => { + let debounced = snapshot.debounced_analysis_arc(); + let handoff = debounced.begin_reader_handoff(); + let fresh = debounced + .wait_until_file_fresh_for( + &cancel_token, + <$fresh_req_type>::METHOD, + uri, + ) + .await; + (fresh, Some(handoff)) + } + None => { + let fresh = snapshot + .debounced_analysis() + .wait_until_fresh_for( + &cancel_token, + <$fresh_req_type>::METHOD, + ) + .await; + (fresh, None) + } + }; + match freshness { + Freshness::Fresh => {} + Freshness::Cancelled => return None, + // Freshness can never arrive: fail as + // cancelled so the client re-sends instead + // of recording an internal error. + Freshness::LoopDead => { + cancel_token.cancel(); + return None; + } } - }; - if !fresh { - return None; + let result = $fresh_handler(snapshot, params, cancel_token).await; + Some(Response::new_ok(id, result)) + } else { + // Profiled path: identical wake-up and handoff + // behavior, plus one `[profile] ls_request` line + // accumulating fresh-wait by observed state. + // `analysis_read_wait` is contributed by + // `read_analysis` via the scoped accumulator; + // `handler_ms` comes from the completion + // handler's guard when present, else from the + // dispatch-side measurement. + let seq = crate::util::ls_profile::next_ls_seq(); + let total_start = std::time::Instant::now(); + let id_string = format!("{:?}", id); + let method = <$fresh_req_type>::METHOD; + let accum = + std::sync::Arc::new(crate::util::ls_profile::LsRequestAccum::new()); + let accum_scope = accum.clone(); + crate::util::ls_profile::LS_REQUEST_ACCUM + .scope(accum_scope, async move { + let (freshness, _handoff, breakdown) = match target_uri.as_ref() { + Some(uri) => { + let debounced = snapshot.debounced_analysis_arc(); + let handoff = debounced.begin_reader_handoff(); + let (fresh, breakdown) = debounced + .wait_until_file_fresh_for_profiled( + &cancel_token, + <$fresh_req_type>::METHOD, + uri, + ) + .await; + (fresh, Some(handoff), breakdown) + } + None => { + let (fresh, breakdown) = snapshot + .debounced_analysis() + .wait_until_fresh_for_profiled( + &cancel_token, + <$fresh_req_type>::METHOD, + ) + .await; + (fresh, None, breakdown) + } + }; + if freshness != Freshness::Fresh { + let total = total_start.elapsed(); + let read_wait = accum.take_read_wait(); + let handler_ms = + accum.take_handler_time().unwrap_or_default(); + crate::util::ls_profile::emit_ls_request( + seq, + &id_string, + method, + total, + breakdown.total, + breakdown.in_flight, + breakdown.blocked, + read_wait, + handler_ms, + ); + if freshness == Freshness::LoopDead { + cancel_token.cancel(); + } + return None; + } + let handler_start = std::time::Instant::now(); + let result = + $fresh_handler(snapshot, params, cancel_token).await; + let handler_measured = handler_start.elapsed(); + let handler_ms = accum + .take_handler_time() + .unwrap_or(handler_measured); + let read_wait = accum.take_read_wait(); + let total = total_start.elapsed(); + crate::util::ls_profile::emit_ls_request( + seq, + &id_string, + method, + total, + breakdown.total, + breakdown.in_flight, + breakdown.blocked, + read_wait, + handler_ms, + ); + Some(Response::new_ok(id, result)) + }) + .await } - let result = $fresh_handler(snapshot, params, cancel_token).await; - Some(Response::new_ok(id, result)) }).await; return Ok(()); } @@ -205,7 +301,7 @@ macro_rules! dispatch_request { .lsp_features() .retries_on_content_modified(<$retry_req_type>::METHOD) { - let fresh = match target_uri.as_ref() { + let freshness = match target_uri.as_ref() { Some(uri) => { debounced .wait_until_file_fresh_for( @@ -224,8 +320,16 @@ macro_rules! dispatch_request { .await } }; - if !fresh { - return None; + match freshness { + Freshness::Fresh => {} + Freshness::Cancelled => return None, + // Freshness can never arrive: fail as + // cancelled so the client re-sends instead + // of recording an internal error. + Freshness::LoopDead => { + cancel_token.cancel(); + return None; + } } let result = $retry_handler(snapshot, params, cancel_token).await; return Some(Response::new_ok(id, result)); diff --git a/crates/glua_ls/src/handlers/semantic_token/mod.rs b/crates/glua_ls/src/handlers/semantic_token/mod.rs index 64a39ad0b..e0b362136 100644 --- a/crates/glua_ls/src/handlers/semantic_token/mod.rs +++ b/crates/glua_ls/src/handlers/semantic_token/mod.rs @@ -151,7 +151,8 @@ mod tests { .analysis() .write() .await - .update_file_by_uri(&uri, Some("local greeting = 1".to_string())); + .update_file_by_uri(&uri, Some("local greeting = 1".to_string())) + .map(|(id, _)| id); // Seen but not yet applied: exactly the window between a didChange // notification and the coalescer applying its preparsed tree. diff --git a/crates/glua_ls/src/handlers/test/completion_resolve_test.rs b/crates/glua_ls/src/handlers/test/completion_resolve_test.rs index c98312fae..7f02dcf63 100644 --- a/crates/glua_ls/src/handlers/test/completion_resolve_test.rs +++ b/crates/glua_ls/src/handlers/test/completion_resolve_test.rs @@ -103,7 +103,7 @@ mod tests { let detail = item.detail.ok_or("item detail is empty").or_fail()?; verify_eq!( detail, - "function lookup(kind: \"steamid\") -> string (+1 overloads)" + "function ix.character.lookup(kind: \"steamid\") -> string (+1 overloads)" )?; verify_that!(item.documentation, none())?; Ok(()) diff --git a/crates/glua_ls/src/handlers/test/completion_test.rs b/crates/glua_ls/src/handlers/test/completion_test.rs index cf2b9eec3..a2a8615a0 100644 --- a/crates/glua_ls/src/handlers/test/completion_test.rs +++ b/crates/glua_ls/src/handlers/test/completion_test.rs @@ -1474,6 +1474,152 @@ 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(()) + } + + /// 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] + 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::INTERFACE, + ..Default::default() + }], + )); + Ok(()) + } + #[gtest] fn test_issue_572() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); @@ -5277,6 +5423,7 @@ mod tests { .or_fail()?; ws.analysis .update_file_by_uri(&uri, Some(content.to_string())) + .map(|(id, _)| id) .or_fail() } } diff --git a/crates/glua_ls/src/handlers/test/hover_function_test.rs b/crates/glua_ls/src/handlers/test/hover_function_test.rs index f7a3b264f..abc6d444e 100644 --- a/crates/glua_ls/src/handlers/test/hover_function_test.rs +++ b/crates/glua_ls/src/handlers/test/hover_function_test.rs @@ -659,7 +659,6 @@ mod tests { #[gtest] fn test_fix_global_index_function_2() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new(); - // TODO: 构建完整的访问路径 check!(ws.check_hover( r#" M = { @@ -669,7 +668,7 @@ mod tests { end "#, VirtualHoverResult { - value: "```lua\nfunction Value()\n```".to_string(), + value: "```lua\nfunction M.K.Value()\n```".to_string(), }, )); Ok(()) @@ -828,7 +827,7 @@ mod tests { markup.value ); assert!( - top_section.contains("function Create(name, safety)"), + top_section.contains("function ents.Create(name, safety)"), "expected override signature in top section, got: {}", markup.value ); @@ -838,7 +837,7 @@ mod tests { markup.value ); assert!( - top_section.contains("function Create(class: string)"), + top_section.contains("function ents.Create(class: string)"), "expected annotated signature in top section, got: {}", markup.value ); @@ -986,7 +985,7 @@ mod tests { assert!( server_markup .value - .contains("function Create(name, safety)"), + .contains("function ents.Create(name, safety)"), "expected server hover to include server override signature, got: {}", server_markup.value ); @@ -1011,7 +1010,7 @@ mod tests { assert!( client_markup .value - .contains("function Create(class: string)"), + .contains("function ents.Create(class: string)"), "expected client hover to show shared annotated signature, got: {}", client_markup.value ); @@ -1030,7 +1029,7 @@ mod tests { assert!( !client_markup .value - .contains("function Create(name, safety)"), + .contains("function ents.Create(name, safety)"), "did not expect server-only override signature in client hover, got: {}", client_markup.value ); diff --git a/crates/glua_ls/src/handlers/test/hover_test.rs b/crates/glua_ls/src/handlers/test/hover_test.rs index b2e769209..796e5465f 100644 --- a/crates/glua_ls/src/handlers/test/hover_test.rs +++ b/crates/glua_ls/src/handlers/test/hover_test.rs @@ -1121,7 +1121,7 @@ local EscapeStringMap: { node["key"] = "value" "#, VirtualHoverResult { - value: "```lua\n(global) node: Node {\n field: number?,\n method: function,\n}\n```".to_string(), + value: "```lua\n(global) node: Node {\n field: number?,\n key: string = \"value\",\n method: function,\n}\n```".to_string(), }, )); @@ -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. /// diff --git a/crates/glua_ls/src/handlers/test_lib/mod.rs b/crates/glua_ls/src/handlers/test_lib/mod.rs index 6b319dca4..cdc07c605 100644 --- a/crates/glua_ls/src/handlers/test_lib/mod.rs +++ b/crates/glua_ls/src/handlers/test_lib/mod.rs @@ -149,6 +149,7 @@ impl ProviderVirtualWorkspace { self.analysis .update_file_by_uri(&uri, Some(content.to_string())) + .map(|(id, _)| id) .unwrap() } diff --git a/crates/glua_ls/src/handlers/text_document/text_document_handler.rs b/crates/glua_ls/src/handlers/text_document/text_document_handler.rs index c0a1e7b55..e9a4d9e11 100644 --- a/crates/glua_ls/src/handlers/text_document/text_document_handler.rs +++ b/crates/glua_ls/src/handlers/text_document/text_document_handler.rs @@ -38,63 +38,96 @@ async fn apply_document_update_without_queuing( version: i32, mut preparsed: Option, trigger_reindex: bool, -) -> Option { +) -> Option<(FileId, Vec)> { if should_drop_stale_version(context, uri, version) { return None; } // Fair-queued `write().await`, not a `try_write` spin, which can starve // for seconds under a stream of readers. + // + // Profiling: when a didChange accumulator is scoped, split the wait for + // the write lock from the hold so `[profile] ls_did_change` can report + // `analysis_write_wait_ms` vs `analysis_write_hold_ms`. Zero clock reads + // when unset or when called outside a didChange (e.g. didOpen). + let profile = crate::util::ls_profile::ls_profile_enabled() + && crate::util::ls_profile::LS_DID_CHANGE_ACCUM + .try_with(|_| ()) + .is_ok(); + let wait_start = profile.then(std::time::Instant::now); let mut analysis = context.analysis().write().await; + if let Some(start) = wait_start { + crate::util::ls_profile::record_did_change_write_wait(start.elapsed()); + } + let hold_start = profile.then(std::time::Instant::now); // The lock wait is unbounded, so re-check staleness now that we hold it. if should_drop_stale_version(context, uri, version) { + drop(analysis); + if let Some(start) = hold_start { + crate::util::ls_profile::record_did_change_write_hold(start.elapsed()); + } return None; } - let (file_id, deferred_drop) = if let Some(preparsed) = preparsed.take() { + let mut outcome: Option<(FileId, Vec)> = None; + let mut deferred_drop = None; + if let Some(preparsed) = preparsed.take() { if trigger_reindex { - ( - analysis.update_file_preparsed( - uri.clone(), - Some(text), - preparsed.tree, - preparsed.line_index, - Some(version), - true, - ), - None, - ) + if let Some((file_id, widened)) = analysis.update_file_preparsed( + uri.clone(), + Some(text), + preparsed.tree, + preparsed.line_index, + Some(version), + true, + ) { + outcome = Some((file_id, widened)); + } } else { - let (file_id, deferred_drop) = analysis.update_file_preparsed_deferred( + let Some((file_id, drop)) = analysis.update_file_preparsed_deferred( uri.clone(), Some(text), preparsed.tree, preparsed.line_index, Some(version), - )?; - (Some(file_id), Some(deferred_drop)) + ) else { + drop(analysis); + if let Some(start) = hold_start { + crate::util::ls_profile::record_did_change_write_hold(start.elapsed()); + } + return None; + }; + outcome = Some((file_id, Vec::new())); + deferred_drop = Some(drop); } } else if trigger_reindex { - (analysis.update_file_by_uri(uri, Some(text)), None) + if let Some((file_id, widened)) = analysis.update_file_by_uri(uri, Some(text)) { + outcome = Some((file_id, widened)); + } } else { - (analysis.update_file_text_only(uri, text), None) - }; + outcome = analysis + .update_file_text_only(uri, text) + .map(|file_id| (file_id, Vec::new())); + } // Text-only updates leave the index alone; the debounced reindex // invalidates under its own write lock. - if file_id.is_some() && trigger_reindex { + if outcome.is_some() && trigger_reindex { context .file_diagnostic() .invalidate_shared_diagnostic_data(); } drop(analysis); + if let Some(start) = hold_start { + crate::util::ls_profile::record_did_change_write_hold(start.elapsed()); + } if let Some(deferred_drop) = deferred_drop { spawn_deferred_drop(deferred_drop); } - file_id + outcome } async fn check_schema_update(context: &ServerContextSnapshot) { @@ -207,6 +240,40 @@ fn parse_error_range_to_lsp_range( } } +/// Fan the widen list an immediate edit path returned out to diagnostics. +/// +/// `widened` is every file the edit may have moved beyond the one it was +/// applied to. Files beyond the edited one get a diagnostic-only task (no +/// debounce slot), and when anything beyond the edited file moved the +/// workspace level is raised to `Fast` — never lowered — with a refresh +/// request so pull clients re-poll. +async fn handle_widened_refresh( + context: &ServerContextSnapshot, + file_id: Option, + widened: Vec, + interval: u64, +) { + let mut widened = widened; + if let Some(file_id) = file_id { + widened.retain(|id| *id != file_id); + } + if widened.is_empty() { + return; + } + context + .file_diagnostic() + .add_files_diagnostic_task(widened, interval, None) + .await; + context + .workspace_manager() + .read() + .await + .update_workspace_version(WorkspaceDiagnosticLevel::Fast, false); + if context.lsp_features().supports_refresh_diagnostic() { + context.client().refresh_workspace_diagnostics(); + } +} + pub async fn on_did_open_text_document( context: ServerContextSnapshot, params: DidOpenTextDocumentParams, @@ -253,8 +320,12 @@ pub async fn on_did_open_text_document( .as_ref() .map_or_else(Vec::new, |parsed| parsed.syntax_diagnostics.clone()); - let file_id = - apply_document_update_without_queuing(&context, &uri, text, version, preparsed, true).await; + let (file_id, widened) = + apply_document_update_without_queuing(&context, &uri, text, version, preparsed, true) + .await + .map_or((None, Vec::new()), |(file_id, widened)| { + (Some(file_id), widened) + }); if file_id.is_some() { context.note_document_applied_version(&uri, version); if context.lsp_features().supports_semantic_tokens_refresh() { @@ -282,6 +353,9 @@ pub async fn on_did_open_text_document( } } + // Files beyond the opened one whose diagnostics the open may have moved. + handle_widened_refresh(&context, file_id, widened, interval).await; + Some(()) } @@ -366,7 +440,18 @@ pub async fn on_did_change_text_document( } let interval = emmyrc.diagnostics.diagnostic_interval.unwrap_or(500); + // Profiling: time the off-lock parse so `ls_did_change` can report + // `preparse_ms`. Only when a didChange accumulator is scoped; didOpen and + // unprofiled runs pay no clock read. + let preparse_start = (crate::util::ls_profile::ls_profile_enabled() + && crate::util::ls_profile::LS_DID_CHANGE_ACCUM + .try_with(|_| ()) + .is_ok()) + .then(std::time::Instant::now); let preparsed = preparse_document(text.clone(), emmyrc.clone()).await; + if let Some(start) = preparse_start { + crate::util::ls_profile::record_did_change_preparse(start.elapsed()); + } let syntax_diagnostics = preparsed .as_ref() .map_or_else(Vec::new, |parsed| parsed.syntax_diagnostics.clone()); @@ -374,9 +459,12 @@ pub async fn on_did_change_text_document( return Some(()); } - let file_id = + let (file_id, _widened) = apply_document_update_without_queuing(&context, &uri, text, version, preparsed, false) - .await; + .await + .map_or((None, Vec::new()), |(file_id, widened)| { + (Some(file_id), widened) + }); if file_id.is_some() { context.note_document_applied_version(&uri, version); } @@ -400,12 +488,24 @@ pub async fn on_did_change_text_document( }); } - // Schedule debounced reindex — rapid edits into a single reindex + // Schedule debounced reindex — rapid edits into a single reindex. + // + // Profiling: `notify_to_schedule_ms` covers the applied-version note + // through `schedule()` returning (the handoff that lets a waiting + // request observe the edit). if let Some(file_id) = file_id { + let schedule_start = (crate::util::ls_profile::ls_profile_enabled() + && crate::util::ls_profile::LS_DID_CHANGE_ACCUM + .try_with(|_| ()) + .is_ok()) + .then(std::time::Instant::now); context .debounced_analysis() .schedule(file_id, uri.clone()) .await; + if let Some(start) = schedule_start { + crate::util::ls_profile::record_did_change_notify_to_schedule(start.elapsed()); + } } // Handle reindex without holding locks @@ -463,18 +563,22 @@ pub async fn on_did_close_document( return Some(()); } - let file_id = { + let (file_id, widened) = { let mut analysis = context.analysis().write().await; if !context.is_document_closed(uri) { return Some(()); } - let file_id = analysis.update_file_by_uri(uri, Some(text)); + let (file_id, widened) = analysis + .update_file_by_uri(uri, Some(text)) + .map_or((None, Vec::new()), |(file_id, widened)| { + (Some(file_id), widened) + }); if file_id.is_some() { context .file_diagnostic() .invalidate_shared_diagnostic_data(); } - file_id + (file_id, widened) }; if !lsp_features.supports_pull_diagnostic() @@ -492,20 +596,26 @@ pub async fn on_did_close_document( ) .await; } + + // Files beyond the reverted one whose diagnostics the revert + // may have moved. + handle_widened_refresh(&context, file_id, widened, interval).await; } } else { if !context.is_document_closed(uri) { return Some(()); } - let mut mut_analysis = context.analysis().write().await; - if !context.is_document_closed(uri) { - return Some(()); - } - mut_analysis.remove_file_by_uri(uri); - context - .file_diagnostic() - .invalidate_shared_diagnostic_data(); - drop(mut_analysis); + let (removed, widened) = { + let mut mut_analysis = context.analysis().write().await; + if !context.is_document_closed(uri) { + return Some(()); + } + let (removed, widened) = mut_analysis.remove_file_by_uri(uri); + context + .file_diagnostic() + .invalidate_shared_diagnostic_data(); + (removed, widened) + }; if !lsp_features.supports_pull_diagnostic() { context @@ -513,6 +623,9 @@ pub async fn on_did_close_document( .clear_push_file_diagnostics(uri.clone()) .await; } + + // Files whose diagnostics the deletion may have moved. + handle_widened_refresh(&context, removed, widened, interval).await; } } diff --git a/crates/glua_ls/src/handlers/text_document/watched_file_handler.rs b/crates/glua_ls/src/handlers/text_document/watched_file_handler.rs index 141f5cda0..83a716ade 100644 --- a/crates/glua_ls/src/handlers/text_document/watched_file_handler.rs +++ b/crates/glua_ls/src/handlers/text_document/watched_file_handler.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::PathBuf; use glua_code_analysis::{read_file_with_encoding, uri_to_file_path}; @@ -23,8 +24,7 @@ pub async fn on_did_change_watched_files( }; let lsp_features = context.lsp_features(); - let mut watched_lua_files: Vec<(Uri, Option)> = Vec::new(); - let mut deleted_lua_uris: Vec = Vec::new(); + let mut lua_batch: Vec<(Uri, Option)> = Vec::new(); let mut editorconfig_paths: Vec = Vec::new(); let mut emmyrc_dirs: Vec = Vec::new(); @@ -41,7 +41,7 @@ pub async fn on_did_change_watched_files( // receive spurious delete events and must not be // purged from the index. if workspace.is_workspace_file(&file_event.uri) { - deleted_lua_uris.push(file_event.uri); + lua_batch.push((file_event.uri.clone(), None)); } continue; } @@ -50,7 +50,7 @@ pub async fn on_did_change_watched_files( && workspace.is_workspace_file(&file_event.uri) { collect_lua_files( - &mut watched_lua_files, + &mut lua_batch, file_event.uri, file_event.typ, &encoding, @@ -80,16 +80,27 @@ pub async fn on_did_change_watched_files( } } // workspace read lock released here, before any write lock - // Apply mutations under the write lock + // Apply mutations under the write lock. Changes and deletions settle in one + // batch: the analyser derives its facts over a whole batch, and a branch + // switch that deletes several files would otherwise pay one ripple each. + let final_deleted = finally_deleted_uris(&lua_batch); let file_ids = { let mut analysis = context.analysis().write().await; - for uri in &deleted_lua_uris { - analysis.remove_file_by_uri(uri); - } + // Deletions and changes arrive as one order-preserving batch; the + // analyser sorts, keeps only the last event per URI and settles in one + // pass, so a delete/create pair lands as the final state on disk. + let batch = lua_batch; - let file_ids = analysis.update_files_by_uri(watched_lua_files); - if !file_ids.is_empty() || !deleted_lua_uris.is_empty() { + // The files the settle touched, which is what still needs diagnosing. + // It is not every file whose diagnostics could differ — a reader that + // resolves a changed fact only when it is diagnosed caches nothing the + // export diff can name — but that is already true of every keystroke: + // the edit path publishes for the edited file and leaves the rest to a + // pull. Paying a full dependency expansion here bought a refresh the + // dominant path never performed. + let file_ids = analysis.apply_file_system_changes(batch); + if !file_ids.is_empty() || !final_deleted.is_empty() { context .file_diagnostic() .invalidate_shared_diagnostic_data(); @@ -97,9 +108,12 @@ pub async fn on_did_change_watched_files( file_ids }; - // Schedule diagnostics and config reloads (no locks needed) + // Schedule diagnostics and config reloads (no locks needed). A URI whose + // final batch event leaves the file present is skipped in both branches: + // identical bytes stay valid in any cached report, and distinct bytes are + // re-diagnosed via the file_ids task below. if !lsp_features.supports_pull_diagnostic() { - for uri in &deleted_lua_uris { + for uri in final_deleted { context .file_diagnostic() .clear_push_file_diagnostics(uri.clone()) @@ -107,7 +121,7 @@ pub async fn on_did_change_watched_files( } } else { // Never replay a report for a file that no longer exists. - for uri in &deleted_lua_uris { + for uri in &final_deleted { context .file_diagnostic() .forget_cached_file_diagnostics(uri) @@ -142,6 +156,29 @@ pub async fn on_did_change_watched_files( Some(()) } +/// URIs whose final event in the batch is a deletion. +/// +/// `apply_file_system_changes` sorts the batch (a stable sort, preserving +/// event order for equal keys) and keeps only the last event per URI, so a +/// delete/create pair settles as the file's final on-disk state. Mirror that +/// last-wins fold here so clearing/forgetting diagnostics only happens for +/// URIs that are actually gone after the batch, never for ones that were +/// re-created with identical or distinct bytes. +fn finally_deleted_uris(batch: &[(Uri, Option)]) -> Vec { + let mut final_state: HashMap<&Uri, bool> = HashMap::new(); + for (uri, text) in batch { + final_state.insert(uri, text.is_some()); + } + let mut deleted: Vec = final_state + .into_iter() + .filter(|(_, present)| !*present) + .map(|(uri, _)| uri.clone()) + .collect(); + deleted.sort_by_cached_key(|uri| uri.to_string()); + deleted.dedup(); + deleted +} + fn collect_lua_files( watched_lua_files: &mut Vec<(Uri, Option)>, uri: Uri, @@ -184,3 +221,69 @@ fn get_file_type(uri: &Uri) -> Option { _ => Some(WatchedFileType::Lua), } } + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::finally_deleted_uris; + use lsp_types::Uri; + + fn uri(s: &str) -> Uri { + Uri::from_str(s).expect("uri should parse") + } + + #[test] + fn deleted_then_changed_distinct_is_not_deleted() { + let batch = vec![ + (uri("file:///a.lua"), None), + (uri("file:///a.lua"), Some("return 1".to_string())), + ]; + assert!(finally_deleted_uris(&batch).is_empty()); + } + + #[test] + fn deleted_then_changed_same_bytes_is_not_deleted() { + let batch = vec![ + (uri("file:///a.lua"), None), + (uri("file:///a.lua"), Some("local x = 1".to_string())), + ]; + assert!(finally_deleted_uris(&batch).is_empty()); + } + + #[test] + fn changed_then_deleted_is_deleted() { + let batch = vec![ + (uri("file:///a.lua"), Some("return 1".to_string())), + (uri("file:///a.lua"), None), + ]; + let deleted = finally_deleted_uris(&batch); + assert_eq!(deleted, vec![uri("file:///a.lua")]); + } + + #[test] + fn deleted_twice_appears_once() { + let batch = vec![(uri("file:///a.lua"), None), (uri("file:///a.lua"), None)]; + let deleted = finally_deleted_uris(&batch); + assert_eq!(deleted, vec![uri("file:///a.lua")]); + } + + #[test] + fn multi_uri_result_is_sorted() { + let batch = vec![ + (uri("file:///z.lua"), None), + (uri("file:///b.lua"), Some("return 2".to_string())), + (uri("file:///a.lua"), None), + (uri("file:///m.lua"), None), + ]; + let deleted = finally_deleted_uris(&batch); + assert_eq!( + deleted, + vec![ + uri("file:///a.lua"), + uri("file:///m.lua"), + uri("file:///z.lua"), + ] + ); + } +} diff --git a/crates/glua_ls/src/handlers/workspace/did_rename_files.rs b/crates/glua_ls/src/handlers/workspace/did_rename_files.rs index 27e9b5f6d..02ee0a929 100644 --- a/crates/glua_ls/src/handlers/workspace/did_rename_files.rs +++ b/crates/glua_ls/src/handlers/workspace/did_rename_files.rs @@ -57,12 +57,22 @@ pub async fn on_did_rename_files_handler( // 更新 let mut analysis = context.analysis().write().await; let encoding = &analysis.get_emmyrc().workspace.encoding; + let interval = analysis + .get_emmyrc() + .diagnostics + .diagnostic_interval + .unwrap_or(500); + // Every live file the renames may have moved, accumulated across the + // whole batch: what each removal settled and what each re-open widened. + let mut affected: Vec = Vec::new(); for rename in all_renames.iter() { - analysis.remove_file_by_uri(&rename.old_uri); + let (_, removed_affected) = analysis.remove_file_by_uri(&rename.old_uri); + affected.extend(removed_affected); if let Some(new_path) = uri_to_file_path(&rename.new_uri) && let Some(text) = read_file_with_encoding(&new_path, encoding) + && let Some((_, widened)) = analysis.update_file_by_uri(&rename.new_uri, Some(text)) { - analysis.update_file_by_uri(&rename.new_uri, Some(text)); + affected.extend(widened); } } context @@ -70,6 +80,23 @@ pub async fn on_did_rename_files_handler( .invalidate_shared_diagnostic_data(); drop(analysis); + affected.sort_unstable(); + affected.dedup(); + if !affected.is_empty() { + context + .file_diagnostic() + .add_files_diagnostic_task(affected, interval, None) + .await; + context + .workspace_manager() + .read() + .await + .update_workspace_version(crate::context::WorkspaceDiagnosticLevel::Fast, false); + if context.lsp_features().supports_refresh_diagnostic() { + context.client().refresh_workspace_diagnostics(); + } + } + let analysis = context.analysis().read().await; if let Some(changes) = try_modify_require_path(&analysis.compilation, &all_renames) { drop(analysis); diff --git a/crates/glua_ls/src/util/analysis_progress.rs b/crates/glua_ls/src/util/analysis_progress.rs index 30f5b83d9..ddb3d0d5b 100644 --- a/crates/glua_ls/src/util/analysis_progress.rs +++ b/crates/glua_ls/src/util/analysis_progress.rs @@ -224,27 +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()); - - 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); - })); - 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()); diff --git a/crates/glua_ls/src/util/ls_profile.rs b/crates/glua_ls/src/util/ls_profile.rs new file mode 100644 index 000000000..35b1e46de --- /dev/null +++ b/crates/glua_ls/src/util/ls_profile.rs @@ -0,0 +1,397 @@ +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +fn profile_gate() -> bool { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| std::env::var_os("GLUALS_PROFILE").is_some()) +} + +/// Whether `GLUALS_PROFILE` instrumentation should record and emit. +/// +/// All `ls_*` records are gated on this. When unset there is no output and +/// callers must not pay for `Instant::now()` or extra locking. +#[inline] +pub fn ls_profile_enabled() -> bool { + profile_gate() +} + +static LS_SEQ: AtomicU64 = AtomicU64::new(1); +static REINDEX_CYCLE: AtomicU64 = AtomicU64::new(1); + +/// Monotonic id shared by request and edit lines so they order against each +/// other. +pub fn next_ls_seq() -> u64 { + LS_SEQ.fetch_add(1, Ordering::Relaxed) +} + +/// Monotonic id for reindex cycles. +pub fn next_reindex_cycle() -> u64 { + REINDEX_CYCLE.fetch_add(1, Ordering::Relaxed) +} + +#[inline] +pub fn ms(duration: Duration) -> u64 { + duration.as_millis() as u64 +} + +/// `Some(Instant::now())` when profiling, `None` otherwise (zero cost path +/// avoids the clock read entirely). +#[inline] +pub fn profile_instant() -> Option { + ls_profile_enabled().then(Instant::now) +} + +// ---------------------------------------------------------------- request --- + +/// Accumulator for one fresh-index request, scoped in the dispatch arm so +/// `read_analysis` can contribute its lock wait without signature changes. +pub struct LsRequestAccum { + read_wait: std::sync::Mutex, + handler_time: std::sync::Mutex>, +} + +impl LsRequestAccum { + pub fn new() -> Self { + Self { + read_wait: std::sync::Mutex::new(Duration::ZERO), + handler_time: std::sync::Mutex::new(None), + } + } + + pub fn add_read_wait(&self, elapsed: Duration) { + if let Ok(mut guard) = self.read_wait.lock() { + *guard += elapsed; + } + } + + pub fn take_read_wait(&self) -> Duration { + self.read_wait + .lock() + .map(|guard| *guard) + .unwrap_or_default() + } + + pub fn set_handler_time(&self, elapsed: Duration) { + if let Ok(mut guard) = self.handler_time.lock() { + *guard = Some(elapsed); + } + } + + pub fn take_handler_time(&self) -> Option { + self.handler_time.lock().ok().and_then(|guard| *guard) + } +} + +impl Default for LsRequestAccum { + fn default() -> Self { + Self::new() + } +} + +tokio::task_local! { + pub static LS_REQUEST_ACCUM: std::sync::Arc; +} + +/// Guard that records handler time into the scoped request accumulator on +/// drop, so every early return in the handler is covered. +pub struct HandlerTimer { + start: Instant, + accum: std::sync::Arc, +} + +impl HandlerTimer { + pub fn scoped() -> Option { + if !ls_profile_enabled() { + return None; + } + let start = Instant::now(); + let accum = LS_REQUEST_ACCUM.try_with(|accum| accum.clone()).ok()?; + Some(Self { start, accum }) + } +} + +impl Drop for HandlerTimer { + fn drop(&mut self) { + self.accum.set_handler_time(self.start.elapsed()); + } +} + +#[allow(clippy::too_many_arguments)] +pub fn emit_ls_request( + seq: u64, + id: &str, + method: &str, + total: Duration, + fresh_total: Duration, + fresh_in_flight: Duration, + fresh_blocked: Duration, + read_wait: Duration, + handler: Duration, +) { + if !ls_profile_enabled() { + return; + } + eprintln!( + "[profile] ls_request seq={} id={} method={} total_ms={} fresh_wait_ms={} fresh_wait_in_flight_ms={} fresh_wait_blocked_ms={} analysis_read_wait_ms={} handler_ms={}", + seq, + id, + method, + ms(total), + ms(fresh_total), + ms(fresh_in_flight), + ms(fresh_blocked), + ms(read_wait), + ms(handler), + ); +} + +// -------------------------------------------------------------- didChange --- + +/// Enqueue-time evidence captured synchronously in the notification handler, +/// so `total_ms` spans notification receipt through schedule. +#[derive(Clone, Copy)] +pub struct DidChangeEnqueue { + pub seq: u64, + pub enqueue_at: Instant, + pub version: i32, +} + +impl DidChangeEnqueue { + pub fn capture(version: i32) -> Option { + if !ls_profile_enabled() { + return None; + } + Some(Self { + seq: next_ls_seq(), + enqueue_at: Instant::now(), + version, + }) + } +} + +#[derive(Default)] +pub struct DidChangeTimings { + pub preparse: Duration, + pub write_wait: Duration, + pub write_hold: Duration, + pub notify_to_schedule: Duration, +} + +pub struct DidChangeAccum { + timings: std::sync::Mutex, +} + +impl Default for DidChangeAccum { + fn default() -> Self { + Self::new() + } +} + +impl DidChangeAccum { + pub fn new() -> Self { + Self { + timings: std::sync::Mutex::new(DidChangeTimings::default()), + } + } + + fn with_timings(&self, f: impl FnOnce(&mut DidChangeTimings)) { + if let Ok(mut guard) = self.timings.lock() { + f(&mut guard); + } + } + + pub fn add_preparse(&self, elapsed: Duration) { + self.with_timings(|timings| timings.preparse += elapsed); + } + + pub fn add_write_wait(&self, elapsed: Duration) { + self.with_timings(|timings| timings.write_wait += elapsed); + } + + pub fn add_write_hold(&self, elapsed: Duration) { + self.with_timings(|timings| timings.write_hold += elapsed); + } + + pub fn add_notify_to_schedule(&self, elapsed: Duration) { + self.with_timings(|timings| timings.notify_to_schedule += elapsed); + } + + pub fn snapshot(&self) -> DidChangeTimingsSnapshot { + let (preparse, write_wait, write_hold, notify_to_schedule) = self + .timings + .lock() + .map(|timings| { + ( + timings.preparse, + timings.write_wait, + timings.write_hold, + timings.notify_to_schedule, + ) + }) + .unwrap_or_default(); + DidChangeTimingsSnapshot { + preparse, + write_wait, + write_hold, + notify_to_schedule, + } + } +} + +pub struct DidChangeTimingsSnapshot { + pub preparse: Duration, + pub write_wait: Duration, + pub write_hold: Duration, + pub notify_to_schedule: Duration, +} + +tokio::task_local! { + pub static LS_DID_CHANGE_ACCUM: std::sync::Arc; +} + +pub fn record_did_change_preparse(elapsed: Duration) { + if !ls_profile_enabled() { + return; + } + let _ = LS_DID_CHANGE_ACCUM.try_with(|accum| accum.add_preparse(elapsed)); +} + +pub fn record_did_change_write_wait(elapsed: Duration) { + if !ls_profile_enabled() { + return; + } + let _ = LS_DID_CHANGE_ACCUM.try_with(|accum| accum.add_write_wait(elapsed)); +} + +pub fn record_did_change_write_hold(elapsed: Duration) { + if !ls_profile_enabled() { + return; + } + let _ = LS_DID_CHANGE_ACCUM.try_with(|accum| accum.add_write_hold(elapsed)); +} + +pub fn record_did_change_notify_to_schedule(elapsed: Duration) { + if !ls_profile_enabled() { + return; + } + let _ = LS_DID_CHANGE_ACCUM.try_with(|accum| accum.add_notify_to_schedule(elapsed)); +} + +#[allow(clippy::too_many_arguments)] +pub fn emit_ls_did_change( + seq: u64, + version: i32, + coalescer_wait: Duration, + preparse: Duration, + write_wait: Duration, + write_hold: Duration, + notify_to_schedule: Duration, + total: Duration, +) { + if !ls_profile_enabled() { + return; + } + eprintln!( + "[profile] ls_did_change seq={} version={} coalescer_wait_ms={} preparse_ms={} analysis_write_wait_ms={} analysis_write_hold_ms={} notify_to_schedule_ms={} total_ms={}", + seq, + version, + ms(coalescer_wait), + ms(preparse), + ms(write_wait), + ms(write_hold), + ms(notify_to_schedule), + ms(total), + ); +} + +// ---------------------------------------------------------------- reindex --- + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum HandoffOutcome { + Idle, + Released, + Timeout, +} + +impl HandoffOutcome { + pub fn as_str(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Released => "released", + Self::Timeout => "timeout", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum QuietOutcome { + Edit, + Timer, + MaxDeferral, +} + +impl QuietOutcome { + pub fn as_str(self) -> &'static str { + match self { + Self::Edit => "edit", + Self::Timer => "timer", + Self::MaxDeferral => "max_deferral", + } + } +} + +#[derive(Default)] +pub struct SelfWriteTimings { + pub wait: Duration, + pub hold: Duration, +} + +#[derive(Default)] +pub struct RippleWriteTimings { + pub wait: Duration, + pub hold: Duration, +} + +#[derive(Default)] +pub struct HandoffTiming { + pub wait: Duration, + pub outcome: Option, +} + +#[derive(Default)] +pub struct QuietTiming { + pub wait: Duration, + pub outcome: Option, +} + +#[allow(clippy::too_many_arguments)] +pub fn emit_ls_reindex( + cycle: u64, + debounce: Duration, + self_wait: Duration, + self_hold: Duration, + handoff_wait: Duration, + handoff_outcome: HandoffOutcome, + quiet_wait: Duration, + quiet_outcome: QuietOutcome, + ripple_wait: Duration, + ripple_hold: Duration, +) { + if !ls_profile_enabled() { + return; + } + eprintln!( + "[profile] ls_reindex cycle={} debounce_ms={} self_write_wait_ms={} self_write_hold_ms={} handoff_wait_ms={} handoff_outcome={} quiet_wait_ms={} quiet_outcome={} ripple_write_wait_ms={} ripple_write_hold_ms={}", + cycle, + ms(debounce), + ms(self_wait), + ms(self_hold), + ms(handoff_wait), + handoff_outcome.as_str(), + ms(quiet_wait), + quiet_outcome.as_str(), + ms(ripple_wait), + ms(ripple_hold), + ); +} diff --git a/crates/glua_ls/src/util/mod.rs b/crates/glua_ls/src/util/mod.rs index afa0e01a1..c034c0fb7 100644 --- a/crates/glua_ls/src/util/mod.rs +++ b/crates/glua_ls/src/util/mod.rs @@ -1,6 +1,7 @@ mod analysis_progress; mod desc; mod long_running_watchdog; +pub mod ls_profile; mod module_name_convert; mod time_cancel_token; diff --git a/crates/glua_parser/Cargo.toml b/crates/glua_parser/Cargo.toml index c453d75c5..d89924675 100644 --- a/crates/glua_parser/Cargo.toml +++ b/crates/glua_parser/Cargo.toml @@ -16,7 +16,7 @@ workspace = true [dependencies] rowan.workspace = true +stacker.workspace = true rustc-hash.workspace = true smol_str.workspace = true serde.workspace = true - diff --git a/crates/glua_parser/src/grammar/doc/test.rs b/crates/glua_parser/src/grammar/doc/test.rs index e0e546bd9..a73f686cd 100644 --- a/crates/glua_parser/src/grammar/doc/test.rs +++ b/crates/glua_parser/src/grammar/doc/test.rs @@ -840,6 +840,155 @@ Syntax(Chunk)@0..179 assert_ast_eq!(code, result); } + #[test] + fn test_return_doc_tuple() { + let code = r#" + ---@return [string, number] + function f() end + "#; + + let result = r#" +Syntax(Chunk)@0..70 + Syntax(Block)@0..70 + Token(TkEndOfLine)@0..1 "\n" + Token(TkWhitespace)@1..9 " " + Syntax(Comment)@9..36 + Token(TkDocStart)@9..13 "---@" + Syntax(DocTagReturn)@13..36 + Token(TkTagReturn)@13..19 "return" + Token(TkWhitespace)@19..20 " " + Syntax(TypeTuple)@20..36 + Token(TkLeftBracket)@20..21 "[" + Syntax(TypeName)@21..27 + Token(TkName)@21..27 "string" + Token(TkComma)@27..28 "," + Token(TkWhitespace)@28..29 " " + Syntax(TypeName)@29..35 + Token(TkName)@29..35 "number" + Token(TkRightBracket)@35..36 "]" + Token(TkEndOfLine)@36..37 "\n" + Token(TkWhitespace)@37..45 " " + Syntax(FuncStat)@45..61 + Token(TkFunction)@45..53 "function" + Token(TkWhitespace)@53..54 " " + Syntax(NameExpr)@54..55 + Token(TkName)@54..55 "f" + Syntax(ClosureExpr)@55..61 + Syntax(ParamList)@55..57 + Token(TkLeftParen)@55..56 "(" + Token(TkRightParen)@56..57 ")" + Token(TkWhitespace)@57..58 " " + Token(TkEnd)@58..61 "end" + Token(TkEndOfLine)@61..62 "\n" + Token(TkWhitespace)@62..70 " " + "#; + + assert_ast_eq!(code, result); + } + + #[test] + fn test_return_doc_tuple_intersection() { + let code = r#" + ---@return [T...] & { n: integer } + function f() end + "#; + + let result = r#" +Syntax(Chunk)@0..77 + Syntax(Block)@0..77 + Token(TkEndOfLine)@0..1 "\n" + Token(TkWhitespace)@1..9 " " + Syntax(Comment)@9..43 + Token(TkDocStart)@9..13 "---@" + Syntax(DocTagReturn)@13..43 + Token(TkTagReturn)@13..19 "return" + Token(TkWhitespace)@19..20 " " + Syntax(TypeBinary)@20..43 + Syntax(TypeTuple)@20..26 + Token(TkLeftBracket)@20..21 "[" + Syntax(TypeVariadic)@21..25 + Syntax(TypeName)@21..22 + Token(TkName)@21..22 "T" + Token(TkDots)@22..25 "..." + Token(TkRightBracket)@25..26 "]" + Token(TkWhitespace)@26..27 " " + Token(TkDocAnd)@27..28 "&" + Token(TkWhitespace)@28..29 " " + Syntax(TypeObject)@29..43 + Token(TkLeftBrace)@29..30 "{" + Token(TkWhitespace)@30..31 " " + Syntax(DocObjectField)@31..41 + Token(TkName)@31..32 "n" + Token(TkColon)@32..33 ":" + Token(TkWhitespace)@33..34 " " + Syntax(TypeName)@34..41 + Token(TkName)@34..41 "integer" + Token(TkWhitespace)@41..42 " " + Token(TkRightBrace)@42..43 "}" + Token(TkEndOfLine)@43..44 "\n" + Token(TkWhitespace)@44..52 " " + Syntax(FuncStat)@52..68 + Token(TkFunction)@52..60 "function" + Token(TkWhitespace)@60..61 " " + Syntax(NameExpr)@61..62 + Token(TkName)@61..62 "f" + Syntax(ClosureExpr)@62..68 + Syntax(ParamList)@62..64 + Token(TkLeftParen)@62..63 "(" + Token(TkRightParen)@63..64 ")" + Token(TkWhitespace)@64..65 " " + Token(TkEnd)@65..68 "end" + Token(TkEndOfLine)@68..69 "\n" + Token(TkWhitespace)@69..77 " " + "#; + + assert_ast_eq!(code, result); + } + + #[test] + fn test_return_doc_tuple_nullable() { + let code = r#" + ---@return [string]? + function f() end + "#; + + let result = r#" +Syntax(Chunk)@0..63 + Syntax(Block)@0..63 + Token(TkEndOfLine)@0..1 "\n" + Token(TkWhitespace)@1..9 " " + Syntax(Comment)@9..29 + Token(TkDocStart)@9..13 "---@" + Syntax(DocTagReturn)@13..29 + Token(TkTagReturn)@13..19 "return" + Token(TkWhitespace)@19..20 " " + Syntax(TypeNullable)@20..29 + Syntax(TypeTuple)@20..28 + Token(TkLeftBracket)@20..21 "[" + Syntax(TypeName)@21..27 + Token(TkName)@21..27 "string" + Token(TkRightBracket)@27..28 "]" + Token(TkDocQuestion)@28..29 "?" + Token(TkEndOfLine)@29..30 "\n" + Token(TkWhitespace)@30..38 " " + Syntax(FuncStat)@38..54 + Token(TkFunction)@38..46 "function" + Token(TkWhitespace)@46..47 " " + Syntax(NameExpr)@47..48 + Token(TkName)@47..48 "f" + Syntax(ClosureExpr)@48..54 + Syntax(ParamList)@48..50 + Token(TkLeftParen)@48..49 "(" + Token(TkRightParen)@49..50 ")" + Token(TkWhitespace)@50..51 " " + Token(TkEnd)@51..54 "end" + Token(TkEndOfLine)@54..55 "\n" + Token(TkWhitespace)@55..63 " " + "#; + + assert_ast_eq!(code, result); + } + #[test] fn test_inline_default_doc() { let code = r#" @@ -3545,4 +3694,44 @@ Syntax(Chunk)@0..60 assert_ast_eq!(code, result); } + + // Regression test: deeply nested doc generics must degrade to a doc + // error, never abort the process with a stack overflow (see the Lua-side + // `deeply_nested_calls_parse_to_error_not_abort`). + #[test] + fn deeply_nested_doc_generics_parse_to_error_not_abort() { + let depth = 3000; + let mut code = String::from("---@param x "); + for _ in 0..depth { + code.push_str("A<"); + } + code.push('T'); + for _ in 0..depth { + code.push('>'); + } + code.push('\n'); + let tree = LuaParser::parse(&code, ParserConfig::default()); + assert!( + !tree.get_errors().is_empty(), + "{depth}-deep doc nesting must degrade to errors, not abort" + ); + } + + // Regression test: `-` chains recurse through `parse_sub_type` itself, + // bypassing any guard placed only on `parse_type`. They must bail to a + // doc error, never abort the process. + #[test] + fn deeply_nested_doc_unary_types_parse_to_error_not_abort() { + let depth = 60000; + let mut code = String::from("---@param x "); + for _ in 0..depth { + code.push_str("- "); + } + code.push_str("1\n"); + let tree = LuaParser::parse(&code, ParserConfig::default()); + assert!( + !tree.get_errors().is_empty(), + "{depth}-deep doc unary nesting must degrade to errors, not abort" + ); + } } diff --git a/crates/glua_parser/src/grammar/doc/types.rs b/crates/glua_parser/src/grammar/doc/types.rs index ee7b76fcd..938feafc0 100644 --- a/crates/glua_parser/src/grammar/doc/types.rs +++ b/crates/glua_parser/src/grammar/doc/types.rs @@ -1,6 +1,6 @@ use crate::{ UNARY_TYPE_PRIORITY, - grammar::DocParseResult, + grammar::{DocParseResult, parser_stack_exhausted}, kind::{LuaOpKind, LuaSyntaxKind, LuaTokenKind, LuaTypeBinaryOperator, LuaTypeUnaryOperator}, lexer::LuaDocLexerState, parser::{CompleteMarker, LuaDocParser, LuaDocParserState, Marker, MarkerEventContainer}, @@ -57,6 +57,12 @@ pub fn parse_type(p: &mut LuaDocParser) -> DocParseResult { // keyof , -1 // | , & , extends , in keyof fn parse_sub_type(p: &mut LuaDocParser, limit: i32) -> DocParseResult { + if parser_stack_exhausted() { + return Err(LuaParseError::doc_error_from( + "type is too deeply nested", + p.current_token_range(), + )); + } let uop = LuaOpKind::to_type_unary_operator(p.current_token()); let mut cm = if uop != LuaTypeUnaryOperator::None { let range = p.current_token_range(); diff --git a/crates/glua_parser/src/grammar/lua/expr.rs b/crates/glua_parser/src/grammar/lua/expr.rs index 12b7eb393..51fca805b 100644 --- a/crates/glua_parser/src/grammar/lua/expr.rs +++ b/crates/glua_parser/src/grammar/lua/expr.rs @@ -6,13 +6,20 @@ use crate::{ parser_error::LuaParseError, }; -use super::{expect_token, if_token_bump, parse_block}; +use super::{expect_token, if_token_bump, parse_block, parser_stack_exhausted}; pub fn parse_expr(p: &mut LuaParser) -> ParseResult { parse_sub_expr(p, 0) } fn parse_sub_expr(p: &mut LuaParser, limit: i32) -> ParseResult { + if parser_stack_exhausted() { + p.push_error(LuaParseError::syntax_error_from( + "expression is too deeply nested", + p.current_token_range(), + )); + return Err(ParseFailReason::UnexpectedToken); + } let uop = LuaOpKind::to_unary_operator(p.current_token()); let mut cm = if uop != UnaryOperator::OpNop { let m = p.mark(LuaSyntaxKind::UnaryExpr); diff --git a/crates/glua_parser/src/grammar/lua/mod.rs b/crates/glua_parser/src/grammar/lua/mod.rs index 2e5ff2b82..abc0d15d7 100644 --- a/crates/glua_parser/src/grammar/lua/mod.rs +++ b/crates/glua_parser/src/grammar/lua/mod.rs @@ -11,7 +11,7 @@ use crate::{ parser_error::LuaParseError, }; -use super::ParseResult; +use super::{ParseResult, parser_stack_exhausted}; pub fn parse_chunk(p: &mut LuaParser) { let m = p.mark(LuaSyntaxKind::Block); @@ -74,6 +74,13 @@ pub fn parse_chunk(p: &mut LuaParser) { } fn parse_block(p: &mut LuaParser) -> ParseResult { + if parser_stack_exhausted() { + p.push_error(LuaParseError::syntax_error_from( + "block is too deeply nested", + p.current_token_range(), + )); + return Err(ParseFailReason::UnexpectedToken); + } let m = p.mark(LuaSyntaxKind::Block); parse_stats(p); diff --git a/crates/glua_parser/src/grammar/lua/stat.rs b/crates/glua_parser/src/grammar/lua/stat.rs index ea0eefb9c..33b5e152a 100644 --- a/crates/glua_parser/src/grammar/lua/stat.rs +++ b/crates/glua_parser/src/grammar/lua/stat.rs @@ -161,6 +161,7 @@ fn parse_variable_name_list(p: &mut LuaParser, support_attrib: bool) -> ParseRes pub fn parse_stats(p: &mut LuaParser) { while !block_follow(p) { let level = p.get_mark_level(); + let token_index = p.current_token_index(); match parse_stat(p) { Ok(_) => {} Err(_) => { @@ -184,7 +185,11 @@ pub fn parse_stats(p: &mut LuaParser) { p.bump(); } - if can_continue { + // A failed statement that consumed nothing (a stack-reserve + // bail, or malformed input sitting on a statement start) + // would otherwise retry the same token forever: only continue + // when recovery advanced past the failure. + if can_continue && p.current_token_index() != token_index { continue; } break; diff --git a/crates/glua_parser/src/grammar/lua/test.rs b/crates/glua_parser/src/grammar/lua/test.rs index c3a9cacc7..2b4147292 100644 --- a/crates/glua_parser/src/grammar/lua/test.rs +++ b/crates/glua_parser/src/grammar/lua/test.rs @@ -1224,4 +1224,209 @@ local y = 2 ); } } + + // Regression test: deep call nesting must degrade to a parse error, + // never abort the process with a stack overflow. Depth exceeds the + // release-frame trip point (~4173 on 2 MB) with margin; debug frames + // are fatter and trip even earlier. + #[test] + fn deeply_nested_calls_parse_to_error_not_abort() { + let depth = 20000; + let mut body = String::from("local function f(x) return x end\nlocal v = "); + for _ in 0..depth { + body.push_str("f("); + } + body.push('1'); + for _ in 0..depth { + body.push(')'); + } + body.push('\n'); + let tree = LuaParser::parse(&body, ParserConfig::default()); + assert!( + !tree.get_errors().is_empty(), + "{depth}-deep nesting must degrade to parse errors, not abort" + ); + } + + // Regression test: `not` chains recurse through `parse_sub_expr` itself, + // bypassing any guard placed only on `parse_expr`. They must bail to + // errors (resuming chunk by chunk as the stack unwinds), never abort. + #[test] + fn deeply_nested_unary_exprs_parse_to_error_not_abort() { + let depth = 30000; + let mut body = String::from("local v = "); + for _ in 0..depth { + body.push_str("not "); + } + body.push_str("x\n"); + let tree = LuaParser::parse(&body, ParserConfig::default()); + assert!( + !tree.get_errors().is_empty(), + "{depth}-deep unary nesting must degrade to parse errors, not abort" + ); + } + + // Regression test: right-associative operators (`^`, `..`) recurse through + // `parse_sub_expr` for every level, bypassing any guard placed only on + // `parse_expr`. They must bail to errors, never abort. + #[test] + fn deeply_nested_right_assoc_chain_parses_to_error_not_abort() { + let depth = 20000; + let mut body = String::from("local v = 1"); + for _ in 0..depth { + body.push_str("^1"); + } + body.push('\n'); + let tree = LuaParser::parse(&body, ParserConfig::default()); + assert!( + !tree.get_errors().is_empty(), + "{depth}-deep right-associative nesting must degrade to parse errors, not abort" + ); + } + + // Regression test: a stack-reserve bail consumes nothing, so when the + // innermost token is a statement start the recovery loop used to retry the + // same token at the same depth forever. `parse_stats` now breaks on + // no-progress instead. The watchdog turns a regression into a failure + // rather than a hung suite. Depth exceeds the release-frame trip point + // (~3755 on 2 MB) with margin; debug frames are fatter and trip earlier. + #[test] + fn deeply_nested_blocks_with_statement_start_terminates() { + let depth = 20000; + let mut body = String::new(); + for _ in 0..depth { + body.push_str("do "); + } + body.push('x'); + for _ in 0..depth { + body.push_str(" end"); + } + body.push('\n'); + let (tx, rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let tree = LuaParser::parse(&body, ParserConfig::default()); + let errors = tree.get_errors(); + let report = ( + errors.len(), + errors + .iter() + .any(|error| error.message.contains("too deeply nested")), + ); + let _ = tx.send(report); + }); + let (error_count, tripped) = rx + .recv_timeout(std::time::Duration::from_secs(30)) + .expect("deeply nested blocks must terminate with errors, not hang"); + worker.join().expect("worker must not overflow its stack"); + assert!( + tripped, + "{depth}-deep blocks must trip the stack reserve guard" + ); + assert!( + error_count > 0, + "{depth}-deep blocks must degrade to parse errors" + ); + } + + // Regression: a guard-tripped block tree must also drop safely on a + // production-sized (2 MB) thread. The parse bails via the reserve guard, + // but the resulting deep green tree previously aborted the process + // in recursive `GreenNode` destruction. Rowan now frees green nodes + // iteratively, so the ordinary drop below is O(1) stack; join-based like + // the existing 2 MB tests. Depth exceeds the release-frame trip point + // (~3755 on 2 MB) with margin; debug frames are fatter and trip earlier. + #[test] + fn deeply_nested_blocks_drop_safely_on_small_stack() { + let depth = 20000; + let mut body = String::new(); + for _ in 0..depth { + body.push_str("do "); + } + body.push('x'); + for _ in 0..depth { + body.push_str(" end"); + } + body.push('\n'); + std::thread::Builder::new() + .stack_size(2 * 1024 * 1024) + .spawn(move || { + let tree = LuaParser::parse(&body, ParserConfig::default()); + assert!( + !tree.get_errors().is_empty(), + "{depth}-deep blocks must degrade to parse errors, not abort" + ); + assert!( + tree.get_errors() + .iter() + .any(|error| error.message.contains("too deeply nested")), + "{depth}-deep blocks must trip the stack reserve guard" + ); + }) + .expect("worker thread should spawn") + .join() + .expect("production-sized worker must not overflow when dropping its tree"); + } + + // A failed statement that consumes nothing while sitting on a statement + // start must not spin `parse_stats` forever, even far from any stack + // limit: recovery without progress breaks out and lets `parse_chunk` + // consume the token. + #[test] + fn statement_start_without_progress_terminates() { + let tree = LuaParser::parse("foo bar", ParserConfig::default()); + assert!( + !tree.get_errors().is_empty(), + "unconsumed statement start must degrade to parse errors, not hang" + ); + } + + // Regression: a red root cloned from a guard-tripped tree must also drop + // safely on a production-sized (2 MB) thread, even when it outlives its + // `LuaSyntaxTree`. `get_red_root` clones the green root, so dropping the + // tree releases only one reference; the final release happens wherever the + // last red dies (memo eviction, thread-local teardown, caches) — + // previously a recursive `GreenNode` free that aborted small-stack threads + // mid-suite. Rowan now frees green nodes iteratively, so the red drop, the + // tree drop, and the memo teardown below are all safe on this 2 MB stack; + // join-based like the existing 2 MB tests. + #[test] + fn deeply_nested_blocks_red_root_outlives_tree_drops_safely_on_small_stack() { + // Depth exceeds the release-frame trip point (~3755 on 2 MB) with + // margin; debug frames are fatter and trip earlier. + let depth = 20000; + let mut body = String::new(); + for _ in 0..depth { + body.push_str("do "); + } + body.push('x'); + for _ in 0..depth { + body.push_str(" end"); + } + body.push('\n'); + std::thread::Builder::new() + .stack_size(2 * 1024 * 1024) + .spawn(move || { + let tree = LuaParser::parse(&body, ParserConfig::default()); + assert!( + tree.get_errors() + .iter() + .any(|error| error.message.contains("too deeply nested")), + "{depth}-deep blocks must trip the stack reserve guard" + ); + // Retain a red root past the tree and soil the per-thread node + // memo with it; the memo pins the green until thread teardown. + let red = tree.get_red_root(); + let id = crate::LuaSyntaxId::from_node(&red); + assert!( + id.to_node_from_root(&red).is_some(), + "memoized resolve of the deep root must succeed" + ); + drop(tree); + assert_eq!(red.text_range().len(), body.len().try_into().unwrap()); + drop(red); + }) + .expect("worker thread should spawn") + .join() + .expect("production-sized worker must not overflow when a retained red root drops"); + } } diff --git a/crates/glua_parser/src/grammar/mod.rs b/crates/glua_parser/src/grammar/mod.rs index f029ad022..e3216e725 100644 --- a/crates/glua_parser/src/grammar/mod.rs +++ b/crates/glua_parser/src/grammar/mod.rs @@ -7,6 +7,24 @@ pub use lua::parse_chunk; type ParseResult = Result; type DocParseResult = Result; + +/// Minimum stack bytes that must remain to keep parsing nested constructs. +/// +/// Source-controlled nesting (expressions, blocks) recurses per level and can +/// exhaust small worker stacks on generated files. Callers bail with a syntax +/// error — the same control flow as a genuine syntax error at that position, +/// so existing recovery terminates exactly as it does for malformed input — +/// instead of aborting the process. +/// +/// Fail closed: a `None` query result (unsupported target / OS query failure) +/// counts as exhausted rather than safe, mirroring how `stacker::maybe_grow` +/// treats `None` as insufficient. +pub(crate) fn parser_stack_exhausted() -> bool { + match stacker::remaining_stack() { + Some(remaining) => remaining < 256 * 1024, + None => true, + } +} pub enum ParseFailReason { /// Parsing was stopped due to reaching the end of the file. Eof, diff --git a/crates/glua_parser/src/parser/parser_config.rs b/crates/glua_parser/src/parser/parser_config.rs index c815a8ff2..844185ab0 100644 --- a/crates/glua_parser/src/parser/parser_config.rs +++ b/crates/glua_parser/src/parser/parser_config.rs @@ -1,3 +1,4 @@ +use rustc_hash::FxHashMap; use std::collections::HashMap; use rowan::NodeCache; @@ -8,7 +9,7 @@ pub struct ParserConfig<'cache> { pub level: LuaLanguageLevel, lexer_config: LexerConfig, node_cache: Option<&'cache mut NodeCache>, - special_like: HashMap, + special_like: FxHashMap, pub enable_emmylua_doc: bool, } @@ -27,7 +28,7 @@ impl<'cache> ParserConfig<'cache> { non_std_symbols, }, node_cache, - special_like, + special_like: special_like.into_iter().collect(), enable_emmylua_doc, } } @@ -76,7 +77,7 @@ impl<'cache> ParserConfig<'cache> { non_std_symbols: LuaNonStdSymbolSet::new(), }, node_cache: None, - special_like: HashMap::new(), + special_like: FxHashMap::default(), enable_emmylua_doc: true, } } @@ -91,7 +92,7 @@ impl Default for ParserConfig<'_> { non_std_symbols: LuaNonStdSymbolSet::new(), }, node_cache: None, - special_like: HashMap::new(), + special_like: FxHashMap::default(), enable_emmylua_doc: true, } } 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..6a78bb782 100644 --- a/crates/glua_parser/src/syntax/node/lua/path_trait.rs +++ b/crates/glua_parser/src/syntax/node/lua/path_trait.rs @@ -1,4 +1,4 @@ -use crate::LuaAstNode; +use crate::{LuaAstNode, LuaSyntaxNode}; use smol_str::SmolStr; use super::{LuaExpr, LuaIndexKey}; @@ -16,56 +16,77 @@ fn join_path(paths: &[SmolStr]) -> SmolStr { SmolStr::new(joined) } +/// How a computed index key is written into an access path. +#[derive(Clone, Copy)] +enum ComputedKey { + /// Spelled out, so `t[a]` and `t[b]` are distinct paths. + Spelled, + /// Collapsed to `[]`, so every spelling of one runtime slot agrees. + Collapsed, +} + +/// Walk an expression's prefix chain into a dotted path, innermost name first. +fn access_path(node: &LuaSyntaxNode, computed: ComputedKey) -> Option { + let mut paths: Vec = Vec::new(); + let mut current_node = node.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(expr) => paths.push(match computed { + ComputedKey::Spelled => SmolStr::new(format!("[{}]", expr.syntax().text())), + ComputedKey::Collapsed => SmolStr::new_static("[]"), + }), + LuaIndexKey::Idx(idx) => paths.push(match computed { + ComputedKey::Spelled => SmolStr::new(format!("[{idx}]")), + ComputedKey::Collapsed => SmolStr::new_static("[]"), + }), + } + current_node = index_expr.get_prefix_expr()?.syntax().clone(); + } + _ => return None, + } + } +} + 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. + /// returns without allocating at all: a name of 22 bytes or fewer lives + /// inline and the segment buffer is never allocated. fn get_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); - } else { - paths.push(name); - paths.reverse(); - return Some(join_path(&paths)); - } - } - LuaExpr::CallExpr(call_expr) => { - let prefix_expr = call_expr.get_prefix_expr()?; - current_node = 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(expr) => { - paths.push(SmolStr::new(format!("[{}]", expr.syntax().text()))); - } - LuaIndexKey::Idx(idx) => { - paths.push(SmolStr::new(format!("[{}]", idx))); - } - } + access_path(self.syntax(), ComputedKey::Spelled) + } - current_node = index_expr.get_prefix_expr()?.syntax().clone(); - } - _ => return None, - } - } + /// 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. + fn get_owner_access_path(&self) -> Option { + access_path(self.syntax(), ComputedKey::Collapsed) } fn get_member_path(&self) -> Option { diff --git a/crates/glua_parser/src/syntax/tree/test.rs b/crates/glua_parser/src/syntax/tree/test.rs index 56eb567de..da42469f7 100644 --- a/crates/glua_parser/src/syntax/tree/test.rs +++ b/crates/glua_parser/src/syntax/tree/test.rs @@ -2,7 +2,7 @@ mod test { use crate::{LuaAstNode, LuaLanguageLevel, LuaNonStdSymbolSet, LuaParser, ParserConfig}; // use std::time::Instant; - use std::{collections::HashMap, thread}; + use std::thread; #[test] fn test_multithreaded_syntax_tree_traversal() { @@ -39,7 +39,7 @@ end let parse_config = ParserConfig::new( LuaLanguageLevel::Lua51, None, - HashMap::new(), + Default::default(), LuaNonStdSymbolSet::new(), false, ); @@ -96,7 +96,7 @@ local t let c = ParserConfig::new( LuaLanguageLevel::Lua54, None, - HashMap::new(), + Default::default(), LuaNonStdSymbolSet::new(), false, ); @@ -118,7 +118,7 @@ end"#; let c = ParserConfig::new( LuaLanguageLevel::Lua54, None, - HashMap::new(), + Default::default(), LuaNonStdSymbolSet::new(), false, ); diff --git a/docs/mintlify/LICENSE b/docs/mintlify/LICENSE index 541137427..069404acd 100644 --- a/docs/mintlify/LICENSE +++ b/docs/mintlify/LICENSE @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/docs/mintlify/annotations/field.mdx b/docs/mintlify/annotations/field.mdx index 6a9a22487..d65dd8f4a 100644 --- a/docs/mintlify/annotations/field.mdx +++ b/docs/mintlify/annotations/field.mdx @@ -137,4 +137,3 @@ Use this when code outside the Lua file adds the function, such as C++. If Lua d - Place `@field` annotations directly after their `@class` declaration. - GLuaLS disables the `inject-field` diagnostic by default. Classes act as `(partial)`, so you can define fields anywhere in the file. Fields are optional, but they improve type checking and autocomplete for the class. - diff --git a/docs/mintlify/annotations/realm.mdx b/docs/mintlify/annotations/realm.mdx index ce187f65a..5c9354849 100644 --- a/docs/mintlify/annotations/realm.mdx +++ b/docs/mintlify/annotations/realm.mdx @@ -10,7 +10,7 @@ description: Declare which GMod realm a file or function belongs to. --- ## Syntax - + ```lua ---@realm client ---@realm server diff --git a/docs/mintlify/annotations/return.mdx b/docs/mintlify/annotations/return.mdx index e529e2907..991884a34 100644 --- a/docs/mintlify/annotations/return.mdx +++ b/docs/mintlify/annotations/return.mdx @@ -161,4 +161,3 @@ end local base = GetEntityBase() base.MyNewMethod = function(self) end -- Added to all Entity instances globally ``` - diff --git a/docs/mintlify/configuration/overview.mdx b/docs/mintlify/configuration/overview.mdx index f0d6ecb4f..045dc1bf4 100644 --- a/docs/mintlify/configuration/overview.mdx +++ b/docs/mintlify/configuration/overview.mdx @@ -106,4 +106,3 @@ The config file is split into top-level sections. Each page lists the available Extension settings control the debugger, AI integration, UI preferences, and annotation downloads. Open VS Code **Settings** and search for `gluals` to find them. Use `.gluarc.json` for analysis behavior. Its settings take precedence over matching VS Code workspace settings. - diff --git a/docs/mintlify/language/formatting.mdx b/docs/mintlify/language/formatting.mdx index ac54ad48b..7b0ccdb51 100644 --- a/docs/mintlify/language/formatting.mdx +++ b/docs/mintlify/language/formatting.mdx @@ -104,4 +104,3 @@ Enable format-on-save in VS Code: } } ``` - diff --git a/docs/mintlify/language/realm-awareness.mdx b/docs/mintlify/language/realm-awareness.mdx index cb0c57d74..fc0af70b0 100644 --- a/docs/mintlify/language/realm-awareness.mdx +++ b/docs/mintlify/language/realm-awareness.mdx @@ -169,5 +169,3 @@ GMod API hovers and completions show where a symbol is available: The badge appears in completions as well, so you can check a function's realm before inserting it. Realm also filters autocomplete. In client files, server-only functions are hidden (and vice versa). If a function seems missing, check inferred realm first. - - diff --git a/tools/benchmark/src/main.rs b/tools/benchmark/src/main.rs index af836a678..4de00cb67 100644 --- a/tools/benchmark/src/main.rs +++ b/tools/benchmark/src/main.rs @@ -179,80 +179,82 @@ fn run_incremental_edits( .get_file_path(&file_id) .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())) .unwrap_or_else(|| format!("{file_id:?}")); - let edited_text = format!("{text}\n-- bench incremental edit\n"); - if std::env::var_os("BENCH_EDIT_LANDMARKS").is_some() { - eprintln!( - " [incremental] BEGIN {name} at t+{:.3}s", - PROCESS_START - .get() - .map_or(0.0, |s| s.elapsed().as_secs_f64()) - ); - } - // `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.compilation.remove_index(vec![file_id]); - analysis.compilation.update_index(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 + for (label, edited_text) in incremental_edit_variants(&text) { + let name = format!("{name} [{label}]"); + let text = text.clone(); + if std::env::var_os("BENCH_EDIT_LANDMARKS").is_some() { + eprintln!( + " [incremental] BEGIN {name} at t+{:.3}s", + PROCESS_START + .get() + .map_or(0.0, |s| s.elapsed().as_secs_f64()) + ); + } + // `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() { + analysis.update_file_text_only(&uri, edited_text); + // Phase 1: the edited file's own entries, the remap of every + // reference into it, and the diff that says who still has to be + // re-analysed. This is the floor a position-based request has to + // wait for if it is gated on its own file rather than on the ripple. + let t = Instant::now(); + let dirty = analysis.self_index_and_diff(vec![file_id]); + let self_only = t.elapsed(); + let dirty_len = dirty.dirty_len(); + // `BENCH_EDIT_SELF_ONLY=1` stops after phase 1, so a profile of the + // run contains nothing but the cost a per-file freshness gate pays. + let ripple = if std::env::var_os("BENCH_EDIT_SELF_ONLY").is_some() { + std::time::Duration::ZERO + } else { + let t = Instant::now(); + analysis.ripple(dirty); + t.elapsed() + }; + eprintln!(" [incremental] {name} dirty set: {dirty_len} file(s)"); + eprintln!( + " [incremental] {name} staged: {:.3}s self-only + {:.3}s ripple", + self_only.as_secs_f64(), + ripple.as_secs_f64() + ); + self_only + ripple } else { let t = Instant::now(); - analysis.reindex_expanded_files(vec![file_id], expansion); + analysis + .update_file_by_uri(&uri, Some(edited_text)) + .map(|(id, _)| id); t.elapsed() }; + + let t = Instant::now(); + let shared = analysis.precompute_diagnostic_shared_data(); + analysis.diagnose_file_with_shared(file_id, CancellationToken::new(), shared); + let diagnostics = t.elapsed(); + let elapsed = reindex + diagnostics; + total += elapsed; + worst = worst.max(elapsed); + edited += 1; eprintln!( - " [incremental] {name} staged: {:.3}s self-only + {:.3}s ripple", - self_only.as_secs_f64(), - ripple.as_secs_f64() + " [incremental] {name} (reindexes {expansion} files): {:.3}s ({:.3}s reindex + {:.3}s diagnostics)", + elapsed.as_secs_f64(), + reindex.as_secs_f64(), + diagnostics.as_secs_f64() ); - self_only + ripple - } else { - let t = Instant::now(); - analysis.update_file_by_uri(&uri, Some(edited_text)); - t.elapsed() - }; - - let t = Instant::now(); - let shared = analysis.precompute_diagnostic_shared_data(); - analysis.diagnose_file_with_shared(file_id, CancellationToken::new(), shared); - let diagnostics = t.elapsed(); - let elapsed = reindex + diagnostics; - total += elapsed; - worst = worst.max(elapsed); - edited += 1; - eprintln!( - " [incremental] {name} (reindexes {expansion} files): {:.3}s ({:.3}s reindex + {:.3}s diagnostics)", - elapsed.as_secs_f64(), - reindex.as_secs_f64(), - 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.compilation.remove_index(vec![file_id]); - analysis.compilation.update_index(vec![file_id]); - } else { - analysis.update_file_by_uri(&uri, Some(text)); + // 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]); + } else { + analysis + .update_file_by_uri(&uri, Some(text)) + .map(|(id, _)| id); + } } } if edited == 0 { @@ -267,6 +269,45 @@ fn run_incremental_edits( Some(worst) } +/// The edits this harness applies to one sampled file, each labelled with the +/// class of change it makes. +/// +/// `no-op` is a comment appended at EOF: nothing above it moves and nothing it +/// exports changes, so a correct edit path does no cross-file work at all. +/// The other two do change what the file exports, which is what makes the +/// ripple numbers a workload measurement rather than a floor. +fn incremental_edit_variants(text: &str) -> Vec<(&'static str, String)> { + let mut variants = vec![ + ("no-op", format!("{text}\n-- bench incremental edit\n")), + ( + "new-export", + format!("{text}\nfunction BenchIncrementalExport() end\n"), + ), + ]; + if let Some(edited) = add_param_to_first_function(text) { + variants.push(("param-add", edited)); + } + variants +} + +/// Adds a parameter to the file's first top-level `function name(...)`, which +/// changes a signature its existing callers are already evidence for. +/// +/// Returns `None` when the file declares no top-level function, in which case +/// the harness simply skips this class for that file. +fn add_param_to_first_function(text: &str) -> Option { + let at = text.find("\nfunction ")? + 1; + let open = text[at..].find('(')? + at; + let close = text[open..].find(')')? + open; + let existing = text[open + 1..close].trim(); + let params = if existing.is_empty() { + "benchAddedParam".to_string() + } else { + format!("{existing}, benchAddedParam") + }; + Some(format!("{}{params}{}", &text[..open + 1], &text[close..])) +} + /// Everything a *consumer* of a file could observe from it: the members it /// attaches and the types it has inferred. fn contribution_entries(analysis: &EmmyLuaAnalysis, file_id: FileId) -> Vec { @@ -379,7 +420,7 @@ fn discover_config_files(root: &Path) -> Vec { /// on Windows, so the tools have to ask for one explicitly. fn main() { std::thread::Builder::new() - .stack_size(256 * 1024 * 1024) + .stack_size(glua_code_analysis::ANALYSIS_STACK_SIZE) .spawn(|| { tokio::runtime::Builder::new_multi_thread() .enable_all() diff --git a/tools/determinism/src/main.rs b/tools/determinism/src/main.rs index 882c0a89b..149749188 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 @@ -120,17 +99,25 @@ //! Always diffs the index, DET_INDEX_DIFF or not. //! Needs DET_EDIT_FIND; without it the stage //! skips loudly instead of gating anything. +//! blast how many files ONE edit to each DET_TARGETS +//! entry dirties, gated against DET_BLAST_MAX +//! (default 50). The only stage that measures the +//! work an edit costs rather than the answer it +//! produces, and the only one that exits non-zero: +//! every result gate stays green when a settle +//! path re-analyses the whole workspace to reach +//! the right answer. Needs DET_EDIT_FIND. //! burst three edits per DET_TARGETS entry, each one -//! self-indexed on its own, then ONE ripple over -//! the union of the three separately-captured -//! expansions — the sequence a debounce that -//! defers the ripple behind a longer idle timer -//! produces. Gates that union against a cold build -//! of the final text. An expansion recomputed -//! after a self-index under-expands badly (739 -//! files collapsed to 8), so union is the only -//! shape that can work; this measures whether it -//! does. Needs DET_EDIT_FIND; without it the stage +//! self-indexed on its own with its dirty set +//! accumulated, then ONE ripple over the union — +//! exactly what `DebouncedAnalysis::run` holds in +//! `owed_ripple` when a typing burst defers the +//! ripple behind its idle timer. Gates the settled +//! result against a cold build of the final text, +//! and reports `control` (the same edits settled +//! one at a time) beside it so a failure separates +//! the deferral from the path underneath it. +//! Needs DET_EDIT_FIND; without it the stage //! skips loudly instead of gating anything. //! exact reindex DET_TARGETS with no text change and no //! dependency expansion (bisects which file's @@ -149,11 +136,20 @@ //! reindex full clear + rebuild, the ground truth //! order rebuild with the file list reversed //! split:N rebuild in N batches instead of one +//! batchexpand re-walk each DET_TARGETS entry's expansion +//! in one `reindex_expanded_files` batch with the +//! text untouched. Localises sites that flip when +//! re-derived against a settled retained +//! neighborhood, without the edit signal `burst` +//! carries //! mainreindex re-analyse every main-workspace file at //! once, deliberately *without* the dependency //! expansion -//! mainexpand same set, but through `reindex_files`, i.e. -//! the expansion the LSP actually applies +//! mainexpand same set, but through `self_index_and_diff` +//! + `ripple`, i.e. the two-phase edit path the +//! LSP actually applies. Phase 1 re-indexes the +//! set; phase 2 re-analyses only the files whose +//! facts the diff says changed. //! allreindex re-analyse every file, library included, //! via per-file removal rather than `clear_index` //! restabilize re-run analysis over every file *without* @@ -169,14 +165,34 @@ //! 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, dynamic-field owner/field maps +//! (with unattributed names and finite members), accessor-func +//! calls, +//! properties, and metatables. Not covered: flow index +//! (signature casts, special-call effects), the +//! name-keyed accessor-func annotation table, and +//! numeric-range population -- none expose a public +//! whole-index iterator //! DET_DUMP_INDEX directory to write each index snapshot to as `