diff --git a/.gitattributes b/.gitattributes index 9656043..a9e25ee 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ * text=auto eol=lf *.tsv text eol=lf +*.lua text eol=lf **/escrowed.lua binary **/encrypted.lua binary diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ae30f2..c8705d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,31 +25,42 @@ jobs: - run: rustup default stable - if: contains(matrix.target, 'musl') run: sudo apt-get update && sudo apt-get install -y musl-tools - - run: cargo build --release --locked -p qbx_lint --target ${{ matrix.target }} + # Build separately to keep server-only documentation out of the CLI. + - run: cargo build --profile lint-release --locked -p qbx_lint --target ${{ matrix.target }} + - run: cargo build --release --locked -p qbx_lua_ls --target ${{ matrix.target }} - name: Check version and package shell: bash run: | set -euo pipefail - cd target/${{ matrix.target }}/release - binary="qbx-lint" - if [ "${{ runner.os }}" = "Windows" ]; then binary="${binary}.exe"; fi - actual="$(./"${binary}" --version)" - expected="qbx-lint ${GITHUB_REF_NAME#v}" - if [ "${actual}" != "${expected}" ]; then - echo "Tag ${GITHUB_REF_NAME} does not match built version: ${actual}" >&2 - exit 1 - fi - cp ../../../LICENSE LICENSE - name="qbx-lint-${{ matrix.target }}" - if [ "${{ matrix.archive }}" = "zip" ]; then - 7z a "../../../${name}.zip" "${binary}" LICENSE - else - tar -czf "../../../${name}.tar.gz" "${binary}" LICENSE - fi + root="$PWD" + for tool in qbx-lint qbx-lua-ls; do + profile=release + if [ "$tool" = qbx-lint ]; then profile=lint-release; fi + ( + cd "target/${{ matrix.target }}/${profile}" + binary="$tool" + if [ "${{ runner.os }}" = "Windows" ]; then binary="${binary}.exe"; fi + actual="$(./"${binary}" --version)" + expected="$tool ${GITHUB_REF_NAME#v}" + if [ "$actual" != "$expected" ]; then + echo "Tag ${GITHUB_REF_NAME} does not match built version: ${actual}" >&2 + exit 1 + fi + cp "$root/LICENSE" LICENSE + name="$tool-${{ matrix.target }}" + if [ "${{ matrix.archive }}" = "zip" ]; then + 7z a "$root/${name}.zip" "$binary" LICENSE + else + tar -czf "$root/${name}.tar.gz" "$binary" LICENSE + fi + ) + done - uses: actions/upload-artifact@v4 with: name: binary-${{ matrix.target }} - path: qbx-lint-${{ matrix.target }}.${{ matrix.archive }} + path: | + qbx-lint-${{ matrix.target }}.${{ matrix.archive }} + qbx-lua-ls-${{ matrix.target }}.${{ matrix.archive }} if-no-files-found: error publish: diff --git a/.gitignore b/.gitignore index 340ec42..3cf3b3d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ Thumbs.db /qbx-lint-*.zip /qbx-lint-*.tar.gz +/qbx-lua-ls-*.zip +/qbx-lua-ls-*.tar.gz diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac3f956..864aec6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,11 +25,22 @@ cargo run --locked -p qbx_lint -- fmt --check path/to/resource | `qbx_fivem_data` | Native data, runtime stubs, and known manifest imports. | | `qbx_lua_analysis` | Scopes, resource models, rules, configuration, and suppressions. | | `qbx_lint` | Command-line interface and report formats. | +| `qbx_lua_ls` | Language server, editor features, and LSP protocol. | | `xtask` | Native data generation. | -The [language server](https://github.com/Qbox-project/qbx-lua-ls) uses sibling path dependencies -on these crates. When changing a shared API, keep the two repositories side by side and run the -language server's tests too. +The [language server](crates/qbx_lua_ls) shares the workspace's parser, formatter, analysis and +FiveM data crates. `cargo test --workspace --locked` checks both tools; shared API changes belong +in the same pull request. See its [contribution guide](crates/qbx_lua_ls/CONTRIBUTING.md) for LSP +checks and manual probes. Editor adapters live in [qbx-editor](https://github.com/Qbox-project/qbx-editor). + +Release builds use separate Cargo invocations so the linter does not enable the server's `docs` +features. The server uses the `release` profile with panic unwinding for request recovery; +the CLI keeps its abort-on-panic behavior in `lint-release`: + +```sh +cargo build --profile lint-release --locked -p qbx_lint +cargo build --release --locked -p qbx_lua_ls +``` ## Tests and fixtures diff --git a/Cargo.lock b/Cargo.lock index d56dda0..c61335b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "borsh" version = "1.8.1" @@ -176,6 +182,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.8" @@ -446,6 +461,32 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lsp-server" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6ada348dbc2703cbe7637b2dda05cff84d3da2819c24abcb305dd613e0ba2e" +dependencies = [ + "crossbeam-channel", + "log", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "lsp-types" +version = "0.95.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e34d33a8e9b006cd3fc4fe69a921affa097bae4bb65f76271f4644f9a334365" +dependencies = [ + "bitflags", + "serde", + "serde_json", + "serde_repr", + "url", +] + [[package]] name = "memchr" version = "2.8.3" @@ -539,6 +580,24 @@ dependencies = [ "serde", ] +[[package]] +name = "qbx_lua_ls" +version = "1.0.4" +dependencies = [ + "crossbeam-channel", + "lsp-server", + "lsp-types", + "qbx_fivem_data", + "qbx_lua_analysis", + "qbx_lua_fmt", + "qbx_lua_syntax", + "rustc-hash", + "serde", + "serde_json", + "smol_str", + "walkdir", +] + [[package]] name = "qbx_lua_syntax" version = "1.0.4" @@ -699,6 +758,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -865,6 +935,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fba9530..0e5ccf5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/qbx_lua_analysis", "crates/qbx_fivem_data", "crates/qbx_lint", + "crates/qbx_lua_ls", "xtask", ] default-members = [ @@ -14,6 +15,7 @@ default-members = [ "crates/qbx_lua_analysis", "crates/qbx_fivem_data", "crates/qbx_lint", + "crates/qbx_lua_ls", ] [workspace.package] @@ -35,4 +37,8 @@ opt-level = 3 lto = "fat" codegen-units = 1 strip = true + +# LSP requests recover from panics, so the server must keep unwinding enabled. +[profile.lint-release] +inherits = "release" panic = "abort" diff --git a/README.md b/README.md index 2390304..c7fa93f 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,14 @@ A Lua linter and formatter for FiveM resources. It parses CfxLua syntax, reads r and checks code against bundled native definitions, runtime globals, and client/server context. It reports Lua mistakes, missing manifest imports, event and export mismatches, and common Qbox -patterns. The parser and analysis crates are also used by -[qbx-lua-ls](https://github.com/Qbox-project/qbx-lua-ls), the server behind the +patterns. The same workspace contains the parser, formatter, analysis crates and +[qbx-lua-ls](crates/qbx_lua_ls), the server behind the [Qbox Lua editor integrations](https://github.com/Qbox-project/qbx-editor). +Shared analysis and language-server changes can be developed and tested in one pull request. +See the [language-server guide](crates/qbx_lua_ls/README.md) for features and setup, and the +[migration notes](docs/repository-migration.md) for Git history and release compatibility. + ## Install Build from a checkout with a stable Rust toolchain and Cargo: @@ -15,13 +19,13 @@ Build from a checkout with a stable Rust toolchain and Cargo: ```sh git clone https://github.com/Qbox-project/qbx-lint.git cd qbx-lint -cargo install --path crates/qbx_lint --locked +cargo install --path crates/qbx_lint --locked --profile lint-release qbx-lint --version ``` Cargo installs the executable into its `bin` directory, which must be on your `PATH`. -For a local build without installation, run `cargo build --release --locked -p qbx_lint`. -The executable is then in `target/release` (`qbx-lint.exe` on Windows). +For a local build without installation, run `cargo build --profile lint-release --locked -p qbx_lint`. +The executable is then in `target/lint-release` (`qbx-lint.exe` on Windows). Download the archive for your platform from the [releases page](https://github.com/Qbox-project/qbx-lint/releases), extract it, and put the diff --git a/crates/qbx_lua_ls/CONTRIBUTING.md b/crates/qbx_lua_ls/CONTRIBUTING.md new file mode 100644 index 0000000..6daf80c --- /dev/null +++ b/crates/qbx_lua_ls/CONTRIBUTING.md @@ -0,0 +1,73 @@ +# Contributing + +## Set up + +Install stable Rust and clone [qbx-lint](https://github.com/Qbox-project/qbx-lint). The language +server is a member of the same Cargo workspace as the parser, formatter, analysis and FiveM data +crates: + +```text +qbx-lint/ + Cargo.toml + crates/ + qbx_lua_ls/ + qbx_lua_analysis/ + ... +``` + +From the workspace root, build with `cargo build --locked -p qbx_lua_ls`. If a change needs shared +parser or lint behavior, include those crate changes in the same pull request. + +## Check changes + +Run these commands from the workspace root before submitting a pull request: + +```sh +cargo fmt --all --check +cargo clippy --workspace --locked --all-targets -- -D warnings +cargo test --workspace --locked +``` + +Use `cargo fmt` to apply formatting. Add a regression test when fixing a bug, using a small Lua +example and the manifest needed to reproduce it. The tests in `tests/lsp.rs` run the server +through an in-memory LSP connection. `tests/workspace_refresh.rs` covers event-side behavior +and index refreshes with temporary files. + +Check both minimal LSP clients and clients that advertise the relevant capability when changing +protocol behavior. Editor integration code belongs in +[qbx-editor](https://github.com/Qbox-project/qbx-editor). + +For manual inspection, build the executable and use the probe script: + +```sh +cargo build --release --locked -p qbx_lua_ls +node crates/qbx_lua_ls/scripts/probe.mjs < snippet.lua +``` + +`probe.mjs` requires Node.js. It opens the supplied text as a document and prints completions +for lines marked with `--^` and hovers for `--?` markers. Use the `.exe` executable on +Windows. Run the command in a shell that supports input redirection. + +## Code layout + +| Location | Purpose | +| --- | --- | +| `src/types.rs`, `src/luacats.rs` | Type representation and LuaCATS annotations. | +| `src/indexer.rs`, `src/index.rs` | File summaries, symbols and visibility between resources. | +| `src/infer.rs` | Type inference used by editor features. | +| `src/workspace.rs` | Resource discovery, manifests, dependencies and index refreshes. | +| `src/features/` | LSP feature implementations. | +| `src/server.rs` | Protocol dispatch, settings and diagnostics publication. | + +Closed files retain summaries rather than full syntax trees. Reference searches may parse them +again. Keep that distinction in mind when adding data to the index or handling unsaved text. + +## Issues and pull requests + +For a bug report, include the server version, operating system, editor/LSP client, relevant +settings, expected behavior and a small Lua/manifest example. For crashes, include server logs +and the last action that triggered the problem. + +Describe the behavior changed by a pull request and list the checks you ran. State any editor +or platform behavior you could not test. Update the docs when the user-facing +behavior changes. Release notes are generated from commit messages, so write them as [Conventional Commits](https://www.conventionalcommits.org). diff --git a/crates/qbx_lua_ls/Cargo.toml b/crates/qbx_lua_ls/Cargo.toml new file mode 100644 index 0000000..a6c75a8 --- /dev/null +++ b/crates/qbx_lua_ls/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "qbx_lua_ls" +description = "Language server for FiveM Lua" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "qbx-lua-ls" +path = "src/main.rs" + +[dependencies] +qbx_lua_syntax.workspace = true +qbx_lua_fmt.workspace = true +qbx_lua_analysis = { workspace = true, features = ["docs"] } +qbx_fivem_data = { workspace = true, features = ["docs"] } +lsp-server = "0.7" +lsp-types = "0.95" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +crossbeam-channel = "0.5" +rustc-hash.workspace = true +smol_str.workspace = true +walkdir = "2" diff --git a/crates/qbx_lua_ls/LICENSE b/crates/qbx_lua_ls/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/crates/qbx_lua_ls/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md new file mode 100644 index 0000000..86ebdf6 --- /dev/null +++ b/crates/qbx_lua_ls/README.md @@ -0,0 +1,128 @@ +# qbx-lua-ls + +A language server for FiveM Lua. It reads `fxmanifest.lua` to resolve resource imports and +client/server scripts, uses LuaCATS annotations for editor help, and provides diagnostics from +[qbx-lint](https://github.com/Qbox-project/qbx-lint). + +The server communicates over standard input and output using the Language Server Protocol +(LSP). Editor integrations and setup instructions live in +[qbx-editor](https://github.com/Qbox-project/qbx-editor/blob/main/docs/editors.md). +Available features depend on the editor's LSP client. + +## Features + +- Completion and hover for Lua symbols, FiveM natives, exports, events and callbacks. +- Reference hovers for literal control IDs in PAD natives and ped configuration flags + in `SetPedConfigFlag` / `GetPedConfigFlag`, using bundled Cfx documentation. +- Optional read-only reference search/detail requests for native, control and ped flag browsers, + with filters, pagination, source links and Lua insertion templates. +- Definitions, references and rename for locals, globals and fields, including static string + keys such as `Config['name']` and supported `---@field` declarations. +- Hover and definitions for the classes, aliases and enums named in LuaCATS annotations. +- Diagnostics and quick fixes with resource and client/server context. +- Signature help, parameter hints, semantic tokens, folding and document/workspace symbols. +- QB-Core and ESX server callback completion, navigation and payload hints from local handlers. +- Whole-document formatting, configured through `qbxlint.toml`. +- Completion for manifest paths, locale keys, convars, state bag keys and LuaCATS annotations. +- Read-only resource, dependency-health, NUI callback and asset-reference requests + for editor tabs, plus paginated diagnostic and symbol-reference queries for assistants. + +Custom requests are documented in the [protocol reference](docs/protocol.md). +The VS Code asset/utility interfaces and assistant MCP adapter live in `qbx-editor`. + +Opening a resource also indexes dependencies and imported scripts found in sibling resource +folders. Add other locations through the `library` setting. + +Hovering `38` in `IsControlJustPressed(0, 38)` shows `INPUT_PICKUP` and its default +QWERTY/Xbox bindings. Ped configuration flag hovers show the documented symbol; +undocumented behavior is identified as such. References work offline and include +source links. ID hovers apply only to recognized native arguments, not variables, +calculated expressions or functions that shadow the native. + +## Framework callbacks + +The server recognizes QB-Core `Functions.CreateCallback` / `Functions.TriggerCallback` +and ESX `RegisterServerCallback` / `TriggerServerCallback`. Receiver provenance comes +from standard framework export initialization (including local aliases), supported +framework imports or the framework's own resource. Arbitrary same-named tables are +not framework receivers. Manifest sides and `IsDuplicityVersion()` guards restrict +registrations to server code and triggers to client code. + +Literal callback names complete and navigate to locally indexed registrations. +Payload signature help and inlay hints omit the handler's leading `source` and `cb`; +LuaCATS annotations on local functions supply types. QB-Core, ESX, ox_lib and native +events have separate name spaces. Conflicting payload definitions suppress derived +hints, while navigation still lists the matching registrations. Handler return +values are not treated as asynchronous callback responses. + +No framework API documentation is downloaded or bundled. Custom wrappers, +client-callback/Await variants and response-type inference are not included. +See the [convention and maintenance notes](docs/framework-callbacks.md) for source revisions and recognition limits. + +## Build and run + +Download the archive for your platform from [Releases](https://github.com/Qbox-project/qbx-lint/releases). +Archives cover Windows x64, Linux x64/ARM64 (musl), and macOS x64/ARM64. You can also build +from source using the steps below. + +Install stable Rust and clone the shared tooling workspace. The server uses the parser, +formatter, analysis and FiveM data crates from the same checkout. + +```sh +git clone https://github.com/Qbox-project/qbx-lint.git +cd qbx-lint +cargo build --release --locked -p qbx_lua_ls +``` + +The executable is `target/release/qbx-lua-ls`, or `target/release/qbx-lua-ls.exe` on Windows. +Put it on `PATH`, or configure its absolute path in your editor. Start it without arguments for +LSP over stdio; `--version` prints the version. It does not need a running FiveM server. + +Use a resource folder or the server's `resources` folder as the editor workspace. Follow the +[editor setup instructions](https://github.com/Qbox-project/qbx-editor/blob/main/docs/editors.md) +to start the server from your editor. + +## Configuration + +Send this object directly as LSP `initializationOptions`: + +```json +{ + "library": [], + "diagnostics": { + "enable": true, + "workspace": true, + "rules": {} + }, + "inlayHints": { "enable": true }, + "semanticTokens": { "enable": true } +} +``` + +For `workspace/didChangeConfiguration`, put the same object in `settings.qbxLua` or directly in +`settings`. Restart the server after changing `library`. A discovered `qbxlint.toml` supplies +lint and formatting settings; editor rule overrides take precedence for diagnostics. + +The server relies on the editor for file-watch notifications. If the client does not send them, +send a `qbx/reindex` request with `null` parameters or restart after external file or manifest +changes. Open-document edits continue to update normally. + +See the [client configuration and request reference](docs/protocol.md) for the full settings +and custom requests. + +## Limits + +- Types support completion, hover and navigation. The server does not check assignment types + or provide full control-flow narrowing or generic inference. +- Field references depend on the inferred owner type. Computed keys and fields reached through + unknown types may not be found; known declarations that cannot be edited safely prevent rename. +- Formatting applies to whole documents. Range formatting is not implemented. +- Encrypted scripts cannot be analyzed. Security diagnostics are heuristic checks, not proof + that an event handler or resource is secure. + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development checks and the +[releases page](https://github.com/Qbox-project/qbx-lint/releases) for release notes. + +## License + +[GPL-3.0-or-later](LICENSE). diff --git a/crates/qbx_lua_ls/docs/framework-callbacks.md b/crates/qbx_lua_ls/docs/framework-callbacks.md new file mode 100644 index 0000000..90eb0ff --- /dev/null +++ b/crates/qbx_lua_ls/docs/framework-callbacks.md @@ -0,0 +1,54 @@ +# Framework callback conventions + +These adapters maintain four call shapes. Callback names, payload parameter names +and types come from the user's indexed Lua handlers, not copied framework API +documentation. No additional dependency, network request or catalog is required. + +| Framework | Server registration | Client trigger | Server handler parameters | +|---|---|---|---| +| QB-Core | `core.Functions.CreateCallback(name, handler)` | `core.Functions.TriggerCallback(name, cb, ...payload)` | `source, cb, ...payload` | +| ESX | `esx.RegisterServerCallback(name, handler)` | `esx.TriggerServerCallback(name, cb, ...payload)` | `source, cb, ...payload` | + +Conventions reviewed on 2026-09-26 against primary sources: + +- QB-Core [client function reference](https://qbcore.org/docs/qb-core/client-function-reference#qbcorefunctionstriggercallback) + and [server function reference](https://qbcore.org/docs/qb-core/server-function-reference#qbcorefunctionscreatecallback). + Source revision: `9b3cddcce93e5e12cbcf6b47b866b687d32ac7bf`, + [client functions](https://github.com/qbcore-fivem/qb-core/blob/9b3cddcce93e5e12cbcf6b47b866b687d32ac7bf/client/functions.lua). +- ESX source revision: `fe59ca0bd6da59e2ec6eb4a8d06ece312e96ae7a`, + [server callbacks](https://github.com/esx-framework/esx_core/blob/fe59ca0bd6da59e2ec6eb4a8d06ece312e96ae7a/%5Bcore%5D/es_extended/server/modules/callback.lua) + and [client callbacks](https://github.com/esx-framework/esx_core/blob/fe59ca0bd6da59e2ec6eb4a8d06ece312e96ae7a/%5Bcore%5D/es_extended/client/modules/callback.lua). + +## Recognition and limits + +`src/framework_callbacks.rs` recognizes literal standard export initializations +(`exports['qb-core']:GetCoreObject()` and +`exports['es_extended']:getSharedObject()`), local aliases, the manifest import +`@es_extended/imports.lua`, and canonical globals in indexed provider resources. +Reassigned receivers, shadowed `exports`/`_ENV` and detected callback member +overwrites disable the adapter. This is conservative static recognition, not a +complete analysis of arbitrary runtime table mutations or wrapper functions. + +Manifest side must identify server registrations and client triggers. Shared +files need a recognized `IsDuplicityVersion()` guard. Names must be nonempty +literal strings. Handler parameter inference supports inline functions and +unreassigned local function references, with LuaCATS annotations where present. + +Each framework has its own event-index family. The first two handler parameters +are removed from client payload hints; handler return values never describe the +asynchronous response. Conflicting payload definitions disable derived signature +and inlay hints and are identified in completion details. Navigation and hovers +can list multiple registrations. + +Client-callback/Await variants, filtered `GetCoreObject` calls, custom wrappers, +computed callback names and response-type inference require a separate scope +decision. Existing native events and ox_lib callbacks keep their own conventions. + +## Maintaining the adapters + +When upstream changes one of these four conventions, review the primary source, +update this revision note, then adjust the classifier/indexer and event-call +mapping. Add an isolated fixture under `tests/fixtures/framework_callbacks` and an +LSP regression in `tests/lsp.rs`; preserve family isolation and shadowing checks. +Run the repository's Rust checks and the editor integration suite. Changes to a +framework's unrelated API documentation require no adapter update. diff --git a/crates/qbx_lua_ls/docs/protocol.md b/crates/qbx_lua_ls/docs/protocol.md new file mode 100644 index 0000000..c4b9d5d --- /dev/null +++ b/crates/qbx_lua_ls/docs/protocol.md @@ -0,0 +1,490 @@ +# LSP client configuration + +Start `qbx-lua-ls` without arguments and communicate over stdio. Give the server file-based +workspace folders through `workspaceFolders` or `rootUri`. It uses UTF-16 positions and accepts +incremental document changes. + +## Settings + +`initializationOptions` must contain the settings object directly, without a `qbxLua` wrapper. +For example: + +```json +{ + "library": ["/srv/fivem/resources"], + "diagnostics": { + "enable": true, + "workspace": true, + "rules": { "unused-argument": "off" } + }, + "inlayHints": { "enable": true }, + "semanticTokens": { "enable": true } +} +``` + +Use absolute paths in `library`; Windows paths such as `C:/server/resources` work too. + +| Setting | Default | Effect | +| --- | --- | --- | +| `library` | `[]` | Extra folders to index for types, definitions and resource imports. Restart after changing it. | +| `diagnostics.enable` | `true` | Publish diagnostics. | +| `diagnostics.workspace` | `true` | Also report diagnostics for closed files in the workspace. | +| `diagnostics.rules` | `{}` | Override rule levels with `off`, `hint`, `info`, `warning` or `error`. | +| `inlayHints.enable` | `true` | Return parameter hints. | +| `semanticTokens.enable` | `true` | Return semantic highlighting tokens. | + +Send updates using `workspace/didChangeConfiguration`. Its parameters may use either form: + +```json +{ "settings": { "qbxLua": { "diagnostics": { "enable": false } } } } +``` + +```json +{ "settings": { "diagnostics": { "enable": false } } } +``` + +Updates replace the settings object rather than merging individual keys. Send the full settings +object when preserving other overrides. The server does not request `workspace/configuration`; +the client must provide settings during initialization or send the notification. + +The server discovers `qbxlint.toml` from the first workspace root and its ancestors. Its +`[format]` section controls formatting. Without a discovered config file, the editor's formatting +request supplies indentation width and tabs/spaces. Diagnostic rule overrides from the client +take precedence over the config file. + +Files matching the config's `exclude` patterns are never indexed or diagnosed, even while they are +open. Files matching `ignore_diagnostics` are indexed like any other file, so definitions, hover +and completion still reach their symbols, but the server publishes no diagnostics for them. +`ignore_diagnostics` and `imports` changes apply once the client reports the saved config file; a +resource outside the workspace that only `imports` names is indexed on the next `qbx/reindex` or +restart. Configured imports also count for definitions, hover and completion. Files added to +or removed from `exclude` leave or enter the index on the next `qbx/reindex` or restart. + +## Client capabilities and file changes + +Snippet completions are sent only when +`textDocument.completion.completionItem.snippetSupport` is `true`. Other clients receive ordinary +symbol, annotation and manifest completions without snippet placeholders. + +The server requests file watches only when +`workspace.didChangeWatchedFiles.dynamicRegistration` is `true`. It watches Lua, lint config, +locale JSON and server config files through the client. It has no internal filesystem watcher +or polling loop. + +If a client cannot provide file-watch notifications, open-document changes still work. After +external changes to files, manifests or dependencies, send `qbx/reindex` with `null` parameters, +or restart the server. Restart after changing workspace folders or library locations. + +## Custom requests + +These requests are optional conveniences for editor integrations; normal language features use +standard LSP requests. + +| Request | Parameters | Result | +| --- | --- | --- | +| `qbx/status` | `null` | Object with `files`, `resources`, `openDocuments` and `natives` counts. | +| `qbx/reindex` | `null` | Rebuilds the index from disk while preserving open-document text; returns `files`, `resources` and `millis`. | +| `qbx/fileInfo` | `{ "uri": "file:///path/to/script.lua" }` | Object with `side` and `resource` (a resource name or `null`). | +| `qbx/snippets` | `{ "uri": "file:///path/to/script.lua" }` or `null` | Array of snippets with `label`, `description`, `body` and `preview`. | +| `qbx/referenceSearch` | Search object below, or `null` for defaults. | A bounded page of native, control or ped flag summaries. | +| `qbx/referenceDetail` | `{ "id": "native:GetEntityCoords" }` | Reference detail object below, or `null` for an unknown ID. | +| `qbx/resourceDetails` | `{ "uri": "file:///path/to/resource" }` | Resource snapshot below; accepts an indexed resource folder or its selected manifest. | +| `qbx/workspaceHealth` | `null` or `{}` | Bounded workspace dependency health snapshot below. | +| `qbx/nuiResource` | `{ "uri": "file:///path/to/resource" }` | Saved NUI page metadata and indexed Lua callback registrations below. | +| `qbx/resourceAssets` | `{ "uri": "file:///path/to/resource" }` | Literal manifest asset declarations and supported native asset arguments with source locations. | +| `qbx/resources` | `{ "query"?: string, "offset"?: number, "limit"?: number }` | A page of indexed resource identities, retaining duplicate names. | +| `qbx/diagnostics` | `{ "uri"?: string, "offset"?: number, "limit"?: number }` | A current, configured diagnostic snapshot for one indexed Lua file/manifest or the workspace. | +| `qbx/symbolReferences` | `{ "uri": string, "line": number, "character": number, "includeDeclaration"?: boolean, "offset"?: number, "limit"?: number }` | A page of symbol locations, including support for closed indexed Lua files. | + +`qbx/fileInfo.side` is one of `client`, `server`, `shared`, `module`, `manifest` or `standalone`. +It describes manifest placement; a guard inside the file can narrow the side of an individual +call. `qbx/snippets` returns snippet syntax in `body` even for a client that has not enabled +completion snippets, so a custom snippet picker must handle that syntax itself. + +### Bundled reference search + +Both reference requests work without an open document. They read the data already bundled +with the server, fetch nothing from the network, and do not write files or execute code. +The search catalog is created only when first requested. Native documentation is returned +only by the detail request, keeping search responses small. + +```ts +type ReferenceKind = 'native' | 'control' | 'pedFlag'; +type ReferenceSide = 'client' | 'server' | 'shared'; + +interface ReferenceSearchParams { + query?: string; + kind?: 'all' | ReferenceKind; + side?: 'all' | ReferenceSide; + namespace?: string; + offset?: number; + limit?: number; +} + +interface ReferenceItem { + id: string; + kind: ReferenceKind; + name: string; + side: ReferenceSide; + namespace?: string; + hash?: string; + numericId?: number; +} + +interface ReferenceSearchResult { + items: ReferenceItem[]; + total: number; + offset: number; + limit: number; + namespaces: string[]; +} + +interface ReferenceDetail extends ReferenceItem { + signature?: string; + parameters?: { name: string; type: string }[]; + returns?: string[]; + documentation: string; + sourceUrl: string; + copyText: string; + insertText: string; + insertSnippet?: string; +} +``` + +Search defaults to an empty query, `kind: "all"`, `side: "all"`, no namespace, +`offset: 0` and `limit: 50`. Limits are clamped to 1–100 and offsets beyond the end +are clamped to `total`, returning an empty page. Offsets and limits must be nonnegative +integers. Queries may contain at most 256 Unicode characters; namespaces at most 64. +Malformed parameters return the LSP `InvalidParams` error (`-32602`). + +Search is case-insensitive and matches names, aliases, native hashes with or without +`0x`, numeric IDs and default control bindings. Lua native names and underscore-separated +spellings both work. Whitespace/underscore-separated tokens must all match. Exact matches +rank before prefixes, which rank before other substrings. Ties retain native name order, +followed by controls and flags in numeric ID order. Native aliases resolve to one canonical +listing rather than duplicate rows. An empty query lists the complete selected catalog. + +The `client` and `server` side filters include shared natives; `shared` includes only +shared natives. Numeric control/flag catalogs have `side: "client"`. Namespace filters +apply only to natives, including under `kind: "all"`. The returned `namespaces` array +always lists all bundled native namespaces in sorted order. + +Stable IDs have the form `native:GetEntityCoords`, `control:38` or `pedFlag:48`. +Detail accepts native aliases and known `N_0x...` spellings and returns the canonical ID. +Numeric IDs require canonical decimal spelling. IDs longer than 512 Unicode characters +are rejected; other unknown IDs return `null`. + +Native details include their signature, parameters, return types and available source +documentation. `copyText` is the native name; `insertText` is a Lua call with parameter +names; `insertSnippet` adds escaped numbered placeholders and a final `$0` tab stop. +For controls and flags, both copy and insert text are the numeric ID. Snippet syntax is +returned regardless of normal completion capabilities; a custom client must explicitly +use its snippet insertion API or use `insertText` instead. + +Control documentation identifies default bindings, including missing/unbound values and +the possibility of remapping. Flag documentation preserves official symbols and explicitly +identifies undocumented behavior and uncertain names. Reference details and numeric hovers +share the same text. `documentation` is Markdown from bundled sources: web clients must +render it as untrusted content, disable raw HTML, and validate links before opening them. +Insertion and clipboard actions require an explicit client/user action; these server +requests themselves only return data. + +### Resource details + +`qbx/resourceDetails` reads the existing local index after the normal dirty-document flush. +It does not rescan directories, contact a running server, write files, or execute commands. +The URI must identify the exact folder or selected manifest of an indexed resource. Script +files, unknown folders, non-file URIs, queries, fragments, and malformed parameters return +`InvalidParams` (`-32602`) with a human-readable explanation. URIs are limited to 16,384 bytes. +Path matching normalizes lexical components and Windows case; it does not resolve filesystem +symlink aliases or select a resource by its name alone. + +```ts +interface ResourceIdentity { + name: string; + uri: string; + manifestUri: string; +} + +interface ResourceSymbol { + name: string; + kind: string; + side: 'client' | 'server' | 'shared' | 'unknown'; + location: { + uri: string; + range: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; + }; + signature?: string; +} + +interface ResourceRelation { + name: string; + kinds: ('dependency' | 'import')[]; + status: 'resolved' | 'missing' | 'ambiguous'; + targets: ResourceIdentity[]; + targetCount: number; +} + +interface ResourceDetails { + resource: ResourceIdentity; + files: { total: number; client: number; server: number; shared: number; module: number }; + counts: { events: number; exports: number }; + events: ResourceSymbol[]; + exports: ResourceSymbol[]; + dependencies: ResourceRelation[]; + dependents: ResourceRelation[]; + constraints: string[]; + notes: string[]; + truncated: { events: number; exports: number; dependencies: number; dependents: number }; +} +``` + +File counts cover owned indexed Lua sources, including modules not directly listed as scripts. +They exclude the manifest, built-in stubs, and imported files owned by other resources. The +side buckets are disjoint. Symbol counts are registration rows, not unique names: a network +registration and separate handler of the same name remain separate rows. Events include native +registrations/handlers and recognized ox_lib, QB-Core and ESX callbacks, with their effective +side. Trigger calls are excluded. Exports are literal Lua export registrations; manifest export +declarations and dynamically computed registrations may be absent. Locations use standard +zero-based UTF-16 LSP ranges and always refer to the original declarations. + +Direct dependencies combine literal manifest `dependency` entries and `@resource/file` script +imports. They are grouped by ASCII-case-insensitive resource name, matching the existing local +resource locator; `kinds` records both origins where applicable. Resolution searches indexed +resource identities: zero candidates is `missing`, one is `resolved`, and multiple distinct +folders with that name is `ambiguous`. An import's resolved resource does not establish that +the individual imported file exists or was indexed. Dependencies beginning with `/`, such as +`/server:7290` or `/onesync`, appear separately under `constraints`. + +Inverse `dependents` retain one row per dependent resource identity, even when several folders +share a name. Each row targets that dependent's folder. If several possible providers share +the selected resource's name, inverse rows have `status: "ambiguous"` and `targetCount: 1`: +the ambiguity concerns which provider the dependent uses. These are potential dependents, +not a claim about actual runtime resolution. The current manifest model does not retain +`provide` aliases; a note explains this limitation when an indexed manifest uses them. + +Results are deterministic. Symbols sort by displayed name, kind, side and source location; +dependencies by normalized name; dependents by name and folder URI; target candidates by name +and URI. Each symbol list is capped at 500 rows, each relation list at 200 rows, candidates at +20 per relation, and constraints at 200. `counts` and `targetCount` retain full indexed totals. +The four `truncated` numbers are **omitted row counts**, not flags. Notes explain candidate and +constraint omissions. Displayed symbol/relation names and constraints are capped at 2,048 +Unicode characters and signatures at 8,192, including a final ellipsis when shortened. +Dependency lookup uses the full name before shortening; source links remain unchanged. + +Unsaved Lua changes appear after the normal index update. Save manifest changes before +refreshing details; manifest metadata and script sides use the saved manifest. External changes +need ordinary watched-file notifications, `qbx/reindex`, or a server restart. Refreshing details +alone does not discover new files. Notes also identify incomplete escrowed, unreadable, +excluded, oversized, non-Lua, or dynamically registered content. This endpoint reports local +source information, not whether a resource is installed, started, or healthy on a live server. + +### Workspace health + +`qbx/workspaceHealth` accepts `null` or an empty object. Other parameters return +`InvalidParams` (`-32602`). It reads the existing index after the normal dirty-document flush, +without scanning folders, resolving new files, contacting a running server, or changing files. + +```ts +interface WorkspaceHealth { + files: number; + resources: number; + counts: { duplicates: number; missing: number; ambiguous: number }; + issues: { + kind: 'duplicate' | 'missing' | 'ambiguous'; + name: string; + resource?: ResourceIdentity; + targets: ResourceIdentity[]; + targetCount: number; + kinds: ('dependency' | 'import')[]; + }[]; + truncated: number; + notes: string[]; +} +``` + +`ResourceIdentity` has the same shape as Resource Details. `resources` counts distinct +normalized resource roots. `files` counts indexed Lua sources, including standalone modules +and configured library files, excluding manifests and built-in stubs. No resource's full +symbol details are constructed: resource-name candidates are grouped once and each distinct +resource's manifest references are checked once. + +`duplicates` counts names shared by multiple distinct resource folders. Each duplicate issue +has no `resource`, an empty `kinds` list, and the candidate provider folders as `targets`. +`missing` and `ambiguous` count referenced names per dependent resource, merging dependency +and import declarations into one row. Their `resource` is that dependent resource. A missing +row has no targets; an ambiguous row has multiple candidate providers. Resource names match +ASCII-case-insensitively, using the same direct dependency/import rules as Resource Details. +Runtime constraints beginning with `/` are excluded. A resolved import's resource name does +not establish whether its individual file exists. `provide` aliases are not retained by the +manifest index; a note explains this limitation when any indexed manifest uses them. + +Duplicate issues are listed first in normalized-name order, followed by reference issues in +normalized dependent-folder and referenced-name order. Candidate folders sort by resource +name and path. The output is capped at 500 issue rows and 20 targets per row. `counts` and +`targetCount` retain full totals; `truncated` is the number of omitted issue rows. Notes explain +omitted candidate folders. Displayed issue and resource names are capped at 2,048 Unicode +characters including an ellipsis; matching always uses the full name and folder/manifest +URIs remain unchanged. + +Save manifest changes before refreshing this snapshot. External file changes need ordinary +watched-file notifications, `qbx/reindex`, or a server restart. The report cannot assess live +server state or dependencies supplied outside the indexed workspace. Computed names and +excluded, unreadable, oversized or non-Lua source files may be absent. + +### NUI resource metadata + +`qbx/nuiResource` requires an object containing a `uri` for an exact indexed resource folder +or its selected manifest. It uses the same URI validation and lexical path matching as +Resource Details; invalid, unknown or script-file URIs return `InvalidParams` (`-32602`). +The request reads the existing local index after the normal dirty-document flush. It does not +parse closed Lua files, scan directories, execute Lua, start a preview, or send any network +request. + +```ts +interface NuiResource { + resource: ResourceIdentity; + uiPage: string | null; + callbacks: { + name: string; + location: { + uri: string; + range: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; + }; + }[]; + truncated: number; + notes: string[]; +} +``` + +`uiPage` is the literal `ui_page` value retained by the saved manifest model, or `null` when +none is recorded. Local paths and remote URLs are returned unchanged; the client must decide +which targets it supports and validate them before previewing anything. A literal exceeding +16,384 bytes is omitted with a note instead of being shortened into a different path. Computed +manifest expressions are not evaluated. Save manifest changes before refreshing the page +metadata. + +Callbacks are recorded during ordinary Lua indexing, separately from network events. Only +direct calls to the global `RegisterNUICallback` or `RegisterNuiCallback` with a nonempty literal +string first argument and a second argument are considered. Local or `_ENV` shadows, +same-file reassignments and direct `_G`/`_ENV` replacements are suppressed. Current visible +global replacements in other indexed files are checked at request time, so changing a global +in an unsaved document can suppress or restore results without reparsing every callback file. +This is conservative static recognition, not proof that a registration runs at runtime. +Dynamic calls, aliases and arbitrary table/environment mutation cannot be fully resolved. + +Results cover owned indexed Lua code with an effective client/shared side. Recognized side +guards exclude server-only branches; server-script files are always excluded. Unclassified +modules without a client-side guard, imported external files, non-Lua and unreadable scripts +may be absent. Unsaved Lua updates use the normal index refresh. Each location points to the +complete original callback-name string literal using zero-based UTF-16 LSP coordinates. +Duplicate registrations remain separate source rows, sorted by name and source location. + +The response contains at most 500 callback rows. Names over 2,048 Unicode characters are +omitted rather than shortened, preserving exact callback keys for clients that offer mock +responses. `truncated` counts all omitted rows, including oversized names, and notes explain +the omissions. This metadata does not run Lua callbacks or establish live game/server state. +The conventions follow the official [Cfx NUI callback documentation](https://docs.fivem.net/docs/scripting-manual/nui-development/nui-callbacks/) +and [fullscreen NUI documentation](https://docs.fivem.net/docs/scripting-manual/nui-development/full-screen-nui/). + +### Resource asset metadata + +`qbx/resourceAssets` requires `{ "uri": "file:///.../resource" }` using an exact +indexed resource folder or its manifest, as with Resource Details. It returns +source metadata; it does not read or decode asset files. The editor combines this +response with its bounded local asset inventory. + +```ts +interface ResourceAssets { + resource: ResourceIdentity; + declarations: { + kind: 'file' | 'client_script' | 'server_script' | 'shared_script' + | 'ui_page' | 'loadscreen' | 'data_file' | 'map'; + value: string; + dataType?: string; + location: Location; + }[]; + references: { + kind: 'model' | 'textureDictionary' | 'texture' | 'particleAsset' | 'audioBank'; + value?: string; + hash?: number; + dictionary?: string; + location: Location; + }[]; + truncated: { declarations: number; references: number }; + notes: string[]; +} +``` + +`Location` is an LSP file URI and zero-based UTF-16 range. Declaration values +preserve literal manifest paths, including globs and `@resource` imports. +`dataType` identifies a literal `data_file` type. Only supported top-level +manifest calls and literal arguments are collected; Lua expressions are not run. +The rules follow the [Cfx resource manifest reference](https://docs.fivem.net/docs/scripting-reference/resource-manifest/). + +Lua references recognize direct native calls for models, streamed texture +dictionaries, `DrawSprite` texture names, named particle assets and audio banks. +Model arguments can be literal strings, Cfx backtick hashes, integer hashes or +literal strings passed to `GetHashKey`/`joaat`. Calculated arguments, aliases and +shadowed/redefined natives are omitted. `hash` is an unsigned 32-bit value; +string hashing is limited to ASCII. Texture names can include a `dictionary` +literal from the same call. A source reference alone does not prove that an asset +exists locally or is missing from the game. + +The request uses unsaved manifest/Lua buffers where available and reads only +owned indexed Lua sources. It excludes scripts imported from other resources. +Closed sources are limited to 2 MiB per file, 32 MiB total and 2,000 files. The +response contains at most 1,000 declarations and 2,000 references; each literal +is limited to 4 KiB. `truncated` counts omitted entries, and notes describe partial +coverage. Index membership still requires normal file notifications or reindexing. + +### Paginated assistant queries + +These read-only requests require object parameters and reject unknown fields +with `InvalidParams` (`-32602`). They do not execute Lua or server commands. + +| Request | Parameters | Items | +| --- | --- | --- | +| `qbx/resources` | Optional `query`, `offset`, `limit` | `ResourceIdentity` | +| `qbx/diagnostics` | Optional indexed Lua/manifest `uri`, `offset`, `limit` | `{ uri, range, severity, code?, message }` | +| `qbx/symbolReferences` | Indexed Lua `uri`, zero-based `line` and UTF-16 `character`; optional `includeDeclaration`, `offset`, `limit` | LSP `Location` | + +Each returns `{ items, total, offset, limit, notes }`. Offsets default to zero and +cannot exceed 1,000,000. Page sizes default to 50, with a maximum of 100 for +resources and 200 for diagnostics/references. The editor assistant adapter +exposes a maximum of 100 for every tool and also checks workspace trust, file +scope and response size. Its portable MCP entry point is part of `qbx-editor`. + +Resource queries match name or folder path using literal case-insensitive text +of up to 256 characters. Results sort by name and URI, preserving duplicate +resource names as separate identities. They use the current index; the adapter's +optional `refresh: true` issues `qbx/reindex` before requesting this list. + +Diagnostics reuse configured Lua/manifest checks and rule overrides. Disabling +diagnostics returns an empty result with a note. The workspace-publishing switch +does not disable this explicit query. Unsaved buffers take precedence over disk; +whole-workspace queries include configured locale checks when coverage permits. +Messages are capped at 4,096 characters and omit code-action payloads. Read the +notes before interpreting a zero count or comparing separate pages. + +Symbol references preserve normal resource visibility and default to including +the declaration. Invalid UTF-16 positions, built-in stubs and unindexed files +are rejected. Results sort by URI/range and remove duplicates. Diagnostic and +reference queries have additional inspection budgets and report incomplete +coverage in `notes`; totals refer to the inspected snapshot, not a live server. +They share limits of 2,000 attempted files, 2 MiB per file, 32 MiB of source and +supporting data, and 20,000 results. Invalid or binary inputs consume the read +budget too. Diagnostic support discovery shares a 20,000-directory-entry budget +for locale selection, manifest file inventory and server startup configuration. +When supporting data is incomplete, notes identify omitted checks rather than +reporting missing resources/files from an incomplete inventory. Locale JSON must +also pass syntax and nesting validation before locale analysis. +Portable MCP reads saved files. Its resource index requires explicit refresh +after edits, even though diagnostic/reference source reads can see newer disk +contents. diff --git a/crates/qbx_lua_ls/scripts/bench-luals.mjs b/crates/qbx_lua_ls/scripts/bench-luals.mjs new file mode 100644 index 0000000..155a7a4 --- /dev/null +++ b/crates/qbx_lua_ls/scripts/bench-luals.mjs @@ -0,0 +1,111 @@ +// Measures lua-language-server on the same workspace for comparison with scripts/bench.mjs. +// usage: node scripts/bench-luals.mjs [library-dir ...] +import { spawn, execFileSync } from 'node:child_process'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const [workspace, binary, ...libraries] = process.argv.slice(2); +if (!workspace || !binary) { + console.error('usage: node scripts/bench-luals.mjs [library-dir ...]'); + process.exit(2); +} + +const scratch = mkdtempSync(join(tmpdir(), 'luals-bench-')); +const server = spawn(resolve(binary), [`--logpath=${join(scratch, 'log')}`, `--metapath=${join(scratch, 'meta')}`], { + stdio: ['pipe', 'pipe', 'inherit'], +}); +let buffer = Buffer.alloc(0); +let nextId = 0; +const pending = new Map(); +let activeProgress = 0; +let lastProgressEnd = 0; +let sawProgress = false; + +const settings = { + Lua: { + runtime: { version: 'Lua 5.4', nonstandardSymbol: ['/**/', '`', '+=', '-=', '*=', '/=', '<<=', '>>=', '&=', '|=', '^='] }, + workspace: { library: libraries.map((l) => resolve(l)), checkThirdParty: false, maxPreload: 100000, preloadFileSize: 10000 }, + telemetry: { enable: false }, + }, +}; + +function lookup(section) { + return section ? section.split('.').reduce((value, key) => value?.[key], settings) ?? null : settings; +} + +server.stdout.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + for (;;) { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd < 0) return; + const length = Number(/Content-Length: (\d+)/i.exec(buffer.subarray(0, headerEnd).toString())[1]); + if (buffer.length < headerEnd + 4 + length) return; + const message = JSON.parse(buffer.subarray(headerEnd + 4, headerEnd + 4 + length).toString()); + buffer = buffer.subarray(headerEnd + 4 + length); + if (message.id !== undefined && message.method) { + const result = message.method === 'workspace/configuration' ? message.params.items.map((item) => lookup(item.section)) : null; + send({ jsonrpc: '2.0', id: message.id, result }); + } else if (message.id !== undefined) { + pending.get(message.id)?.(message.result); + pending.delete(message.id); + } else if (message.method === '$/progress') { + sawProgress = true; + if (message.params.value.kind === 'begin') activeProgress++; + if (message.params.value.kind === 'end') { + activeProgress--; + lastProgressEnd = performance.now(); + } + } + } +}); + +function send(message) { + const body = JSON.stringify(message); + server.stdin.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`); +} + +function request(method, params) { + const id = ++nextId; + return new Promise((done) => { + pending.set(id, done); + send({ jsonrpc: '2.0', id, method, params }); + }); +} + +function memoryMb() { + if (process.platform === 'win32') { + const out = execFileSync('powershell', ['-NoProfile', '-Command', `(Get-Process -Id ${server.pid}).WorkingSet64`]); + return Number(out.toString().trim()) / 1024 / 1024; + } + return Number(execFileSync('ps', ['-o', 'rss=', '-p', String(server.pid)]).toString().trim()) / 1024; +} + +const started = performance.now(); +const rootUri = pathToFileURL(resolve(workspace)).href; +await request('initialize', { + processId: process.pid, + rootUri, + capabilities: { window: { workDoneProgress: true }, workspace: { configuration: true } }, + workspaceFolders: [{ uri: rootUri, name: 'bench' }], +}); +send({ jsonrpc: '2.0', method: 'initialized', params: {} }); + +let peak = 0; +for (;;) { + await new Promise((r) => setTimeout(r, 1000)); + peak = Math.max(peak, memoryMb()); + const idleFor = performance.now() - lastProgressEnd; + const elapsed = performance.now() - started; + if ((sawProgress && activeProgress === 0 && idleFor > 5000) || elapsed > 180000) { + const loaded = sawProgress ? (lastProgressEnd - started) / 1000 : NaN; + console.log(`workspace loaded after ~${loaded.toFixed(1)} s`); + console.log(`memory once idle: ${memoryMb().toFixed(1)} MB (peak ${peak.toFixed(1)} MB)`); + break; + } +} + +await request('shutdown', null); +send({ jsonrpc: '2.0', method: 'exit' }); +setTimeout(() => process.exit(0), 1000); diff --git a/crates/qbx_lua_ls/scripts/bench.mjs b/crates/qbx_lua_ls/scripts/bench.mjs new file mode 100644 index 0000000..d9c0edd --- /dev/null +++ b/crates/qbx_lua_ls/scripts/bench.mjs @@ -0,0 +1,94 @@ +// Starts the server over stdio against a folder, opens one file and reports timings and memory. +// usage: node scripts/bench.mjs [file-to-open] [server-binary] +import { spawn, execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const defaultBinary = fileURLToPath(new URL(`../../../target/release/qbx-lua-ls${process.platform === 'win32' ? '.exe' : ''}`, import.meta.url)); +const [workspace, fileToOpen, binary = defaultBinary] = process.argv.slice(2); +if (!workspace) { + console.error('usage: node scripts/bench.mjs [file-to-open] [server-binary]'); + process.exit(2); +} + +const server = spawn(resolve(binary), [], { stdio: ['pipe', 'pipe', 'inherit'] }); +let buffer = Buffer.alloc(0); +let nextId = 0; +const pending = new Map(); + +server.stdout.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + for (;;) { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd < 0) return; + const length = Number(/Content-Length: (\d+)/i.exec(buffer.subarray(0, headerEnd).toString())[1]); + if (buffer.length < headerEnd + 4 + length) return; + const message = JSON.parse(buffer.subarray(headerEnd + 4, headerEnd + 4 + length).toString()); + buffer = buffer.subarray(headerEnd + 4 + length); + if (message.id !== undefined && message.method) { + send({ jsonrpc: '2.0', id: message.id, result: null }); + } else if (message.id !== undefined) { + pending.get(message.id)?.(message.result); + pending.delete(message.id); + } else if (message.method === 'window/logMessage') { + console.log(`server: ${message.params.message}`); + } + } +}); + +function send(message) { + const body = JSON.stringify(message); + server.stdin.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`); +} + +function request(method, params) { + const id = ++nextId; + const started = performance.now(); + return new Promise((done) => { + pending.set(id, (result) => done({ result, ms: performance.now() - started })); + send({ jsonrpc: '2.0', id, method, params }); + }); +} + +function memoryMb() { + if (process.platform === 'win32') { + const out = execFileSync('powershell', ['-NoProfile', '-Command', `(Get-Process -Id ${server.pid}).WorkingSet64`]); + return Number(out.toString().trim()) / 1024 / 1024; + } + const out = execFileSync('ps', ['-o', 'rss=', '-p', String(server.pid)]); + return Number(out.toString().trim()) / 1024; +} + +const rootUri = pathToFileURL(resolve(workspace)).href; +const init = await request('initialize', { + processId: process.pid, + rootUri, + capabilities: {}, + workspaceFolders: [{ uri: rootUri, name: 'bench' }], +}); +send({ jsonrpc: '2.0', method: 'initialized', params: {} }); +const status = await request('qbx/status', null); +console.log(`initialize: ${init.ms.toFixed(0)} ms, index ready after: ${status.ms.toFixed(0)} ms`, status.result); +console.log(`memory after indexing: ${memoryMb().toFixed(1)} MB`); + +if (fileToOpen) { + const uri = pathToFileURL(resolve(fileToOpen)).href; + const text = readFileSync(resolve(fileToOpen), 'utf8'); + send({ jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri, languageId: 'lua', version: 1, text } } }); + const lines = text.split('\n'); + const line = Math.floor(lines.length / 2); + const position = { line, character: Math.min(4, lines[line].length) }; + for (const method of ['textDocument/documentSymbol', 'textDocument/semanticTokens/full', 'textDocument/foldingRange']) { + const { ms } = await request(method, { textDocument: { uri } }); + console.log(`${method}: ${ms.toFixed(1)} ms`); + } + for (const method of ['textDocument/hover', 'textDocument/completion', 'textDocument/definition']) { + const { ms } = await request(method, { textDocument: { uri }, position }); + console.log(`${method}: ${ms.toFixed(1)} ms`); + } + console.log(`memory with one document open: ${memoryMb().toFixed(1)} MB`); +} + +await request('shutdown', null); +send({ jsonrpc: '2.0', method: 'exit' }); diff --git a/crates/qbx_lua_ls/scripts/probe.mjs b/crates/qbx_lua_ls/scripts/probe.mjs new file mode 100644 index 0000000..1ebecc0 --- /dev/null +++ b/crates/qbx_lua_ls/scripts/probe.mjs @@ -0,0 +1,56 @@ +// Ad-hoc probe: opens a virtual document inside a workspace and prints completions/hovers. +// usage: node scripts/probe.mjs < probe.lua +// Lines ending in `--^` request completion at the end of the code, `--?N` requests hover at column N. +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const [workspace, virtualFile, binary] = process.argv.slice(2); +const source = readFileSync(0, 'utf8'); +const server = spawn(resolve(binary), [], { stdio: ['pipe', 'pipe', 'inherit'] }); +let buffer = Buffer.alloc(0), nextId = 0; +const pending = new Map(); +server.stdout.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + for (;;) { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd < 0) return; + const length = Number(/Content-Length: (\d+)/i.exec(buffer.subarray(0, headerEnd).toString())[1]); + if (buffer.length < headerEnd + 4 + length) return; + const message = JSON.parse(buffer.subarray(headerEnd + 4, headerEnd + 4 + length).toString()); + buffer = buffer.subarray(headerEnd + 4 + length); + if (message.id !== undefined && message.method) send({ jsonrpc: '2.0', id: message.id, result: null }); + else if (message.id !== undefined) { pending.get(message.id)?.(message.result); pending.delete(message.id); } + else if (message.method === 'textDocument/publishDiagnostics' && message.params.uri === probedUri) diagnostics = message.params.diagnostics; + } +}); +let diagnostics = [], probedUri; +const send = (m) => { const b = JSON.stringify(m); server.stdin.write(`Content-Length: ${Buffer.byteLength(b)}\r\n\r\n${b}`); }; +const request = (method, params) => new Promise((done) => { const id = ++nextId; pending.set(id, done); send({ jsonrpc: '2.0', id, method, params }); }); + +const rootUri = pathToFileURL(resolve(workspace)).href; +await request('initialize', { processId: null, rootUri, capabilities: {}, workspaceFolders: [{ uri: rootUri, name: 'probe' }] }); +send({ jsonrpc: '2.0', method: 'initialized', params: {} }); +const uri = (probedUri = pathToFileURL(resolve(virtualFile)).href); +const lines = source.split(/\r?\n/); +const clean = lines.map((l) => l.replace(/\s*--(\^|\?\d+)$/, '')); +send({ jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri, languageId: 'lua', version: 1, text: clean.join('\n') } } }); +for (const [line, raw] of lines.entries()) { + const marker = /--(\^|\?(\d+))$/.exec(raw); + if (!marker) continue; + if (marker[1] === '^') { + const result = await request('textDocument/completion', { textDocument: { uri }, position: { line, character: clean[line].length } }); + const items = result?.items ?? []; + console.log(`${clean[line].trim()} => ${items.length} items: ${items.slice(0, 14).map((i) => i.label).join(', ')}`); + } else { + const result = await request('textDocument/hover', { textDocument: { uri }, position: { line, character: Number(marker[2]) } }); + console.log(`${clean[line].trim()} @${marker[2]} => ${(result?.contents?.value ?? '').split('\n').filter((l) => l && !l.startsWith('```')).slice(0, 2).join(' | ')}`); + } +} +console.log('file:', JSON.stringify(await request('qbx/fileInfo', { uri }))); +await request('qbx/status', null); +await new Promise((r) => setTimeout(r, 200)); +console.log('diagnostics:', diagnostics.map((d) => `${d.range.start.line + 1}:${d.code}${process.env.PROBE_VERBOSE ? ' ' + d.message : ''}`).join(process.env.PROBE_VERBOSE ? '\n' : ', ') || 'none'); +await request('shutdown', null); +send({ jsonrpc: '2.0', method: 'exit' }); diff --git a/crates/qbx_lua_ls/scripts/stress.mjs b/crates/qbx_lua_ls/scripts/stress.mjs new file mode 100644 index 0000000..fe8bdc9 --- /dev/null +++ b/crates/qbx_lua_ls/scripts/stress.mjs @@ -0,0 +1,125 @@ +// Opens every Lua file of a workspace and fires every feature at many positions, looking for +// crashes, internal errors and slow requests. +// usage: node scripts/stress.mjs [server-binary] [positions-per-file] +import { spawn } from 'node:child_process'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const defaultBinary = fileURLToPath(new URL(`../../../target/release/qbx-lua-ls${process.platform === 'win32' ? '.exe' : ''}`, import.meta.url)); +const [workspace, binary = defaultBinary, perFile = '25'] = process.argv.slice(2); +if (!workspace) { + console.error('usage: node scripts/stress.mjs [server-binary] [positions-per-file]'); + process.exit(2); +} + +function luaFiles(dir, out = []) { + for (const entry of readdirSync(dir)) { + if (entry === 'node_modules' || entry.startsWith('.')) continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) luaFiles(full, out); + else if (entry.endsWith('.lua')) out.push(full); + } + return out; +} + +const server = spawn(resolve(binary), [], { stdio: ['pipe', 'pipe', 'inherit'] }); +let exited = false; +server.on('exit', (code) => { + exited = true; + if (code !== 0) console.error(`SERVER EXITED with code ${code}`); +}); +let buffer = Buffer.alloc(0); +let nextId = 0; +const pending = new Map(); +let recovered = 0; + +server.stdout.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + for (;;) { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd < 0) return; + const length = Number(/Content-Length: (\d+)/i.exec(buffer.subarray(0, headerEnd).toString())[1]); + if (buffer.length < headerEnd + 4 + length) return; + const message = JSON.parse(buffer.subarray(headerEnd + 4, headerEnd + 4 + length).toString()); + buffer = buffer.subarray(headerEnd + 4 + length); + if (message.id !== undefined && message.method) send({ jsonrpc: '2.0', id: message.id, result: null }); + else if (message.id !== undefined) { + pending.get(message.id)?.(message); + pending.delete(message.id); + } else if (message.method === 'window/logMessage' && message.params.message.includes('recovered')) recovered++; + } +}); + +const send = (message) => { + const body = JSON.stringify(message); + server.stdin.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`); +}; +const request = (method, params) => + new Promise((done, fail) => { + if (exited) return fail(new Error('server is gone')); + const id = ++nextId; + const started = performance.now(); + pending.set(id, (message) => done({ message, ms: performance.now() - started })); + send({ jsonrpc: '2.0', id, method, params }); + }); + +const rootUri = pathToFileURL(resolve(workspace)).href; +await request('initialize', { processId: process.pid, rootUri, capabilities: {}, workspaceFolders: [{ uri: rootUri, name: 'stress' }] }); +send({ jsonrpc: '2.0', method: 'initialized', params: {} }); + +const files = luaFiles(resolve(workspace)); +const slow = []; +const errors = []; +let requests = 0; +let seed = 12345; +const random = (max) => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed % max; +}; + +async function call(method, params, file) { + const { message, ms } = await request(method, params); + requests++; + if (message.error) errors.push(`${method} ${file}: ${message.error.message}`); + if (ms > 100) slow.push(`${ms.toFixed(0)} ms ${method} ${file}`); +} + +for (const file of files) { + const uri = pathToFileURL(file).href; + const text = readFileSync(file, 'utf8'); + const lines = text.split('\n'); + send({ jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri, languageId: 'lua', version: 1, text } } }); + const textDocument = { uri }; + const whole = { start: { line: 0, character: 0 }, end: { line: lines.length, character: 0 } }; + await call('textDocument/documentSymbol', { textDocument }, file); + await call('textDocument/semanticTokens/full', { textDocument }, file); + await call('textDocument/foldingRange', { textDocument }, file); + await call('textDocument/inlayHint', { textDocument, range: whole }, file); + for (let i = 0; i < Number(perFile); i++) { + const line = random(lines.length); + const position = { line, character: random(lines[line].length + 1) }; + const at = { textDocument, position }; + await call('textDocument/hover', at, file); + await call('textDocument/completion', at, file); + await call('textDocument/definition', at, file); + await call('textDocument/signatureHelp', at, file); + await call('textDocument/documentHighlight', at, file); + } + // Truncate the file mid-token to mimic typing, then ask again. + const cut = random(text.length); + send({ jsonrpc: '2.0', method: 'textDocument/didChange', params: { textDocument: { uri, version: 2 }, contentChanges: [{ text: text.slice(0, cut) }] } }); + const last = text.slice(0, cut).split('\n'); + const end = { textDocument, position: { line: last.length - 1, character: last[last.length - 1].length } }; + await call('textDocument/completion', end, file); + await call('textDocument/hover', end, file); + await call('textDocument/semanticTokens/full', { textDocument }, file); + send({ jsonrpc: '2.0', method: 'textDocument/didClose', params: { textDocument } }); +} + +console.log(`${files.length} files, ${requests} requests, ${errors.length} errors, ${recovered} recovered panics, ${slow.length} slow (>100 ms)`); +errors.slice(0, 10).forEach((e) => console.log(` error: ${e}`)); +slow.slice(0, 10).forEach((s) => console.log(` slow: ${s}`)); +await request('shutdown', null); +send({ jsonrpc: '2.0', method: 'exit' }); +process.exitCode = errors.length || recovered ? 1 : 0; diff --git a/crates/qbx_lua_ls/src/document.rs b/crates/qbx_lua_ls/src/document.rs new file mode 100644 index 0000000..92aec0a --- /dev/null +++ b/crates/qbx_lua_ls/src/document.rs @@ -0,0 +1,65 @@ +use std::path::PathBuf; + +use lsp_types::{Position, Range, TextDocumentContentChangeEvent, Url}; +use qbx_lua_analysis::scope::{resolve, Resolution}; +use qbx_lua_syntax::ast::Chunk; +use qbx_lua_syntax::{parse, LineCol, LineIndex, Span}; + +use crate::index::FileId; + +pub struct Document { + pub uri: Url, + pub path: PathBuf, + pub version: i32, + pub text: String, + pub lines: LineIndex, + pub chunk: Chunk, + pub resolution: Resolution, + pub file: FileId, +} + +impl Document { + pub fn new(uri: Url, path: PathBuf, version: i32, text: String) -> Self { + let lines = LineIndex::new(&text); + let chunk = parse(&text); + let resolution = resolve(&chunk); + Self { uri, path, version, text, lines, chunk, resolution, file: 0 } + } + + pub fn apply_changes(&mut self, version: i32, changes: Vec) { + for change in changes { + match change.range { + Some(range) => { + let start = self.offset(range.start) as usize; + let end = self.offset(range.end) as usize; + self.text.replace_range(start..end.max(start), &change.text); + self.lines = LineIndex::new(&self.text); + } + None => { + self.text = change.text; + self.lines = LineIndex::new(&self.text); + } + } + } + self.version = version; + self.chunk = parse(&self.text); + self.resolution = resolve(&self.chunk); + } + + pub fn offset(&self, position: Position) -> u32 { + self.lines.offset_utf16(&self.text, LineCol { line: position.line, col: position.character }) + } + + pub fn position(&self, offset: u32) -> Position { + let pos = self.lines.line_col_utf16(&self.text, offset); + Position::new(pos.line, pos.col) + } + + pub fn range(&self, span: Span) -> Range { + Range::new(self.position(span.start), self.position(span.end.max(span.start))) + } + + pub fn is_manifest(&self) -> bool { + qbx_lua_analysis::project::is_manifest_file(&self.path) + } +} diff --git a/crates/qbx_lua_ls/src/features/assistant.rs b/crates/qbx_lua_ls/src/features/assistant.rs new file mode 100644 index 0000000..2ece727 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/assistant.rs @@ -0,0 +1,673 @@ +//! Explicit, read-only, paginated views for editor agents and the standalone MCP adapter. +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use lsp_types::{DiagnosticSeverity, Location, NumberOrString, Position, Range, Url}; +use qbx_lua_analysis::startup::StartOrder; +use qbx_lua_analysis::{locale, Level}; +use serde::{Deserialize, Serialize}; + +use super::resource_assets::SOURCE_BYTES; +use super::resource_details::{identity, normalized, ResourceIdentity}; +use crate::document::Document; +use crate::features::{diagnostics, references}; +use crate::index::{FileId, FileOrigin}; +use crate::server::Documents; +use crate::workspace::{path_to_uri, uri_to_path, Workspace}; + +const MAX_FILES: usize = 2000; +const MAX_BYTES: usize = 32 * 1024 * 1024; +const MAX_RESULTS: usize = 20_000; +const MAX_DIRECTORY_ENTRIES: usize = 20_000; + +/// Shared agent-only read limits, including auxiliary locale files. Editor LSP features stay unchanged. +pub(crate) struct InspectionBudget { + roots: Vec, + seen: BTreeMap, + omitted: BTreeSet, + bytes: usize, + sources: BTreeMap, + pub result_limit: bool, +} +impl InspectionBudget { + pub fn new(roots: &[PathBuf]) -> Self { + Self { + roots: roots.iter().filter_map(|root| std::fs::canonicalize(root).ok()).collect(), + seen: BTreeMap::new(), + omitted: BTreeSet::new(), + bytes: 0, + sources: BTreeMap::new(), + result_limit: false, + } + } + pub fn skip(&mut self, path: &Path) { + self.omitted.insert(path.to_path_buf()); + } + fn contains(&self, path: &Path) -> bool { + std::fs::canonicalize(path).ok().is_some_and(|path| self.roots.iter().any(|root| path.starts_with(root))) + } + pub fn claim(&mut self, path: &Path, bytes: usize) -> bool { + let extra = bytes.saturating_sub(self.seen.get(path).copied().unwrap_or(0)); + if bytes > SOURCE_BYTES + || self.bytes + extra > MAX_BYTES + || (!self.seen.contains_key(path) && self.seen.len() >= MAX_FILES) + { + self.skip(path); + return false; + } + self.seen.entry(path.to_path_buf()).and_modify(|count| *count = (*count).max(bytes)).or_insert(bytes); + self.bytes += extra; + true + } + pub fn read(&mut self, path: &Path) -> Option { + if let Some(source) = self.sources.get(path) { + return Some(source.clone()); + } + if self.omitted.contains(path) { + return None; + } + if (!self.seen.contains_key(path) && self.seen.len() >= MAX_FILES) || self.bytes >= MAX_BYTES { + self.skip(path); + return None; + } + // Attempts count even when metadata/open/decoding fails. Otherwise many invalid files + // could each consume the full per-file read allowance without using the shared budget. + if !self.claim(path, 0) { + return None; + } + let mut load = || -> Option { + let canonical = std::fs::canonicalize(path).ok()?; + if !self.roots.iter().any(|root| canonical.starts_with(root)) { + return None; + } + let stat = std::fs::metadata(&canonical).ok()?; + let remaining = SOURCE_BYTES.min(MAX_BYTES - self.bytes); + if !stat.is_file() || stat.len() > remaining as u64 { + return None; + } + if !self.claim(path, stat.len() as usize) { + return None; + } + let file = std::fs::File::open(canonical).ok()?; + let mut data = Vec::new(); + let result = file.take(remaining as u64 + 1).read_to_end(&mut data); + if !self.claim(path, data.len().min(remaining)) { + return None; + } + result.ok()?; + if data.len() > remaining || qbx_lua_analysis::project::is_not_source(&data) { + return None; + } + String::from_utf8(data).ok() + }; + match load() { + Some(text) if self.claim(path, text.len()) => { + self.sources.insert(path.to_path_buf(), text.clone()); + Some(text) + } + _ => { + self.skip(path); + None + } + } + } + pub fn notes(&self) -> Vec { + let mut notes = Vec::new(); + if !self.omitted.is_empty() { + notes.push(format!("{} candidate files were omitted because they were unreadable, outside the workspace roots, or exceeded inspection limits (2,000 files, 2 MiB each, 32 MiB total, including supporting data). Results are partial.", self.omitted.len())); + } + if self.result_limit { + notes + .push("Results reached the 20,000-entry inspection limit and may be partial. Narrow the query.".into()); + } + notes + } +} + +/// Bounded snapshots can return many spans on one minified line. Sparse UTF-16 checkpoints +/// avoid rescanning that entire line for each result, including non-ASCII JSON keys. +pub(crate) struct InspectionPositions<'a> { + source: &'a str, + checkpoints: Vec<(usize, Position)>, +} +impl<'a> InspectionPositions<'a> { + pub fn new(source: &'a str) -> Self { + let mut checkpoints = vec![(0, Position::new(0, 0))]; + let mut position = Position::new(0, 0); + let mut last = 0; + for (offset, character) in source.char_indices() { + if offset - last >= 256 { + checkpoints.push((offset, position)); + last = offset; + } + if character == '\n' { + position.line += 1; + position.character = 0; + } else { + position.character += character.len_utf16() as u32; + } + } + Self { source, checkpoints } + } + fn position(&self, offset: u32) -> Position { + let mut offset = (offset as usize).min(self.source.len()); + while !self.source.is_char_boundary(offset) { + offset -= 1; + } + let checkpoint = self.checkpoints.partition_point(|(start, _)| *start <= offset) - 1; + let (start, mut position) = self.checkpoints[checkpoint]; + for character in self.source[start..offset].chars() { + if character == '\n' { + position.line += 1; + position.character = 0; + } else { + position.character += character.len_utf16() as u32; + } + } + position + } + pub fn range(&self, span: qbx_lua_syntax::Span) -> Range { + Range::new(self.position(span.start), self.position(span.end.max(span.start))) + } +} + +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResourcesParams { + pub query: Option, + pub offset: Option, + pub limit: Option, +} + +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DiagnosticsParams { + pub uri: Option, + pub offset: Option, + pub limit: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReferencesParams { + pub uri: Url, + pub line: u32, + pub character: u32, + pub include_declaration: Option, + pub offset: Option, + pub limit: Option, +} + +#[derive(Debug, Serialize)] +pub struct Page { + pub items: Vec, + pub total: usize, + pub offset: usize, + pub limit: usize, + pub notes: Vec, +} + +fn paging(offset: Option, limit: Option, max: usize) -> Result<(usize, usize), String> { + let (offset, limit) = (offset.unwrap_or(0), limit.unwrap_or(50)); + if offset > 1_000_000 || limit == 0 || limit > max { + return Err(format!("offset must be 0–1,000,000 and limit 1–{max}")); + } + Ok((offset, limit)) +} + +fn page(items: Vec, offset: usize, limit: usize, notes: Vec) -> Page { + Page { total: items.len(), items: items.into_iter().skip(offset).take(limit).collect(), offset, limit, notes } +} + +pub fn resources(ws: &Workspace, params: ResourcesParams) -> Result, String> { + let (offset, limit) = paging(params.offset, params.limit, 100)?; + let query = params.query.unwrap_or_default(); + if query.chars().count() > 256 || query.chars().any(char::is_control) { + return Err("Resource query must contain at most 256 characters of printable text.".into()); + } + let query = query.to_lowercase(); + let mut items: Vec<_> = ws + .index + .resources + .iter() + .filter(|resource| { + query.is_empty() + || resource.name.to_lowercase().contains(&query) + || resource.root.to_string_lossy().to_lowercase().contains(&query) + }) + .map(identity) + .collect(); + items.sort_by(|a, b| (&a.name, a.uri.as_str()).cmp(&(&b.name, b.uri.as_str()))); + Ok(page(items, offset, limit, Vec::new())) +} + +fn local_path(uri: &Url) -> Result { + if uri.scheme() != "file" || uri.query().is_some() || uri.fragment().is_some() { + return Err("Use an indexed file URI without query or fragment.".into()); + } + uri_to_path(uri).ok_or_else(|| "Use a local file URI.".into()) +} + +pub fn symbol_references(ws: &Workspace, docs: &Documents, params: ReferencesParams) -> Result, String> { + let (offset, limit) = paging(params.offset, params.limit, 200)?; + let path = local_path(¶ms.uri)?; + let id = ws.index.file_id(&path).ok_or("The Lua file is not indexed.")?; + let entry = + ws.index.file(id).filter(|entry| entry.origin != FileOrigin::Stub).ok_or("The Lua file is not indexed.")?; + if entry.path.extension().is_none_or(|ext| !ext.eq_ignore_ascii_case("lua")) { + return Err("Choose an indexed Lua source file.".into()); + } + let mut budget = InspectionBudget::new(&ws.roots); + let owned; + let doc = if let Some(doc) = docs.get(&entry.uri) { + doc + } else { + owned = { + let mut doc = Document::new( + entry.uri.clone(), + entry.path.clone(), + 0, + budget.read(&entry.path).ok_or("Source is unavailable or exceeds inspection limits.")?, + ); + doc.file = id; + doc + }; + &owned + }; + if !budget.claim(&doc.path, doc.text.len()) { + return Err("The source exceeds the 2 MiB inspection limit.".into()); + } + let position = Position::new(params.line, params.character); + if doc.position(doc.offset(position)) != position { + return Err("The UTF-16 position is outside the source or inside a surrogate pair.".into()); + } + let mut items = references::references_bounded( + ws, + docs, + doc, + position, + params.include_declaration.unwrap_or(true), + &mut budget, + ); + items.retain(|item| item.uri.scheme() == "file"); + items.sort_by(|a, b| { + (a.uri.as_str(), a.range.start.line, a.range.start.character, a.range.end.line, a.range.end.character).cmp(&( + b.uri.as_str(), + b.range.start.line, + b.range.start.character, + b.range.end.line, + b.range.end.character, + )) + }); + items.dedup(); + Ok(page(items, offset, limit, budget.notes())) +} + +#[derive(Debug, Serialize)] +pub struct DiagnosticItem { + pub uri: Url, + pub range: Range, + pub severity: DiagnosticSeverity, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + pub message: String, +} + +#[derive(Default)] +struct SupportingData { + locale: Option>, + inventory: Option<(Vec, bool)>, +} + +fn bounded_locale( + root: &Path, + budget: &mut InspectionBudget, + entries: &mut usize, + notes: &mut Vec, +) -> Option { + let dir = root.join("locales"); + if !dir.exists() { + return None; + } + if !budget.contains(&dir) { + budget.skip(&dir); + return None; + } + let preferred = dir.join("en.json"); + let path = if preferred.is_file() { + preferred + } else { + let Ok(mut listing) = std::fs::read_dir(&dir) else { + budget.skip(&dir); + return None; + }; + let mut first: Option = None; + while *entries > 0 { + let Some(entry) = listing.next() else { break }; + *entries -= 1; + let Ok(entry) = entry else { + budget.skip(&dir); + return None; + }; + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "json") && first.as_ref().is_none_or(|first| path < *first) { + first = Some(path); + } + } + if listing.next().is_some() { + notes.push("Locale directory inspection reached the 20,000-entry shared directory limit; locale checks for this resource were omitted.".into()); + return None; + } + first? + }; + let source = budget.read(&path)?; + // The lightweight locale scanner is recursive. Serde validates syntax and bounds nesting + // before that scanner sees untrusted input; neither parser reads from disk itself. + if serde_json::from_str::(&source).is_err() { + notes.push(format!( + "Locale checks were omitted for {} because its JSON is invalid or exceeds the nesting limit.", + path.display() + )); + return None; + } + Some(locale::LocaleFile::parse(path, source)) +} + +fn bounded_inventory(root: &Path, budget: &mut InspectionBudget, entries: &mut usize) -> (Vec, bool) { + if !budget.contains(root) { + budget.skip(root); + return (Vec::new(), false); + } + let mut files = Vec::new(); + let mut complete = true; + let mut walker = walkdir::WalkDir::new(root).into_iter().filter_entry(|entry| { + entry.depth() == 0 || (entry.file_name() != "node_modules" && entry.file_name() != ".git") + }); + while *entries > 0 { + let Some(entry) = walker.next() else { break }; + *entries -= 1; + match entry { + Ok(entry) if entry.file_type().is_file() => { + files.push(qbx_lua_analysis::project::relative_slash_path(root, entry.path())) + } + Ok(_) => {} + Err(_) => complete = false, + } + } + if walker.next().is_some() { + complete = false; + } + (files, complete) +} + +pub fn diagnostic_snapshot( + ws: &Workspace, + docs: &Documents, + params: DiagnosticsParams, + enabled: bool, + overrides: &[(String, Level)], +) -> Result, String> { + let (offset, limit) = paging(params.offset, params.limit, 200)?; + let requested = params.uri.as_ref().map(local_path).transpose()?.map(|path| normalized(&path)); + let mut targets = BTreeMap::)>::new(); + for (id, file) in ws.index.files().filter(|(_, file)| file.origin != FileOrigin::Stub) { + if requested.as_ref().is_some_and(|path| *path == normalized(&file.path)) + || (requested.is_none() && file.origin == FileOrigin::Workspace) + { + targets.insert(file.uri.clone(), (file.path.clone(), Some(id))); + } + } + for resource in &ws.index.resources { + if requested.as_ref().is_some_and(|path| *path == normalized(&resource.manifest_path)) + || (requested.is_none() + && ws.roots.iter().any(|root| normalized(&resource.manifest_path).starts_with(normalized(root)))) + { + targets.insert( + path_to_uri(&resource.manifest_path), + (resource.manifest_path.clone(), ws.index.file_id(&resource.manifest_path)), + ); + } + } + if requested.is_some() && targets.is_empty() { + return Err("Choose an indexed Lua file or resource manifest.".into()); + } + if !enabled { + return Ok(page( + Vec::new(), + offset, + limit, + vec!["Diagnostics are disabled by the language-server configuration.".into()], + )); + } + let mut items = Vec::new(); + let mut notes = Vec::new(); + let mut budget = InspectionBudget::new(&ws.roots); + let mut directory_entries = MAX_DIRECTORY_ENTRIES; + let mut supporting = BTreeMap::::new(); + let mut orders = BTreeMap::>>::new(); + let mut skipped = targets.len().saturating_sub(MAX_FILES); + let crossrefs = ws.crossrefs(); + let mut usages = BTreeMap::>::new(); + let mut incomplete = BTreeSet::new(); + for (uri, (path, id)) in targets.into_iter().take(MAX_FILES) { + let resource = id.and_then(|id| ws.index.file(id)).and_then(|file| file.resource); + let owned; + let doc = if let Some(doc) = docs.get(&uri) { + doc + } else { + let text = match budget.read(&path) { + Some(text) => text, + None => { + skipped += 1; + if let Some(resource) = resource { + incomplete.insert(resource); + } + continue; + } + }; + owned = { + let mut doc = Document::new(uri.clone(), path, 0, text); + doc.file = id.unwrap_or(u32::MAX); + doc + }; + &owned + }; + if !budget.claim(&doc.path, doc.text.len()) { + skipped += 1; + if let Some(resource) = resource { + incomplete.insert(resource); + } + continue; + } + if requested.is_none() { + if let Some(resource) = resource { + usages.entry(resource).or_default().push(locale::locale_usage(&doc.chunk)); + } + } + let entry = resource + .and_then(|id| ws.index.resource(id)) + .or_else(|| ws.index.resources.iter().find(|resource| resource.manifest_path == doc.path)); + let root = entry.map(|entry| &entry.root); + let mut start_order = None; + let mut start_order_complete = true; + if let Some(root) = root { + let data = supporting.entry(root.clone()).or_default(); + if doc.is_manifest() { + if data.inventory.is_none() { + let inventory = bounded_inventory(root, &mut budget, &mut directory_entries); + if !inventory.1 { + notes.push(format!("Manifest file checks for {} were omitted because directory inspection was incomplete (20,000 entries shared across supporting data). Other manifest checks remain available.", root.display())); + } + data.inventory = Some(inventory); + } + } else { + if data.locale.is_none() { + data.locale = Some(bounded_locale(root, &mut budget, &mut directory_entries, &mut notes)); + } + if let Some(cfg) = StartOrder::configuration_path(root) { + start_order = orders.entry(cfg.clone()).or_insert_with(|| { + let (order, partial) = StartOrder::discover_bounded(root, &mut |path| budget.read(path), &mut directory_entries); + if partial || order.is_none() { + notes.push(format!("Start-order checks from {} were omitted because configuration or installed-resource inspection was incomplete. Other diagnostics remain available.", cfg.display())); + None + } else { order.map(Arc::new) } + }).as_deref(); + start_order_complete = start_order.is_some(); + } + } + } + let data = root.and_then(|root| supporting.get(root)); + let support = diagnostics::DiagnosticSupport { + locale: data.and_then(|data| data.locale.as_ref()).and_then(Option::as_ref), + resource_files: data + .and_then(|data| data.inventory.as_ref()) + .map_or(&[], |inventory| inventory.0.as_slice()), + inventory_complete: data.and_then(|data| data.inventory.as_ref()).is_none_or(|inventory| inventory.1), + start_order, + start_order_complete, + }; + for diagnostic in diagnostics::diagnostics_with_support(ws, doc, overrides, &crossrefs, Some(&support)) { + if items.len() >= MAX_RESULTS { + break; + } + items.push(DiagnosticItem { + uri: uri.clone(), + range: diagnostic.range, + severity: diagnostic.severity.unwrap_or(DiagnosticSeverity::WARNING), + code: diagnostic.code, + message: diagnostic.message.chars().take(4096).collect(), + }); + } + if items.len() >= MAX_RESULTS { + break; + } + } + // Whole-workspace queries include the same unused locale-key checks as the Problems panel. + // If any Lua file was skipped, do not infer unused keys from incomplete usage information; + // supporting files that could not be read (a server.cfg above the roots, say) do not affect + // which keys the scripts use. + if requested.is_none() && skipped == 0 && items.len() < MAX_RESULTS { + for (id, usage) in usages { + let Some(resource) = ws.index.resource(id).filter(|entry| !entry.escrowed && !incomplete.contains(&id)) + else { + continue; + }; + let Some(locale) = + supporting.get(&resource.root).and_then(|data| data.locale.as_ref()).and_then(Option::as_ref) + else { + continue; + }; + if diagnostics::is_silenced(ws, &locale.path) { + continue; + } + let mut config = ws.lint_config.for_file(&locale.path); + overrides.iter().for_each(|(code, level)| config.set(code, *level)); + let Some(severity) = config.severity(qbx_lua_analysis::rules::UNUSED_LOCALE_KEY) else { continue }; + let positions = InspectionPositions::new(&locale.source); + for diagnostic in qbx_lua_analysis::lint::unused_locale_keys_from(locale, usage.into_iter()) { + if items.len() >= MAX_RESULTS { + break; + } + items.push(DiagnosticItem { + uri: path_to_uri(&locale.path), + range: positions.range(diagnostic.span), + severity: match severity { + qbx_lua_analysis::Severity::Error => DiagnosticSeverity::ERROR, + qbx_lua_analysis::Severity::Warning => DiagnosticSeverity::WARNING, + qbx_lua_analysis::Severity::Info => DiagnosticSeverity::INFORMATION, + qbx_lua_analysis::Severity::Hint => DiagnosticSeverity::HINT, + }, + code: Some(NumberOrString::String(diagnostic.code.to_string())), + message: diagnostic.message.chars().take(4096).collect(), + }); + } + } + } + if skipped > 0 { + notes.push(format!("{skipped} source files were not inspected. Unused locale-key checks were omitted because usage information is incomplete. Query a specific file for a narrower snapshot.")); + } + if items.len() >= MAX_RESULTS { + budget.result_limit = true; + } + notes.extend(budget.notes()); + items.sort_by(|a, b| { + (a.uri.as_str(), a.range.start.line, a.range.start.character, &a.message).cmp(&( + b.uri.as_str(), + b.range.start.line, + b.range.start.character, + &b.message, + )) + }); + Ok(page(items, offset, limit, notes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inspection_budget_counts_unique_sources_growth_and_exhaustion() { + let mut budget = InspectionBudget::new(&[]); + let first = Path::new("first.lua"); + assert!(budget.claim(first, 10)); + assert!(budget.claim(first, 10)); + assert_eq!(budget.bytes, 10); + assert!(budget.claim(first, SOURCE_BYTES)); + assert_eq!(budget.bytes, SOURCE_BYTES); + assert!(!budget.claim(first, SOURCE_BYTES + 1)); + for index in 1..MAX_BYTES / SOURCE_BYTES { + assert!(budget.claim(Path::new(&format!("file{index}.lua")), SOURCE_BYTES)); + } + assert_eq!(budget.bytes, MAX_BYTES); + assert!(!budget.claim(Path::new("too-many-bytes.lua"), 1)); + assert!(budget.read(Path::new("never-opened.lua")).is_none()); + assert!(budget.sources.is_empty()); + let mut files = InspectionBudget::new(&[]); + for index in 0..MAX_FILES { + assert!(files.claim(Path::new(&format!("file{index}.lua")), 0)); + } + assert!(!files.claim(Path::new("extra.lua"), 0)); + assert!(!files.notes().is_empty()); + } + + #[test] + fn inspection_positions_match_lsp_utf16_across_long_lines_and_char_boundaries() { + let source = format!("{}\r\n{}\n", "a😀é".repeat(200), "z😀".repeat(180)); + let positions = InspectionPositions::new(&source); + let lines = qbx_lua_syntax::LineIndex::new(&source); + for offset in 0..source.len() as u32 + 5 { + let expected = lines.line_col_utf16(&source, offset); + assert_eq!(positions.position(offset), Position::new(expected.line, expected.col)); + } + } + + #[test] + fn inspection_budget_charges_invalid_utf8_before_rejecting_it() { + let root = std::env::temp_dir().join(format!("qbx-assistant-budget-{}", std::process::id())); + std::fs::create_dir(&root).unwrap(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + assert!(self.0.starts_with(std::env::temp_dir())); + assert!(self.0.file_name().unwrap().to_string_lossy().starts_with("qbx-assistant-budget-")); + std::fs::remove_dir_all(&self.0).unwrap(); + } + } + let _cleanup = Cleanup(root.clone()); + let invalid = root.join("invalid.lua"); + let valid = root.join("valid.lua"); + std::fs::write(&invalid, vec![0xff; SOURCE_BYTES]).unwrap(); + std::fs::write(&valid, "print('valid')").unwrap(); + let mut budget = InspectionBudget::new(std::slice::from_ref(&root)); + for index in 1..MAX_BYTES / SOURCE_BYTES { + assert!(budget.claim(&root.join(format!("file{index}.lua")), SOURCE_BYTES)); + } + assert!(budget.read(&invalid).is_none()); + assert_eq!(budget.bytes, MAX_BYTES); + assert_eq!(budget.seen.len(), MAX_BYTES / SOURCE_BYTES); + assert!(budget.read(&valid).is_none(), "failed decoding still exhausts the shared read allowance"); + assert!(budget.sources.is_empty()); + } +} diff --git a/crates/qbx_lua_ls/src/features/code_action.rs b/crates/qbx_lua_ls/src/features/code_action.rs new file mode 100644 index 0000000..50ef149 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/code_action.rs @@ -0,0 +1,51 @@ +use std::collections::HashMap; + +use lsp_types::{ + CodeAction, CodeActionKind, CodeActionOrCommand, Diagnostic, NumberOrString, Position, Range, TextEdit, + WorkspaceEdit, +}; + +use super::diagnostics::{FixData, SOURCE}; +use crate::document::Document; + +fn edit_for(doc: &Document, edits: Vec) -> WorkspaceEdit { + WorkspaceEdit { changes: Some(HashMap::from([(doc.uri.clone(), edits)])), ..WorkspaceEdit::default() } +} + +pub fn code_actions(doc: &Document, diagnostics: &[Diagnostic]) -> Vec { + let mut actions = Vec::new(); + let ours = diagnostics.iter().filter(|d| d.source.as_deref() == Some(SOURCE)); + for diagnostic in ours { + let Some(NumberOrString::String(code)) = &diagnostic.code else { continue }; + let fix = diagnostic.data.clone().and_then(|data| serde_json::from_value::(data).ok()); + if let Some(fix) = fix { + let edits = fix.edits.into_iter().map(|(range, text)| TextEdit::new(range, text)).collect(); + actions.push(CodeActionOrCommand::CodeAction(CodeAction { + title: fix.title, + kind: Some(CodeActionKind::QUICKFIX), + diagnostics: Some(vec![diagnostic.clone()]), + edit: Some(edit_for(doc, edits)), + is_preferred: Some(true), + ..CodeAction::default() + })); + } + if code == "syntax-error" { + continue; + } + let line = diagnostic.range.start.line; + let line_text = doc.lines.line_span(line).text(&doc.text); + let indent: String = line_text.chars().take_while(|c| c.is_whitespace() && *c != '\n' && *c != '\r').collect(); + let at = Position::new(line, 0); + actions.push(CodeActionOrCommand::CodeAction(CodeAction { + title: format!("Disable {code} for this line"), + kind: Some(CodeActionKind::QUICKFIX), + diagnostics: Some(vec![diagnostic.clone()]), + edit: Some(edit_for( + doc, + vec![TextEdit::new(Range::new(at, at), format!("{indent}-- qbx-lint: disable-next-line {code}\n"))], + )), + ..CodeAction::default() + })); + } + actions +} diff --git a/crates/qbx_lua_ls/src/features/completion.rs b/crates/qbx_lua_ls/src/features/completion.rs new file mode 100644 index 0000000..24634c2 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/completion.rs @@ -0,0 +1,737 @@ +use lsp_types::{ + CompletionItem, CompletionItemKind, CompletionItemLabelDetails, CompletionItemTag, CompletionList, + CompletionResponse, Documentation, InsertTextFormat, Position, TextEdit, +}; +use qbx_fivem_data::{native, native_docs, natives, Side}; +use qbx_lua_analysis::manifest::KNOWN_DIRECTIVES; +use qbx_lua_analysis::project::relative_slash_path; +use qbx_lua_analysis::scope::LocalKind; +use qbx_lua_syntax::ast::ExprKind; +use qbx_lua_syntax::{CommentKind, Span, TokenKind}; +use rustc_hash::{FxHashMap, FxHashSet}; +use serde_json::json; + +use super::hover::{event_handler_signature, event_string_context}; +use super::{lua_block, markdown, with_infer}; +use crate::document::Document; +use crate::index::{EventFamily, EventKind, FileOrigin, SymbolKind}; +use crate::infer::{Infer, MemberInfo}; +use crate::locate::{locate, string_content_span}; +use crate::types::Type; +use crate::workspace::Workspace; + +const MAX_NATIVES: usize = 120; +const MAX_ITEMS: usize = 600; + +const KEYWORDS: &[&str] = &[ + "and", "break", "do", "else", "elseif", "end", "false", "for", "function", "goto", "if", "in", "local", "nil", + "not", "or", "repeat", "return", "then", "true", "until", "while", +]; + +const THREAD_LOOP: &str = "CreateThread(function()\n\twhile true do\n\t\t$0\n\t\tWait(${1:0})\n\tend\nend)"; + +const SNIPPETS: &[(&str, &str, &str)] = &[ + ("CreateThread", THREAD_LOOP, "Thread with a loop that yields every iteration"), + ("thread", THREAD_LOOP, "Thread with a loop that yields every iteration"), + ("CreateThread once", "CreateThread(function()\n\t$0\nend)", "Thread that runs its body once"), + ("SetTimeout", "SetTimeout(${1:1000}, function()\n\t$0\nend)", "Run a function after a delay"), + ( + "RegisterNetEvent", + "RegisterNetEvent('${1:resource}:${2:event}', function(${3})\n\t$0\nend)", + "Register a network event with a handler", + ), + ("AddEventHandler", "AddEventHandler('${1:eventName}', function(${2})\n\t$0\nend)", "Handle a local event"), + ( + "RegisterCommand", + "RegisterCommand('${1:name}', function(source, args, raw)\n\t$0\nend, ${2:false})", + "Register a console/chat command", + ), + ( + "lib.callback.register", + "lib.callback.register('${1:resource}:${2:name}', function(source${3})\n\t$0\nend)", + "Register an ox_lib server callback", + ), + ("lib.callback.await", "lib.callback.await('${1:resource}:${2:name}', ${3:false}$0)", "Await an ox_lib callback"), + ("for pairs", "for ${1:k}, ${2:v} in pairs(${3:t}) do\n\t$0\nend", "Iterate over a table"), + ("for ipairs", "for ${1:i}, ${2:v} in ipairs(${3:t}) do\n\t$0\nend", "Iterate over an array"), + ("for i", "for ${1:i} = ${2:1}, ${3:#t} do\n\t$0\nend", "Numeric for loop"), + ("function", "function ${1:name}(${2})\n\t$0\nend", "Function declaration"), + ("local function", "local function ${1:name}(${2})\n\t$0\nend", "Local function declaration"), + ("if", "if ${1:condition} then\n\t$0\nend", "If statement"), + ("while", "while ${1:condition} do\n\t$0\nend", "While loop"), +]; + +const DOC_TAGS: &[(&str, &str)] = &[ + ("param", "param ${1:name} ${2:type}"), + ("return", "return ${1:type}"), + ("type", "type ${1:type}"), + ("class", "class ${1:Name}"), + ("field", "field ${1:name} ${2:type}"), + ("alias", "alias ${1:Name} ${2:type}"), + ("enum", "enum ${1:Name}"), + ("generic", "generic ${1:T}"), + ("overload", "overload fun(${1}): ${2:any}"), + ("deprecated", "deprecated"), + ("async", "async"), + ("nodiscard", "nodiscard"), + ("meta", "meta"), + ("diagnostic", "diagnostic disable-next-line: ${1:undefined-global}"), + ("see", "see ${1:symbol}"), +]; + +const PRIMITIVE_TYPES: &[&str] = &[ + "any", + "nil", + "boolean", + "number", + "integer", + "string", + "table", + "function", + "thread", + "userdata", + "unknown", + "fun()", + "table", +]; + +const REQUIRE_CALLS: &[&str] = &["require", "lib.require", "lib.load"]; +const RESOURCE_NAME_CALLS: &[&str] = &[ + "GetResourceState", + "StartResource", + "StopResource", + "GetResourcePath", + "GetResourceMetadata", + "LoadResourceFile", +]; + +fn kind_of(kind: SymbolKind, ty: &Type) -> CompletionItemKind { + match kind { + SymbolKind::Function | SymbolKind::Export => CompletionItemKind::FUNCTION, + SymbolKind::Method => CompletionItemKind::METHOD, + SymbolKind::Class => CompletionItemKind::CLASS, + SymbolKind::Alias => CompletionItemKind::INTERFACE, + SymbolKind::Table => CompletionItemKind::MODULE, + _ if ty.as_fun().is_some() => CompletionItemKind::FUNCTION, + SymbolKind::Field => CompletionItemKind::FIELD, + SymbolKind::Variable => CompletionItemKind::VARIABLE, + } +} + +fn detail_of(name: &str, ty: &Type) -> Option { + match ty { + Type::Unknown => None, + Type::Fun(fun) => Some(fun.signature(name)), + Type::GlobalTable(path) if path.starts_with('%') => Some("table".into()), + other => Some(other.to_string()), + } +} + +fn item(label: &str, kind: CompletionItemKind, sort_group: u8) -> CompletionItem { + CompletionItem { + label: label.to_string(), + kind: Some(kind), + sort_text: Some(format!("{sort_group}{label}")), + ..CompletionItem::default() + } +} + +/// Snippets sort ahead of the plain name they share a label with, otherwise accepting the first +/// `CreateThread` would only insert the word. +fn snippet_item(label: &str, body: &str, description: &str) -> CompletionItem { + let is_statement = body.starts_with(|c: char| c.is_ascii_lowercase()) && !body.starts_with("lib."); + let kind = if is_statement { CompletionItemKind::KEYWORD } else { CompletionItemKind::EVENT }; + let mut out = item(label, kind, 0); + out.sort_text = Some(format!("/{label}")); + out.filter_text = Some(label.to_string()); + out.insert_text = Some(body.to_string()); + out.insert_text_format = Some(InsertTextFormat::SNIPPET); + out.detail = Some(description.to_string()); + out.label_details = Some(CompletionItemLabelDetails { detail: None, description: Some("snippet".to_string()) }); + out.documentation = Some(Documentation::MarkupContent(markdown(lua_block(&snippet_preview(body))))); + out +} + +/// The snippet as it looks right after insertion: `${1:0}` becomes `0`, `${1|a,b|}` becomes `a`. +pub fn snippet_preview(body: &str) -> String { + let mut out = String::new(); + let mut rest = body; + while let Some(start) = rest.find('$') { + out.push_str(&rest[..start]); + rest = &rest[start + 1..]; + if let Some(inner) = rest.strip_prefix('{') { + let end = inner.find('}').unwrap_or(inner.len()); + let placeholder = &inner[..end]; + let shown = match placeholder.split_once([':', '|']) { + Some((_, default)) => default.split([',', '|']).next().unwrap_or_default(), + None => "", + }; + out.push_str(shown); + rest = inner.get(end + 1..).unwrap_or_default(); + } else { + rest = rest.trim_start_matches(|c: char| c.is_ascii_digit()); + } + } + out.push_str(rest); + out.replace('\t', " ") +} + +pub struct SnippetInfo { + pub label: String, + pub description: String, + pub body: String, +} + +/// Every snippet the server offers, for the editor's "show snippets" picker. +pub fn all_snippets(ws: &Workspace, doc: Option<&Document>) -> Vec { + let mut out: Vec = SNIPPETS + .iter() + .map(|(label, body, description)| SnippetInfo { + label: label.to_string(), + description: description.to_string(), + body: body.to_string(), + }) + .collect(); + let on_cache = match doc { + Some(doc) => with_infer(ws, doc, |infer| on_cache_snippet(infer, "lib.")), + None => on_cache_item(Vec::new(), "lib."), + }; + out.push(SnippetInfo { + label: on_cache.label, + description: on_cache.detail.unwrap_or_default(), + body: on_cache.insert_text.unwrap_or_default(), + }); + out +} + +fn member_item(member: &MemberInfo) -> CompletionItem { + let mut out = item(&member.name, kind_of(member.kind, &member.ty), 0); + out.detail = match (detail_of(&member.name, &member.ty), &member.literal) { + (Some(ty), Some(value)) => Some(format!("{ty} = {value}")), + (detail, _) => detail, + }; + out.documentation = member.doc.as_ref().map(|d| Documentation::MarkupContent(markdown(d.to_string()))); + if member.deprecated { + out.tags = Some(vec![CompletionItemTag::DEPRECATED]); + } + if !is_identifier(&member.name) { + out.filter_text = Some(member.name.to_string()); + } + out +} + +fn is_identifier(text: &str) -> bool { + let mut chars = text.chars(); + chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +fn identifier_prefix(before: &str) -> &str { + let start = before.rfind(|c: char| !(c.is_ascii_alphanumeric() || c == '_')).map_or(0, |i| i + 1); + &before[start..] +} + +pub fn completion(ws: &Workspace, doc: &Document, position: Position, snippets: bool) -> Option { + let offset = doc.offset(position); + let line_start = doc.lines.line_start(position.line) as usize; + let before = doc.text.get(line_start..offset as usize)?; + + if let Some(comment) = doc.chunk.comments.iter().find(|c| c.span.start < offset && offset <= c.span.end) { + let is_doc = comment.kind == CommentKind::Line && comment.span.text(&doc.text).starts_with("---"); + return is_doc.then(|| respond(doc_comment_items(ws, before, snippets), false)); + } + + let in_string = doc.chunk.tokens.iter().position(|t| { + let text = t.span.text(&doc.text); + let unterminated = text.len() < 2 || text.as_bytes()[0] != text.as_bytes()[text.len() - 1]; + matches!(t.kind, TokenKind::String) + && t.span.start < offset + && (offset < t.span.end || (unterminated && offset == t.span.end)) + }); + if let Some(token_index) = in_string { + return Some(respond(string_items(ws, doc, offset, token_index), false)); + } + + let prefix = identifier_prefix(before); + let head = before[..before.len() - prefix.len()].trim_end(); + if prefix.chars().next().is_some_and(|c| c.is_ascii_digit()) { + return None; + } + + if (head.ends_with('.') && !head.ends_with("..")) || (head.ends_with(':') && !head.ends_with("::")) { + let via_colon = head.ends_with(':'); + let mut items = with_infer(ws, doc, |infer| member_items(infer, doc, offset, head, via_colon, snippets)); + let base = head[..head.len() - 1].trim_end(); + if !via_colon && (base.ends_with(".state") || base.ends_with("GlobalState")) { + items.extend(state_key_items(ws)); + } + return Some(respond(items, false)); + } + + if doc.is_manifest() { + return Some(respond(manifest_items(before, prefix, snippets), false)); + } + + if head.ends_with('{') || head.ends_with(',') || head.is_empty() { + let fields = with_infer(ws, doc, |infer| expected_field_items(infer, doc, offset)); + if !fields.is_empty() { + return Some(respond(fields, false)); + } + } + if prefix.is_empty() { + return None; + } + let (items, incomplete) = with_infer(ws, doc, |infer| scope_items(ws, infer, doc, offset, prefix, snippets)); + Some(respond(items, incomplete)) +} + +fn respond(mut items: Vec, incomplete: bool) -> CompletionResponse { + let truncated = items.len() > MAX_ITEMS; + items.truncate(MAX_ITEMS); + CompletionResponse::List(CompletionList { is_incomplete: incomplete || truncated, items }) +} + +fn doc_comment_items(ws: &Workspace, before: &str, snippets: bool) -> Vec { + let Some(at) = before.rfind("---") else { return Vec::new() }; + let content = before[at + 3..].trim_start(); + if let Some(tag_prefix) = content.strip_prefix('@').filter(|rest| !rest.contains(char::is_whitespace)) { + return DOC_TAGS + .iter() + .filter(|(tag, _)| tag.starts_with(tag_prefix)) + .map(|(tag, snippet)| { + let mut out = item(tag, CompletionItemKind::KEYWORD, 0); + out.insert_text = + Some(if snippets { snippet.to_string() } else { tag.trim_start_matches('@').to_string() }); + if snippets { + out.insert_text_format = Some(InsertTextFormat::SNIPPET); + } + out + }) + .collect(); + } + let takes_type = ["@type", "@return", "@param", "@field", "@alias", "@class", "@overload", "@generic", "|"] + .iter() + .any(|tag| content.starts_with(tag)); + if !takes_type { + return Vec::new(); + } + let mut items: Vec = + PRIMITIVE_TYPES.iter().map(|t| item(t, CompletionItemKind::KEYWORD, 1)).collect(); + let mut seen = FxHashSet::default(); + for name in ws.index.class_names().filter(|n| seen.insert((*n).clone())) { + items.push(item(name, CompletionItemKind::CLASS, 0)); + } + items +} + +fn string_items(ws: &Workspace, doc: &Document, offset: u32, token_index: usize) -> Vec { + let tokens = &doc.chunk.tokens; + let indexes_exports = token_index >= 2 + && tokens[token_index - 1].kind == TokenKind::LBracket + && tokens[token_index - 2].span.text(&doc.text) == "exports"; + if indexes_exports { + return resource_items(ws); + } + if doc.is_manifest() { + return manifest_path_items(ws, doc); + } + + let located = locate(&doc.chunk, offset); + let Some((_, Some((call, arg_index)))) = located.string else { return Vec::new() }; + let path = match &call.kind { + ExprKind::Call { callee, .. } => callee.dotted_path(), + _ => None, + }; + let context = with_infer(ws, doc, |infer| event_string_context(infer, Some((call, arg_index)))); + if let Some(context) = context { + if !context.active { + return Vec::new(); + } + // Event names contain punctuation. Give clients the whole string content so a `:` + // retrigger keeps filtering from the opening quote and accepting does not duplicate it. + let token = &tokens[token_index]; + let content = string_content_span(token.span, &doc.text) + .unwrap_or_else(|| Span::new(token.span.start + 1, token.span.end)); + let range = doc.range(content); + let wants_callbacks = context.family != EventFamily::Native; + let target_side = context.target_side; + let handled_on_target = |side: Option| !matches!((target_side, side), (Some(target), Some(side)) if !side.is_available_on(target)); + let mut conflicting = FxHashSet::default(); + if context.framework() { + let mut payloads = FxHashMap::default(); + for (_, event) in ws.index.events().filter(|(_, event)| context.accepts_registration(event)) { + let signature = event_handler_signature(event); + if let Some(previous) = payloads.get(&event.name) { + if previous != &signature { + conflicting.insert(event.name.clone()); + } + } else { + payloads.insert(event.name.clone(), signature); + } + } + } + let candidates = |strict: bool| { + let mut seen = FxHashSet::default(); + ws.index + .events() + .filter(|(_, event)| event.family == context.family) + .filter(|(_, e)| (e.kind == EventKind::Callback) == wants_callbacks || e.kind == EventKind::Trigger) + .filter(|(_, e)| !strict || (e.kind != EventKind::Trigger && handled_on_target(e.side))) + .filter(|(_, event)| !context.framework() || context.accepts_registration(event)) + .filter(|(_, e)| seen.insert(e.name.clone())) + .map(|(file, event)| { + let mut out = item(&event.name, CompletionItemKind::EVENT, 0); + if range.start.line == range.end.line { + out.text_edit = Some(TextEdit { range, new_text: event.name.to_string() }.into()); + } + let entry = ws.index.file(file); + let origin = entry.and_then(|f| f.resource).and_then(|r| ws.index.resource(r)); + let side = event.side.map_or(String::new(), |s| format!(" ({})", s.label())); + out.detail = if conflicting.contains(&event.name) { + Some("Multiple handlers; payloads differ".into()) + } else { + match (event_handler_signature(event), origin) { + (Some(handler), Some(resource)) => Some(format!("{}{side} · {handler}", resource.name)), + (None, Some(resource)) => Some(format!("{}{side}", resource.name)), + (Some(handler), None) => Some(handler), + (None, None) => None, + } + }; + out + }) + .collect::>() + }; + return candidates(target_side.is_some()); + } + let Some(path) = path else { return Vec::new() }; + let path = path.as_str(); + if arg_index == 0 && matches!(path, "lib.onCache") { + return cache_key_items(ws, doc); + } + if arg_index == 0 && path == "locale" { + let locale = ws.index.resource_of(doc.file).and_then(|r| qbx_lua_analysis::locale::LocaleFile::load(&r.root)); + return locale + .iter() + .flat_map(|file| &file.keys) + .map(|(key, _, text)| { + let mut out = item(key, CompletionItemKind::TEXT, 0); + out.detail = Some(text.clone()); + out + }) + .collect(); + } + if arg_index == 0 && crate::indexer::CONVAR_CALLS.contains(&path) { + let mut seen = FxHashSet::default(); + let indexed = ws.index.files().flat_map(|(_, f)| f.index.convars.iter()); + return ws + .cfg_convars + .iter() + .chain(indexed) + .filter(|name| seen.insert((*name).clone())) + .map(|name| item(name, CompletionItemKind::CONSTANT, 0)) + .collect(); + } + if arg_index == 0 && path == "AddStateBagChangeHandler" { + return state_key_items(ws); + } + if arg_index == 0 && REQUIRE_CALLS.contains(&path) { + return module_items(ws, doc); + } + if arg_index == 0 && RESOURCE_NAME_CALLS.contains(&path) { + return resource_items(ws); + } + Vec::new() +} + +/// State bag keys are plain strings that both sides must agree on, so every key seen anywhere is offered. +fn state_key_items(ws: &Workspace) -> Vec { + let mut seen = FxHashSet::default(); + ws.index + .files() + .flat_map(|(_, f)| f.index.state_keys.iter()) + .filter(|key| seen.insert((*key).clone())) + .map(|key| { + let mut out = item(key, CompletionItemKind::FIELD, 1); + out.detail = Some("state bag key".into()); + out + }) + .collect() +} + +/// The value fields of ox_lib's `cache` as seen from this file, read from the indexed ox_lib source. +fn cache_keys(infer: &Infer) -> Vec { + let mut keys: Vec = + infer.members(&infer.global_type("cache")).into_iter().filter(|m| m.ty.as_fun().is_none()).collect(); + keys.sort_by(|a, b| a.name.cmp(&b.name)); + keys +} + +fn cache_key_items(ws: &Workspace, doc: &Document) -> Vec { + with_infer(ws, doc, |infer| { + cache_keys(infer) + .iter() + .map(|key| { + let mut out = item(&key.name, CompletionItemKind::ENUM_MEMBER, 0); + out.detail = detail_of(&key.name, &key.ty).map(|ty| format!("cache.{}: {ty}", key.name)); + out + }) + .collect() + }) +} + +/// What ox_lib caches on the client, for workspaces that do not contain ox_lib itself. +const DEFAULT_CACHE_KEYS: &[&str] = &["ped", "vehicle", "seat", "weapon", "playerId", "serverId", "coords"]; + +fn on_cache_snippet(infer: &Infer, prefix: &str) -> CompletionItem { + on_cache_item(cache_keys(infer).iter().map(|k| k.name.to_string()).collect(), prefix) +} + +fn on_cache_item(mut keys: Vec, prefix: &str) -> CompletionItem { + if keys.is_empty() { + keys = DEFAULT_CACHE_KEYS.iter().map(|k| k.to_string()).collect(); + } + let body = + format!("{prefix}onCache('${{1|{}|}}', function(${{2:value}}, ${{3:oldValue}})\n\t$0\nend)", keys.join(",")); + snippet_item("onCache", &body, &format!("React to an ox_lib cache change ({})", keys.join(", "))) +} + +fn resource_items(ws: &Workspace) -> Vec { + ws.index.resources.iter().map(|r| item(&r.name, CompletionItemKind::MODULE, 0)).collect() +} + +fn module_items(ws: &Workspace, doc: &Document) -> Vec { + let Some(resource) = ws.index.resource_of(doc.file) else { return Vec::new() }; + resource + .files + .iter() + .filter_map(|id| ws.index.file(*id)) + .filter(|f| f.path != doc.path) + .map(|f| { + let relative = relative_slash_path(&resource.root, &f.path); + let module = relative.trim_end_matches(".lua").replace('/', "."); + let mut out = item(&module, CompletionItemKind::FILE, 0); + out.detail = Some(relative); + out + }) + .collect() +} + +fn manifest_path_items(ws: &Workspace, doc: &Document) -> Vec { + let Some(root) = doc.path.parent() else { return Vec::new() }; + let mut items: Vec = qbx_lua_analysis::lint::all_files(root) + .into_iter() + .filter(|f| !f.ends_with("fxmanifest.lua") && !f.starts_with('.')) + .take(400) + .map(|f| item(&f, CompletionItemKind::FILE, 1)) + .collect(); + for import in qbx_fivem_data::KNOWN_IMPORTS { + items.push(item(import.path, CompletionItemKind::REFERENCE, 0)); + } + let _ = ws; + items +} + +fn manifest_items(before: &str, prefix: &str, snippets: bool) -> Vec { + if before.trim_start().len() != prefix.len() { + return Vec::new(); + } + KNOWN_DIRECTIVES + .iter() + .map(|directive| { + let mut out = item(directive, CompletionItemKind::PROPERTY, 0); + if !snippets { + return out; + } + let snippet = match *directive { + "fx_version" => "fx_version '${1|cerulean,bodacious,adamant|}'".to_string(), + "game" => "game '${1|gta5,rdr3|}'".to_string(), + "lua54" | "use_experimental_fxv2_oal" => format!("{directive} 'yes'"), + d if d.ends_with('s') && !matches!(d, "this_is_a_map") => format!("{d} {{\n\t'$0',\n}}"), + d => format!("{d} '$0'"), + }; + out.insert_text = Some(snippet); + out.insert_text_format = Some(InsertTextFormat::SNIPPET); + out + }) + .collect() +} + +fn member_items( + infer: &Infer, + doc: &Document, + offset: u32, + head: &str, + via_colon: bool, + snippets: bool, +) -> Vec { + let located = locate(&doc.chunk, offset); + let base_type = match &located.member { + Some(access) => infer.expr(access.base()), + None => type_of_path(infer, &head[..head.len() - 1], offset), + }; + let members = infer.members(&base_type); + let has_methods = members.iter().any(|m| m.ty.as_fun().is_some()); + members + .iter() + .filter(|m| !via_colon || !has_methods || m.ty.as_fun().is_some()) + .filter(|m| is_identifier(&m.name)) + .map(|m| { + let mut out = member_item(m); + let is_method = m.ty.as_fun().is_some_and(|f| f.is_method); + if via_colon != is_method && m.ty.as_fun().is_some() { + out.sort_text = Some(format!("1{}", m.name)); + } + out + }) + .chain((snippets && head == "lib.").then(|| on_cache_snippet(infer, ""))) + .collect() +} + +/// Fallback for member completion when the parser could not attach the trailing `.` to an expression. +fn type_of_path(infer: &Infer, text: &str, offset: u32) -> Type { + let start = text.rfind(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | ':'))).map_or(0, |i| i + 1); + let mut segments = text[start..].split(['.', ':']).filter(|s| !s.is_empty()); + let Some(root) = segments.next() else { return Type::Unknown }; + let mut ty = match infer.ctx.resolution.lookup_local_at(root, offset) { + Some(id) => infer.local_type(id), + None => infer.global_type(root), + }; + for segment in segments { + ty = infer.member(&ty, segment).map(|m| m.ty).unwrap_or_default(); + } + ty +} + +/// Field names of the table type a call expects, when the cursor is inside a table argument. +fn expected_field_items(infer: &Infer, doc: &Document, offset: u32) -> Vec { + let located = locate(&doc.chunk, offset); + let Some((call, arg_index, table)) = located.table_in_call else { return Vec::new() }; + let ExprKind::Table(existing) = &table.kind else { return Vec::new() }; + let fun = match &call.kind { + ExprKind::Call { callee, .. } => infer.callee_fun(callee, None), + ExprKind::MethodCall { base, method, .. } => infer.callee_fun(base, Some(method)), + _ => None, + }; + let Some((fun, _)) = fun else { return Vec::new() }; + let (skip_params, skip_args) = fun.call_offsets(matches!(call.kind, ExprKind::MethodCall { .. })); + let Some(param) = (arg_index + skip_params).checked_sub(skip_args).and_then(|i| fun.params.get(i)) else { + return Vec::new(); + }; + let present: FxHashSet<&str> = existing + .iter() + .filter_map(|f| match f { + qbx_lua_syntax::ast::TableField::Named { name, .. } => Some(name.text.as_str()), + _ => None, + }) + .collect(); + infer + .members(¶m.ty.without_nil()) + .iter() + .filter(|m| !present.contains(m.name.as_str()) && is_identifier(&m.name)) + .map(|m| { + let mut out = member_item(m); + out.kind = Some(CompletionItemKind::PROPERTY); + out.insert_text = Some(format!("{} = ", m.name)); + out + }) + .collect() +} + +fn scope_items( + ws: &Workspace, + infer: &Infer, + doc: &Document, + offset: u32, + prefix: &str, + snippets: bool, +) -> (Vec, bool) { + let matches = |name: &str| name.len() >= prefix.len() && name[..prefix.len()].eq_ignore_ascii_case(prefix); + let mut items = Vec::new(); + let mut seen: FxHashSet = FxHashSet::default(); + + let mut locals: Vec<_> = doc.resolution.locals_visible_at(offset).filter(|(_, l)| matches(&l.name)).collect(); + locals.sort_by_key(|(_, l)| std::cmp::Reverse(l.visible_from)); + for (id, local) in locals { + if local.name.is_empty() || !seen.insert(local.name.to_string()) { + continue; + } + let ty = infer.local_type(id); + let kind = match local.kind { + _ if ty.as_fun().is_some() => CompletionItemKind::FUNCTION, + LocalKind::Param => CompletionItemKind::VARIABLE, + _ => CompletionItemKind::VARIABLE, + }; + let mut out = item(&local.name, kind, 0); + out.detail = detail_of(&local.name, &ty); + items.push(out); + } + + for (file, symbol) in ws.index.visible_globals(doc.file) { + if !matches(&symbol.name) || !seen.insert(symbol.name.to_string()) { + continue; + } + let is_stub = ws.index.file(file).is_some_and(|f| f.origin == FileOrigin::Stub); + let mut out = item(&symbol.name, kind_of(symbol.kind, &symbol.ty), if is_stub { 2 } else { 1 }); + out.detail = detail_of(&symbol.name, &symbol.ty); + out.documentation = symbol.doc.as_ref().map(|d| Documentation::MarkupContent(markdown(d.to_string()))); + if symbol.deprecated { + out.tags = Some(vec![CompletionItemTag::DEPRECATED]); + } + items.push(out); + } + + for keyword in KEYWORDS.iter().filter(|k| matches(k)) { + items.push(item(keyword, CompletionItemKind::KEYWORD, 3)); + } + if snippets { + for (label, body, description) in SNIPPETS.iter().filter(|(label, ..)| matches(label)) { + items.push(snippet_item(label, body, description)); + } + if matches("onCache") { + items.push(on_cache_snippet(infer, "lib.")); + } + } + + let mut incomplete = false; + if prefix.len() >= 3 { + let file_side = ws.index.file(doc.file).and_then(|f| f.side); + let side = qbx_lua_analysis::side_guard::SideRegions::of(&doc.text, &doc.chunk) + .effective(offset, file_side) + .unwrap_or(Side::Shared); + let mut count = 0; + for native in natives().filter(|n| matches(n.name) && n.side.is_available_on(side)) { + if native.name.starts_with("N_0x") || !seen.insert(native.name.to_string()) { + continue; + } + if count == MAX_NATIVES { + incomplete = true; + break; + } + count += 1; + let mut out = item(native.name, CompletionItemKind::FUNCTION, 5); + out.detail = Some(native.signature()); + out.data = Some(json!({ "native": native.name })); + if native.alias_of.is_some() { + out.tags = Some(vec![CompletionItemTag::DEPRECATED]); + } + items.push(out); + } + } else { + incomplete = true; + } + (items, incomplete) +} + +pub fn resolve(mut item: CompletionItem) -> CompletionItem { + let name = item.data.as_ref().and_then(|d| d.get("native")).and_then(|n| n.as_str()).map(str::to_string); + if let Some(native) = name.as_deref().and_then(native) { + let mut text = lua_block(&native.signature()); + text.push_str(&format!("\n\n*{} native* · `{}`", native.side.label(), native.namespace)); + if let Some(docs) = native_docs(native.name) { + text.push_str("\n\n"); + text.push_str(&docs); + } + item.documentation = Some(Documentation::MarkupContent(markdown(text))); + } + item +} diff --git a/crates/qbx_lua_ls/src/features/definition.rs b/crates/qbx_lua_ls/src/features/definition.rs new file mode 100644 index 0000000..e9942b9 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/definition.rs @@ -0,0 +1,86 @@ +use lsp_types::{GotoDefinitionResponse, Location, Position, Range}; +use qbx_lua_syntax::ast::ExprKind; + +use super::hover::{event_string_context, target_at, Target}; +use super::with_infer; +use crate::document::Document; +use crate::index::{EventFamily, EventKind, FileId, FileOrigin}; +use crate::infer::Infer; +use crate::locate::locate; +use crate::workspace::Workspace; + +const REQUIRE_CALLS: &[&str] = &["require", "lib.require", "lib.load"]; + +fn location(ws: &Workspace, file: FileId, range: Range) -> Option { + let entry = ws.index.file(file).filter(|f| f.origin != FileOrigin::Stub)?; + Some(Location::new(entry.uri.clone(), range)) +} + +fn string_definition(ws: &Workspace, infer: &Infer, doc: &Document, offset: u32) -> Vec { + let located = locate(&doc.chunk, offset); + let Some((string, call)) = located.string else { return Vec::new() }; + let ExprKind::String(value) = &string.kind else { return Vec::new() }; + + let callee = call.and_then(|(call, _)| match &call.kind { + ExprKind::Call { callee, .. } => callee.dotted_path(), + _ => None, + }); + if callee.as_deref() == Some("locale") { + let locale = ws.index.resource_of(doc.file).and_then(|r| qbx_lua_analysis::locale::LocaleFile::load(&r.root)); + let Some(locale) = locale else { return Vec::new() }; + let lines = qbx_lua_syntax::LineIndex::new(&locale.source); + return locale + .keys + .iter() + .filter(|(key, ..)| key == value.as_str()) + .map(|(_, span, _)| { + let range = crate::indexer::span_to_range(&locale.source, &lines, *span); + Location::new(crate::workspace::path_to_uri(&locale.path), range) + }) + .collect(); + } + if callee.as_deref().is_some_and(|path| REQUIRE_CALLS.contains(&path)) { + return ws + .index + .resolve_require(value, doc.file) + .and_then(|file| location(ws, file, Range::default())) + .into_iter() + .collect(); + } + let context = event_string_context(infer, call); + ws.index + .events() + .filter(|(_, e)| e.name == *value && e.kind != EventKind::Trigger) + .filter(|(_, event)| match &context { + Some(context) => context.accepts_registration(event), + None => matches!(event.family, EventFamily::Native | EventFamily::OxLib), + }) + .filter_map(|(file, event)| location(ws, file, event.range)) + .collect() +} + +pub fn definition(ws: &Workspace, doc: &Document, position: Position) -> Option { + let offset = doc.offset(position); + let locations = with_infer(ws, doc, |infer| match target_at(infer, doc, offset) { + Some(Target::Local(id, _)) => { + let local = doc.resolution.local(id); + vec![Location::new(doc.uri.clone(), doc.range(local.decl))] + } + Some(Target::Global(name, _)) => ws + .index + .globals_named(&name, doc.file) + .into_iter() + .filter_map(|(file, symbol)| location(ws, file, symbol.range)) + .collect(), + Some(Target::Member { info, .. }) => { + info.location.and_then(|(file, range)| location(ws, file, range)).into_iter().collect() + } + Some(Target::Type(name, _)) => { + let classes = ws.index.class_defs(&name).into_iter().map(|(file, class)| (file, class.range)); + let aliases = ws.index.alias_defs(&name).into_iter().map(|(file, alias)| (file, alias.range)); + classes.chain(aliases).filter_map(|(file, range)| location(ws, file, range)).collect() + } + None => string_definition(ws, infer, doc, offset), + }); + (!locations.is_empty()).then_some(GotoDefinitionResponse::Array(locations)) +} diff --git a/crates/qbx_lua_ls/src/features/diagnostics.rs b/crates/qbx_lua_ls/src/features/diagnostics.rs new file mode 100644 index 0000000..c03f300 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/diagnostics.rs @@ -0,0 +1,162 @@ +use std::path::Path; + +use lsp_types::{Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString}; +use qbx_lua_analysis::lint::all_files; +use qbx_lua_analysis::summary::summarize; +use qbx_lua_analysis::{check_file, check_manifest, FileInput, Level, ManifestInput, ResourceInput, Severity, Tag}; +use serde::{Deserialize, Serialize}; + +use crate::document::Document; +use crate::workspace::Workspace; + +pub const SOURCE: &str = "qbx-lint"; + +/// Carried in `Diagnostic.data` so code actions can offer the fix without re-running the linter. +#[derive(Serialize, Deserialize)] +pub struct FixData { + pub title: String, + pub edits: Vec<(lsp_types::Range, String)>, +} + +pub fn is_silenced(ws: &Workspace, path: &Path) -> bool { + ws.lint_config.is_excluded(path) || ws.lint_config.ignores_diagnostics(path) +} + +pub fn diagnostics( + ws: &Workspace, + doc: &Document, + rule_overrides: &[(String, Level)], + crossrefs: &qbx_lua_analysis::crossref::CrossRefs, +) -> Vec { + diagnostics_with_support(ws, doc, rule_overrides, crossrefs, None) +} + +pub(crate) struct DiagnosticSupport<'a> { + pub locale: Option<&'a qbx_lua_analysis::locale::LocaleFile>, + pub resource_files: &'a [String], + pub inventory_complete: bool, + pub start_order: Option<&'a qbx_lua_analysis::startup::StartOrder>, + pub start_order_complete: bool, +} + +pub(crate) fn diagnostics_with_support( + ws: &Workspace, + doc: &Document, + rule_overrides: &[(String, Level)], + crossrefs: &qbx_lua_analysis::crossref::CrossRefs, + support: Option<&DiagnosticSupport<'_>>, +) -> Vec { + // Escrow-encrypted and binary files can still be opened in the editor; they are not Lua. + if qbx_lua_analysis::project::is_not_source(doc.text.as_bytes()) || is_silenced(ws, &doc.path) { + return Vec::new(); + } + let mut config = ws.lint_config.for_file(&doc.path); + for (code, level) in rule_overrides { + config.set(code, *level); + } + if support.is_some_and(|support| !support.inventory_complete) { + config.set(qbx_lua_analysis::rules::MANIFEST_MISSING_FILE, Level::Off); + } + if support.is_some_and(|support| !support.start_order_complete) { + config.set(qbx_lua_analysis::rules::MANIFEST_MISSING_DEPENDENCY, Level::Off); + config.set(qbx_lua_analysis::rules::RESOURCE_NOT_FOUND, Level::Off); + } + let entry = ws.index.file(doc.file); + let resource_id = entry.and_then(|f| f.resource); + let resource = resource_id.and_then(|id| ws.index.resource(id)); + + let found = if doc.is_manifest() { + let Some(resource) = resource.or_else(|| ws.index.resources.iter().find(|r| r.manifest_path == doc.path)) + else { + return Vec::new(); + }; + let manifest = qbx_lua_analysis::manifest::Manifest::from_chunk(&doc.chunk); + let files = if support.is_none() { all_files(&resource.root) } else { Vec::new() }; + check_manifest(&ManifestInput { + source: &doc.text, + chunk: &doc.chunk, + manifest: &manifest, + config: &config, + resource_files: support.map_or(files.as_slice(), |support| support.resource_files), + has_lua_scripts: !resource.files.is_empty(), + }) + } else { + let summary = summarize(&doc.chunk, &doc.resolution); + let is_map = resource.is_some_and(|r| { + r.manifest.is_map_file(&qbx_lua_analysis::project::relative_slash_path(&r.root, &doc.path)) + }); + if is_map { + config.set(qbx_lua_analysis::rules::UNDEFINED_GLOBAL, Level::Off); + } + let env = resource_id.map(|id| ws.resource_env(id)); + let owned_order = if support.is_none() { + resource.and_then(|r| qbx_lua_analysis::startup::StartOrder::discover(&r.root)) + } else { + None + }; + let start_order = support.and_then(|support| support.start_order).or(owned_order.as_deref()); + let started_before = resource.zip(start_order).map(|(r, order)| order.started_before(&r.name)); + let resource_input = match (resource, &env) { + (Some(resource), Some(env)) => Some(ResourceInput { + name: &resource.name, + env, + manifest: &resource.manifest, + started_before: started_before.as_ref(), + installed: start_order.map(|order| &order.installed), + }), + _ => None, + }; + let relative_path = + resource.map(|r| qbx_lua_analysis::project::relative_slash_path(&r.root, &doc.path)).unwrap_or_default(); + let owned_locale = if support.is_none() { + resource.and_then(|r| qbx_lua_analysis::locale::LocaleFile::load(&r.root)) + } else { + None + }; + check_file(&FileInput { + relative_path: &relative_path, + source: &doc.text, + chunk: &doc.chunk, + resolution: &doc.resolution, + summary: &summary, + config: &config, + side: entry.and_then(|f| f.side), + resource: resource_input, + crossrefs: Some(crossrefs), + locale: support.and_then(|support| support.locale).or(owned_locale.as_ref()), + }) + }; + + let positions = support.map(|_| super::assistant::InspectionPositions::new(&doc.text)); + found + .into_iter() + .take(if support.is_some() { 20_000 } else { usize::MAX }) + .map(|d| { + let range = |span| positions.as_ref().map_or_else(|| doc.range(span), |positions| positions.range(span)); + let fix = d.fix.as_ref().map(|fix| FixData { + title: fix.title.clone(), + edits: fix.edits.iter().map(|e| (range(e.span), e.new_text.clone())).collect(), + }); + Diagnostic { + range: range(d.span), + severity: Some(match d.severity { + Severity::Error => DiagnosticSeverity::ERROR, + Severity::Warning => DiagnosticSeverity::WARNING, + Severity::Info => DiagnosticSeverity::INFORMATION, + Severity::Hint => DiagnosticSeverity::HINT, + }), + code: Some(NumberOrString::String(d.code.to_string())), + source: Some(SOURCE.to_string()), + message: d.message, + tags: d.tag.map(|tag| { + vec![match tag { + Tag::Unnecessary => DiagnosticTag::UNNECESSARY, + Tag::Deprecated => DiagnosticTag::DEPRECATED, + }] + }), + data: fix.and_then(|f| serde_json::to_value(f).ok()), + ..Diagnostic::default() + } + }) + .collect() +} diff --git a/crates/qbx_lua_ls/src/features/event_call.rs b/crates/qbx_lua_ls/src/features/event_call.rs new file mode 100644 index 0000000..75ec5bb --- /dev/null +++ b/crates/qbx_lua_ls/src/features/event_call.rs @@ -0,0 +1,138 @@ +use qbx_fivem_data::Side; +use qbx_lua_analysis::crossref::trigger_target; +use qbx_lua_syntax::ast::Expr; + +use crate::document::Document; +use crate::framework_callbacks; +use crate::index::{EventFamily, EventKind}; +use crate::infer::Infer; +use crate::types::{FunType, Param, Type}; +use crate::workspace::Workspace; + +pub struct EventCall { + pub fun: FunType, + pub event: String, + /// Where the handler whose parameters are shown lives, e.g. `server/main.lua:12`. + pub handler_location: String, +} + +/// Leading arguments of `lib.callback` style calls that are not passed on to the handler. +fn callback_skip(call: &str) -> Option { + match call { + "lib.callback.await" => Some(2), + "lib.callback" => Some(3), + _ => None, + } +} + +/// For `TriggerServerEvent('name', ...)` and friends: the signature of the call with the payload +/// parameters taken from the handler registered for that event. +pub fn event_call( + ws: &Workspace, + doc: &Document, + infer: &Infer, + callee: &Expr, + args: &[Expr], + native: Option<&FunType>, +) -> Option { + let call = callee.dotted_path()?; + let name = args.first()?.as_string()?; + let framework = framework_callbacks::classify(infer.ctx, &ws.index, callee); + if framework.is_none() { + native?; + if trigger_target(&call, None).is_none() && callback_skip(&call).is_none() { + return None; + } + } + let own_side = ws.index.file(doc.file).and_then(|f| f.side); + let own_side = + qbx_lua_analysis::side_guard::SideRegions::of(&doc.text, &doc.chunk).effective(callee.span.start, own_side); + + let (target, skip, wanted, family) = if let Some(framework) = framework { + if framework.kind != EventKind::Trigger || own_side != Some(framework.required_side()) { + return None; + } + (Some(Side::Server), 2, [EventKind::Callback, EventKind::Callback], framework.family) + } else { + // Keep the existing native/ox_lib behavior dependent on a known callee signature. + native?; + match trigger_target(&call, own_side) { + Some((target, skip)) => (target, skip, [EventKind::NetEvent, EventKind::Handler], EventFamily::Native), + None => { + let target = own_side.map(|side| if side == Side::Server { Side::Client } else { Side::Server }); + (target, callback_skip(&call)?, [EventKind::Callback, EventKind::Callback], EventFamily::OxLib) + } + } + }; + + let framework_callback = matches!(family, EventFamily::QbCore | EventFamily::Esx); + let candidates = ws + .index + .events() + .filter(|(_, e)| e.name == *name && e.family == family && wanted.contains(&e.kind)) + .filter(|(_, e)| framework_callback || e.handler.is_some()) + .filter(|(_, e)| !matches!((target, e.side), (Some(target), Some(side)) if !side.is_available_on(target))); + let candidates: Vec<_> = candidates.collect(); + if framework_callback && candidates.iter().any(|(_, event)| event.handler.is_none()) { + return None; + } + let (file, event) = candidates.iter().copied().max_by_key( + |(_, event)| matches!((target, event.side), (Some(target), Some(side)) if side.is_available_on(target)), + )?; + let handler = event.handler.as_deref()?; + let entry = ws.index.file(file)?; + + // Server callbacks receive the calling player as their first parameter. + let is_callback = event.kind == EventKind::Callback; + let handler_skip = if framework_callback { + 2 // Both frameworks supply source and the response callback before the payload. + } else { + usize::from(is_callback && event.side != Some(Side::Client) && !handler.params.is_empty()) + }; + if framework_callback + && candidates.iter().any(|(_, other)| { + other.handler.as_ref().is_some_and(|other| { + !other.params.iter().skip(handler_skip).eq(handler.params.iter().skip(handler_skip)) + }) + }) + { + // Navigation may list all handlers; payload hints must not choose conflicting ones arbitrarily. + return None; + } + + let mut params: Vec = native.into_iter().flat_map(|fun| fun.params.iter().take(skip).cloned()).collect(); + while params.len() < skip { + let (name, ty) = if framework_callback { + if params.is_empty() { + ("name".into(), Type::String) + } else { + ("cb".into(), Type::Function) + } + } else { + (format!("arg{}", params.len() + 1).into(), Type::Unknown) + }; + params.push(Param { name, ty, optional: false }); + } + params.extend(handler.params.iter().skip(handler_skip).cloned()); + + let file_name = entry.path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let resource = entry.resource.and_then(|id| ws.index.resource(id)).map(|r| format!("{}/", r.name)); + Some(EventCall { + fun: FunType { + params, + returns: if framework_callback { + // The handler responds through cb; its own return value is not the trigger's return value. + native.map(|fun| fun.returns.clone()).unwrap_or_default() + } else if is_callback { + handler.returns.clone() + } else { + Vec::new() + }, + is_method: false, + generics: Vec::new(), + overloads: Vec::new(), + }, + event: name.to_string(), + handler_location: format!("{}{file_name}:{}", resource.unwrap_or_default(), event.range.start.line + 1), + }) +} diff --git a/crates/qbx_lua_ls/src/features/folding.rs b/crates/qbx_lua_ls/src/features/folding.rs new file mode 100644 index 0000000..5d121ef --- /dev/null +++ b/crates/qbx_lua_ls/src/features/folding.rs @@ -0,0 +1,106 @@ +use lsp_types::{FoldingRange, FoldingRangeKind}; +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::visit::{self, Visitor}; +use qbx_lua_syntax::Span; + +use crate::document::Document; + +struct Folds<'a> { + doc: &'a Document, + out: Vec, +} + +impl Folds<'_> { + fn push(&mut self, span: Span, kind: Option) { + let start = self.doc.lines.line_of(span.start); + let end = self.doc.lines.line_of(span.end.saturating_sub(1).max(span.start)); + if end > start { + let end_line = if kind.is_none() { end - 1 } else { end }; + if end_line > start || kind.is_some() { + self.out.push(FoldingRange { + start_line: start, + end_line: end_line.max(start), + kind, + ..FoldingRange::default() + }); + } + } + } +} + +impl<'ast> Visitor<'ast> for Folds<'_> { + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + match &stmt.kind { + StmtKind::If { branches, else_block } => { + for (i, branch) in branches.iter().enumerate() { + let end = branches + .get(i + 1) + .map(|b| b.keyword_span.start) + .or(else_block.as_ref().map(|b| b.span.start)) + .unwrap_or(stmt.span.end); + self.push(Span::new(branch.keyword_span.start, end), None); + } + if let Some(block) = else_block { + self.push(Span::new(block.span.start, stmt.span.end), None); + } + } + StmtKind::Function { .. } + | StmtKind::LocalFunction { .. } + | StmtKind::Do(_) + | StmtKind::While { .. } + | StmtKind::Repeat { .. } + | StmtKind::NumericFor { .. } + | StmtKind::GenericFor { .. } + | StmtKind::Defer(_) => self.push(stmt.span, None), + _ => {} + } + visit::walk_stmt(self, stmt); + } + + fn visit_expr(&mut self, expr: &'ast Expr) { + match &expr.kind { + ExprKind::Function(func) => self.push(func.span, None), + ExprKind::Table(_) => self.push(expr.span, None), + ExprKind::Call { args_span, .. } | ExprKind::MethodCall { args_span, .. } => self.push(*args_span, None), + _ => {} + } + visit::walk_expr(self, expr); + } +} + +pub fn folding_ranges(doc: &Document) -> Vec { + let mut folds = Folds { doc, out: Vec::new() }; + folds.visit_block(&doc.chunk.block); + + let mut run: Option<(u32, u32)> = None; + for comment in &doc.chunk.comments { + let first = doc.lines.line_of(comment.span.start); + let last = doc.lines.line_of(comment.span.end.saturating_sub(1).max(comment.span.start)); + run = match run { + Some((start, end)) if first <= end + 1 => Some((start, last.max(end))), + Some((start, end)) => { + if end > start { + folds.out.push(FoldingRange { + start_line: start, + end_line: end, + kind: Some(FoldingRangeKind::Comment), + ..FoldingRange::default() + }); + } + Some((first, last)) + } + None => Some((first, last)), + }; + } + if let Some((start, end)) = run.filter(|(s, e)| e > s) { + folds.out.push(FoldingRange { + start_line: start, + end_line: end, + kind: Some(FoldingRangeKind::Comment), + ..FoldingRange::default() + }); + } + folds.out.sort_by_key(|f| (f.start_line, f.end_line)); + folds.out.dedup_by_key(|f| f.start_line); + folds.out +} diff --git a/crates/qbx_lua_ls/src/features/hover.rs b/crates/qbx_lua_ls/src/features/hover.rs new file mode 100644 index 0000000..1455e7a --- /dev/null +++ b/crates/qbx_lua_ls/src/features/hover.rs @@ -0,0 +1,453 @@ +use lsp_types::{Hover, HoverContents, Position}; +use qbx_fivem_data::{native, native_docs, Side}; +use qbx_lua_analysis::scope::{LocalId, LocalKind, Resolved}; +use qbx_lua_syntax::ast::{Expr, ExprKind}; +use qbx_lua_syntax::{CommentKind, SmolStr, Span}; + +use super::{lua_block, markdown, with_infer}; +use crate::document::Document; +use crate::index::{ClassDef, EventDef, EventFamily, EventKind, FileId, FileOrigin, SymbolKind}; +use crate::indexer::render_doc; +use crate::infer::{Decl, Infer, MemberInfo}; +use crate::locate::locate; +use crate::luacats::type_name_at; +use crate::types::Type; +use crate::workspace::Workspace; + +pub enum Target { + Local(LocalId, Span), + Global(SmolStr, Span), + Member { info: MemberInfo, owner: Type, span: Span }, + Type(SmolStr, Span), +} + +impl Target { + pub fn span(&self) -> Span { + match self { + Target::Local(_, span) | Target::Global(_, span) | Target::Member { span, .. } => *span, + Target::Type(_, span) => *span, + } + } +} + +/// A class or alias named in the doc comment under the cursor. +fn annotation_type_at(doc: &Document, offset: u32) -> Option<(SmolStr, Span)> { + let comment = doc.chunk.comments.iter().find(|c| c.span.contains_inclusive(offset))?; + let content = comment.content.text(&doc.text); + // The content of a `---` line starts at its third dash. `--[[@as T]]` is the only long form. + let line = match comment.kind { + CommentKind::Line => content.strip_prefix('-')?, + CommentKind::Long if content.starts_with("@as") => content, + _ => return None, + }; + let line_start = comment.content.end - line.len() as u32; + let (start, name) = type_name_at(line, offset.checked_sub(line_start)? as usize)?; + let start = line_start + start as u32; + Some((SmolStr::new(name), Span::new(start, start + name.len() as u32))) +} + +pub fn target_at(infer: &Infer, doc: &Document, offset: u32) -> Option { + if let Some((resolved, span)) = doc.resolution.resolved_at_offset(offset) { + return Some(match resolved { + Resolved::Local(id) => Target::Local(id, span), + Resolved::Global(index) => Target::Global(doc.resolution.globals[index as usize].name.clone(), span), + }); + } + let located = locate(&doc.chunk, offset); + if let Some(access) = located.member { + let owner = infer.expr(access.base()); + let name = access.name(&doc.text)?; + let info = infer.member(&owner, &name.text)?; + return Some(Target::Member { info, owner, span: name.span }); + } + if let Some((func_name, segment)) = located.func_name.filter(|(_, segment)| *segment > 0) { + let segments: Vec<_> = func_name.path.iter().chain(&func_name.method).collect(); + let mut owner = infer.func_name_owner_type(&qbx_lua_syntax::ast::FuncName { + base: func_name.base.clone(), + path: Vec::new(), + method: None, + span: func_name.base.span, + }); + for name in &segments[..segment - 1] { + owner = infer.member(&owner, &name.text)?.ty; + } + let name = segments[segment - 1]; + let info = infer.member(&owner, &name.text)?; + return Some(Target::Member { info, owner, span: name.span }); + } + annotation_type_at(doc, offset).map(|(name, span)| Target::Type(name, span)) +} + +const MAX_OVERVIEW_FIELDS: usize = 14; + +/// `name: type`, a function signature, or for tables an overview of the fields that are in scope +/// for this file, with the literal values the index remembered. Aliases follow on their own lines. +fn describe_value(infer: &Infer, prefix: &str, name: &str, ty: &Type, literal: Option<&str>) -> String { + if let (Some(fun), Type::Fun(_)) = (ty.as_fun(), ty) { + return format!("{prefix}{}", fun.signature(name)); + } + let mut out = value_overview(infer, prefix, name, ty, literal); + for (alias, target) in alias_expansions(infer, ty) { + out.push_str(&format!("\ntype {alias} = {target}")); + } + out +} + +fn value_overview(infer: &Infer, prefix: &str, name: &str, ty: &Type, literal: Option<&str>) -> String { + let bare = ty.without_nil(); + let members = infer.members(&table_part(infer, &bare, 0)); + if members.is_empty() { + let value = literal.map(|l| format!(" = {l}")).unwrap_or_default(); + return format!("{prefix}{name}: {}{value}", shown_type(infer, ty)); + } + let label = match &bare { + Type::Named(class, _) => format!("{class} "), + _ => String::new(), + }; + let mut out = format!("{prefix}{name}: {label}{{"); + for member in members.iter().take(MAX_OVERVIEW_FIELDS) { + let ty = match &member.ty { + Type::Fun(_) => "function".to_string(), + Type::GlobalTable(_) | Type::Shape(_) => "table".to_string(), + other => other.to_string(), + }; + let value = member.literal.as_ref().map(|l| format!(" = {l}")).unwrap_or_default(); + out.push_str(&format!("\n {}: {ty}{value},", member.name)); + } + if members.len() > MAX_OVERVIEW_FIELDS { + out.push_str(&format!("\n ...(+{})", members.len() - MAX_OVERVIEW_FIELDS)); + } + out.push_str("\n}"); + out +} + +/// A table the index holds with only integer keys, such as `local list = { 'a', 'b' }` or +/// `Config.Items = { 'a', 'b' }`, is shown as `string[]` rather than as a bare `table`. +fn shown_type(infer: &Infer, ty: &Type) -> Type { + match ty { + Type::GlobalTable(_) => match infer.key_value_types(ty, false) { + (Type::Integer, value) if !value.is_unknown() => Type::Array(Box::new(value)), + _ => ty.clone(), + }, + _ => ty.clone(), + } +} + +/// The parts of `ty` whose members a hover lists. `"male"|"female"`, or an alias of it, is shown as +/// itself rather than as the `string` library, and `Garage|string` lists only the `Garage` fields. +fn table_part(infer: &Infer, ty: &Type, depth: u32) -> Type { + if depth > 8 { + return Type::Unknown; + } + match infer.resolve_alias(ty) { + Type::Union(types) => Type::union(types.iter().map(|t| table_part(infer, t, depth + 1))), + Type::GlobalTable(_) | Type::Named(..) | Type::Shape(_) | Type::Require(_) | Type::Exports(Some(_)) => { + ty.clone() + } + _ => Type::Unknown, + } +} + +/// The aliases in `ty` that stand for something other than a table, such as `"male"|"female"`, +/// expanded one layer the way a class is expanded into its fields. +fn alias_expansions(infer: &Infer, ty: &Type) -> Vec<(SmolStr, Type)> { + let parts = match ty { + Type::Union(types) => types.as_slice(), + other => std::slice::from_ref(other), + }; + let mut out: Vec<(SmolStr, Type)> = Vec::new(); + for part in parts { + let part = match part { + Type::Array(inner) => &**inner, + other => other, + }; + let Type::Named(name, _) = part else { continue }; + if infer.index.class(name).is_some() || out.iter().any(|(seen, _)| seen == name) { + continue; + } + let Some((_, alias)) = infer.index.alias(name) else { continue }; + if table_part(infer, &alias.ty, 0).is_unknown() { + out.push((name.clone(), alias.ty.clone())); + } + } + out +} + +fn local_hover(infer: &Infer, id: LocalId) -> String { + let local = infer.ctx.resolution.local(id); + let ty = infer.local_type(id); + let prefix = match local.kind { + LocalKind::Param => "(parameter) ", + LocalKind::ImplicitSelf => "(self) ", + LocalKind::LoopVar => "(loop variable) ", + LocalKind::Local | LocalKind::LocalFunction => "local ", + }; + let mut out = lua_block(&describe_value(infer, prefix, &local.name, &ty, None)); + let doc = match infer.ctx.decl(local.decl.start) { + Some(Decl::Local { stmt, .. } | Decl::LocalFunction { stmt, .. }) => { + render_doc(&infer.ctx.doc_at(stmt.span.start)) + } + Some(Decl::Param { doc_anchor: Some(anchor), .. }) => { + infer.ctx.doc_at(*anchor).param_description(&local.name).map(Into::into) + } + _ => None, + }; + if let Some(doc) = doc { + out.push_str("\n\n"); + out.push_str(&doc); + } + out +} + +fn native_hover(name: &str) -> Option { + let native = native(name)?; + let mut out = lua_block(&native.signature()); + let canonical = native.alias_of.map(|target| format!(" · alias of `{target}`")).unwrap_or_default(); + out.push_str(&format!( + "\n\n*{} native* · `{}` · `{}`{canonical}", + native.side.label(), + native.namespace, + native.hash + )); + if let Some(docs) = native_docs(name) { + out.push_str("\n\n"); + out.push_str(&docs); + } + Some(out) +} + +fn global_hover(ws: &Workspace, infer: &Infer, name: &str) -> Option { + let symbols = ws.index.globals_named(name, infer.ctx.file); + let preferred = symbols + .iter() + .max_by_key(|(_, s)| (matches!(s.ty, Type::GlobalTable(_) | Type::Named(..)), s.ty.specificity())); + let Some((file, symbol)) = preferred else { + if let Some(hover) = native_hover(name) { + return Some(hover); + } + let ty = infer.global_type(name); + return (!ty.is_unknown()).then(|| lua_block(&describe_value(infer, "(global) ", name, &ty, None))); + }; + // Going through `global_type` merges the table with members other files of the resource add. + let ty = match infer.global_type(name) { + Type::Unknown => symbol.ty.clone(), + resolved => resolved, + }; + let mut out = lua_block(&describe_value(infer, "(global) ", name, &ty, symbol.literal.as_deref())); + if let Some(doc) = &symbol.doc { + out.push_str("\n\n"); + out.push_str(doc); + } + if let Some(entry) = ws.index.file(*file).filter(|f| f.origin != FileOrigin::Stub && *file != infer.ctx.file) { + let resource = entry.resource.and_then(|r| ws.index.resource(r)); + let location = match resource { + Some(resource) => format!( + "{}/{}", + resource.name, + qbx_lua_analysis::project::relative_slash_path(&resource.root, &entry.path) + ), + None => entry.path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + }; + out.push_str(&format!("\n\n*defined in* `{location}`")); + } + Some(out) +} + +pub fn member_hover(infer: &Infer, info: &MemberInfo, owner: &Type) -> String { + let owner_label = match owner { + Type::GlobalTable(path) if path.starts_with('%') => String::new(), + other => other.without_nil().to_string(), + }; + let is_method = info.ty.as_fun().is_some_and(|f| f.is_method); + let qualified = match (owner_label.is_empty(), is_method) { + (true, _) => info.name.to_string(), + (false, true) => format!("{owner_label}:{}", info.name), + (false, false) => format!("{owner_label}.{}", info.name), + }; + let prefix = if matches!(info.kind, SymbolKind::Field) && info.ty.as_fun().is_none() { "(field) " } else { "" }; + let mut out = lua_block(&describe_value(infer, prefix, &qualified, &info.ty, info.literal.as_deref())); + if info.deprecated { + out.push_str("\n\n**Deprecated**"); + } + if let Some(doc) = &info.doc { + out.push_str("\n\n"); + out.push_str(doc); + } + out +} + +fn class_hover(infer: &Infer, class: &ClassDef) -> String { + let mut declaration = format!("(class) {}", class.name); + if !class.parents.is_empty() { + declaration.push_str(&format!(" : {}", class.parents.join(", "))); + } + let members = infer.members(&Type::Named(class.name.clone(), Vec::new())); + if !members.is_empty() || class.index.is_some() { + declaration.push_str(" {"); + for member in members.iter().take(MAX_OVERVIEW_FIELDS) { + declaration.push_str(&format!("\n {}: {},", member.name, member.ty)); + } + if let Some((key, value)) = &class.index { + declaration.push_str(&format!("\n [{key}]: {value},")); + } + if members.len() > MAX_OVERVIEW_FIELDS { + declaration.push_str(&format!("\n ...(+{})", members.len() - MAX_OVERVIEW_FIELDS)); + } + declaration.push_str("\n}"); + } + let mut out = lua_block(&declaration); + if let Some(doc) = &class.doc { + out.push_str("\n\n"); + out.push_str(doc); + } + out +} + +/// A class or alias, preferring workspace declarations over the built-in library and this file's +/// over those of other files. +fn type_hover(infer: &Infer, name: &str) -> Option { + let preference = |file: FileId| { + (infer.index.file(file).is_some_and(|entry| entry.origin != FileOrigin::Stub), file == infer.ctx.file) + }; + if let Some((_, class)) = infer.index.class_defs(name).into_iter().max_by_key(|(file, _)| preference(*file)) { + return Some(class_hover(infer, class)); + } + let (_, alias) = infer.index.alias_defs(name).into_iter().max_by_key(|(file, _)| preference(*file))?; + let mut out = lua_block(&format!("type {name} = {}", alias.ty)); + if let Some(doc) = &alias.doc { + out.push_str("\n\n"); + out.push_str(doc); + } + Some(out) +} + +pub(super) struct EventStringContext { + pub family: EventFamily, + pub target_side: Option, + pub active: bool, +} + +impl EventStringContext { + pub fn framework(&self) -> bool { + matches!(self.family, EventFamily::QbCore | EventFamily::Esx) + } + + pub fn accepts_registration(&self, event: &EventDef) -> bool { + self.active + && event.family == self.family + && event.kind != EventKind::Trigger + && (!self.framework() || (event.kind == EventKind::Callback && event.side == Some(Side::Server))) + } +} + +/// A literal first argument is the only place framework callback names acquire special meaning. +pub(super) fn event_string_context(infer: &Infer, call: Option<(&Expr, usize)>) -> Option { + let (call, 0) = call? else { return None }; + let ExprKind::Call { callee, .. } = &call.kind else { return None }; + let own_side = || { + let side = infer.index.file(infer.ctx.file).and_then(|file| file.side); + qbx_lua_analysis::side_guard::SideRegions::of(infer.ctx.source, infer.ctx.chunk) + .effective(call.span.start, side) + }; + let path = callee.dotted_path(); + let (family, target_side) = match path.as_deref() { + Some("TriggerServerEvent" | "TriggerLatentServerEvent") => (EventFamily::Native, Some(Side::Server)), + Some("TriggerClientEvent" | "TriggerLatentClientEvent") => (EventFamily::Native, Some(Side::Client)), + Some("TriggerEvent") => (EventFamily::Native, own_side()), + Some("AddEventHandler" | "RegisterNetEvent" | "RegisterServerEvent") => (EventFamily::Native, None), + Some("lib.callback" | "lib.callback.await") => { + let target = match own_side() { + Some(Side::Client) => Some(Side::Server), + Some(Side::Server) => Some(Side::Client), + _ => None, + }; + (EventFamily::OxLib, target) + } + Some("lib.callback.register") => (EventFamily::OxLib, None), + _ => { + let framework = crate::framework_callbacks::classify(infer.ctx, infer.index, callee)?; + return Some(EventStringContext { + family: framework.family, + target_side: Some(Side::Server), + active: own_side() == Some(framework.required_side()), + }); + } + }; + Some(EventStringContext { family, target_side, active: true }) +} + +pub(super) fn event_handler_signature(event: &EventDef) -> Option { + let handler = event.handler.as_deref()?; + if !matches!(event.family, EventFamily::QbCore | EventFamily::Esx) { + return Some(handler.signature("")); + } + let mut payload = handler.clone(); + payload.params.drain(..payload.params.len().min(2)); + // Responses arrive through cb; returning from the server handler is not the client call result. + payload.returns.clear(); + Some(payload.signature("")) +} + +fn string_hover(ws: &Workspace, infer: &Infer, doc: &Document, offset: u32) -> Option<(String, Span)> { + let located = locate(&doc.chunk, offset); + let (string, call) = located.string?; + let ExprKind::String(value) = &string.kind else { return None }; + let callee = call.and_then(|(call, _)| match &call.kind { + ExprKind::Call { callee, .. } => callee.dotted_path(), + _ => None, + }); + if callee.as_deref() == Some("locale") { + let resource = ws.index.resource_of(doc.file)?; + let locale = qbx_lua_analysis::locale::LocaleFile::load(&resource.root)?; + let file = locale.path.file_name()?.to_string_lossy().into_owned(); + let text = locale.text_of(value)?; + return Some((format!("`{value}` · locales/{file}\n\n{text}"), string.span)); + } + let context = event_string_context(infer, call); + let registrations: Vec<_> = ws + .index + .events() + .filter(|(_, event)| event.name == *value && event.kind != EventKind::Trigger) + .filter(|(_, event)| match &context { + Some(context) => context.accepts_registration(event), + None => matches!(event.family, EventFamily::Native | EventFamily::OxLib), + }) + .collect(); + if registrations.is_empty() { + return None; + } + let label = match context.as_ref().map(|context| context.family) { + Some(EventFamily::QbCore) => "QB-Core callback", + Some(EventFamily::Esx) => "ESX callback", + _ => "event", + }; + let mut out = format!("{label} `{value}`"); + for (file, event) in registrations.iter().take(5) { + let Some(entry) = ws.index.file(*file) else { continue }; + let name = entry.path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let side = entry.side.map_or("", |s| s.label()); + let handler = event_handler_signature(event).map(|signature| format!(" `{signature}`")).unwrap_or_default(); + out.push_str(&format!("\n- {side} `{name}:{}`{handler}", event.range.start.line + 1)); + } + if context.as_ref().is_some_and(EventStringContext::framework) { + out.push_str("\n\nPayload parameters omit the server's `source` and response `cb`. Responses are asynchronous; Lua return values are not inferred."); + } + Some((out, string.span)) +} + +pub fn hover(ws: &Workspace, doc: &Document, position: Position) -> Option { + let offset = doc.offset(position); + let (text, span) = with_infer(ws, doc, |infer| { + let Some(target) = target_at(infer, doc, offset) else { + return super::native_argument::hover(ws, doc, offset).or_else(|| string_hover(ws, infer, doc, offset)); + }; + let text = match &target { + Target::Local(id, _) => Some(local_hover(infer, *id)), + Target::Global(name, _) => global_hover(ws, infer, name), + Target::Member { info, owner, .. } => Some(member_hover(infer, info, owner)), + Target::Type(name, _) => type_hover(infer, name), + }; + text.map(|t| (t, target.span())) + })?; + Some(Hover { contents: HoverContents::Markup(markdown(text)), range: Some(doc.range(span)) }) +} diff --git a/crates/qbx_lua_ls/src/features/inlay.rs b/crates/qbx_lua_ls/src/features/inlay.rs new file mode 100644 index 0000000..a5e731f --- /dev/null +++ b/crates/qbx_lua_ls/src/features/inlay.rs @@ -0,0 +1,88 @@ +use lsp_types::{InlayHint, InlayHintKind, InlayHintLabel, Range}; +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::visit::{self, Visitor}; +use qbx_lua_syntax::Span; + +use super::event_call::event_call; +use super::with_infer; +use crate::document::Document; +use crate::infer::Infer; +use crate::workspace::Workspace; + +const MAX_HINTS: usize = 400; + +struct Hints<'a, 'b> { + doc: &'a Document, + infer: &'a Infer<'b>, + ws: &'a Workspace, + range: Span, + out: Vec, +} + +fn is_literal(expr: &Expr) -> bool { + match &expr.kind { + ExprKind::Nil | ExprKind::True | ExprKind::False | ExprKind::Number(_) | ExprKind::String(_) => true, + ExprKind::Unary { op: UnOp::Neg, expr } => matches!(expr.kind, ExprKind::Number(_)), + ExprKind::Table(fields) => fields.is_empty(), + _ => false, + } +} + +impl Hints<'_, '_> { + fn call(&mut self, base: &Expr, method: Option<&Name>, args: &[Expr]) { + if self.out.len() >= MAX_HINTS || !args.iter().any(is_literal) { + return; + } + let native = self.infer.callee_fun(base, method).map(|(fun, _)| fun); + let event = method + .is_none() + .then(|| event_call(self.ws, self.doc, self.infer, base, args, native.as_deref())) + .flatten(); + let Some(fun) = event.map(|e| e.fun.into()).or(native) else { return }; + let (skip_params, skip_args) = fun.call_offsets(method.is_some()); + for (arg, param) in args.iter().skip(skip_args).zip(fun.params.iter().skip(skip_params)) { + if !is_literal(arg) || param.name == "..." || param.name.is_empty() || !self.range.contains(arg.span.start) + { + continue; + } + self.out.push(InlayHint { + position: self.doc.position(arg.span.start), + label: InlayHintLabel::String(format!("{}:", param.name)), + kind: Some(InlayHintKind::PARAMETER), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: Some(true), + data: None, + }); + } + } +} + +impl<'ast> Visitor<'ast> for Hints<'_, '_> { + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + if stmt.span.end >= self.range.start && stmt.span.start <= self.range.end { + visit::walk_stmt(self, stmt); + } + } + + fn visit_expr(&mut self, expr: &'ast Expr) { + match &expr.kind { + ExprKind::Call { callee, args, style: CallStyle::Paren, .. } => self.call(callee, None, args), + ExprKind::MethodCall { base, method, args, style: CallStyle::Paren, .. } => { + self.call(base, Some(method), args) + } + _ => {} + } + visit::walk_expr(self, expr); + } +} + +pub fn inlay_hints(ws: &Workspace, doc: &Document, range: Range) -> Vec { + let span = Span::new(doc.offset(range.start), doc.offset(range.end)); + with_infer(ws, doc, |infer| { + let mut hints = Hints { doc, infer, ws, range: span, out: Vec::new() }; + hints.visit_block(&doc.chunk.block); + hints.out + }) +} diff --git a/crates/qbx_lua_ls/src/features/member_refs.rs b/crates/qbx_lua_ls/src/features/member_refs.rs new file mode 100644 index 0000000..c2ab12c --- /dev/null +++ b/crates/qbx_lua_ls/src/features/member_refs.rs @@ -0,0 +1,348 @@ +use lsp_types::{Range, Url}; +use qbx_lua_analysis::project::read_source; +use qbx_lua_analysis::scope::Resolved; +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::lexer::{decode_string, lex, TokenKind}; +use qbx_lua_syntax::visit::{self, Visitor}; +use qbx_lua_syntax::{SmolStr, Span}; + +use super::hover::{target_at, Target}; +use super::with_infer; +use crate::document::Document; +use crate::index::{FileId, FileOrigin}; +use crate::infer::{table_fields, Infer}; +use crate::locate::string_content_span; +use crate::server::Documents; +use crate::types::Type; +use crate::workspace::Workspace; + +pub struct MemberTarget { + pub name: SmolStr, + pub file: FileId, + declarations: Vec<(FileId, Range)>, +} + +/// The field or method under the cursor, identified by where it is defined. +pub fn member_target(ws: &Workspace, doc: &Document, offset: u32) -> Option { + let located = with_infer(ws, doc, |infer| match target_at(infer, doc, offset)? { + Target::Member { info, .. } => { + let (file, range) = info.location?; + Some((info.name, file, range)) + } + _ => None, + }); + // Constructor keys and annotation declarations are not expressions in the scope resolver. + let (name, file, range) = located.or_else(|| { + let entry = ws.index.file(doc.file)?; + entry + .index + .members + .iter() + .map(|member| &member.symbol) + .chain(entry.index.classes.iter().flat_map(|class| &class.fields)) + .find_map(|symbol| { + let range = declaration_range(doc, &symbol.name, symbol.range)?; + let position = doc.position(offset); + (range.start <= position && position <= range.end) + .then(|| (symbol.name.clone(), doc.file, symbol.range)) + }) + })?; + let mut declarations = vec![(file, range)]; + let entry = ws.index.file(file)?; + let owner = entry + .index + .members + .iter() + .find(|member| member.symbol.name == name && member.symbol.range == range) + .map(|member| &member.owner) + .or_else(|| { + entry + .index + .classes + .iter() + .find(|class| class.fields.iter().any(|field| field.name == name && field.range == range)) + .map(|class| &class.name) + }); + if let Some(owner) = owner { + declarations.extend( + ws.index + .members_of(owner, file) + .into_iter() + .filter(|(_, symbol)| symbol.name == name) + .map(|(file, symbol)| (file, symbol.range)), + ); + declarations.extend(ws.index.class_defs(owner).into_iter().flat_map(|(file, class)| { + class.fields.iter().filter(|field| field.name == name).map(move |field| (file, field.range)) + })); + } + declarations.sort_by_key(|(file, range)| (*file, range.start, range.end)); + declarations.dedup(); + Some(MemberTarget { name, file, declarations }) +} + +/// Index entries may point at a quoted key or a whole @field comment. Resolve the actual token; +/// if it cannot be resolved, rename must fail instead of silently omitting its declaration. +fn declaration_range(doc: &Document, name: &str, range: Range) -> Option { + let span = Span::new(doc.offset(range.start), doc.offset(range.end)); + let raw = span.text(&doc.text); + if raw == name { + return Some(range); + } + let string_range = |token_span: Span| { + let decoded = decode_string(token_span.text(&doc.text), token_span.start); + (decoded.errors.is_empty() && decoded.value == name) + .then(|| string_content_span(token_span, &doc.text)) + .flatten() + .map(|span| doc.range(span)) + }; + if string_content_span(span, &doc.text).is_some() { + return string_range(span); + } + let mut rest = raw.strip_prefix("---")?.trim_start().strip_prefix("@field")?; + if !rest.starts_with(char::is_whitespace) { + return None; + } + rest = rest.trim_start(); + for scope in ["public ", "private ", "protected ", "package "] { + if let Some(stripped) = rest.strip_prefix(scope) { + rest = stripped.trim_start(); + } + } + if let Some(index) = rest.strip_prefix('[') { + rest = index.trim_start(); + let tokens = lex(rest); + let token = tokens.tokens.first()?; + if !matches!(token.kind, TokenKind::String | TokenKind::LongString) { + return None; + } + let start = span.start + (raw.len() - rest.len()) as u32; + return string_range(Span::new(start + token.span.start, start + token.span.end)); + } + let len = rest.bytes().take_while(|b| b.is_ascii_alphanumeric() || *b == b'_').count(); + if &rest[..len] != name { + return None; + } + let start = span.start + (raw.len() - rest.len()) as u32; + Some(doc.range(Span::new(start, start + len as u32))) +} + +struct Finder<'a, 'b> { + doc: &'a Document, + infer: &'a Infer<'b>, + target: &'a MemberTarget, + out: Vec, + limit: usize, + positions: Option>, +} + +impl Finder<'_, '_> { + fn check(&mut self, owner: &Type, name: &str, span: Span) { + if self.out.len() >= self.limit { + return; + } + let same = self + .infer + .member(owner, name) + .and_then(|m| m.location) + .is_some_and(|location| self.target.declarations.contains(&location)); + if same { + self.out + .push(self.positions.as_ref().map_or_else(|| self.doc.range(span), |positions| positions.range(span))); + } + } + + fn table(&mut self, owner: &Type, expr: &Expr) { + if self.out.len() >= self.limit { + return; + } + let Some(fields) = table_fields(expr) else { return }; + for field in fields { + let (name, span, value) = match field { + TableField::Named { name, value } => (name.text.as_str(), name.span, value), + TableField::Keyed { key, value } => { + let Some(name) = key.as_string() else { continue }; + let Some(span) = string_content_span(key.span, &self.doc.text) else { continue }; + (name.as_str(), span, value) + } + _ => continue, + }; + if name == self.target.name { + self.check(owner, name, span); + } + if let Some(member) = self.infer.member(owner, name) { + self.table(&member.ty, value); + } + } + } +} + +impl<'ast> Visitor<'ast> for Finder<'_, '_> { + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + if self.out.len() >= self.limit { + return; + } + if let StmtKind::Function { name, .. } = &stmt.kind { + let segments: Vec<&Name> = name.path.iter().chain(&name.method).collect(); + if segments.iter().any(|s| s.text == self.target.name) { + let base = FuncName { base: name.base.clone(), path: Vec::new(), method: None, span: name.base.span }; + let mut owner = self.infer.func_name_owner_type(&base); + for segment in segments { + if segment.text == self.target.name { + self.check(&owner, &segment.text, segment.span); + } + owner = self.infer.member(&owner, &segment.text).map(|m| m.ty).unwrap_or_default(); + } + } + } + match &stmt.kind { + StmtKind::Local { names, exprs, .. } => { + for (name, expr) in names.iter().zip(exprs) { + if let Some(Resolved::Local(id)) = self.doc.resolution.resolve_at(name.name.span.start) { + self.table(&self.infer.local_type(id), expr); + } + } + } + StmtKind::Assign { targets, exprs } => { + for (target, expr) in targets.iter().zip(exprs) { + self.table(&self.infer.expr(target), expr); + } + } + _ => {} + } + visit::walk_stmt(self, stmt); + } + + fn visit_expr(&mut self, expr: &'ast Expr) { + if self.out.len() >= self.limit { + return; + } + match &expr.kind { + ExprKind::Field { base, name, .. } | ExprKind::MethodCall { base, method: name, .. } + if name.text == self.target.name => + { + let owner = self.infer.expr(base); + self.check(&owner, &name.text, name.span); + } + ExprKind::Index { base, index, .. } if index.as_string() == Some(&self.target.name) => { + if let Some(span) = string_content_span(index.span, &self.doc.text) { + let owner = self.infer.expr(base); + self.check(&owner, &self.target.name, span); + } + } + _ => {} + } + visit::walk_expr(self, expr); + } +} + +fn occurrences_in(ws: &Workspace, doc: &Document, target: &MemberTarget) -> Option> { + occurrences_limited(ws, doc, target, usize::MAX) +} +fn occurrences_limited(ws: &Workspace, doc: &Document, target: &MemberTarget, limit: usize) -> Option> { + with_infer(ws, doc, |infer| { + let mut finder = Finder { + doc, + infer, + target, + out: Vec::new(), + limit, + positions: (limit != usize::MAX).then(|| super::assistant::InspectionPositions::new(&doc.text)), + }; + finder.visit_block(&doc.chunk.block); + for (_, range) in target.declarations.iter().filter(|(file, _)| *file == doc.file) { + if finder.out.len() >= limit { + break; + } + finder.out.push(declaration_range(doc, &target.name, *range)?); + } + finder.out.sort_by_key(|range| (range.start, range.end)); + finder.out.dedup(); + Some(finder.out) + }) +} + +pub(crate) fn member_occurrences_bounded( + ws: &Workspace, + docs: &Documents, + doc: &Document, + target: &MemberTarget, + budget: &mut super::assistant::InspectionBudget, +) -> Vec<(Url, Range)> { + let mut out = Vec::new(); + for (id, entry) in ws.index.files() { + if out.len() >= 20_000 { + budget.result_limit = true; + break; + } + let reachable = target.declarations.iter().any(|(file, _)| *file == id) + || ws.index.is_related(doc.file, id) + || ws.index.is_related(target.file, id); + if entry.origin == FileOrigin::Stub || !reachable { + continue; + } + let closed; + let source = if entry.uri == doc.uri { + doc + } else if let Some(open) = docs.get(&entry.uri) { + open + } else { + let Some(text) = budget.read(&entry.path) else { continue }; + closed = { + let mut closed = Document::new(entry.uri.clone(), entry.path.clone(), 0, text); + closed.file = id; + closed + }; + &closed + }; + if !budget.claim(&entry.path, source.text.len()) { + continue; + } + match occurrences_limited(ws, source, target, 20_000 - out.len()) { + Some(ranges) => out.extend(ranges.into_iter().map(|range| (entry.uri.clone(), range))), + None => budget.skip(&entry.path), + } + } + if out.len() >= 20_000 { + budget.result_limit = true; + } + out +} + +/// Every use of the member across the files that can reach its definition. Closed files are parsed +/// on demand. Literal spellings can be escaped, so a substring search cannot rule out references. +pub fn member_occurrences( + ws: &Workspace, + docs: &Documents, + doc: &Document, + target: &MemberTarget, +) -> Option> { + let mut out: Vec<(Url, Range)> = Vec::new(); + for (id, entry) in ws.index.files() { + let reachable = target.declarations.iter().any(|(file, _)| *file == id) + || ws.index.is_related(doc.file, id) + || ws.index.is_related(target.file, id); + if entry.origin == FileOrigin::Stub || !reachable { + continue; + } + let ranges = match docs.get(&entry.uri) { + Some(open) => occurrences_in(ws, open, target)?, + None => { + let source = read_source(&entry.path).ok()?; + let mut closed = Document::new(entry.uri.clone(), entry.path.clone(), 0, source); + closed.file = id; + occurrences_in(ws, &closed, target)? + } + }; + out.extend(ranges.into_iter().map(|range| (entry.uri.clone(), range))); + } + Some(out) +} + +pub fn in_document(ws: &Workspace, doc: &Document, target: &MemberTarget) -> Vec { + occurrences_in(ws, doc, target).unwrap_or_default() +} + +/// Members of the runtime stubs and of indexed libraries cannot be renamed from here. +pub fn is_renamable(ws: &Workspace, target: &MemberTarget) -> bool { + target.declarations.iter().all(|(file, _)| ws.index.file(*file).is_some_and(|f| f.origin == FileOrigin::Workspace)) +} diff --git a/crates/qbx_lua_ls/src/features/mod.rs b/crates/qbx_lua_ls/src/features/mod.rs new file mode 100644 index 0000000..b5aa5b6 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/mod.rs @@ -0,0 +1,38 @@ +pub mod assistant; +pub mod code_action; +pub mod completion; +pub mod definition; +pub mod diagnostics; +pub mod event_call; +pub mod folding; +pub mod hover; +pub mod inlay; +pub mod member_refs; +mod native_argument; +pub mod nui_resource; +pub mod reference; +pub mod references; +pub mod resource_assets; +pub mod resource_details; +pub mod semantic_tokens; +pub mod signature; +pub mod symbols; +pub mod workspace_health; + +use crate::document::Document; +use crate::infer::{FileContext, Infer}; +use crate::workspace::Workspace; + +pub fn with_infer(ws: &Workspace, doc: &Document, f: impl FnOnce(&Infer) -> R) -> R { + let ctx = FileContext::new(doc.file, &doc.text, &doc.chunk, &doc.resolution); + let infer = Infer::new(&ctx, &ws.index); + f(&infer) +} + +pub fn markdown(value: String) -> lsp_types::MarkupContent { + lsp_types::MarkupContent { kind: lsp_types::MarkupKind::Markdown, value } +} + +pub fn lua_block(code: &str) -> String { + format!("```lua\n{code}\n```") +} diff --git a/crates/qbx_lua_ls/src/features/native_argument.rs b/crates/qbx_lua_ls/src/features/native_argument.rs new file mode 100644 index 0000000..eb6a1f5 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/native_argument.rs @@ -0,0 +1,137 @@ +use std::sync::OnceLock; + +use qbx_fivem_data::{ + control, native, natives, ped_config_flag, Native, CONTROLS_SOURCE_URL, PED_CONFIG_FLAGS_SOURCE_URL, +}; +use qbx_lua_analysis::scope::Resolved; +use qbx_lua_syntax::ast::ExprKind; +use qbx_lua_syntax::{NumberValue, Span}; + +use crate::document::Document; +use crate::index::FileOrigin; +use crate::locate::locate; +use crate::workspace::Workspace; + +enum ArgumentKind { + Control, + PedConfigFlag, +} + +fn argument_kind(native: Native, index: usize) -> Option { + let (parameter, _) = native.params().nth(index)?; + if native.namespace == "PAD" && parameter == "control" { + Some(ArgumentKind::Control) + } else if matches!(native.name, "SetPedConfigFlag" | "GetPedConfigFlag") && parameter == "flagId" { + Some(ArgumentKind::PedConfigFlag) + } else { + None + } +} + +fn named_native(name: &str) -> Option { + if let Some(found) = native(name) { + return found.alias_of.and_then(native).or(Some(found)); + } + // Known natives are callable by hash even when there is no separate alias row. + let hex = name.strip_prefix("N_0x")?; + if hex.is_empty() || hex.len() > 16 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let hash = u64::from_str_radix(hex, 16).ok()?; + static HASHES: OnceLock> = OnceLock::new(); + let hashes = HASHES.get_or_init(|| { + natives() + .filter(|native| { + native.alias_of.is_none() + && native.params().enumerate().any(|(i, _)| argument_kind(*native, i).is_some()) + }) + .filter_map(|native| Some((u64::from_str_radix(native.hash.strip_prefix("0x")?, 16).ok()?, native))) + .collect() + }); + hashes.iter().find(|(value, _)| *value == hash).map(|(_, native)| *native) +} + +fn literal_id(value: NumberValue) -> Option { + match value { + NumberValue::Int(value) => u32::try_from(value).ok(), + NumberValue::Float(value) if value >= 0.0 && value <= u32::MAX as f64 && value.fract() == 0.0 => { + Some(value as u32) + } + _ => None, + } +} + +fn binding(value: &str) -> String { + if value.is_empty() { + return "Not documented".to_owned(); + } + if value.contains('`') { + // CommonMark code spans need a longer fence and padding for literal backtick keys. + let fence = "`".repeat(value.split(|c| c != '`').map(str::len).max().unwrap_or(0) + 1); + format!("{fence} {value} {fence}") + } else { + format!("`{value}`") + } +} + +pub(super) fn control_documentation(id: u32) -> Option { + let control = control(id)?; + Some(format!( + "**{}** · control `{id}`\n\nDefault keyboard (QWERTY): {} \nDefault Xbox controller: {}\n\n\ + These are documented defaults. The player's bindings may be remapped.\n\n\ + [Cfx controls reference]({CONTROLS_SOURCE_URL})", + control.name, + binding(control.keyboard), + binding(control.controller) + )) +} + +pub(super) fn ped_flag_documentation(id: u32) -> Option { + let flag = ped_config_flag(id)?; + let description = flag.description.unwrap_or("Behavior is not documented in the bundled Cfx reference."); + Some(format!( + "**Ped config flag `{id}`**\n\n`{}`\n\n{description}\n\n\ + The Cfx reference includes potential names and hash collisions.\n\n\ + [Cfx ped config flags reference]({PED_CONFIG_FLAGS_SOURCE_URL})", + flag.name + )) +} + +/// Enum documentation belongs to a literal passed directly to a recognized native parameter. +/// Never infer an enum from a nearby token, arbitrary number or a user function of the same name. +pub fn hover(ws: &Workspace, doc: &Document, offset: u32) -> Option<(String, Span)> { + let call = locate(&doc.chunk, offset).call?; + if call.method.is_some() { + return None; + } + let (index, argument) = call.args.iter().enumerate().find(|(_, arg)| arg.span.contains(offset))?; + let argument = argument.unparen(); + if !argument.span.contains(offset) { + return None; + } + let ExprKind::Number(value) = argument.kind else { return None }; + if doc.chunk.errors.iter().any(|error| error.span.start < argument.span.end && error.span.end > argument.span.start) + { + return None; + } + let id = literal_id(value)?; + // A qualified or method call can be an unrelated wrapper, even when its last segment matches. + let ExprKind::Name(name) = &call.base.kind else { return None }; + if !matches!(doc.resolution.resolve_at(name.span.start), Some(Resolved::Global(_))) + || !ws.index.globals_named(&name.text, doc.file).is_empty() + || doc.resolution.lookup_local_at("_ENV", name.span.start).is_some() + || ws + .index + .globals_named("_ENV", doc.file) + .iter() + .any(|(file, _)| ws.index.file(*file).is_some_and(|entry| entry.origin != FileOrigin::Stub)) + { + return None; + } + let native = named_native(&name.text)?; + let text = match argument_kind(native, index)? { + ArgumentKind::Control => control_documentation(id)?, + ArgumentKind::PedConfigFlag => ped_flag_documentation(id)?, + }; + Some((text, argument.span)) +} diff --git a/crates/qbx_lua_ls/src/features/nui_resource.rs b/crates/qbx_lua_ls/src/features/nui_resource.rs new file mode 100644 index 0000000..cb44832 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/nui_resource.rs @@ -0,0 +1,119 @@ +//! Local NUI metadata and literal Lua callback registrations from the existing index. +use lsp_types::Location; +use qbx_fivem_data::Side; +use qbx_lua_analysis::project::is_manifest_file; +use serde::Serialize; + +use crate::index::{FileOrigin, Index}; +use crate::nui_callbacks::REGISTRATION_GLOBALS; + +use super::resource_details::{identity, normalized, selected_resource, DetailsParams, ResourceIdentity}; + +const CALLBACK_LIMIT: usize = 500; +const NAME_LIMIT: usize = 2048; +const PAGE_LIMIT: usize = 16_384; + +#[derive(Debug, Serialize)] +pub struct NuiCallback { + pub name: String, + pub location: Location, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NuiResource { + pub resource: ResourceIdentity, + pub ui_page: Option, + pub callbacks: Vec, + pub truncated: usize, + pub notes: Vec, +} + +pub fn resource(index: &Index, params: DetailsParams) -> Result { + let selected = selected_resource(index, params)?; + let root = normalized(&selected.root); + let mut callbacks = Vec::new(); + let mut long_names = 0; + for (id, file) in index.files().filter(|(_, file)| { + file.origin != FileOrigin::Stub + && !is_manifest_file(&file.path) + && file.resource.and_then(|id| index.resource(id)).is_some_and(|owner| normalized(&owner.root) == root) + }) { + // Other open files can replace or restore a global without reindexing this callback file. + let replaced = |name| { + index.globals_named(name, id).iter().any(|(file, _)| { + index + .file(*file) + .is_some_and(|entry| entry.origin != FileOrigin::Stub && entry.side != Some(Side::Server)) + }) + }; + if replaced("_ENV") { + continue; + } + let globals = REGISTRATION_GLOBALS.map(|name| (name, replaced(name))); + for callback in &file.index.nui_callbacks { + if globals.iter().any(|(name, replaced)| *name == callback.registration && *replaced) { + continue; + } + // These are actionable callback names: never turn a shortened label into another callback. + if callback.name.chars().take(NAME_LIMIT + 1).count() > NAME_LIMIT { + long_names += 1; + continue; + } + callbacks.push(NuiCallback { + name: callback.name.to_string(), + location: Location::new(file.uri.clone(), callback.range), + }); + } + } + callbacks.sort_by(|a, b| { + ( + &a.name, + a.location.uri.as_str(), + a.location.range.start.line, + a.location.range.start.character, + a.location.range.end.line, + a.location.range.end.character, + ) + .cmp(&( + &b.name, + b.location.uri.as_str(), + b.location.range.start.line, + b.location.range.start.character, + b.location.range.end.line, + b.location.range.end.character, + )) + }); + let truncated = long_names + callbacks.len().saturating_sub(CALLBACK_LIMIT); + callbacks.truncate(CALLBACK_LIMIT); + let page = selected.manifest.ui_page.as_ref().map(|entry| entry.value.as_str()); + let ui_page = page.filter(|page| page.len() <= PAGE_LIMIT).map(str::to_owned); + let mut notes = vec![ + "Preview runs browser UI with mocked callbacks. Test game behavior in FiveM.".into(), + "Save manifest changes before reloading the UI page.".into(), + "Callbacks include literal client-side RegisterNUICallback and RegisterNuiCallback calls. Dynamic names, aliases and imported code may be absent.".into(), + "Unsaved Lua edits are included. Multiple registrations remain separate so you can choose a source.".into(), + ]; + if page.is_some() && ui_page.is_none() { + notes.push("The literal UI page exceeds 16,384 bytes and was omitted; its path was not shortened.".into()); + } else if page.is_none() { + notes.push( + "No literal ui_page is recorded in the saved manifest. Computed manifest values are not evaluated.".into(), + ); + } + if long_names > 0 { + notes.push(format!( + "{long_names} callback names longer than 2,048 characters were omitted rather than changed." + )); + } + if truncated > long_names { + notes + .push(format!("{} additional callback rows were omitted to keep this view small.", truncated - long_names)); + } + if selected.escrowed || selected.manifest.has_non_lua_scripts() { + notes.push( + "Escrowed, unreadable and non-Lua scripts are not fully represented in these callback results.".into(), + ); + } + Ok(NuiResource { resource: identity(selected), ui_page, callbacks, truncated, notes }) +} diff --git a/crates/qbx_lua_ls/src/features/reference.rs b/crates/qbx_lua_ls/src/features/reference.rs new file mode 100644 index 0000000..d56a148 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/reference.rs @@ -0,0 +1,368 @@ +//! On-demand, read-only search over the reference data already bundled with the server. + +use std::collections::BTreeSet; +use std::sync::OnceLock; + +use qbx_fivem_data::{ + controls, native, native_docs, natives, ped_config_flags, Native, CONTROLS_SOURCE_URL, PED_CONFIG_FLAGS_SOURCE_URL, +}; +use rustc_hash::FxHashMap; +use serde::{Deserialize, Serialize}; + +use super::native_argument::{control_documentation, ped_flag_documentation}; + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ReferenceKind { + Native, + Control, + PedFlag, +} + +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum KindFilter { + #[default] + All, + Native, + Control, + PedFlag, +} + +impl KindFilter { + fn includes(&self, kind: ReferenceKind) -> bool { + matches!( + (self, kind), + (Self::All, _) + | (Self::Native, ReferenceKind::Native) + | (Self::Control, ReferenceKind::Control) + | (Self::PedFlag, ReferenceKind::PedFlag) + ) + } +} + +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SideFilter { + #[default] + All, + Client, + Server, + Shared, +} + +impl SideFilter { + fn includes(&self, side: &str) -> bool { + match self { + Self::All => true, + Self::Client => matches!(side, "client" | "shared"), + Self::Server => matches!(side, "server" | "shared"), + Self::Shared => side == "shared", + } + } +} + +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct SearchParams { + pub query: String, + pub kind: KindFilter, + pub side: SideFilter, + pub namespace: Option, + pub offset: u64, + pub limit: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReferenceItem { + pub id: String, + pub kind: ReferenceKind, + pub name: &'static str, + pub side: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub hash: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub numeric_id: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchResult { + pub items: Vec, + pub total: usize, + pub offset: usize, + pub limit: usize, + pub namespaces: Vec<&'static str>, +} + +#[derive(Deserialize)] +pub struct DetailParams { + pub id: String, +} + +#[derive(Serialize)] +pub struct Parameter { + pub name: &'static str, + #[serde(rename = "type")] + pub lua_type: &'static str, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReferenceDetail { + #[serde(flatten)] + pub item: ReferenceItem, + #[serde(skip_serializing_if = "Option::is_none")] + pub signature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parameters: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub returns: Option>, + pub documentation: String, + pub source_url: String, + pub copy_text: String, + pub insert_text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub insert_snippet: Option, +} + +struct Entry { + item: ReferenceItem, + fields: Vec, +} + +struct Catalog { + entries: Vec, + /// Canonical names, documented aliases and synthesized N_0x hash spellings all resolve to one row. + native_names: FxHashMap, + namespaces: Vec<&'static str>, +} + +fn native_item(native: Native) -> ReferenceItem { + ReferenceItem { + id: format!("native:{}", native.name), + kind: ReferenceKind::Native, + name: native.name, + side: native.side.label(), + namespace: Some(native.namespace), + hash: Some(native.hash), + numeric_id: None, + } +} + +fn numeric_item(kind: ReferenceKind, id: u32, name: &'static str) -> ReferenceItem { + let prefix = if kind == ReferenceKind::Control { "control" } else { "pedFlag" }; + ReferenceItem { + id: format!("{prefix}:{id}"), + kind, + name, + side: "client", + namespace: None, + hash: None, + numeric_id: Some(id), + } +} + +fn catalog() -> &'static Catalog { + static CATALOG: OnceLock = OnceLock::new(); + CATALOG.get_or_init(|| { + let mut entries = Vec::new(); + let mut native_names = FxHashMap::default(); + let mut namespaces = BTreeSet::new(); + for native in natives().filter(|native| native.alias_of.is_none()) { + let name = native.name.to_ascii_lowercase(); + let hash = native.hash.to_ascii_lowercase(); + let hash_name = format!("n_{hash}"); + native_names.insert(name.clone(), entries.len()); + native_names.insert(hash_name.clone(), entries.len()); + namespaces.insert(native.namespace); + entries.push(Entry { + item: native_item(native), + fields: vec![ + name.replace('_', ""), + name, + hash.trim_start_matches("0x").to_owned(), + hash, + hash_name, + native.namespace.to_ascii_lowercase(), + ], + }); + } + for alias in natives().filter(|native| native.alias_of.is_some()) { + let target = alias.alias_of.unwrap().to_ascii_lowercase(); + if let Some(&index) = native_names.get(&target) { + let name = alias.name.to_ascii_lowercase(); + entries[index].fields.push(name.clone()); + entries[index].fields.push(name.replace('_', "")); + native_names.insert(name, index); + } + } + for control in controls() { + entries.push(Entry { + item: numeric_item(ReferenceKind::Control, control.id, control.name), + fields: vec![ + control.name.to_ascii_lowercase(), + control.name.to_ascii_lowercase().replace('_', ""), + control.id.to_string(), + control.keyboard.to_ascii_lowercase(), + control.controller.to_ascii_lowercase(), + ], + }); + } + for flag in ped_config_flags() { + entries.push(Entry { + item: numeric_item(ReferenceKind::PedFlag, flag.id, flag.name), + fields: vec![ + flag.name.to_ascii_lowercase(), + flag.name.to_ascii_lowercase().replace('_', ""), + flag.id.to_string(), + ], + }); + } + Catalog { entries, native_names, namespaces: namespaces.into_iter().collect() } + }) +} + +fn match_rank(fields: &[String], query: &str, compact_query: &str, tokens: &[&str]) -> Option { + if query.is_empty() { + return Some(0); + } + if fields.iter().any(|field| field == query || (!compact_query.is_empty() && field == compact_query)) { + return Some(0); + } + if !tokens.iter().all(|token| fields.iter().any(|field| field.contains(token))) { + return None; + } + Some(if tokens.iter().all(|token| fields.iter().any(|field| field.starts_with(token))) { 1 } else { 2 }) +} + +pub fn search(params: SearchParams) -> Result { + if params.query.chars().count() > 256 { + return Err("reference query must not exceed 256 characters".into()); + } + if params.namespace.as_ref().is_some_and(|value| value.chars().count() > 64) { + return Err("reference namespace must not exceed 64 characters".into()); + } + let catalog = catalog(); + let query = params.query.trim().to_ascii_lowercase(); + let compact_query = query.replace('_', ""); + let mut tokens: Vec<_> = + query.split(|c: char| c.is_whitespace() || c == '_').filter(|token| !token.is_empty()).collect(); + if tokens.is_empty() && !query.is_empty() { + tokens.push(&query); + } + let namespace = params.namespace.as_deref().unwrap_or("").trim(); + let mut matches: Vec<_> = catalog + .entries + .iter() + .enumerate() + .filter_map(|(index, entry)| { + if !params.kind.includes(entry.item.kind) + || !params.side.includes(entry.item.side) + || (entry.item.kind == ReferenceKind::Native + && !namespace.is_empty() + && !entry.item.namespace.is_some_and(|value| value.eq_ignore_ascii_case(namespace))) + { + return None; + } + Some((match_rank(&entry.fields, &query, &compact_query, &tokens)?, index)) + }) + .collect(); + // Catalog order breaks ties: native name order first, followed by controls/flags in numeric ID order. + matches.sort_unstable(); + let total = matches.len(); + let offset = usize::try_from(params.offset).unwrap_or(usize::MAX).min(total); + let limit = params.limit.unwrap_or(50).clamp(1, 100) as usize; + let items = + matches.iter().skip(offset).take(limit).map(|(_, index)| catalog.entries[*index].item.clone()).collect(); + Ok(SearchResult { items, total, offset, limit, namespaces: catalog.namespaces.clone() }) +} + +fn snippet_escape(text: &str) -> String { + text.replace('\\', "\\\\").replace('$', "\\$").replace('}', "\\}") +} + +pub fn detail(params: DetailParams) -> Result, String> { + if params.id.chars().count() > 512 { + return Err("reference ID must not exceed 512 characters".into()); + } + let Some((kind, value)) = params.id.split_once(':') else { return Ok(None) }; + let item = match kind { + "native" => { + catalog().native_names.get(&value.to_ascii_lowercase()).map(|&index| catalog().entries[index].item.clone()) + } + "control" | "pedFlag" => { + // Stable numeric IDs use canonical decimal spelling, not signed or zero-padded aliases. + let number = value.parse::().ok().filter(|id| id.to_string() == value); + number.and_then(|id| { + if kind == "control" { + qbx_fivem_data::control(id).map(|row| numeric_item(ReferenceKind::Control, id, row.name)) + } else { + qbx_fivem_data::ped_config_flag(id).map(|row| numeric_item(ReferenceKind::PedFlag, id, row.name)) + } + }) + } + _ => None, + }; + let Some(item) = item else { return Ok(None) }; + let mut detail = ReferenceDetail { + item, + signature: None, + parameters: None, + returns: None, + documentation: String::new(), + source_url: String::new(), + copy_text: String::new(), + insert_text: String::new(), + insert_snippet: None, + }; + if detail.item.kind == ReferenceKind::Native { + let native = native(detail.item.name).expect("catalog comes from bundled native metadata"); + let params: Vec<_> = native.params().collect(); + detail.signature = Some(native.signature()); + detail.parameters = Some(params.iter().map(|&(name, lua_type)| Parameter { name, lua_type }).collect()); + detail.returns = Some(native.returns().collect()); + detail.documentation = native_docs(native.name) + .filter(|doc| !doc.trim().is_empty()) + .unwrap_or_else(|| "No description is documented in the bundled Cfx native reference.".into()); + detail.source_url = format!("https://docs.fivem.net/natives/?_{}", native.hash); + detail.copy_text = native.name.into(); + detail.insert_text = + format!("{}({})", native.name, params.iter().map(|(name, _)| *name).collect::>().join(", ")); + detail.insert_snippet = Some(format!( + "{}({})$0", + snippet_escape(native.name), + params + .iter() + .enumerate() + .map(|(index, (name, _))| format!("${{{}:{}}}", index + 1, snippet_escape(name))) + .collect::>() + .join(", ") + )); + } else { + let id = detail.item.numeric_id.expect("numeric catalog entries have IDs"); + let (documentation, url) = if detail.item.kind == ReferenceKind::Control { + (control_documentation(id), CONTROLS_SOURCE_URL) + } else { + (ped_flag_documentation(id), PED_CONFIG_FLAGS_SOURCE_URL) + }; + detail.documentation = documentation.expect("catalog comes from bundled reference metadata"); + detail.source_url = url.into(); + detail.copy_text = id.to_string(); + detail.insert_text = id.to_string(); + } + Ok(Some(detail)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn placeholder_defaults_escape_vscode_snippet_metacharacters() { + assert_eq!(snippet_escape(r"a$}\b"), r"a\$\}\\b"); + } +} diff --git a/crates/qbx_lua_ls/src/features/references.rs b/crates/qbx_lua_ls/src/features/references.rs new file mode 100644 index 0000000..45ab9bc --- /dev/null +++ b/crates/qbx_lua_ls/src/features/references.rs @@ -0,0 +1,263 @@ +use std::collections::HashMap; + +use lsp_types::{ + DocumentHighlight, DocumentHighlightKind, Location, Position, PrepareRenameResponse, TextEdit, Url, WorkspaceEdit, +}; +use qbx_lua_analysis::project::read_source; +use qbx_lua_analysis::scope::{resolve, GlobalRefKind, Resolved}; +use qbx_lua_syntax::{parse, LineIndex, Span}; + +use super::assistant::{InspectionBudget, InspectionPositions}; +use super::member_refs::{in_document, is_renamable, member_occurrences, member_target}; +use crate::document::Document; +use crate::index::FileOrigin; +use crate::indexer::span_to_range; +use crate::server::Documents; +use crate::workspace::Workspace; + +struct Occurrence { + span: Span, + write: bool, +} + +fn local_occurrences(doc: &Document, offset: u32) -> Option> { + let (Resolved::Local(id), _) = doc.resolution.resolved_at_offset(offset)? else { return None }; + let local = doc.resolution.local(id); + let mut out = Vec::new(); + if !local.decl.is_empty() { + out.push(Occurrence { span: local.decl, write: true }); + } + out.extend(local.refs.iter().map(|r| Occurrence { span: r.span, write: r.write })); + Some(out) +} + +fn global_name_at(doc: &Document, offset: u32) -> Option<&str> { + match doc.resolution.resolved_at_offset(offset)? { + (Resolved::Global(index), _) => Some(doc.resolution.globals[index as usize].name.as_str()), + _ => None, + } +} + +/// Every occurrence of a global across the files that can see it. Closed files are parsed on demand +/// instead of keeping their reference lists in memory. +fn global_occurrences( + ws: &Workspace, + docs: &Documents, + doc: &Document, + name: &str, +) -> Vec<(Url, lsp_types::Range, bool)> { + global_occurrences_inner(ws, docs, doc, name, None) +} + +fn global_occurrences_inner( + ws: &Workspace, + docs: &Documents, + doc: &Document, + name: &str, + mut budget: Option<&mut InspectionBudget>, +) -> Vec<(Url, lsp_types::Range, bool)> { + let mut out = Vec::new(); + for (id, entry) in ws.index.files() { + if entry.origin == FileOrigin::Stub || !ws.index.is_related(doc.file, id) { + continue; + } + let mentions = |text: &str| text.contains(name); + if budget.is_some() && out.len() >= 20_000 { + budget.as_deref_mut().unwrap().result_limit = true; + break; + } + if let Some(open) = (budget.is_some() && entry.uri == doc.uri).then_some(doc).or_else(|| docs.get(&entry.uri)) { + if budget.as_deref_mut().is_some_and(|budget| !budget.claim(&entry.path, open.text.len())) { + continue; + } + let positions = budget.as_ref().map(|_| InspectionPositions::new(&open.text)); + for global in open.resolution.globals.iter().filter(|g| g.name == name) { + if budget.is_some() && out.len() >= 20_000 { + budget.as_deref_mut().unwrap().result_limit = true; + break; + } + let range = positions + .as_ref() + .map_or_else(|| open.range(global.span), |positions| positions.range(global.span)); + out.push((entry.uri.clone(), range, global.kind != GlobalRefKind::Read)); + } + continue; + } + let source = match budget.as_deref_mut() { + Some(budget) => budget.read(&entry.path), + None => read_source(&entry.path).ok(), + }; + let Some(source) = source else { continue }; + if !mentions(&source) { + continue; + } + let resolution = resolve(&parse(&source)); + let lines = LineIndex::new(&source); + let positions = budget.as_ref().map(|_| InspectionPositions::new(&source)); + for global in resolution.globals.iter().filter(|g| g.name == name) { + if budget.is_some() && out.len() >= 20_000 { + budget.as_deref_mut().unwrap().result_limit = true; + break; + } + out.push(( + entry.uri.clone(), + positions.as_ref().map_or_else( + || span_to_range(&source, &lines, global.span), + |positions| positions.range(global.span), + ), + global.kind != GlobalRefKind::Read, + )); + } + } + out +} + +/// Agent-only variant: all related closed files share the request's source/result budget. +pub(crate) fn references_bounded( + ws: &Workspace, + docs: &Documents, + doc: &Document, + position: Position, + include_declaration: bool, + budget: &mut InspectionBudget, +) -> Vec { + let offset = doc.offset(position); + if let Some((Resolved::Local(id), _)) = doc.resolution.resolved_at_offset(offset) { + let local = doc.resolution.local(id); + let mut out = Vec::new(); + let positions = InspectionPositions::new(&doc.text); + if include_declaration && !local.decl.is_empty() { + out.push(Location::new(doc.uri.clone(), positions.range(local.decl))); + } + for reference in &local.refs { + if out.len() >= 20_000 { + budget.result_limit = true; + break; + } + out.push(Location::new(doc.uri.clone(), positions.range(reference.span))); + } + return out; + } + let Some(name) = global_name_at(doc, offset) else { + let Some(target) = member_target(ws, doc, offset) else { return Vec::new() }; + return super::member_refs::member_occurrences_bounded(ws, docs, doc, &target, budget) + .into_iter() + .map(|(uri, range)| Location::new(uri, range)) + .collect(); + }; + global_occurrences_inner(ws, docs, doc, name, Some(budget)) + .into_iter() + .filter(|(_, _, declaration)| include_declaration || !declaration) + .map(|(uri, range, _)| Location::new(uri, range)) + .collect() +} + +pub fn references( + ws: &Workspace, + docs: &Documents, + doc: &Document, + position: Position, + include_declaration: bool, +) -> Vec { + let offset = doc.offset(position); + if let Some(occurrences) = local_occurrences(doc, offset) { + let decl = doc.resolution.resolved_at_offset(offset).and_then(|(r, _)| match r { + Resolved::Local(id) => Some(doc.resolution.local(id).decl), + Resolved::Global(_) => None, + }); + return occurrences + .into_iter() + .filter(|o| include_declaration || Some(o.span) != decl) + .map(|o| Location::new(doc.uri.clone(), doc.range(o.span))) + .collect(); + } + let Some(name) = global_name_at(doc, offset) else { + let Some(target) = member_target(ws, doc, offset) else { return Vec::new() }; + return member_occurrences(ws, docs, doc, &target) + .unwrap_or_default() + .into_iter() + .map(|(uri, range)| Location::new(uri, range)) + .collect(); + }; + global_occurrences(ws, docs, doc, name) + .into_iter() + .filter(|(_, _, is_definition)| include_declaration || !is_definition) + .map(|(uri, range, _)| Location::new(uri, range)) + .collect() +} + +pub fn highlights(ws: &Workspace, doc: &Document, position: Position) -> Vec { + let offset = doc.offset(position); + let kind = |write: bool| Some(if write { DocumentHighlightKind::WRITE } else { DocumentHighlightKind::READ }); + if let Some(occurrences) = local_occurrences(doc, offset) { + return occurrences + .into_iter() + .map(|o| DocumentHighlight { range: doc.range(o.span), kind: kind(o.write) }) + .collect(); + } + let Some(name) = global_name_at(doc, offset) else { + let Some(target) = member_target(ws, doc, offset) else { return Vec::new() }; + return in_document(ws, doc, &target) + .into_iter() + .map(|range| DocumentHighlight { range, kind: Some(DocumentHighlightKind::TEXT) }) + .collect(); + }; + doc.resolution + .globals + .iter() + .filter(|g| g.name == name) + .map(|g| DocumentHighlight { range: doc.range(g.span), kind: kind(g.kind != GlobalRefKind::Read) }) + .collect() +} + +pub fn prepare_rename(ws: &Workspace, doc: &Document, position: Position) -> Option { + let offset = doc.offset(position); + let Some((resolved, span)) = doc.resolution.resolved_at_offset(offset) else { + let target = member_target(ws, doc, offset).filter(|t| is_renamable(ws, t))?; + let here = in_document(ws, doc, &target).into_iter().find(|r| r.start <= position && position <= r.end)?; + return Some(PrepareRenameResponse::Range(here)); + }; + if span.is_empty() { + return None; + } + if let Resolved::Global(index) = resolved { + let name = &doc.resolution.globals[index as usize].name; + let is_runtime = + qbx_lua_analysis::env::builtins().get(name).is_some() || qbx_fivem_data::native(name).is_some(); + if is_runtime { + return None; + } + } + Some(PrepareRenameResponse::Range(doc.range(span))) +} + +pub fn rename( + ws: &Workspace, + docs: &Documents, + doc: &Document, + position: Position, + new_name: &str, +) -> Option { + let valid = new_name.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') + && new_name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + && qbx_lua_syntax::lexer::keyword(new_name).is_none(); + if !valid { + return None; + } + let offset = doc.offset(position); + let mut changes: HashMap> = HashMap::new(); + if let Some(occurrences) = local_occurrences(doc, offset) { + let edits = occurrences.into_iter().map(|o| TextEdit::new(doc.range(o.span), new_name.to_string())).collect(); + changes.insert(doc.uri.clone(), edits); + } else if let Some(name) = global_name_at(doc, offset) { + for (uri, range, _) in global_occurrences(ws, docs, doc, name) { + changes.entry(uri).or_default().push(TextEdit::new(range, new_name.to_string())); + } + } else { + let target = member_target(ws, doc, offset).filter(|t| is_renamable(ws, t))?; + for (uri, range) in member_occurrences(ws, docs, doc, &target)? { + changes.entry(uri).or_default().push(TextEdit::new(range, new_name.to_string())); + } + } + Some(WorkspaceEdit { changes: Some(changes), ..WorkspaceEdit::default() }) +} diff --git a/crates/qbx_lua_ls/src/features/resource_assets.rs b/crates/qbx_lua_ls/src/features/resource_assets.rs new file mode 100644 index 0000000..53a3788 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/resource_assets.rs @@ -0,0 +1,402 @@ +//! Bounded source metadata for the on-demand asset browser. Asset bytes stay in the editor. +use std::collections::BTreeMap; +use std::io::Read; +use std::path::Path; + +use lsp_types::Location; +use qbx_lua_analysis::project::{is_manifest_file, is_not_source}; +use qbx_lua_analysis::scope::Resolved; +use qbx_lua_syntax::ast::{Expr, ExprKind, StmtKind, TableField, UnOp}; +use qbx_lua_syntax::lexer::NumberValue; +use qbx_lua_syntax::visit::{walk_expr, Visitor}; +use serde::Serialize; + +use super::resource_details::{identity, normalized, selected_resource, DetailsParams, ResourceIdentity}; +use crate::document::Document; +use crate::framework_callbacks::global_field_redefined; +use crate::index::{FileOrigin, Index}; +use crate::infer::FileContext; +use crate::server::Documents; +use crate::workspace::path_to_uri; + +const DECLARATIONS: usize = 1000; +const REFERENCES: usize = 2000; +const VALUE_BYTES: usize = 4096; +pub(crate) const SOURCE_BYTES: usize = 2 * 1024 * 1024; +const TOTAL_BYTES: usize = 32 * 1024 * 1024; +const FILES: usize = 2000; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetDeclaration { + pub kind: &'static str, + pub value: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data_type: Option, + pub location: Location, +} + +#[derive(Debug, Serialize)] +pub struct AssetReference { + pub kind: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dictionary: Option, + pub location: Location, +} + +#[derive(Debug, Default, Serialize)] +pub struct Omitted { + pub declarations: usize, + pub references: usize, +} + +#[derive(Debug, Serialize)] +pub struct ResourceAssets { + pub resource: ResourceIdentity, + pub declarations: Vec, + pub references: Vec, + pub truncated: Omitted, + pub notes: Vec, +} + +/// Prevent growth races and avoid parsing escrow/binary files as editable Lua. +pub(crate) fn read_bounded_source(path: &Path) -> Result { + let file = std::fs::File::open(path).map_err(|_| "Source file is unavailable.")?; + let stat = file.metadata().map_err(|_| "Source file is unavailable.")?; + if !stat.is_file() || stat.len() > SOURCE_BYTES as u64 { + return Err("Source exceeds the 2 MiB inspection limit.".into()); + } + let mut bytes = Vec::new(); + file.take(SOURCE_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(|_| "Source file is unavailable.")?; + if bytes.len() > SOURCE_BYTES { + return Err("Source exceeds the 2 MiB inspection limit.".into()); + } + if is_not_source(&bytes) { + return Err("Encrypted or binary source is not readable.".into()); + } + String::from_utf8(bytes).map_err(|_| "Source is not valid UTF-8.".into()) +} + +fn unparen(mut expr: &Expr) -> &Expr { + while let ExprKind::Paren(inner) = &expr.kind { + expr = inner; + } + expr +} + +fn strings<'a>(expr: &'a Expr, result: &mut Vec<&'a Expr>) { + match &unparen(expr).kind { + ExprKind::String(_) => result.push(unparen(expr)), + ExprKind::Table(fields) => { + for field in fields { + if let TableField::Positional(value) = field { + if matches!(unparen(value).kind, ExprKind::String(_)) { + result.push(unparen(value)); + } + } + } + } + _ => {} + } +} + +fn manifest(doc: &Document, result: &mut ResourceAssets) { + for stmt in &doc.chunk.block.stmts { + let StmtKind::Expr(first_expr) = &stmt.kind else { continue }; + let mut expr = first_expr; + let mut groups = Vec::new(); + let name = loop { + match &expr.kind { + ExprKind::Call { callee, args, .. } => { + groups.push(args.as_slice()); + expr = callee; + } + ExprKind::Name(name) => break name.text.as_str(), + _ => break "", + } + }; + groups.reverse(); + let kind = match name { + "file" | "files" => "file", + "client_script" | "client_scripts" => "client_script", + "server_script" | "server_scripts" => "server_script", + "shared_script" | "shared_scripts" => "shared_script", + "ui_page" => "ui_page", + "loadscreen" => "loadscreen", + "data_file" => "data_file", + "map" => "map", + _ => continue, + }; + let Some(first) = groups.first() else { continue }; + let (args, data_type) = if kind == "data_file" { + let Some(Expr { kind: ExprKind::String(ty), .. }) = first.first() else { continue }; + if ty.len() > VALUE_BYTES { + result.truncated.declarations += 1; + continue; + } + let paths = if first.len() > 1 { &first[1..] } else { groups.get(1).copied().unwrap_or_default() }; + (paths, Some(ty.to_string())) + } else { + (*first, None) + }; + for arg in args { + let mut values = Vec::new(); + strings(arg, &mut values); + for value in values { + let ExprKind::String(text) = &value.kind else { continue }; + if text.len() > VALUE_BYTES || result.declarations.len() >= DECLARATIONS { + result.truncated.declarations += 1; + continue; + } + result.declarations.push(AssetDeclaration { + kind, + value: text.to_string(), + data_type: data_type.clone(), + location: Location::new(doc.uri.clone(), doc.range(value.span)), + }); + } + } + } +} + +fn argument(name: &str) -> Option<(&'static str, usize)> { + Some(match name { + "RequestModel" + | "HasModelLoaded" + | "SetModelAsNoLongerNeeded" + | "IsModelValid" + | "IsModelInCdimage" + | "CreateObject" + | "CreateObjectNoOffset" + | "CreateVehicle" => ("model", 0), + "CreatePed" => ("model", 1), + "CreatePedInsideVehicle" => ("model", 2), + "RequestStreamedTextureDict" + | "HasStreamedTextureDictLoaded" + | "SetStreamedTextureDictAsNoLongerNeeded" + | "DrawSprite" => ("textureDictionary", 0), + "RequestNamedPtfxAsset" + | "HasNamedPtfxAssetLoaded" + | "RemoveNamedPtfxAsset" + | "UseParticleFxAsset" + | "UseParticleFxAssetNextCall" => ("particleAsset", 0), + "RequestScriptAudioBank" | "RequestMissionAudioBank" | "RequestAmbientAudioBank" => ("audioBank", 0), + _ => return None, + }) +} + +fn ascii_hash(value: &str) -> Option { + if !value.is_ascii() || value.contains('\0') { + return None; + } + let mut hash = 0u32; + for byte in value.bytes() { + hash = hash.wrapping_add(u32::from(byte.to_ascii_lowercase())); + hash = hash.wrapping_add(hash << 10); + hash ^= hash >> 6; + } + hash = hash.wrapping_add(hash << 3); + hash ^= hash >> 11; + Some(hash.wrapping_add(hash << 15)) +} + +struct References<'a, 'b> { + doc: &'a Document, + ctx: FileContext<'a>, + index: &'a Index, + global_cache: BTreeMap, + result: &'b mut ResourceAssets, +} + +impl References<'_, '_> { + fn global(&mut self, expr: &Expr) -> Option { + let ExprKind::Name(name) = &unparen(expr).kind else { return None }; + if !matches!(self.doc.resolution.resolve_at(name.span.start), Some(Resolved::Global(_))) + || self.doc.resolution.lookup_local_at("_ENV", name.span.start).is_some() + { + return None; + } + let available = if let Some(available) = self.global_cache.get(name.text.as_str()) { + *available + } else { + let modified = |target: &str| { + self.doc.resolution.globals.iter().any(|global| global.name == target && global.is_definition()) + || self + .index + .globals_named(target, self.doc.file) + .iter() + .any(|(id, _)| self.index.file(*id).is_some_and(|entry| entry.origin != FileOrigin::Stub)) + || global_field_redefined(&self.ctx, target) + }; + let available = !modified("_ENV") && !modified(&name.text); + self.global_cache.insert(name.text.to_string(), available); + available + }; + available.then(|| name.text.to_string()) + } + + fn value(&mut self, expr: &Expr, model: bool) -> Option<(Option, Option)> { + let expr = unparen(expr); + match &expr.kind { + ExprKind::String(value) => Some((Some(value.to_string()), model.then(|| ascii_hash(value)).flatten())), + ExprKind::JenkinsHash(value) if model => Some((Some(value.to_string()), ascii_hash(value))), + ExprKind::Number(NumberValue::Int(value)) if model && (-2_147_483_648..=4_294_967_295).contains(value) => { + Some((None, Some(*value as u32))) + } + ExprKind::Unary { op: UnOp::Neg, expr } if model => { + if let ExprKind::Number(NumberValue::Int(value)) = unparen(expr).kind { + if (0..=2_147_483_648).contains(&value) { + return Some((None, Some((-value) as u32))); + } + } + None + } + ExprKind::Call { callee, args, .. } if model && args.len() == 1 => { + let name = self.global(callee)?; + if name == "GetHashKey" || name == "joaat" { + if let ExprKind::String(value) = &unparen(&args[0]).kind { + Some((Some(value.to_string()), ascii_hash(value))) + } else { + None + } + } else { + None + } + } + _ => None, + } + } + + fn add(&mut self, kind: &'static str, expr: &Expr, dictionary: Option) { + let Some((value, hash)) = self.value(expr, kind == "model") else { return }; + if value.as_ref().is_some_and(|value| value.is_empty()) { + return; + } + if self.result.references.len() >= REFERENCES + || value.as_ref().is_some_and(|value| value.len() > VALUE_BYTES) + || dictionary.as_ref().is_some_and(|value| value.len() > VALUE_BYTES) + { + self.result.truncated.references += 1; + return; + } + self.result.references.push(AssetReference { + kind, + value, + hash, + dictionary, + location: Location::new(self.doc.uri.clone(), self.doc.range(expr.span)), + }); + } +} + +impl<'ast> Visitor<'ast> for References<'_, '_> { + fn visit_expr(&mut self, expr: &'ast Expr) { + if let ExprKind::Call { callee, args, .. } = &expr.kind { + if let ExprKind::Name(name) = &unparen(callee).kind { + if let Some((kind, position)) = argument(&name.text) { + if self.global(callee).is_some() { + if let Some(value) = args.get(position) { + self.add(kind, value, None); + } + if name.text == "DrawSprite" && args.len() >= 2 { + let dictionary = args[0].as_string().map(ToString::to_string); + self.add("texture", &args[1], dictionary); + } + } + } + } + } + walk_expr(self, expr); + } +} + +pub fn resource(index: &Index, docs: &Documents, params: DetailsParams) -> Result { + let selected = selected_resource(index, params)?; + let root = normalized(&selected.root); + let mut result = ResourceAssets { resource: identity(selected), declarations: Vec::new(), references: Vec::new(), + truncated: Omitted::default(), notes: vec![ + "Literal manifest paths and supported native asset arguments are shown; computed paths, aliases and runtime registrations may be absent.".into(), + "Unresolved Lua asset names may belong to the base game or another resource. They are not proof of a missing file.".into(), + ] }; + let manifest_uri = path_to_uri(&selected.manifest_path); + let manifest_owned; + let manifest_doc = if let Some(doc) = docs.get(&manifest_uri) { + doc + } else { + manifest_owned = Document::new( + manifest_uri, + selected.manifest_path.clone(), + 0, + read_bounded_source(&selected.manifest_path)?, + ); + &manifest_owned + }; + if manifest_doc.text.len() > SOURCE_BYTES { + return Err("Manifest exceeds the 2 MiB inspection limit.".into()); + } + manifest(manifest_doc, &mut result); + let mut files: Vec<_> = index + .files() + .filter(|(_, file)| { + file.origin != FileOrigin::Stub + && !is_manifest_file(&file.path) + && file.resource.and_then(|id| index.resource(id)).is_some_and(|owner| normalized(&owner.root) == root) + }) + .collect(); + files.sort_by(|(_, a), (_, b)| a.uri.as_str().cmp(b.uri.as_str())); + let mut skipped = files.len().saturating_sub(FILES); + let candidates = files.len().min(FILES); + let mut bytes = manifest_doc.text.len(); + for (slot, (id, file)) in files.into_iter().take(FILES).enumerate() { + let owned; + let doc = if let Some(doc) = docs.get(&file.uri) { + doc + } else { + let Ok(text) = read_bounded_source(&file.path) else { + skipped += 1; + continue; + }; + if bytes + text.len() > TOTAL_BYTES { + skipped += candidates - slot; + break; + } + owned = { + let mut doc = Document::new(file.uri.clone(), file.path.clone(), 0, text); + doc.file = id; + doc + }; + &owned + }; + if doc.text.len() > SOURCE_BYTES { + skipped += 1; + continue; + } + if bytes + doc.text.len() > TOTAL_BYTES { + skipped += candidates - slot; + break; + } + bytes += doc.text.len(); + let ctx = FileContext::new(id, &doc.text, &doc.chunk, &doc.resolution); + References { doc, ctx, index, global_cache: BTreeMap::new(), result: &mut result } + .visit_block(&doc.chunk.block); + } + result.references.sort_by(|a, b| { + (a.location.uri.as_str(), a.location.range.start.line, a.location.range.start.character, a.kind).cmp(&( + b.location.uri.as_str(), + b.location.range.start.line, + b.location.range.start.character, + b.kind, + )) + }); + if skipped > 0 { + result.notes.push(format!("{skipped} Lua files were not inspected because they were unreadable or exceeded inspection limits (2,000 files, 2 MiB per file, 32 MiB total).")); + } + if result.truncated.declarations + result.truncated.references > 0 { + result.notes.push("Some rows were omitted to keep results bounded (1,000 declarations, 2,000 references and 4 KiB per value).".into()); + } + Ok(result) +} diff --git a/crates/qbx_lua_ls/src/features/resource_details.rs b/crates/qbx_lua_ls/src/features/resource_details.rs new file mode 100644 index 0000000..5bfbfd1 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/resource_details.rs @@ -0,0 +1,393 @@ +//! Bounded, read-only resource summaries from the existing workspace index. +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Component, Path, PathBuf}; + +use lsp_types::{Location, Url}; +use qbx_fivem_data::Side; +use qbx_lua_analysis::project::{is_manifest_file, split_import}; +use serde::{Deserialize, Serialize}; + +use crate::index::{normalize_path, EventFamily, EventKind, FileOrigin, Index, ResourceEntry}; +use crate::workspace::{path_to_uri, uri_to_path}; + +const SYMBOL_LIMIT: usize = 500; +const RELATION_LIMIT: usize = 200; +const TARGET_LIMIT: usize = 20; +const CONSTRAINT_LIMIT: usize = 200; + +pub(super) fn display_text(value: &str, limit: usize, shortened: &mut bool) -> String { + if value.chars().take(limit + 1).count() > limit { + *shortened = true; + value.chars().take(limit - 1).chain(std::iter::once('…')).collect() + } else { + value.to_owned() + } +} + +#[derive(Deserialize)] +pub struct DetailsParams { + pub uri: Url, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourceIdentity { + pub name: String, + pub uri: Url, + pub manifest_uri: Url, +} + +#[derive(Debug, Serialize)] +pub struct ResourceSymbol { + pub name: String, + pub kind: &'static str, + pub side: &'static str, + pub location: Location, + #[serde(skip_serializing_if = "Option::is_none")] + pub signature: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourceRelation { + pub name: String, + pub kinds: Vec<&'static str>, + pub status: &'static str, + pub targets: Vec, + pub target_count: usize, +} + +#[derive(Debug, Default, Serialize)] +pub struct FileCounts { + pub total: usize, + pub client: usize, + pub server: usize, + pub shared: usize, + pub module: usize, +} + +#[derive(Debug, Serialize)] +pub struct SymbolCounts { + pub events: usize, + pub exports: usize, +} + +#[derive(Debug, Serialize)] +pub struct OmittedCounts { + pub events: usize, + pub exports: usize, + pub dependencies: usize, + pub dependents: usize, +} + +#[derive(Debug, Serialize)] +pub struct ResourceDetails { + pub resource: ResourceIdentity, + pub files: FileCounts, + pub counts: SymbolCounts, + pub events: Vec, + pub exports: Vec, + pub dependencies: Vec, + pub dependents: Vec, + pub constraints: Vec, + pub notes: Vec, + pub truncated: OmittedCounts, +} + +pub(super) fn normalized(path: &Path) -> PathBuf { + let mut result = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + result.pop(); + } + other => result.push(other.as_os_str()), + } + } + normalize_path(&result) +} + +pub(super) fn identity(resource: &ResourceEntry) -> ResourceIdentity { + ResourceIdentity { + name: resource.name.to_string(), + uri: path_to_uri(&resource.root), + manifest_uri: path_to_uri(&resource.manifest_path), + } +} + +#[derive(Default)] +pub(super) struct References { + pub(super) names: BTreeMap)>, + pub(super) constraints: BTreeSet, + pub(super) malformed: usize, +} + +impl References { + fn add(&mut self, name: &str, kind: &'static str) { + if name.is_empty() { + self.malformed += 1; + return; + } + let entry = self.names.entry(name.to_ascii_lowercase()).or_insert_with(|| (name.to_owned(), BTreeSet::new())); + entry.1.insert(kind); + } +} + +pub(super) fn references(resource: &ResourceEntry) -> References { + let mut result = References::default(); + for dependency in &resource.manifest.dependencies { + if dependency.value.starts_with('/') { + result.constraints.insert(dependency.value.to_string()); + } else { + result.add(&dependency.value, "dependency"); + } + } + for import in resource.manifest.imports() { + if let Some((resource, _)) = split_import(&import.pattern) { + result.add(resource, "import"); + } else { + result.malformed += 1; + } + } + result +} + +fn symbol_order(a: &ResourceSymbol, b: &ResourceSymbol) -> std::cmp::Ordering { + ( + &a.name, + a.kind, + a.side, + a.location.uri.as_str(), + a.location.range.start.line, + a.location.range.start.character, + a.location.range.end.line, + a.location.range.end.character, + ) + .cmp(&( + &b.name, + b.kind, + b.side, + b.location.uri.as_str(), + b.location.range.start.line, + b.location.range.start.character, + b.location.range.end.line, + b.location.range.end.character, + )) +} + +/// Accepts only an exact indexed resource directory or its selected manifest; never probes disk. +pub(super) fn selected_resource(index: &Index, params: DetailsParams) -> Result<&ResourceEntry, String> { + if params.uri.as_str().len() > 16_384 || params.uri.query().is_some() || params.uri.fragment().is_some() { + return Err("Use the file URI of an indexed resource folder or manifest without a query or fragment.".into()); + } + let path = uri_to_path(¶ms.uri) + .filter(|path| path.is_absolute()) + .ok_or("Resource Details requires an absolute file URI.")?; + let path = normalized(&path); + index + .resources + .iter() + .find(|resource| normalized(&resource.root) == path || normalized(&resource.manifest_path) == path) + .ok_or_else(|| { + "This folder or manifest is not an indexed resource. Open its workspace and refresh the index first.".into() + }) +} + +pub fn details(index: &Index, params: DetailsParams) -> Result { + let selected = selected_resource(index, params)?; + let selected_root = normalized(&selected.root); + + // Preserve distinct roots with the same name; first-name lookup would hide ambiguity. + let mut by_name: BTreeMap> = BTreeMap::new(); + for resource in &index.resources { + by_name.entry(resource.name.to_ascii_lowercase()).or_default().insert(normalized(&resource.root), resource); + } + let selected_references = references(selected); + let mut shortened = false; + let mut dependencies = Vec::new(); + let mut hidden_targets = 0; + for (key, (name, kinds)) in &selected_references.names { + let candidates = by_name.get(key); + let target_count = candidates.map_or(0, BTreeMap::len); + let mut targets: Vec<_> = + candidates.into_iter().flat_map(BTreeMap::values).map(|resource| identity(resource)).collect(); + targets.sort_by(|a, b| (&a.name, a.uri.as_str()).cmp(&(&b.name, b.uri.as_str()))); + hidden_targets += targets.len().saturating_sub(TARGET_LIMIT); + targets.truncate(TARGET_LIMIT); + dependencies.push(ResourceRelation { + name: display_text(name, 2048, &mut shortened), + kinds: kinds.iter().copied().collect(), + status: match target_count { + 0 => "missing", + 1 => "resolved", + _ => "ambiguous", + }, + targets, + target_count, + }); + } + let selected_name = selected.name.to_ascii_lowercase(); + let selected_candidates = by_name.get(&selected_name).map_or(0, BTreeMap::len); + let mut dependents_by_root = BTreeMap::new(); + for resource in &index.resources { + let own_root = normalized(&resource.root); + if own_root == selected_root { + continue; + } + let own_references = references(resource); + if let Some((_, kinds)) = own_references.names.get(&selected_name) { + dependents_by_root.insert( + own_root, + ResourceRelation { + name: resource.name.to_string(), + kinds: kinds.iter().copied().collect(), + status: if selected_candidates > 1 { "ambiguous" } else { "resolved" }, + targets: vec![identity(resource)], + target_count: 1, + }, + ); + } + } + let mut dependents: Vec<_> = dependents_by_root.into_values().collect(); + dependents.sort_by(|a, b| (&a.name, a.targets[0].uri.as_str()).cmp(&(&b.name, b.targets[0].uri.as_str()))); + + let mut files = FileCounts::default(); + let mut events = Vec::new(); + let mut exports = Vec::new(); + let mut dynamic_exports = false; + for (_, file) in index.files().filter(|(_, file)| { + file.origin != FileOrigin::Stub + && !is_manifest_file(&file.path) + && file + .resource + .and_then(|id| index.resource(id)) + .is_some_and(|owner| normalized(&owner.root) == selected_root) + }) { + files.total += 1; + match file.side { + Some(Side::Client) => files.client += 1, + Some(Side::Server) => files.server += 1, + Some(Side::Shared) => files.shared += 1, + None => files.module += 1, + } + dynamic_exports |= file.index.dynamic_exports; + for event in file.index.events.iter().filter(|event| event.kind != EventKind::Trigger) { + let kind = match (event.family, event.kind) { + (_, EventKind::NetEvent) => "Network event", + (_, EventKind::Handler) => "Event handler", + (EventFamily::QbCore, _) => "QB-Core callback", + (EventFamily::Esx, _) => "ESX callback", + (EventFamily::OxLib, _) => "ox_lib callback", + _ => "Callback", + }; + events.push(ResourceSymbol { + name: display_text(&event.name, 2048, &mut shortened), + kind, + side: event.side.map_or("unknown", Side::label), + location: Location::new(file.uri.clone(), event.range), + signature: event + .handler + .as_ref() + .map(|handler| display_text(&handler.signature(&event.name), 8192, &mut shortened)), + }); + } + for export in &file.index.exports { + exports.push(ResourceSymbol { + name: display_text(&export.name, 2048, &mut shortened), + kind: "Export", + side: file.side.map_or("unknown", Side::label), + location: Location::new(file.uri.clone(), export.range), + signature: export + .ty + .as_fun() + .map(|function| display_text(&function.signature(&export.name), 8192, &mut shortened)), + }); + } + } + events.sort_by(symbol_order); + exports.sort_by(symbol_order); + let counts = SymbolCounts { events: events.len(), exports: exports.len() }; + let truncated = OmittedCounts { + events: events.len().saturating_sub(SYMBOL_LIMIT), + exports: exports.len().saturating_sub(SYMBOL_LIMIT), + dependencies: dependencies.len().saturating_sub(RELATION_LIMIT), + dependents: dependents.len().saturating_sub(RELATION_LIMIT), + }; + events.truncate(SYMBOL_LIMIT); + exports.truncate(SYMBOL_LIMIT); + dependencies.truncate(RELATION_LIMIT); + dependents.truncate(RELATION_LIMIT); + let constraint_count = selected_references.constraints.len(); + let constraints = selected_references + .constraints + .into_iter() + .take(CONSTRAINT_LIMIT) + .map(|constraint| display_text(&constraint, 2048, &mut shortened)) + .collect(); + let mut notes = vec![ + "This is a local index snapshot, not live server state. Dependencies are matched to indexed resource names; an import match does not verify that its individual file is available.".into(), + "Save manifest changes to refresh their metadata and script sides. Unsaved Lua source changes are reflected after the normal index update.".into(), + "Counts cover owned indexed Lua files and literal registrations. Computed names, excluded, oversized or unreadable files, and registrations created at runtime may be absent.".into(), + ]; + if selected.escrowed { + notes.push( + "This resource contains escrowed or unreadable scripts; the visible symbols may be incomplete.".into(), + ); + } + if selected.manifest.has_non_lua_scripts() { + notes.push("JavaScript and C# scripts are not represented in Lua file or symbol counts.".into()); + } + if dynamic_exports { + notes.push("Some export names are computed at runtime; only literal export registrations are listed.".into()); + } + if selected + .manifest + .directives + .iter() + .any(|directive| matches!(directive.name.as_str(), "export" | "exports" | "server_export" | "server_exports")) + { + notes.push("Manifest export declarations are not included; this list contains export registrations indexed from Lua source.".into()); + } + if index.resources.iter().any(|resource| { + resource.manifest.directives.iter().any(|directive| matches!(directive.name.as_str(), "provide" | "provides")) + }) { + notes.push("The index does not retain provide/provides aliases. Replacement resources can affect dependency resolution beyond these direct name matches.".into()); + } + if selected_references.malformed > 0 { + notes.push(format!( + "{} empty dependency names or malformed import paths were omitted.", + selected_references.malformed + )); + } + if selected_candidates > 1 { + notes.push("Several indexed resources share this name. Inverse dependents are potential matches; their ambiguous status refers to which provider they use.".into()); + } + if hidden_targets > 0 { + notes.push(format!("{hidden_targets} candidate resource folders were omitted from the dependency lists; their counts still show the full totals.")); + } + if constraint_count > CONSTRAINT_LIMIT { + notes.push(format!("{} runtime constraints were omitted.", constraint_count - CONSTRAINT_LIMIT)); + } + if truncated.events + truncated.exports + truncated.dependencies + truncated.dependents > 0 { + notes.push( + "Some list entries were omitted to keep this view small. The counts still show the full indexed totals." + .into(), + ); + } + if shortened { + notes.push("Some long names, signatures or constraints were shortened for display. Source links still open the original declarations.".into()); + } + Ok(ResourceDetails { + resource: identity(selected), + files, + counts, + events, + exports, + dependencies, + dependents, + constraints, + notes, + truncated, + }) +} diff --git a/crates/qbx_lua_ls/src/features/semantic_tokens.rs b/crates/qbx_lua_ls/src/features/semantic_tokens.rs new file mode 100644 index 0000000..61c91fe --- /dev/null +++ b/crates/qbx_lua_ls/src/features/semantic_tokens.rs @@ -0,0 +1,194 @@ +use lsp_types::{SemanticToken, SemanticTokenModifier, SemanticTokenType, SemanticTokens, SemanticTokensLegend}; +use qbx_fivem_data::native; +use qbx_lua_analysis::env::builtins; +use qbx_lua_analysis::scope::{LocalKind, Resolved}; +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::visit::{self, Visitor}; +use qbx_lua_syntax::Span; + +use crate::document::Document; +use crate::workspace::Workspace; + +const TYPE_VARIABLE: u32 = 0; +const TYPE_PARAMETER: u32 = 1; +const TYPE_FUNCTION: u32 = 2; +const TYPE_METHOD: u32 = 3; +const TYPE_PROPERTY: u32 = 4; +const TYPE_NAMESPACE: u32 = 5; + +const MOD_DECLARATION: u32 = 1 << 0; +const MOD_READONLY: u32 = 1 << 1; +const MOD_STATIC: u32 = 1 << 2; +const MOD_DEFAULT_LIBRARY: u32 = 1 << 3; +const MOD_MODIFICATION: u32 = 1 << 4; + +pub fn legend() -> SemanticTokensLegend { + SemanticTokensLegend { + token_types: vec![ + SemanticTokenType::VARIABLE, + SemanticTokenType::PARAMETER, + SemanticTokenType::FUNCTION, + SemanticTokenType::METHOD, + SemanticTokenType::PROPERTY, + SemanticTokenType::NAMESPACE, + ], + token_modifiers: vec![ + SemanticTokenModifier::DECLARATION, + SemanticTokenModifier::READONLY, + SemanticTokenModifier::STATIC, + SemanticTokenModifier::DEFAULT_LIBRARY, + SemanticTokenModifier::MODIFICATION, + ], + } +} + +struct Collector<'a> { + doc: &'a Document, + ws: &'a Workspace, + raw: Vec<(Span, u32, u32)>, +} + +impl Collector<'_> { + fn name(&mut self, name: &Name, called: bool) { + if name.is_missing() { + return; + } + let Some(resolved) = self.doc.resolution.resolve_at(name.span.start) else { return }; + let (ty, modifiers) = match resolved { + Resolved::Local(id) => { + let local = self.doc.resolution.local(id); + let mut modifiers = if local.decl == name.span { MOD_DECLARATION } else { 0 }; + if local.attrib.is_some() || !local.refs.iter().any(|r| r.write) { + modifiers |= MOD_READONLY; + } + let ty = match local.kind { + LocalKind::Param | LocalKind::ImplicitSelf => TYPE_PARAMETER, + LocalKind::LocalFunction => TYPE_FUNCTION, + _ if called => TYPE_FUNCTION, + _ => TYPE_VARIABLE, + }; + (ty, modifiers) + } + Resolved::Global(index) => { + let global = &self.doc.resolution.globals[index as usize]; + let mut modifiers = MOD_STATIC; + if global.is_definition() { + modifiers |= MOD_MODIFICATION; + } + let user_defined = !self.ws.index.globals_named(&name.text, self.doc.file).is_empty() + && builtins().get(&name.text).is_none(); + let is_native = !user_defined && native(&name.text).is_some(); + let is_builtin = builtins().get(&name.text); + if is_native || is_builtin.is_some() { + modifiers |= MOD_DEFAULT_LIBRARY; + } + let is_library_table = is_builtin.is_some_and(|b| !b.fields.is_empty()); + let ty = if is_library_table { + TYPE_NAMESPACE + } else if called || is_native { + TYPE_FUNCTION + } else { + TYPE_VARIABLE + }; + (ty, modifiers) + } + }; + self.raw.push((name.span, ty, modifiers)); + } + + fn callee(&mut self, callee: &Expr) { + match &callee.kind { + ExprKind::Name(name) => self.name(name, true), + ExprKind::Field { name, .. } if !name.is_missing() => self.raw.push((name.span, TYPE_FUNCTION, 0)), + _ => {} + } + } +} + +impl<'ast> Visitor<'ast> for Collector<'_> { + fn visit_func_body(&mut self, func: &'ast FuncBody) { + for param in &func.params { + self.name(param, false); + } + visit::walk_func_body(self, func); + } + + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + match &stmt.kind { + StmtKind::Local { names, .. } => names.iter().for_each(|n| self.name(&n.name, false)), + StmtKind::LocalFunction { name, .. } => self.name(name, true), + StmtKind::Function { name, .. } => { + let is_plain = name.path.is_empty() && name.method.is_none(); + self.name(&name.base, is_plain); + let last = name.method.as_ref().or(name.path.last()); + for segment in &name.path { + let is_last = last.is_some_and(|l| std::ptr::eq(l, segment)); + self.raw.push((segment.span, if is_last { TYPE_FUNCTION } else { TYPE_PROPERTY }, MOD_DECLARATION)); + } + if let Some(method) = &name.method { + self.raw.push((method.span, TYPE_METHOD, MOD_DECLARATION)); + } + } + StmtKind::NumericFor { var, .. } => self.name(var, false), + StmtKind::GenericFor { names, .. } => names.iter().for_each(|n| self.name(n, false)), + _ => {} + } + visit::walk_stmt(self, stmt); + } + + fn visit_expr(&mut self, expr: &'ast Expr) { + match &expr.kind { + ExprKind::Name(name) => self.name(name, false), + ExprKind::Call { callee, args, .. } => { + self.callee(callee); + match &callee.kind { + ExprKind::Name(_) => {} + ExprKind::Field { base, .. } => self.visit_expr(base), + _ => self.visit_expr(callee), + } + args.iter().for_each(|a| self.visit_expr(a)); + return; + } + ExprKind::MethodCall { method, .. } if !method.is_missing() => self.raw.push((method.span, TYPE_METHOD, 0)), + ExprKind::Field { name, .. } if !name.is_missing() => self.raw.push((name.span, TYPE_PROPERTY, 0)), + ExprKind::Table(fields) => { + for field in fields { + if let TableField::Named { name, .. } | TableField::SetMember(name) = field { + self.raw.push((name.span, TYPE_PROPERTY, MOD_DECLARATION)); + } + } + } + _ => {} + } + visit::walk_expr(self, expr); + } +} + +pub fn semantic_tokens(ws: &Workspace, doc: &Document) -> SemanticTokens { + let mut collector = Collector { doc, ws, raw: Vec::new() }; + collector.visit_block(&doc.chunk.block); + collector.raw.sort_by_key(|(span, ..)| span.start); + collector.raw.dedup_by_key(|(span, ..)| span.start); + + let mut data = Vec::with_capacity(collector.raw.len()); + let (mut prev_line, mut prev_col) = (0u32, 0u32); + for (span, token_type, modifiers) in collector.raw { + let start = doc.position(span.start); + let end = doc.position(span.end); + if end.line != start.line || end.character <= start.character { + continue; + } + let delta_line = start.line - prev_line; + let delta_start = if delta_line == 0 { start.character - prev_col } else { start.character }; + data.push(SemanticToken { + delta_line, + delta_start, + length: end.character - start.character, + token_type, + token_modifiers_bitset: modifiers, + }); + prev_line = start.line; + prev_col = start.character; + } + SemanticTokens { result_id: None, data } +} diff --git a/crates/qbx_lua_ls/src/features/signature.rs b/crates/qbx_lua_ls/src/features/signature.rs new file mode 100644 index 0000000..5dfde99 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/signature.rs @@ -0,0 +1,82 @@ +use lsp_types::{Documentation, ParameterInformation, ParameterLabel, Position, SignatureHelp, SignatureInformation}; +use qbx_fivem_data::native_docs; +use qbx_lua_syntax::ast::ExprKind; + +use super::event_call::event_call; +use super::{markdown, with_infer}; +use crate::document::Document; +use crate::locate::locate; +use crate::workspace::Workspace; + +pub fn signature_help(ws: &Workspace, doc: &Document, position: Position) -> Option { + let offset = doc.offset(position); + let located = locate(&doc.chunk, offset); + let site = located.call?; + with_infer(ws, doc, |infer| { + let resolved = infer.callee_fun(site.base, site.method); + let event = site + .method + .is_none() + .then(|| event_call(ws, doc, infer, site.base, site.args, resolved.as_ref().map(|(fun, _)| fun.as_ref()))) + .flatten(); + let (native, member) = resolved.map(|(fun, member)| (Some(fun), member)).unwrap_or_default(); + let fun = event.as_ref().map(|e| e.fun.clone().into()).or(native)?; + let (skip_params, skip_args) = fun.call_offsets(site.method.is_some()); + let params: Vec<_> = fun.params.iter().skip(skip_params).collect(); + + let name = match (site.method, &site.base.kind) { + (Some(method), _) => method.text.to_string(), + (None, _) => site.base.dotted_path().unwrap_or_default(), + }; + let labels: Vec = params.iter().map(|p| p.to_string()).collect(); + let mut label = format!("{name}({})", labels.join(", ")); + if !fun.returns.is_empty() { + let returns: Vec = fun.returns.iter().map(|r| r.to_string()).collect(); + label.push_str(&format!(": {}", returns.join(", "))); + } + + let mut documentation = member.and_then(|m| m.doc).map(|d| d.to_string()); + if documentation.is_none() { + if let ExprKind::Name(global) = &site.base.kind { + documentation = ws + .index + .globals_named(&global.text, doc.file) + .into_iter() + .find_map(|(_, s)| s.doc.as_ref().map(|d| d.to_string())) + .or_else(|| native_docs(&global.text)); + } + } + + if let Some(event) = &event { + let note = format!("Parameters of `{}` as handled in `{}`.", event.event, event.handler_location); + documentation = Some(documentation.map_or(note.clone(), |d| { + format!( + "{note} + +{d}" + ) + })); + } + + let mut active = site.active_argument(&doc.text, offset).saturating_sub(skip_args); + let is_variadic = params.last().is_some_and(|p| p.name == "..."); + if active >= params.len() && is_variadic { + active = params.len() - 1; + } + Some(SignatureHelp { + signatures: vec![SignatureInformation { + label, + documentation: documentation.map(|d| Documentation::MarkupContent(markdown(d))), + parameters: Some( + labels + .into_iter() + .map(|l| ParameterInformation { label: ParameterLabel::Simple(l), documentation: None }) + .collect(), + ), + active_parameter: Some(active as u32), + }], + active_signature: Some(0), + active_parameter: Some(active as u32), + }) + }) +} diff --git a/crates/qbx_lua_ls/src/features/symbols.rs b/crates/qbx_lua_ls/src/features/symbols.rs new file mode 100644 index 0000000..81e0a17 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/symbols.rs @@ -0,0 +1,214 @@ +use lsp_types::{DocumentSymbol, Location, SymbolInformation, SymbolKind as LspKind}; +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::Span; + +use crate::document::Document; +use crate::index::{FileOrigin, SymbolKind}; +use crate::workspace::Workspace; + +const MAX_WORKSPACE_SYMBOLS: usize = 300; + +#[allow(deprecated)] +fn symbol( + doc: &Document, + name: String, + detail: Option, + kind: LspKind, + full: Span, + selection: Span, + children: Vec, +) -> DocumentSymbol { + let selection = if full.contains_span(selection) { selection } else { full }; + DocumentSymbol { + name: if name.is_empty() { "".into() } else { name }, + detail, + kind, + tags: None, + deprecated: None, + range: doc.range(full), + selection_range: doc.range(selection), + children: (!children.is_empty()).then_some(children), + } +} + +fn params_detail(func: &FuncBody) -> String { + let mut params: Vec<&str> = func.params.iter().map(|p| p.text.as_str()).collect(); + if func.vararg.is_some() { + params.push("..."); + } + format!("({})", params.join(", ")) +} + +fn block_symbols(doc: &Document, block: &Block, out: &mut Vec) { + for stmt in &block.stmts { + match &stmt.kind { + StmtKind::Function { name, func } => { + let mut label = name.base.text.to_string(); + for segment in &name.path { + label.push('.'); + label.push_str(&segment.text); + } + if let Some(method) = &name.method { + label.push(':'); + label.push_str(&method.text); + } + let kind = if name.method.is_some() { LspKind::METHOD } else { LspKind::FUNCTION }; + let mut children = Vec::new(); + block_symbols(doc, &func.body, &mut children); + out.push(symbol(doc, label, Some(params_detail(func)), kind, stmt.span, name.span, children)); + } + StmtKind::LocalFunction { name, func } => { + let mut children = Vec::new(); + block_symbols(doc, &func.body, &mut children); + out.push(symbol( + doc, + name.text.to_string(), + Some(params_detail(func)), + LspKind::FUNCTION, + stmt.span, + name.span, + children, + )); + } + StmtKind::Local { names, exprs, .. } => { + for (i, name) in names.iter().enumerate() { + out.push(value_symbol(doc, name.name.text.to_string(), name.name.span, stmt.span, exprs.get(i))); + } + } + StmtKind::Assign { targets, exprs } => { + for (i, target) in targets.iter().enumerate() { + if let Some(path) = target.dotted_path() { + out.push(value_symbol(doc, path, target.span, stmt.span, exprs.get(i))); + } + } + } + StmtKind::Expr(expr) => call_symbols(doc, expr, stmt.span, out), + StmtKind::Do(body) | StmtKind::While { body, .. } | StmtKind::Repeat { body, .. } => { + block_symbols(doc, body, out) + } + StmtKind::NumericFor { body, .. } | StmtKind::GenericFor { body, .. } => block_symbols(doc, body, out), + StmtKind::If { branches, else_block } => { + branches.iter().for_each(|b| block_symbols(doc, &b.block, out)); + if let Some(block) = else_block { + block_symbols(doc, block, out); + } + } + _ => {} + } + } +} + +fn value_symbol(doc: &Document, name: String, selection: Span, full: Span, value: Option<&Expr>) -> DocumentSymbol { + let mut children = Vec::new(); + let (kind, detail) = match value.map(|v| &v.kind) { + Some(ExprKind::Function(func)) => { + block_symbols(doc, &func.body, &mut children); + (LspKind::FUNCTION, Some(params_detail(func))) + } + Some(ExprKind::Table(fields)) => { + for field in fields.iter().take(200) { + if let TableField::Named { name, value } = field { + children.push(value_symbol( + doc, + name.text.to_string(), + name.span, + name.span.to(value.span), + Some(value), + )); + } + } + (LspKind::OBJECT, None) + } + Some(ExprKind::String(_)) => (LspKind::STRING, None), + Some(ExprKind::Number(_)) => (LspKind::NUMBER, None), + Some(ExprKind::True | ExprKind::False) => (LspKind::BOOLEAN, None), + _ => (LspKind::VARIABLE, None), + }; + symbol(doc, name, detail, kind, full, selection, children) +} + +/// Event handlers, threads, commands and exports are the landmarks of a FiveM script, so they +/// show up in the outline even though they are plain calls. +fn call_symbols(doc: &Document, expr: &Expr, full: Span, out: &mut Vec) { + let ExprKind::Call { callee, args, .. } = &expr.kind else { return }; + let Some(path) = callee.dotted_path() else { return }; + let label = match path.as_str() { + "RegisterNetEvent" + | "AddEventHandler" + | "RegisterServerEvent" + | "RegisterCommand" + | "RegisterNUICallback" + | "exports" + | "lib.callback.register" => { + let Some(name) = args.first().and_then(|a| a.as_string()) else { return }; + format!("{path} '{name}'") + } + "CreateThread" | "Citizen.CreateThread" | "SetTimeout" => path.clone(), + _ => return, + }; + let mut children = Vec::new(); + for arg in args { + if let ExprKind::Function(func) = &arg.kind { + block_symbols(doc, &func.body, &mut children); + } + } + out.push(symbol(doc, label, None, LspKind::EVENT, full, callee.span, children)); +} + +pub fn document_symbols(doc: &Document) -> Vec { + let mut out = Vec::new(); + block_symbols(doc, &doc.chunk.block, &mut out); + out +} + +#[allow(deprecated)] +pub fn workspace_symbols(ws: &Workspace, query: &str) -> Vec { + let query = query.to_ascii_lowercase(); + let matches = |name: &str| query.is_empty() || name.to_ascii_lowercase().contains(&query); + let mut out = Vec::new(); + for (_, file) in ws.index.files().filter(|(_, f)| f.origin != FileOrigin::Stub) { + let mut push = |name: String, kind: LspKind, range, container: Option| { + if out.len() < MAX_WORKSPACE_SYMBOLS { + out.push(SymbolInformation { + name, + kind, + tags: None, + deprecated: None, + location: Location::new(file.uri.clone(), range), + container_name: container, + }); + } + }; + for symbol in file.index.globals.iter().filter(|s| matches(&s.name)) { + let kind = if symbol.ty.as_fun().is_some() { LspKind::FUNCTION } else { LspKind::VARIABLE }; + push(symbol.name.to_string(), kind, symbol.range, None); + } + for member in file.index.members.iter().filter(|m| matches(&m.symbol.name) && !m.owner.starts_with('%')) { + let kind = match member.symbol.kind { + SymbolKind::Method => LspKind::METHOD, + _ if member.symbol.ty.as_fun().is_some() => LspKind::FUNCTION, + _ => LspKind::FIELD, + }; + push( + format!("{}.{}", member.owner, member.symbol.name), + kind, + member.symbol.range, + Some(member.owner.to_string()), + ); + } + for class in file.index.classes.iter().filter(|c| matches(&c.name)) { + push(class.name.to_string(), LspKind::CLASS, class.range, None); + } + for export in file.index.exports.iter().filter(|e| matches(&e.name)) { + push(format!("exports:{}", export.name), LspKind::INTERFACE, export.range, None); + } + for event in file.index.events.iter().filter(|e| e.kind != crate::index::EventKind::Trigger && matches(&e.name)) + { + push(event.name.to_string(), LspKind::EVENT, event.range, None); + } + if out.len() >= MAX_WORKSPACE_SYMBOLS { + break; + } + } + out +} diff --git a/crates/qbx_lua_ls/src/features/workspace_health.rs b/crates/qbx_lua_ls/src/features/workspace_health.rs new file mode 100644 index 0000000..de65d87 --- /dev/null +++ b/crates/qbx_lua_ls/src/features/workspace_health.rs @@ -0,0 +1,175 @@ +//! Bounded workspace dependency checks over the current index, without filesystem access. +use std::collections::BTreeMap; + +use qbx_lua_analysis::project::is_manifest_file; +use serde::Serialize; + +use crate::index::{FileOrigin, Index, ResourceEntry}; + +use super::resource_details::{display_text, identity, normalized, references, ResourceIdentity}; + +const ISSUE_LIMIT: usize = 500; +const TARGET_LIMIT: usize = 20; + +#[derive(Debug, Default, Serialize)] +pub struct HealthCounts { + pub duplicates: usize, + pub missing: usize, + pub ambiguous: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HealthIssue { + pub kind: &'static str, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub resource: Option, + pub targets: Vec, + pub target_count: usize, + pub kinds: Vec<&'static str>, +} + +#[derive(Debug, Serialize)] +pub struct WorkspaceHealth { + pub files: usize, + pub resources: usize, + pub counts: HealthCounts, + pub issues: Vec, + pub truncated: usize, + pub notes: Vec, +} + +struct Providers { + name: String, + targets: Vec, + count: usize, +} + +fn bounded_identity(resource: &ResourceEntry, shortened: &mut bool) -> ResourceIdentity { + let mut result = identity(resource); + result.name = display_text(&result.name, 2048, shortened); + result +} + +/// Checks name resolution once per distinct resource; does not build per-resource symbol details. +pub fn health(index: &Index) -> WorkspaceHealth { + let mut shortened = false; + let mut resources: BTreeMap<_, &ResourceEntry> = BTreeMap::new(); + for resource in &index.resources { + resources + .entry(normalized(&resource.root)) + .and_modify(|selected| { + if (&resource.root, &resource.manifest_path, &resource.name) + < (&selected.root, &selected.manifest_path, &selected.name) + { + *selected = resource; + } + }) + .or_insert(resource); + } + let mut by_name: BTreeMap> = BTreeMap::new(); + for resource in resources.values() { + by_name.entry(resource.name.to_ascii_lowercase()).or_default().push(resource); + } + // Build each candidate list only once, even when many resources reference the same name. + let providers: BTreeMap<_, _> = by_name + .into_iter() + .map(|(key, mut entries)| { + entries.sort_by(|a, b| (&a.name, &a.root).cmp(&(&b.name, &b.root))); + let name = entries[0].name.to_string(); + let count = entries.len(); + let targets = + entries.iter().take(TARGET_LIMIT).map(|entry| bounded_identity(entry, &mut shortened)).collect(); + (key, Providers { name, targets, count }) + }) + .collect(); + + let mut counts = HealthCounts::default(); + let mut issues = Vec::new(); + let mut hidden_targets = 0; + // Duplicate groups come first in normalized-name order, so a capped report still shows them. + for group in providers.values().filter(|group| group.count > 1) { + counts.duplicates += 1; + if issues.len() < ISSUE_LIMIT { + hidden_targets += group.count.saturating_sub(TARGET_LIMIT); + issues.push(HealthIssue { + kind: "duplicate", + name: display_text(&group.name, 2048, &mut shortened), + resource: None, + targets: group.targets.clone(), + target_count: group.count, + kinds: Vec::new(), + }); + } + } + let mut malformed = 0; + for resource in resources.values() { + let refs = references(resource); + malformed += refs.malformed; + for (key, (name, kinds)) in refs.names { + let group = providers.get(&key); + let target_count = group.map_or(0, |group| group.count); + let kind = match target_count { + 0 => { + counts.missing += 1; + "missing" + } + 1 => continue, + _ => { + counts.ambiguous += 1; + "ambiguous" + } + }; + if issues.len() < ISSUE_LIMIT { + hidden_targets += target_count.saturating_sub(TARGET_LIMIT); + issues.push(HealthIssue { + kind, + name: display_text(&name, 2048, &mut shortened), + resource: Some(bounded_identity(resource, &mut shortened)), + targets: group.map_or_else(Vec::new, |group| group.targets.clone()), + target_count, + kinds: kinds.into_iter().collect(), + }); + } + } + } + let truncated = (counts.duplicates + counts.missing + counts.ambiguous).saturating_sub(issues.len()); + let mut notes = vec![ + "This is a local index snapshot, not live server state. Missing and ambiguous references describe indexed resource names; an import match does not verify that its individual file is available.".into(), + "Save manifest changes before refreshing this report. Newly created or removed files need the normal watched-file update or an index refresh; this report does not scan folders.".into(), + "File counts include indexed Lua sources, including modules and configured library files, but exclude manifests and built-in stubs. Excluded, oversized, unreadable and non-Lua files may be absent.".into(), + "Dependencies beginning with / are runtime constraints and are excluded from these checks. Computed dependency names cannot be checked from the manifest index.".into(), + ]; + if resources.values().any(|resource| { + resource.manifest.directives.iter().any(|directive| matches!(directive.name.as_str(), "provide" | "provides")) + }) { + notes.push("The index does not retain provide/provides aliases. Replacement resources can affect dependency resolution beyond these direct name matches.".into()); + } + if malformed > 0 { + notes.push(format!("{malformed} empty dependency names or malformed import paths were omitted.")); + } + if hidden_targets > 0 { + notes.push(format!("{hidden_targets} candidate resource folders were omitted from the displayed lists; their counts still show the full totals.")); + } + if truncated > 0 { + notes.push( + "Some issue rows were omitted to keep this report small. The counts still show the full indexed totals." + .into(), + ); + } + if shortened { + notes.push("Some long resource or dependency names were shortened for display. Folder and manifest links still identify the original resources.".into()); + } + WorkspaceHealth { + files: index + .files() + .filter(|(_, file)| file.origin != FileOrigin::Stub && !is_manifest_file(&file.path)) + .count(), + resources: resources.len(), + counts, + issues, + truncated, + notes, + } +} diff --git a/crates/qbx_lua_ls/src/framework_callbacks.rs b/crates/qbx_lua_ls/src/framework_callbacks.rs new file mode 100644 index 0000000..a4ba8d2 --- /dev/null +++ b/crates/qbx_lua_ls/src/framework_callbacks.rs @@ -0,0 +1,374 @@ +//! Small callback conventions backed by proven local framework receivers, never a docs catalog. +use qbx_fivem_data::Side; +use qbx_lua_analysis::scope::{Resolution, Resolved}; +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::visit::{self, Visitor}; + +use crate::index::{EventFamily, EventKind, FileOrigin, Index}; +use crate::infer::{Decl, FileContext}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FrameworkCallbackCall { + pub family: EventFamily, + pub kind: EventKind, +} + +impl FrameworkCallbackCall { + pub fn required_side(self) -> Side { + if self.kind == EventKind::Callback { + Side::Server + } else { + Side::Client + } + } +} + +/// Recognizes only the four standard dot calls. Callers check the effective manifest/guard side. +pub fn classify(ctx: &FileContext<'_>, index: &Index, callee: &Expr) -> Option { + let ExprKind::Field { base, name, safe: false } = &callee.kind else { return None }; + let (root, family, kind, path): (&Name, _, _, &[&str]) = match name.text.as_str() { + "CreateCallback" | "TriggerCallback" => { + let ExprKind::Field { base, name: functions, safe: false } = &base.kind else { return None }; + if functions.text != "Functions" { + return None; + } + let ExprKind::Name(root) = &base.kind else { return None }; + let kind = if name.text == "CreateCallback" { EventKind::Callback } else { EventKind::Trigger }; + (root, EventFamily::QbCore, kind, &["Functions", name.text.as_str()]) + } + "RegisterServerCallback" | "TriggerServerCallback" => { + let ExprKind::Name(root) = &base.kind else { return None }; + let kind = if name.text == "RegisterServerCallback" { EventKind::Callback } else { EventKind::Trigger }; + (root, EventFamily::Esx, kind, &[name.text.as_str()]) + } + _ => return None, + }; + if !standard_environment(ctx, index, root.span.start) || receiver_family(ctx, index, root, path, 0)? != family { + return None; + } + Some(FrameworkCallbackCall { family, kind }) +} + +fn standard_environment(ctx: &FileContext<'_>, index: &Index, offset: u32) -> bool { + ctx.resolution.lookup_local_at("_ENV", offset).is_none() && !global_redefined(ctx, index, "_ENV") +} + +fn global_redefined(ctx: &FileContext<'_>, index: &Index, name: &str) -> bool { + ctx.resolution.globals.iter().any(|global| global.name == name && global.is_definition()) + || global_field_redefined(ctx, name) + || index.globals_named(name, ctx.file).iter().any(|(file, _)| { + *file != ctx.file && index.file(*file).is_some_and(|entry| entry.origin != FileOrigin::Stub) + }) +} + +fn global_table(ctx: &FileContext<'_>, root: &Name) -> bool { + matches!(root.text.as_str(), "_G" | "_ENV") + && matches!(ctx.resolution.resolve_at(root.span.start), Some(Resolved::Global(_))) +} + +pub(crate) fn global_field_redefined(ctx: &FileContext<'_>, name: &str) -> bool { + struct Writes<'a, 'b> { + ctx: &'a FileContext<'b>, + name: &'a str, + found: bool, + } + impl Writes<'_, '_> { + fn target(&mut self, target: &Expr) { + let (base, field) = match &target.kind { + ExprKind::Field { base, name, .. } => (base.as_ref(), Some(name.text.as_str())), + ExprKind::Index { base, index, .. } => (base.as_ref(), index.as_string().map(|name| name.as_str())), + _ => return, + }; + if let ExprKind::Name(root) = &base.kind { + if global_table(self.ctx, root) && field.is_none_or(|field| field == self.name) { + self.found = true; + } + } + } + } + impl<'ast> Visitor<'ast> for Writes<'_, '_> { + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + match &stmt.kind { + StmtKind::Assign { targets, .. } => targets.iter().for_each(|target| self.target(target)), + StmtKind::CompoundAssign { target, .. } => self.target(target), + StmtKind::Function { name, .. } if global_table(self.ctx, &name.base) => { + let mut fields = name.path.iter().chain(name.method.iter()); + if fields.next().is_some_and(|field| field.text == self.name) && fields.next().is_none() { + self.found = true; + } + } + _ => {} + } + if !self.found { + visit::walk_stmt(self, stmt); + } + } + } + let mut writes = Writes { ctx, name, found: false }; + writes.visit_block(&ctx.chunk.block); + writes.found +} + +fn receiver_family(ctx: &FileContext<'_>, index: &Index, root: &Name, path: &[&str], depth: u8) -> Option { + if depth >= 8 || mutated(ctx, root, path) { + return None; + } + match ctx.resolution.resolve_at(root.span.start)? { + Resolved::Local(id) => { + let local = ctx.resolution.local(id); + if local.refs.iter().any(|reference| reference.write) { + return None; + } + let Decl::Local { stmt, index: position } = ctx.decl(local.decl.start)? else { return None }; + let StmtKind::Local { exprs, in_unpack: false, .. } = &stmt.kind else { return None }; + match &exprs.get(*position)?.unparen().kind { + ExprKind::Name(alias) => receiver_family(ctx, index, alias, path, depth + 1), + _ => export_receiver(ctx, index, exprs.get(*position)?.unparen()), + } + } + Resolved::Global(_) => framework_global(ctx, index, root, path), + } +} + +fn export_receiver(ctx: &FileContext<'_>, index: &Index, value: &Expr) -> Option { + let ExprKind::MethodCall { base, method, args, safe: false, .. } = &value.kind else { return None }; + if !args.is_empty() { + return None; + } + let (exports, resource) = match &base.kind { + ExprKind::Field { base, name, safe: false } => (base.as_ref(), name.text.as_str()), + ExprKind::Index { base, index, safe: false } => (base.as_ref(), index.as_string()?.as_str()), + _ => return None, + }; + let ExprKind::Name(exports) = &exports.kind else { return None }; + if exports.text != "exports" + || !matches!(ctx.resolution.resolve_at(exports.span.start), Some(Resolved::Global(_))) + || !standard_environment(ctx, index, exports.span.start) + || global_redefined(ctx, index, "exports") + || mutated(ctx, exports, &[resource, method.text.as_str()]) + { + return None; + } + match (resource, method.text.as_str()) { + ("qb-core", "GetCoreObject") => Some(EventFamily::QbCore), + ("es_extended", "getSharedObject") => Some(EventFamily::Esx), + _ => None, + } +} + +fn framework_global(ctx: &FileContext<'_>, index: &Index, root: &Name, path: &[&str]) -> Option { + if global_field_redefined(ctx, &root.text) { + return None; + } + let resource = index.resource_of(ctx.file)?; + let (provider, family) = match root.text.as_str() { + "QBCore" => ("qb-core", EventFamily::QbCore), + "ESX" => ("es_extended", EventFamily::Esx), + _ => return None, + }; + // A canonical global in the provider's own files is supported only when its source is indexed. + if resource.name == provider { + let writes: Vec<_> = + ctx.resolution.globals.iter().filter(|global| global.name == root.text && global.is_definition()).collect(); + match writes.as_slice() { + [] => {} + [write] + if ctx.chunk.block.stmts.iter().any(|stmt| { + let StmtKind::Assign { targets, exprs } = &stmt.kind else { return false }; + matches!((targets.as_slice(), exprs.as_slice()), ([target], [value]) + if matches!(&target.kind, ExprKind::Name(name) if name.span == write.span) + && matches!(value.kind, ExprKind::Table(_))) + }) => {} + _ => return None, + } + return index + .globals_named(&root.text, ctx.file) + .iter() + .any(|(file, _)| index.resource_of(*file).is_some_and(|entry| entry.name == provider)) + .then_some(family); + } + if family != EventFamily::Esx + || ctx.resolution.globals.iter().any(|global| global.name == root.text && global.is_definition()) + { + return None; + } + let side = index.file(ctx.file)?.side; + let effective = + qbx_lua_analysis::side_guard::SideRegions::of(ctx.source, ctx.chunk).effective(root.span.start, side)?; + if !resource.manifest.imports_path("@es_extended/imports.lua", effective) { + return None; + } + // Imported ESX must not be replaced or have the relevant member defined by this resource. + if index + .globals_named(&root.text, ctx.file) + .iter() + .any(|(file, _)| *file != ctx.file && index.resource_of(*file).is_some_and(|entry| entry.name != provider)) + { + return None; + } + let owner = if path.len() > 1 { + format!("{}.{}", root.text, path[..path.len() - 1].join(".")) + } else { + root.text.to_string() + }; + if index.members_of(&owner, ctx.file).iter().any(|(file, member)| { + *file != ctx.file + && path.last().is_some_and(|last| member.name == *last) + && index.resource_of(*file).is_some_and(|entry| entry.name != provider) + }) { + return None; + } + Some(family) +} + +fn same_binding(resolution: &Resolution, first: &Name, other: &Name) -> bool { + match (resolution.resolve_at(first.span.start), resolution.resolve_at(other.span.start)) { + (Some(Resolved::Local(a)), Some(Resolved::Local(b))) => a == b, + (Some(Resolved::Global(_)), Some(Resolved::Global(_))) => first.text == other.text, + _ => false, + } +} + +fn same_binding_or_alias(ctx: &FileContext<'_>, first: &Name, other: &Name, depth: u8) -> bool { + if same_binding(ctx.resolution, first, other) { + return true; + } + if depth >= 8 { + return false; + } + let Some(Resolved::Local(id)) = ctx.resolution.resolve_at(other.span.start) else { return false }; + let local = ctx.resolution.local(id); + let Some(Decl::Local { stmt, index: position }) = ctx.decl(local.decl.start) else { return false }; + let StmtKind::Local { exprs, in_unpack: false, .. } = &stmt.kind else { return false }; + match exprs.get(*position).map(|value| &value.unparen().kind) { + Some(ExprKind::Name(alias)) => same_binding_or_alias(ctx, first, alias, depth + 1), + _ => false, + } +} + +fn mutated(ctx: &FileContext<'_>, root: &Name, path: &[&str]) -> bool { + struct Mutation<'a, 'b> { + ctx: &'a FileContext<'b>, + root: &'a Name, + path: &'a [&'a str], + found: bool, + } + impl Mutation<'_, '_> { + fn target(&mut self, target: &Expr) { + fn split<'a>(expr: &'a Expr, fields: &mut Vec>) -> Option<&'a Name> { + match &expr.kind { + ExprKind::Name(name) => Some(name), + ExprKind::Field { base, name, .. } => { + let root = split(base, fields)?; + fields.push(Some(&name.text)); + Some(root) + } + ExprKind::Index { base, index, .. } => { + let root = split(base, fields)?; + fields.push(index.as_string().map(|key| key.as_str())); + Some(root) + } + _ => None, + } + } + let mut fields = Vec::new(); + if let Some(root) = split(target, &mut fields) { + self.check(root, &fields); + } + } + + fn check(&mut self, root: &Name, fields: &[Option<&str>]) { + if matches!(self.ctx.resolution.resolve_at(self.root.span.start), Some(Resolved::Global(_))) + && global_table(self.ctx, root) + && fields.first().is_some_and(|field| field.is_none_or(|field| field == self.root.text)) + { + let fields = &fields[1..]; + if fields.len() <= self.path.len() + && fields.iter().zip(self.path).all(|(field, wanted)| field.is_none_or(|field| field == *wanted)) + { + self.found = true; + } + } + if !fields.is_empty() + && same_binding_or_alias(self.ctx, self.root, root, 0) + && fields.len() <= self.path.len() + && fields.iter().zip(self.path).all(|(field, wanted)| field.is_none_or(|field| field == *wanted)) + { + self.found = true; + } + } + } + impl<'ast> Visitor<'ast> for Mutation<'_, '_> { + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + match &stmt.kind { + StmtKind::Assign { targets, .. } => targets.iter().for_each(|target| self.target(target)), + StmtKind::CompoundAssign { target, .. } => self.target(target), + StmtKind::Function { name, .. } => { + let fields: Vec<_> = + name.path.iter().chain(name.method.iter()).map(|name| Some(name.text.as_str())).collect(); + self.check(&name.base, &fields); + } + _ => {} + } + if !self.found { + visit::walk_stmt(self, stmt); + } + } + } + let mut scan = Mutation { ctx, root, path, found: false }; + scan.visit_block(&ctx.chunk.block); + scan.found +} + +#[cfg(test)] +mod tests { + use super::*; + use qbx_lua_analysis::scope::resolve; + use qbx_lua_syntax::parse; + + fn last_call(source: &str) -> Option { + let chunk = parse(source); + assert!(chunk.errors.is_empty(), "{source}"); + let resolution = resolve(&chunk); + let ctx = FileContext::new(0, source, &chunk, &resolution); + let StmtKind::Expr(expr) = &chunk.block.stmts.last()?.kind else { return None }; + let ExprKind::Call { callee, .. } = &expr.kind else { return None }; + classify(&ctx, &Index::default(), callee) + } + + #[test] + fn recognizes_export_initialized_receivers_and_local_aliases() { + let qb = "local core = exports['qb-core']:GetCoreObject()\nlocal alias = core\nalias.Functions.CreateCallback('x', function() end)"; + assert_eq!( + last_call(qb), + Some(FrameworkCallbackCall { family: EventFamily::QbCore, kind: EventKind::Callback }) + ); + let esx = "local framework = exports.es_extended:getSharedObject()\nframework.TriggerServerCallback('x', function() end)"; + assert_eq!(last_call(esx), Some(FrameworkCallbackCall { family: EventFamily::Esx, kind: EventKind::Trigger })); + } + + #[test] + fn rejects_shadowed_reassigned_and_unrelated_framework_shapes() { + for prefix in [ + "local core = {}", + "local exports = {}; local core = exports['qb-core']:GetCoreObject()", + "exports = {}; local core = exports['qb-core']:GetCoreObject()", + "_G.exports = {}; local core = exports['qb-core']:GetCoreObject()", + "_ENV['exports'] = {}; local core = exports['qb-core']:GetCoreObject()", + "_G.exports['qb-core'].GetCoreObject = function() end; local core = exports['qb-core']:GetCoreObject()", + "local _ENV = {}; local core = exports['qb-core']:GetCoreObject()", + "_ENV = {}; local core = exports['qb-core']:GetCoreObject()", + "local core = exports['qb-core']:GetCoreObject(); core = {}", + "local core = exports['qb-core']:GetCoreObject(); core.Functions = {}", + "local core = exports['qb-core']:GetCoreObject(); core.Functions.CreateCallback = function() end", + "local core = exports['qb-core']:GetCoreObject(); function core.Functions.CreateCallback() end", + "local core = exports['qb-core']:GetCoreObject(); local alias = core; alias.Functions = {}", + "local core = exports['qb-core']:GetCoreObject(); core[key] = {}", + "local core = exports['other']:GetCoreObject()", + "local core = exports['qb-core']:GetCoreObject({'Functions'})", + ] { + let source = format!("{prefix}\ncore.Functions.CreateCallback('x', function() end)"); + assert!(last_call(&source).is_none(), "{source}"); + } + } +} diff --git a/crates/qbx_lua_ls/src/index.rs b/crates/qbx_lua_ls/src/index.rs new file mode 100644 index 0000000..59500ab --- /dev/null +++ b/crates/qbx_lua_ls/src/index.rs @@ -0,0 +1,482 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use lsp_types::{Range, Url}; +use qbx_fivem_data::Side; +use qbx_lua_analysis::manifest::Manifest; +use qbx_lua_analysis::summary::FileSummary; +use rustc_hash::FxHashMap; +use smol_str::SmolStr; + +use crate::types::{FunType, Type}; + +pub type FileId = u32; +pub type ResourceId = u32; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SymbolKind { + Function, + Method, + Variable, + Table, + Field, + Class, + Alias, + Export, +} + +#[derive(Clone, Debug)] +pub struct Symbol { + pub name: SmolStr, + pub kind: SymbolKind, + pub ty: Type, + pub doc: Option>, + pub deprecated: bool, + /// Source text of a short literal value, shown in hovers as `name: type = value`. + pub literal: Option, + pub range: Range, +} + +#[derive(Clone, Debug)] +pub struct Member { + pub owner: SmolStr, + pub symbol: Symbol, +} + +/// The entries of a table constructor that have no name: its array part (`key` is `None`), or its +/// other `[key] = value` pairs. +#[derive(Clone, Debug)] +pub struct Element { + pub owner: SmolStr, + pub key: Option, + pub value: Type, +} + +#[derive(Clone, Debug)] +pub struct ClassDef { + pub name: SmolStr, + pub parents: Vec, + pub fields: Vec, + pub index: Option<(Type, Type)>, + pub call: Option>, + pub doc: Option>, + pub range: Range, +} + +#[derive(Clone, Debug)] +pub struct AliasDef { + pub name: SmolStr, + pub ty: Type, + pub doc: Option>, + pub range: Range, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EventKind { + NetEvent, + Handler, + Callback, + Trigger, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum EventFamily { + Native, + OxLib, + QbCore, + Esx, +} + +#[derive(Clone, Debug)] +pub struct EventDef { + pub name: SmolStr, + pub kind: EventKind, + pub family: EventFamily, + /// The manifest side narrowed by the guard around this registration or trigger. + pub side: Option, + pub handler: Option>, + pub range: Range, +} + +#[derive(Clone, Debug)] +pub struct NuiCallbackDef { + pub name: SmolStr, + /// The exact registration global, retained to check current cross-file replacements. + pub registration: SmolStr, + pub range: Range, +} + +#[derive(Clone, Debug, Default)] +pub struct FileIndex { + pub globals: Vec, + pub members: Vec, + pub elements: Vec, + pub classes: Vec, + pub aliases: Vec, + pub exports: Vec, + pub events: Vec, + /// NUI registrations are not network events and must not enter event completion. + pub nui_callbacks: Vec, + pub module_return: Option, + pub convars: Vec, + pub state_keys: Vec, + /// The file registers exports under names computed at runtime, so the listed ones are not all. + pub dynamic_exports: bool, + pub summary: FileSummary, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FileOrigin { + Stub, + Workspace, + Library, +} + +#[derive(Debug)] +pub struct FileEntry { + pub path: PathBuf, + pub uri: Url, + pub origin: FileOrigin, + pub resource: Option, + pub side: Option, + pub index: FileIndex, +} + +#[derive(Debug)] +pub struct ResourceEntry { + pub name: SmolStr, + pub root: PathBuf, + pub manifest_path: PathBuf, + pub manifest: Manifest, + pub files: Vec, + /// Files pulled in through `@resource/file.lua` manifest entries, with the side they load on. + pub imports: Vec<(FileId, Side)>, + /// Ships a `.fxap` marker or an encrypted file, so part of its code cannot be read. + pub escrowed: bool, +} + +type Slot = (FileId, u32); + +#[derive(Debug, Default)] +pub struct Index { + files: Vec>, + by_path: FxHashMap, + pub resources: Vec, + globals: FxHashMap>, + members: FxHashMap>, + elements: FxHashMap>, + classes: FxHashMap>, + aliases: FxHashMap>, +} + +pub fn normalize_path(path: &Path) -> PathBuf { + if cfg!(windows) { + PathBuf::from(path.to_string_lossy().replace('/', "\\").to_lowercase()) + } else { + path.to_path_buf() + } +} + +fn remove_file_slots<'a>( + map: &mut FxHashMap>, + keys: impl Iterator, + file: FileId, +) { + for key in keys { + if let Some(slots) = map.get_mut(key) { + slots.retain(|(f, _)| *f != file); + if slots.is_empty() { + map.remove(key); + } + } + } +} + +impl Index { + /// Start a fresh disk scan without discarding the built-in library. + pub fn clear_workspace(&mut self) { + let stubs: Vec = + self.files.iter_mut().filter_map(Option::take).filter(|file| file.origin == FileOrigin::Stub).collect(); + *self = Self::default(); + for entry in stubs { + let id = self.allocate(&entry.path); + self.set_file(id, entry); + } + } + + pub fn file_id(&self, path: &Path) -> Option { + self.by_path.get(&normalize_path(path)).copied() + } + + pub fn file(&self, id: FileId) -> Option<&FileEntry> { + self.files.get(id as usize).and_then(Option::as_ref) + } + + pub fn files(&self) -> impl Iterator { + self.files.iter().enumerate().filter_map(|(id, f)| f.as_ref().map(|f| (id as FileId, f))) + } + + pub fn file_count(&self) -> usize { + self.files.iter().flatten().count() + } + + pub fn resource(&self, id: ResourceId) -> Option<&ResourceEntry> { + self.resources.get(id as usize) + } + + pub fn resource_by_name(&self, name: &str) -> Option<(ResourceId, &ResourceEntry)> { + self.resources + .iter() + .enumerate() + .find(|(_, r)| r.name.eq_ignore_ascii_case(name)) + .map(|(id, r)| (id as ResourceId, r)) + } + + pub fn resource_of(&self, file: FileId) -> Option<&ResourceEntry> { + self.file(file)?.resource.and_then(|id| self.resource(id)) + } + + /// Reserves an id for `path`, reusing the existing one when the file is already known. + pub fn allocate(&mut self, path: &Path) -> FileId { + let key = normalize_path(path); + if let Some(id) = self.by_path.get(&key) { + return *id; + } + let id = self.files.len() as FileId; + self.files.push(None); + self.by_path.insert(key, id); + id + } + + pub fn set_file(&mut self, id: FileId, entry: FileEntry) { + self.clear_slots(id); + for (i, symbol) in entry.index.globals.iter().enumerate() { + self.globals.entry(symbol.name.clone()).or_default().push((id, i as u32)); + } + for (i, member) in entry.index.members.iter().enumerate() { + self.members.entry(member.owner.clone()).or_default().push((id, i as u32)); + } + for (i, element) in entry.index.elements.iter().enumerate() { + self.elements.entry(element.owner.clone()).or_default().push((id, i as u32)); + } + for (i, class) in entry.index.classes.iter().enumerate() { + self.classes.entry(class.name.clone()).or_default().push((id, i as u32)); + } + for (i, alias) in entry.index.aliases.iter().enumerate() { + self.aliases.entry(alias.name.clone()).or_default().push((id, i as u32)); + } + if let Some(resource) = entry.resource.and_then(|r| self.resources.get_mut(r as usize)) { + if !resource.files.contains(&id) { + resource.files.push(id); + } + } + self.files[id as usize] = Some(entry); + } + + fn clear_slots(&mut self, id: FileId) { + let Some(old) = self.files.get_mut(id as usize).and_then(Option::take) else { return }; + remove_file_slots(&mut self.globals, old.index.globals.iter().map(|s| &s.name), id); + remove_file_slots(&mut self.members, old.index.members.iter().map(|m| &m.owner), id); + remove_file_slots(&mut self.elements, old.index.elements.iter().map(|e| &e.owner), id); + remove_file_slots(&mut self.classes, old.index.classes.iter().map(|c| &c.name), id); + remove_file_slots(&mut self.aliases, old.index.aliases.iter().map(|a| &a.name), id); + } + + pub fn remove_file(&mut self, path: &Path) { + let Some(id) = self.file_id(path) else { return }; + self.clear_slots(id); + for resource in &mut self.resources { + resource.files.retain(|f| *f != id); + resource.imports.retain(|(f, _)| *f != id); + } + self.files[id as usize] = None; + } + + /// Whether symbols of `target` are in scope for code in `from`. + pub fn is_visible(&self, from: FileId, target: FileId) -> bool { + if from == target { + return true; + } + let (Some(source), Some(other)) = (self.file(from), self.file(target)) else { return false }; + let sides_match = match (source.side, other.side) { + (Some(a), Some(b)) => b.is_available_on(a), + _ => true, + }; + if !sides_match { + return false; + } + if other.origin == FileOrigin::Stub { + return true; + } + match (source.resource, other.resource) { + (Some(a), Some(b)) if a == b => true, + (Some(a), _) => self.resource(a).is_some_and(|r| { + r.imports + .iter() + .any(|(file, side)| *file == target && source.side.is_none_or(|s| side.is_available_on(s))) + }), + (None, None) => true, + (None, Some(_)) => false, + } + } + + /// Whether two files can share globals at all, regardless of the side either one runs on. + pub fn is_related(&self, a: FileId, b: FileId) -> bool { + let (Some(first), Some(second)) = (self.file(a), self.file(b)) else { return false }; + match (first.resource, second.resource) { + (Some(x), Some(y)) if x == y => true, + (Some(x), Some(y)) => { + let imports = |from: ResourceId, file: FileId| { + self.resource(from).is_some_and(|r| r.imports.iter().any(|(imported, _)| *imported == file)) + }; + imports(x, b) || imports(y, a) + } + (None, None) => true, + _ => false, + } + } + + fn visible_first<'a, T>( + &'a self, + slots: Option<&'a Vec>, + from: FileId, + get: impl Fn(&'a FileEntry, u32) -> Option<&'a T>, + ) -> Vec<(FileId, &'a T)> { + // Strictly what the runtime would see: a `Config` of some other resource is a different table. + let Some(slots) = slots else { return Vec::new() }; + let resolve = |(file, i): &Slot| Some((*file, get(self.file(*file)?, *i)?)); + slots.iter().filter(|(f, _)| self.is_visible(from, *f)).filter_map(resolve).collect() + } + + pub fn globals_named(&self, name: &str, from: FileId) -> Vec<(FileId, &Symbol)> { + self.visible_first(self.globals.get(name), from, |f, i| f.index.globals.get(i as usize)) + } + + /// Members are looked up per resource rather than per file: libraries such as ox_lib load the + /// files that extend their table lazily, so importing one file makes all of them reachable. + pub fn members_of(&self, owner: &str, from: FileId) -> Vec<(FileId, &Symbol)> { + self.owner_slots(self.members.get(owner), owner, from) + .into_iter() + .filter_map(|(file, i)| Some((file, &self.file(file)?.index.members.get(i as usize)?.symbol))) + .collect() + } + + /// The array parts and `[key]` entries of the tables `owner` names, visible like its members. + pub fn elements_of(&self, owner: &str, from: FileId) -> Vec<&Element> { + self.owner_slots(self.elements.get(owner), owner, from) + .into_iter() + .filter_map(|(file, i)| self.file(file)?.index.elements.get(i as usize)) + .collect() + } + + fn owner_slots(&self, slots: Option<&Vec>, owner: &str, from: FileId) -> Vec { + let Some(slots) = slots else { return Vec::new() }; + let reachable = |target: FileId| { + let sides_match = match (self.file(from).and_then(|f| f.side), self.file(target).and_then(|f| f.side)) { + (Some(a), Some(b)) => b.is_available_on(a), + _ => true, + }; + sides_match + && (self.is_visible(from, target) + || self.is_related(from, target) + || self.imports_resource_of(from, target)) + }; + // `%`-owners name one specific table of one file (a local or a module return), so whoever + // holds a value of that type may see all of it. + if owner.starts_with('%') { + return slots.clone(); + } + // A table the resource fills itself (`Config`, `Shared`) is its own; only tables that come + // from an imported library (`lib`, `qbx`) are completed from that library's other files. + let own_resource = self.file(from).and_then(|f| f.resource); + let is_own = |target: FileId| { + target == from || (own_resource.is_some() && self.file(target).and_then(|f| f.resource) == own_resource) + }; + let in_scope: Vec = slots.iter().copied().filter(|(f, _)| self.is_visible(from, *f)).collect(); + if in_scope.iter().any(|(f, _)| is_own(*f)) { + return in_scope; + } + let visible: Vec = slots.iter().copied().filter(|(f, _)| reachable(*f)).collect(); + // Classes travel between resources through exports and events, so their members are looked + // up everywhere; plain tables of unrelated resources are not. + if !visible.is_empty() || !self.classes.contains_key(owner) { + return visible; + } + slots.clone() + } + + fn imports_resource_of(&self, from: FileId, target: FileId) -> bool { + let (Some(resource), Some(target_resource)) = + (self.resource_of(from), self.file(target).and_then(|f| f.resource)) + else { + return false; + }; + resource.imports.iter().any(|(file, _)| self.file(*file).and_then(|f| f.resource) == Some(target_resource)) + } + + pub fn has_members(&self, owner: &str) -> bool { + self.members.contains_key(owner) + } + + pub fn class(&self, name: &str) -> Option<(FileId, &ClassDef)> { + let (file, i) = *self.classes.get(name)?.first()?; + Some((file, self.file(file)?.index.classes.get(i as usize)?)) + } + + pub fn class_defs(&self, name: &str) -> Vec<(FileId, &ClassDef)> { + let Some(slots) = self.classes.get(name) else { return Vec::new() }; + slots.iter().filter_map(|(f, i)| Some((*f, self.file(*f)?.index.classes.get(*i as usize)?))).collect() + } + + pub fn alias(&self, name: &str) -> Option<(FileId, &AliasDef)> { + let (file, i) = *self.aliases.get(name)?.first()?; + Some((file, self.file(file)?.index.aliases.get(i as usize)?)) + } + + pub fn alias_defs(&self, name: &str) -> Vec<(FileId, &AliasDef)> { + let Some(slots) = self.aliases.get(name) else { return Vec::new() }; + slots.iter().filter_map(|(f, i)| Some((*f, self.file(*f)?.index.aliases.get(*i as usize)?))).collect() + } + + pub fn class_names(&self) -> impl Iterator { + self.classes.keys().chain(self.aliases.keys()) + } + + /// Every global visible from `from`, for completion. + pub fn visible_globals(&self, from: FileId) -> impl Iterator { + self.files() + .filter(move |(id, _)| self.is_visible(from, *id)) + .flat_map(|(id, file)| file.index.globals.iter().map(move |s| (id, s))) + } + + pub fn exports_of(&self, resource: &str) -> Vec<(FileId, &Symbol)> { + let Some((_, entry)) = self.resource_by_name(resource) else { return Vec::new() }; + entry + .files + .iter() + .filter_map(|id| Some((*id, self.file(*id)?))) + .flat_map(|(id, file)| file.index.exports.iter().map(move |s| (id, s))) + .collect() + } + + pub fn events(&self) -> impl Iterator { + self.files().flat_map(|(id, file)| file.index.events.iter().map(move |e| (id, e))) + } + + /// Resolves a `require` argument the way ox_lib does: dotted or slashed, relative to the + /// resource root, optionally prefixed with `@resource`. + pub fn resolve_require(&self, module: &str, from: FileId) -> Option { + let (resource, module) = match module.strip_prefix('@') { + Some(rest) => { + let (name, path) = rest.split_once(['/', '.'])?; + (self.resource_by_name(name)?.1, path) + } + None => (self.resource_of(from)?, module), + }; + let relative = if module.contains('/') { module.to_string() } else { module.replace('.', "/") }; + let relative = relative.trim_end_matches(".lua"); + [format!("{relative}.lua"), format!("{relative}/init.lua")] + .iter() + .find_map(|candidate| self.file_id(&resource.root.join(candidate))) + } +} diff --git a/crates/qbx_lua_ls/src/indexer.rs b/crates/qbx_lua_ls/src/indexer.rs new file mode 100644 index 0000000..6c8a858 --- /dev/null +++ b/crates/qbx_lua_ls/src/indexer.rs @@ -0,0 +1,754 @@ +use std::fmt::Write as _; +use std::sync::Arc; + +use lsp_types::{Position, Range}; +use qbx_fivem_data::Side; +use qbx_lua_analysis::scope::{Resolution, Resolved}; +use qbx_lua_analysis::side_guard::SideRegions; +use qbx_lua_analysis::summary::summarize; +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::{Comment, LineIndex, SmolStr, Span}; + +use crate::index::{ + AliasDef, ClassDef, Element, EventDef, EventFamily, EventKind, FileId, FileIndex, Index, Member, NuiCallbackDef, + Symbol, SymbolKind, +}; +use crate::infer::{table_elements, table_fields, FileContext, Infer}; +use crate::luacats::{parse_doc_lines, DocGroup}; +use crate::types::Type; + +const MAX_TABLE_DEPTH: u32 = 4; +const MAX_TABLE_FIELDS: usize = 400; +const MAX_MEMBERS_PER_FILE: usize = 6000; + +pub fn span_to_range(source: &str, lines: &LineIndex, span: Span) -> Range { + let start = lines.line_col_utf16(source, span.start); + let end = lines.line_col_utf16(source, span.end.max(span.start)); + Range::new(Position::new(start.line, start.col), Position::new(end.line, end.col)) +} + +pub fn render_doc(doc: &DocGroup) -> Option> { + let mut out = doc.description.clone(); + if let Some(reason) = &doc.deprecated { + let _ = write!(out, "\n\n**Deprecated** {reason}"); + } + let documented: Vec<_> = doc.params.iter().filter(|p| !p.description.is_empty()).collect(); + if !documented.is_empty() { + out.push('\n'); + for param in documented { + let _ = write!(out, "\n- `{}`: {}", param.name, param.description); + } + } + for ret in doc.returns.iter().filter(|r| !r.description.is_empty()) { + let name = ret.name.as_deref().unwrap_or("returns"); + let _ = write!(out, "\n\n*{name}*: {}", ret.description); + } + let out = out.trim(); + (!out.is_empty()).then(|| Arc::from(out)) +} + +pub fn index_file( + file: FileId, + source: &str, + chunk: &Chunk, + resolution: &Resolution, + index: &Index, + side: Option, +) -> FileIndex { + let ctx = FileContext::new(file, source, chunk, resolution); + let infer = Infer::new(&ctx, index); + let lines = LineIndex::new(source); + let mut indexer = Indexer { + file, + source, + lines: &lines, + ctx: &ctx, + infer: &infer, + side, + regions: SideRegions::of(source, chunk), + nui_globals: crate::nui_callbacks::NuiGlobals::of(&ctx), + out: FileIndex::default(), + depth: 0, + }; + indexer.doc_comments(&chunk.comments); + indexer.block(&chunk.block); + if let Some(Stmt { kind: StmtKind::Return(exprs), .. }) = chunk.block.stmts.last() { + indexer.module_return(exprs); + } + let mut out = indexer.out; + out.summary = summarize(chunk, resolution); + out +} + +struct Indexer<'a> { + file: FileId, + source: &'a str, + lines: &'a LineIndex, + ctx: &'a FileContext<'a>, + infer: &'a Infer<'a>, + side: Option, + regions: SideRegions, + nui_globals: crate::nui_callbacks::NuiGlobals, + out: FileIndex, + depth: u32, +} + +pub const CONVAR_CALLS: &[&str] = &[ + "GetConvar", + "GetConvarInt", + "GetConvarBool", + "GetConvarFloat", + "SetConvar", + "SetConvarReplicated", + "SetConvarServerInfo", +]; + +/// `Entity(x).state`, `LocalPlayer.state`, `Player(src).state` and `GlobalState`. +pub fn is_state_bag(expr: &Expr) -> bool { + match &expr.kind { + ExprKind::Field { name, .. } => name.text == "state", + ExprKind::Name(name) => name.text == "GlobalState", + _ => false, + } +} + +const NET_EVENT_CALLS: &[&str] = &["RegisterNetEvent", "RegisterServerEvent"]; +const HANDLER_CALLS: &[&str] = &["AddEventHandler"]; +const CALLBACK_CALLS: &[&str] = &["lib.callback.register"]; +const TRIGGER_CALLS: &[&str] = &[ + "TriggerEvent", + "TriggerServerEvent", + "TriggerClientEvent", + "TriggerLatentServerEvent", + "TriggerLatentClientEvent", + "lib.callback", + "lib.callback.await", +]; + +impl<'a> Indexer<'a> { + fn range(&self, span: Span) -> Range { + span_to_range(self.source, self.lines, span) + } + + fn is_global(&self, name: &Name) -> bool { + matches!(self.ctx.resolution.resolve_at(name.span.start), Some(Resolved::Global(_))) + } + + /// Groups adjacent `---` comments and records the classes and aliases they declare. + fn doc_comments(&mut self, comments: &[Comment]) { + let mut group: Vec<&Comment> = Vec::new(); + for comment in comments { + let text = comment.span.text(self.source); + let adjacent = group.last().is_some_and(|prev: &&Comment| { + let gap = &self.source[prev.span.end as usize..comment.span.start as usize]; + gap.bytes().filter(|b| *b == b'\n').count() <= 1 && gap.trim().is_empty() + }); + if !adjacent { + self.flush_doc_group(&group); + group.clear(); + } + if text.starts_with("---") { + group.push(comment); + } else { + self.flush_doc_group(&group); + group.clear(); + } + } + self.flush_doc_group(&group); + } + + fn flush_doc_group(&mut self, group: &[&Comment]) { + if !group.iter().any(|c| c.span.text(self.source).contains('@')) { + return; + } + let lines: Vec<&str> = + group.iter().map(|c| c.span.text(self.source).strip_prefix("---").unwrap_or_default()).collect(); + let doc = parse_doc_lines(&lines); + for class in doc.classes { + let range = self.range(group[class.line.min(group.len() - 1)].span); + let fields = class + .fields + .into_iter() + .map(|field| Symbol { + kind: if field.ty.as_fun().is_some() { SymbolKind::Method } else { SymbolKind::Field }, + ty: if field.optional { field.ty.optional() } else { field.ty }, + doc: (!field.description.is_empty()).then(|| Arc::from(field.description.as_str())), + deprecated: false, + literal: None, + range: self.range(group[field.line.min(group.len() - 1)].span), + name: field.name, + }) + .collect(); + self.out.classes.push(ClassDef { + name: class.name, + parents: class.parents, + fields, + index: class.index, + call: class.call, + doc: (!class.description.is_empty()).then(|| Arc::from(class.description.as_str())), + range, + }); + } + for alias in doc.aliases { + let range = self.range(group[alias.line.min(group.len() - 1)].span); + self.out.aliases.push(AliasDef { + name: alias.name, + ty: alias.ty, + doc: (!alias.description.is_empty()).then(|| Arc::from(alias.description.as_str())), + range, + }); + } + } + + fn owner_of(&self, ty: &Type) -> Option { + match ty { + Type::Named(name, _) => Some(name.clone()), + Type::GlobalTable(owner) => Some(owner.clone()), + Type::Union(types) => { + let owners: Vec = types.iter().filter_map(|t| self.owner_of(t)).collect(); + owners.iter().find(|o| !o.starts_with('%')).or(owners.first()).cloned() + } + _ => None, + } + } + + /// The class a global was annotated with, preferring this file so the answer does not depend + /// on whether the file is already part of the index. + fn global_class(&self, name: &str) -> Option { + let own = self.out.globals.iter().rev().find(|s| s.name == name).map(|s| s.ty.clone()); + match own.unwrap_or_else(|| self.infer.global_type(name)) { + Type::Named(class, _) => Some(class), + _ => None, + } + } + + fn push_member(&mut self, owner: SmolStr, symbol: Symbol) { + if self.out.members.len() < MAX_MEMBERS_PER_FILE { + self.out.members.push(Member { owner, symbol }); + } + } + + /// The symbol for `name = value`, registering nested table fields under `nested_owner`. + fn value_symbol( + &mut self, + name: &Name, + value: Option<&Expr>, + doc_anchor: u32, + nested_owner: &str, + table_depth: u32, + ) -> Symbol { + let doc = self.ctx.doc_at(doc_anchor); + let mut kind = SymbolKind::Variable; + let ty = if let Some(class) = doc.classes.last() { + if let Some(fields) = value.and_then(table_fields) { + self.table_members(class.name.clone(), fields, table_depth + 1); + } + kind = SymbolKind::Table; + Type::Named(class.name.clone(), Vec::new()) + } else if let Some(ty) = &doc.ty { + ty.clone() + } else { + match value.map(|v| (&v.kind, v)) { + Some((_, expr)) if table_fields(expr).is_some_and(<[TableField]>::is_empty) => { + kind = SymbolKind::Table; + Type::Table + } + Some((_, expr)) if table_fields(expr).is_some() && table_depth < MAX_TABLE_DEPTH => { + let fields = table_fields(expr).unwrap_or_default(); + kind = SymbolKind::Table; + if let Some(enum_name) = &doc.enum_name { + self.enum_class(enum_name.clone(), doc.enum_keys, fields, name.span); + } + self.table_members(SmolStr::new(nested_owner), fields, table_depth + 1); + Type::GlobalTable(SmolStr::new(nested_owner)) + } + Some((ExprKind::Function(func), _)) => { + kind = SymbolKind::Function; + Type::Fun(Arc::new(self.infer.fun_type(func, Some(doc_anchor), false))) + } + Some((_, expr)) => self.infer.expr(expr).widen(), + None => Type::Unknown, + } + }; + if matches!(ty, Type::GlobalTable(_)) { + kind = SymbolKind::Table; + } + Symbol { + name: name.text.clone(), + kind, + ty, + doc: render_doc(&doc), + deprecated: doc.deprecated.is_some(), + literal: value.and_then(|v| self.literal_text(v)), + range: self.range(name.span), + } + } + + /// Short literals are kept so hovers can show `Debug: boolean = true` without the source file. + fn literal_text(&self, value: &Expr) -> Option { + let is_literal = match &value.kind { + ExprKind::True | ExprKind::False | ExprKind::Number(_) | ExprKind::String(_) | ExprKind::JenkinsHash(_) => { + true + } + ExprKind::Unary { op: UnOp::Neg, expr } => matches!(expr.kind, ExprKind::Number(_)), + _ => false, + }; + let text = value.span.text(self.source); + (is_literal && text.len() <= 48 && !text.contains('\n')).then(|| SmolStr::new(text)) + } + + fn enum_class(&mut self, name: SmolStr, keys: bool, fields: &[TableField], span: Span) { + let values: Vec = fields + .iter() + .filter_map(|f| match f { + TableField::Named { name: key, .. } if keys => Some(Type::StringLit(key.text.clone())), + TableField::Keyed { key, .. } if keys => Some(self.infer.expr(key)), + TableField::Named { value, .. } | TableField::Keyed { value, .. } => Some(self.infer.expr(value)), + _ => None, + }) + .collect(); + let range = self.range(span); + self.out.aliases.push(AliasDef { name, ty: Type::union(values), doc: None, range }); + } + + fn push_element(&mut self, owner: SmolStr, key: Option, value: Type) { + if self.out.elements.len() < MAX_MEMBERS_PER_FILE { + self.out.elements.push(Element { owner, key, value }); + } + } + + fn table_members(&mut self, owner: SmolStr, fields: &[TableField], depth: u32) { + let fields = &fields[..fields.len().min(MAX_TABLE_FIELDS)]; + for field in fields { + let (name, value) = match field { + TableField::Named { name, value } => (name.clone(), value), + TableField::Keyed { key: Expr { kind: ExprKind::String(key), span }, value } => { + (Name { text: key.clone(), span: *span }, value) + } + TableField::Keyed { .. } | TableField::Positional(_) | TableField::SetMember(_) => continue, + }; + let nested = format!("{owner}.{}", name.text); + let mut symbol = self.value_symbol(&name, Some(value), name.span.start, &nested, depth); + if symbol.kind == SymbolKind::Variable { + symbol.kind = SymbolKind::Field; + } + self.push_member(owner.clone(), symbol); + } + let elements = table_elements(fields); + if !elements.array.is_empty() { + let array = Type::union(elements.array.iter().map(|value| self.infer.expr(value).widen())); + self.push_element(owner.clone(), None, array); + } + if !elements.keyed.is_empty() { + let (keys, values): (Vec, Vec) = elements + .keyed + .iter() + .map(|(key, value)| (self.infer.expr(key).widen(), self.infer.expr(value).widen())) + .unzip(); + self.push_element(owner, Some(Type::union(keys)), Type::union(values)); + } + } + + fn block(&mut self, block: &Block) { + for stmt in &block.stmts { + self.stmt(stmt); + } + } + + fn func_body(&mut self, func: &FuncBody) { + self.depth += 1; + self.block(&func.body); + self.depth -= 1; + } + + fn stmt(&mut self, stmt: &Stmt) { + match &stmt.kind { + StmtKind::Function { name, func } => { + self.function_decl(stmt, name, func); + self.func_body(func); + } + StmtKind::LocalFunction { func, .. } => self.func_body(func), + StmtKind::Local { names, exprs, .. } => { + if self.depth == 0 { + for (i, name) in names.iter().enumerate() { + if let Some(fields) = exprs.get(i).and_then(table_fields) { + let doc = self.ctx.doc_at(stmt.span.start); + let owner = match doc.classes.last() { + Some(class) => class.name.clone(), + None => self.ctx.local_owner_key(name.name.span.start), + }; + if let Some(enum_name) = &doc.enum_name { + self.enum_class(enum_name.clone(), doc.enum_keys, fields, name.name.span); + } + self.table_members(owner, fields, 1); + } + } + } + exprs.iter().for_each(|e| self.expr(e)); + } + StmtKind::Assign { targets, exprs } => { + for (i, target) in targets.iter().enumerate() { + self.assignment(stmt, target, exprs.get(i)); + self.expr(target); + } + exprs.iter().for_each(|e| self.expr(e)); + } + StmtKind::CompoundAssign { expr, .. } => self.expr(expr), + StmtKind::Expr(expr) => { + self.call_stmt(stmt, expr); + self.expr(expr); + } + StmtKind::Do(body) | StmtKind::Defer(body) => self.block(body), + StmtKind::While { cond, body } => { + self.expr(cond); + self.block(body); + } + StmtKind::Repeat { body, cond } => { + self.block(body); + self.expr(cond); + } + StmtKind::If { branches, else_block } => { + for branch in branches { + self.expr(&branch.cond); + self.block(&branch.block); + } + if let Some(block) = else_block { + self.block(block); + } + } + StmtKind::NumericFor { body, .. } => self.block(body), + StmtKind::GenericFor { exprs, body, .. } => { + exprs.iter().for_each(|e| self.expr(e)); + self.block(body); + } + StmtKind::Return(exprs) => exprs.iter().for_each(|e| self.expr(e)), + StmtKind::Break | StmtKind::Goto(_) | StmtKind::Label(_) | StmtKind::Error => {} + } + } + + fn function_decl(&mut self, stmt: &Stmt, name: &FuncName, func: &FuncBody) { + let doc = self.ctx.doc_at(stmt.span.start); + let is_method = name.method.is_some(); + let fun = Type::Fun(Arc::new(self.infer.fun_type(func, Some(stmt.span.start), is_method))); + let last = name.method.as_ref().or(name.path.last()).unwrap_or(&name.base); + let symbol = Symbol { + name: last.text.clone(), + kind: if is_method { SymbolKind::Method } else { SymbolKind::Function }, + ty: fun, + doc: render_doc(&doc), + deprecated: doc.deprecated.is_some(), + literal: None, + range: self.range(last.span), + }; + if name.path.is_empty() && name.method.is_none() { + if self.is_global(&name.base) { + self.out.globals.push(symbol); + } + return; + } + let owner_path = if is_method { &name.path[..] } else { &name.path[..name.path.len() - 1] }; + let mut owner = if self.is_global(&name.base) { + self.global_class(&name.base.text).unwrap_or_else(|| name.base.text.clone()) + } else { + let base = FuncName { base: name.base.clone(), path: Vec::new(), method: None, span: name.base.span }; + match self.owner_of(&self.infer.func_name_owner_type(&base)) { + Some(owner) => owner, + None => return, + } + }; + for segment in owner_path { + owner = SmolStr::new(format!("{owner}.{}", segment.text)); + } + self.push_member(owner, symbol); + } + + fn assignment(&mut self, stmt: &Stmt, target: &Expr, value: Option<&Expr>) { + match &target.kind { + ExprKind::Name(name) if self.is_global(name) => { + let symbol = self.value_symbol(name, value, stmt.span.start, &name.text.clone(), 0); + self.out.globals.push(symbol); + } + ExprKind::Field { base, name, .. } if !name.is_missing() => { + self.member_assignment(stmt, base, name, value); + } + ExprKind::Index { base, index, .. } => { + if let Some(text) = index.as_string() { + let name = Name { text: text.clone(), span: index.span }; + self.member_assignment(stmt, base, &name, value); + } + } + _ => {} + } + } + + fn member_assignment(&mut self, stmt: &Stmt, base: &Expr, name: &Name, value: Option<&Expr>) { + if let ExprKind::Name(root) = &base.kind { + if self.is_global(root) && matches!(root.text.as_str(), "_ENV" | "_G") { + let symbol = self.value_symbol(name, value, stmt.span.start, &name.text.clone(), 0); + self.out.globals.push(symbol); + return; + } + } + let owner = match base.dotted_path() { + Some(path) if self.root_is_global(base) => { + let own_class = match &base.kind { + ExprKind::Name(root) => self.global_class(&root.text), + _ => None, + }; + match own_class.map(|class| Type::Named(class, Vec::new())).unwrap_or_else(|| self.infer.expr(base)) { + Type::Named(class, _) => class, + _ => SmolStr::new(path), + } + } + _ => match self.owner_of(&self.infer.expr(base)) { + Some(owner) => owner, + None => return, + }, + }; + let nested = format!("{owner}.{}", name.text); + let mut symbol = self.value_symbol(name, value, stmt.span.start, &nested, 1); + if symbol.kind == SymbolKind::Variable { + symbol.kind = SymbolKind::Field; + } + self.push_member(owner, symbol); + } + + fn root_is_global(&self, expr: &Expr) -> bool { + let mut current = expr; + loop { + match ¤t.kind { + ExprKind::Field { base, .. } | ExprKind::Index { base, .. } => current = base, + ExprKind::Name(name) => return self.is_global(name), + _ => return false, + } + } + } + + fn module_return(&mut self, exprs: &[Expr]) { + let Some(first) = exprs.first() else { return }; + self.out.module_return = Some(match table_fields(first) { + Some(fields) => { + let owner = SmolStr::new(format!("%mod{}", self.file)); + self.table_members(owner.clone(), fields, 1); + Type::GlobalTable(owner) + } + None => self.infer.expr(first).widen(), + }); + } + + fn call_stmt(&mut self, stmt: &Stmt, expr: &Expr) { + let ExprKind::Call { callee, args, .. } = &expr.kind else { return }; + if callee.dotted_path().as_deref() != Some("exports") { + return; + } + let (Some(name_arg), Some(value)) = (args.first(), args.get(1)) else { return }; + let Some(name) = name_arg.as_string() else { + self.out.dynamic_exports = true; + return; + }; + let doc = self.ctx.doc_at(stmt.span.start); + let ty = match &value.kind { + ExprKind::Function(func) => Type::Fun(Arc::new(self.infer.fun_type(func, Some(stmt.span.start), false))), + ExprKind::Name(global) if self.is_global(global) => { + let own = self.out.globals.iter().rev().find(|s| s.name == global.text).map(|s| s.ty.clone()); + own.unwrap_or_else(|| self.infer.expr(value)) + } + _ => self.infer.expr(value), + }; + let mut symbol_doc = render_doc(&doc); + if symbol_doc.is_none() { + symbol_doc = self.referenced_doc(value); + } + self.out.exports.push(Symbol { + name: name.clone(), + kind: SymbolKind::Export, + ty, + doc: symbol_doc, + deprecated: doc.deprecated.is_some(), + literal: None, + range: self.range(name_arg.span), + }); + } + + /// Docs of the function an export refers to by name, e.g. `exports('GetPlayer', GetPlayer)`. + fn referenced_doc(&self, value: &Expr) -> Option> { + let ExprKind::Name(name) = &value.kind else { return None }; + match self.ctx.resolution.resolve_at(name.span.start)? { + Resolved::Local(id) => { + let decl = self.ctx.resolution.local(id).decl.start; + let anchor = match self.ctx.decl(decl)? { + crate::infer::Decl::LocalFunction { stmt, .. } | crate::infer::Decl::Local { stmt, .. } => { + stmt.span.start + } + _ => return None, + }; + render_doc(&self.ctx.doc_at(anchor)) + } + Resolved::Global(_) => self.out.globals.iter().find(|s| s.name == name.text).and_then(|s| s.doc.clone()), + } + } + + fn state_key(&mut self, key: Option<&SmolStr>) { + let Some(key) = key.filter(|k| !k.is_empty() && k.as_str() != "set") else { return }; + if !self.out.state_keys.contains(key) { + self.out.state_keys.push(key.clone()); + } + } + + /// ox_lib fills its cache through `cache:set('ped', ped)`, so the field names only exist as strings. + fn keyed_setter(&mut self, base: &Expr, method: &Name, args: &[Expr]) { + let is_cache = matches!(&base.kind, ExprKind::Name(name) if name.text == "cache"); + let (true, "set", [key, value, ..]) = (is_cache, method.text.as_str(), args) else { return }; + let (Some(field), Some(owner)) = (key.as_string(), self.owner_of(&self.infer.expr(base))) else { return }; + let symbol = Symbol { + name: field.clone(), + kind: SymbolKind::Field, + ty: self.infer.expr(value).widen(), + doc: None, + deprecated: false, + literal: None, + range: self.range(key.span), + }; + self.push_member(owner, symbol); + } + + fn event(&mut self, callee: &Expr, args: &[Expr], offset: u32) { + let framework = crate::framework_callbacks::classify(self.ctx, self.infer.index, callee); + let side = self.regions.effective(offset, self.side); + let (family, kind) = if let Some(call) = framework { + if side != Some(call.required_side()) { + return; + } + (call.family, call.kind) + } else { + let Some(path) = callee.dotted_path() else { return }; + let path = path.as_str(); + if NET_EVENT_CALLS.contains(&path) { + (EventFamily::Native, EventKind::NetEvent) + } else if HANDLER_CALLS.contains(&path) { + (EventFamily::Native, EventKind::Handler) + } else if CALLBACK_CALLS.contains(&path) { + (EventFamily::OxLib, EventKind::Callback) + } else if TRIGGER_CALLS.contains(&path) { + (if path.starts_with("lib.") { EventFamily::OxLib } else { EventFamily::Native }, EventKind::Trigger) + } else { + return; + } + }; + let Some(name_arg) = args.first() else { return }; + let Some(name) = name_arg.as_string().filter(|n| !n.is_empty()) else { return }; + let handler = if framework.is_some() { + args.get(1).filter(|_| kind == EventKind::Callback).and_then(|arg| match &arg.kind { + ExprKind::Function(func) => Some(Arc::new(self.infer.fun_type(func, Some(offset), false))), + ExprKind::Name(name) => { + let Some(Resolved::Local(id)) = self.ctx.resolution.resolve_at(name.span.start) else { + return None; + }; + if self.ctx.resolution.local(id).refs.iter().any(|reference| reference.write) { + return None; + } + self.infer.expr(arg).as_fun().cloned() + } + _ => None, + }) + } else { + args.iter().skip(1).find_map(|arg| match &arg.kind { + ExprKind::Function(func) => Some(Arc::new(self.infer.fun_type(func, None, false))), + _ => None, + }) + }; + self.out.events.push(EventDef { + name: name.clone(), + kind, + family, + side, + handler, + range: self.range(name_arg.span), + }); + } + + fn nui_callback(&mut self, callee: &Expr, args: &[Expr], offset: u32) { + let side = self.regions.effective(offset, self.side); + if !matches!(side, Some(Side::Client | Side::Shared)) || self.side == Some(Side::Server) { + return; + } + let Some(registration) = self.nui_globals.registration(self.ctx, callee) else { return }; + let [argument, _, ..] = args else { return }; + let Some(name) = argument.as_string().filter(|name| !name.is_empty()) else { return }; + if self + .ctx + .chunk + .errors + .iter() + .any(|error| error.span.start < argument.span.end && error.span.end > argument.span.start) + { + return; + } + self.out.nui_callbacks.push(NuiCallbackDef { + name: name.clone(), + registration: registration.into(), + range: self.range(argument.span), + }); + } + + fn expr(&mut self, expr: &Expr) { + match &expr.kind { + ExprKind::Function(func) => self.func_body(func), + ExprKind::Call { callee, args, .. } => { + self.event(callee, args, expr.span.start); + self.nui_callback(callee, args, expr.span.start); + if let Some(path) = callee.dotted_path() { + let first = args.first().and_then(|a| a.as_string()); + if CONVAR_CALLS.contains(&path.as_str()) { + if let Some(name) = first.filter(|n| !n.is_empty() && !self.out.convars.contains(n)) { + self.out.convars.push(name.clone()); + } + } else if path == "AddStateBagChangeHandler" { + self.state_key(first); + } + } + self.expr(callee); + args.iter().for_each(|a| self.expr(a)); + } + ExprKind::MethodCall { base, method, args, .. } => { + self.keyed_setter(base, method, args); + if let (true, "set", Some(key)) = (is_state_bag(base), method.text.as_str(), args.first()) { + self.state_key(key.as_string()); + } + self.expr(base); + args.iter().for_each(|a| self.expr(a)); + } + ExprKind::Index { base, index, .. } => { + if is_state_bag(base) { + self.state_key(index.as_string()); + } + self.expr(base); + self.expr(index); + } + ExprKind::Field { base, name, .. } => { + if is_state_bag(base) { + self.state_key(Some(&name.text)); + } + self.expr(base) + } + ExprKind::Binary { lhs, rhs, .. } => { + self.expr(lhs); + self.expr(rhs); + } + ExprKind::Unary { expr, .. } | ExprKind::Paren(expr) => self.expr(expr), + ExprKind::Table(fields) => { + for field in fields { + match field { + TableField::Positional(value) | TableField::Named { value, .. } => self.expr(value), + TableField::Keyed { key, value } => { + self.expr(key); + self.expr(value); + } + TableField::SetMember(_) => {} + } + } + } + _ => {} + } + } +} diff --git a/crates/qbx_lua_ls/src/infer.rs b/crates/qbx_lua_ls/src/infer.rs new file mode 100644 index 0000000..b5b2c5f --- /dev/null +++ b/crates/qbx_lua_ls/src/infer.rs @@ -0,0 +1,1322 @@ +use std::cell::{Cell, OnceCell, RefCell}; +use std::rc::Rc; +use std::sync::Arc; + +use lsp_types::Range; +use qbx_fivem_data::native; +use qbx_lua_analysis::env::leading_doc_lines; +use qbx_lua_analysis::scope::{LocalId, LocalKind, Resolution, Resolved}; +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::{NumberValue, SmolStr}; +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::index::{FileId, Index, SymbolKind}; +use crate::luacats::{parse_doc_lines, DocGroup}; +use crate::types::{FunType, Param, Shape, ShapeField, Type}; + +const MAX_DEPTH: u32 = 24; +const MAX_SHAPE_FIELDS: usize = 96; + +/// Bits for the kinds of Lua value a type allows, used to pick the `@overload` a call fits. +mod kind { + pub const NIL: u8 = 1; + pub const BOOLEAN: u8 = 2; + pub const NUMBER: u8 = 4; + pub const STRING: u8 = 8; + pub const TABLE: u8 = 16; + pub const FUNCTION: u8 = 32; + pub const OTHER: u8 = 64; +} + +/// How well a call's arguments line up with a signature. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Fit { + No, + /// Only by passing some of them to its `...`. + ThroughVararg, + Exact, +} + +/// The arguments of one call, each inferred at most once while its signature is chosen and its +/// generics are bound. +struct CallArgs<'e> { + exprs: &'e [Expr], + types: Vec>, +} + +impl<'e> CallArgs<'e> { + fn new(exprs: &'e [Expr]) -> Self { + Self { exprs, types: exprs.iter().map(|_| OnceCell::new()).collect() } + } + + fn ty(&self, infer: &Infer, index: usize) -> &Type { + self.types[index].get_or_init(|| infer.expr(&self.exprs[index])) + } +} + +pub enum Decl<'a> { + Local { stmt: &'a Stmt, index: usize }, + LocalFunction { stmt: &'a Stmt, func: &'a FuncBody }, + Param { func: &'a FuncBody, index: usize, doc_anchor: Option, expected: Option> }, + SelfParam { name: &'a FuncName }, + NumericFor, + GenericFor { stmt: &'a Stmt, index: usize }, +} + +/// A function literal passed as a call argument: its parameters can be typed from the callee. +#[derive(Clone, Copy)] +pub struct Expected<'a> { + pub call: &'a Expr, + pub arg_index: usize, +} + +pub struct FileContext<'a> { + pub file: FileId, + pub source: &'a str, + pub chunk: &'a Chunk, + pub resolution: &'a Resolution, + decls: FxHashMap>, + docs: RefCell>>, +} + +impl<'a> FileContext<'a> { + pub fn new(file: FileId, source: &'a str, chunk: &'a Chunk, resolution: &'a Resolution) -> Self { + let mut collector = DeclCollector { decls: FxHashMap::default() }; + collector.block(&chunk.block); + Self { file, source, chunk, resolution, decls: collector.decls, docs: RefCell::new(FxHashMap::default()) } + } + + pub fn decl(&self, decl_start: u32) -> Option<&Decl<'a>> { + self.decls.get(&decl_start) + } + + pub fn doc_at(&self, stmt_start: u32) -> Rc { + if let Some(doc) = self.docs.borrow().get(&stmt_start) { + return doc.clone(); + } + let lines = leading_doc_lines(self.source, &self.chunk.comments, stmt_start); + let doc = Rc::new(parse_doc_lines(&lines)); + self.docs.borrow_mut().insert(stmt_start, doc.clone()); + doc + } + + pub fn local_owner_key(&self, decl_start: u32) -> SmolStr { + SmolStr::new(format!("%f{}:{}", self.file, decl_start)) + } + + /// The declaration behind a `local_owner_key` of this file. + fn local_owner_decl(&self, owner: &str) -> Option { + let (file, decl_start) = owner.strip_prefix("%f")?.split_once(':')?; + (file.parse::().ok()? == self.file).then(|| decl_start.parse().ok()).flatten() + } +} + +struct DeclCollector<'a> { + decls: FxHashMap>, +} + +impl<'a> DeclCollector<'a> { + fn block(&mut self, block: &'a Block) { + for stmt in &block.stmts { + self.stmt(stmt); + } + } + + fn func(&mut self, func: &'a FuncBody, doc_anchor: Option, expected: Option>) { + for (index, param) in func.params.iter().enumerate() { + self.decls.insert(param.span.start, Decl::Param { func, index, doc_anchor, expected }); + } + self.block(&func.body); + } + + fn stmt(&mut self, stmt: &'a Stmt) { + let anchor = Some(stmt.span.start); + match &stmt.kind { + StmtKind::Local { names, exprs, .. } => { + for (index, name) in names.iter().enumerate() { + self.decls.insert(name.name.span.start, Decl::Local { stmt, index }); + } + for expr in exprs { + self.expr(expr, anchor); + } + } + StmtKind::LocalFunction { name, func } => { + self.decls.insert(name.span.start, Decl::LocalFunction { stmt, func }); + self.func(func, anchor, None); + } + StmtKind::Function { name, func } => { + if name.method.is_some() { + self.decls.insert(func.params_span.start, Decl::SelfParam { name }); + } + self.func(func, anchor, None); + } + StmtKind::Assign { targets, exprs } => { + targets.iter().for_each(|e| self.expr(e, None)); + exprs.iter().for_each(|e| self.expr(e, anchor)); + } + StmtKind::CompoundAssign { target, expr, .. } => { + self.expr(target, None); + self.expr(expr, None); + } + StmtKind::Expr(expr) => self.expr(expr, None), + StmtKind::Do(body) | StmtKind::Defer(body) => self.block(body), + StmtKind::While { cond, body } => { + self.expr(cond, None); + self.block(body); + } + StmtKind::Repeat { body, cond } => { + self.block(body); + self.expr(cond, None); + } + StmtKind::If { branches, else_block } => { + for branch in branches { + self.expr(&branch.cond, None); + self.block(&branch.block); + } + if let Some(block) = else_block { + self.block(block); + } + } + StmtKind::NumericFor { var, start, limit, step, body } => { + self.decls.insert(var.span.start, Decl::NumericFor); + self.expr(start, None); + self.expr(limit, None); + if let Some(step) = step { + self.expr(step, None); + } + self.block(body); + } + StmtKind::GenericFor { names, exprs, body } => { + for (index, name) in names.iter().enumerate() { + self.decls.insert(name.span.start, Decl::GenericFor { stmt, index }); + } + exprs.iter().for_each(|e| self.expr(e, None)); + self.block(body); + } + StmtKind::Return(exprs) => exprs.iter().for_each(|e| self.expr(e, None)), + StmtKind::Break | StmtKind::Goto(_) | StmtKind::Label(_) | StmtKind::Error => {} + } + } + + fn expr(&mut self, expr: &'a Expr, doc_anchor: Option) { + match &expr.kind { + ExprKind::Function(func) => self.func(func, doc_anchor, None), + ExprKind::Call { callee, args, .. } => { + self.expr(callee, None); + self.call_args(expr, args); + } + ExprKind::MethodCall { base, args, .. } => { + self.expr(base, None); + self.call_args(expr, args); + } + ExprKind::Index { base, index, .. } => { + self.expr(base, None); + self.expr(index, None); + } + ExprKind::Field { base, .. } => self.expr(base, None), + ExprKind::Binary { lhs, rhs, .. } => { + self.expr(lhs, None); + self.expr(rhs, None); + } + ExprKind::Unary { expr, .. } | ExprKind::Paren(expr) => self.expr(expr, None), + ExprKind::Table(fields) => { + for field in fields { + match field { + TableField::Positional(value) | TableField::Named { value, .. } => self.expr(value, None), + TableField::Keyed { key, value } => { + self.expr(key, None); + self.expr(value, None); + } + TableField::SetMember(_) => {} + } + } + } + _ => {} + } + } + + fn call_args(&mut self, call: &'a Expr, args: &'a [Expr]) { + for (arg_index, arg) in args.iter().enumerate() { + match &arg.kind { + ExprKind::Function(func) => self.func(func, None, Some(Expected { call, arg_index })), + _ => self.expr(arg, None), + } + } + } +} + +#[derive(Clone, Debug)] +pub struct MemberInfo { + pub name: SmolStr, + pub ty: Type, + pub doc: Option>, + pub deprecated: bool, + pub literal: Option, + pub kind: SymbolKind, + pub location: Option<(FileId, Range)>, +} + +pub struct Infer<'a> { + pub ctx: &'a FileContext<'a>, + pub index: &'a Index, + locals: RefCell>, + in_progress: RefCell>, + depth: Cell, +} + +/// Natives call their handles `Vehicle`, `Ped` and so on. They are integers, and resources (ox_lib, +/// qbx_core) declare unrelated classes under the same names, so they must not resolve as classes. +const NATIVE_HANDLE_TYPES: &[&str] = + &["Vehicle", "Ped", "Entity", "Object", "Player", "Hash", "Cam", "Blip", "Pickup", "ScrHandle", "FireId"]; + +fn native_type(name: &str) -> Type { + if NATIVE_HANDLE_TYPES.contains(&name) { + return Type::Handle(SmolStr::new(name)); + } + Type::named(name) +} + +pub fn native_fun_type(native: &qbx_fivem_data::Native) -> FunType { + FunType { + params: native + .params() + .map(|(name, ty)| Param { name: SmolStr::new(name), ty: native_type(ty), optional: false }) + .collect(), + returns: native.returns().map(native_type).collect(), + is_method: false, + generics: Vec::new(), + overloads: Vec::new(), + } +} + +impl<'a> Infer<'a> { + pub fn new(ctx: &'a FileContext<'a>, index: &'a Index) -> Self { + Self { + ctx, + index, + locals: RefCell::new(FxHashMap::default()), + in_progress: RefCell::new(FxHashSet::default()), + depth: Cell::new(0), + } + } + + fn guarded(&self, f: impl FnOnce() -> T) -> T { + if self.depth.get() >= MAX_DEPTH { + return T::default(); + } + self.depth.set(self.depth.get() + 1); + let out = f(); + self.depth.set(self.depth.get() - 1); + out + } + + pub fn expr(&self, expr: &Expr) -> Type { + self.expr_multi(expr).into_iter().next().unwrap_or_default() + } + + pub fn expr_multi(&self, expr: &Expr) -> Vec { + self.guarded(|| match &expr.kind { + ExprKind::Call { callee, args, .. } => self.call(callee, None, args), + ExprKind::MethodCall { base, method, args, .. } => self.call(base, Some(method), args), + _ => vec![self.single(expr)], + }) + } + + fn single(&self, expr: &Expr) -> Type { + match &expr.kind { + ExprKind::Nil => Type::Nil, + ExprKind::True => Type::BooleanLit(true), + ExprKind::False => Type::BooleanLit(false), + ExprKind::Number(NumberValue::Int(i)) => Type::IntLit(*i), + ExprKind::Number(NumberValue::Float(_)) => Type::Number, + ExprKind::String(s) => Type::StringLit(s.clone()), + ExprKind::JenkinsHash(_) => Type::Integer, + ExprKind::Vararg => Type::Any, + ExprKind::Function(func) => Type::Fun(Arc::new(self.fun_type(func, None, false))), + ExprKind::Name(name) => self.name(name), + ExprKind::Paren(inner) => self.expr(inner), + ExprKind::Field { base, name, .. } => { + if let ExprKind::Name(root) = &base.kind { + let is_env = matches!(root.text.as_str(), "_ENV" | "_G") + && matches!(self.ctx.resolution.resolve_at(root.span.start), Some(Resolved::Global(_))); + if is_env { + return self.global_type(&name.text); + } + } + let base_ty = self.expr(base); + self.member(&base_ty, &name.text).map(|m| m.ty).unwrap_or_default() + } + ExprKind::Index { base, index, .. } => self.index_expr(base, index), + ExprKind::Table(fields) => self.table(fields), + ExprKind::Binary { op, lhs, rhs, .. } => self.binary(*op, lhs, rhs), + ExprKind::Unary { op, expr } => match op { + UnOp::Not => Type::Boolean, + UnOp::Len => match self.expr(expr) { + Type::Named(name, _) if name.starts_with("vector") => Type::Number, + _ => Type::Integer, + }, + UnOp::BNot => Type::Integer, + UnOp::Neg => self.expr(expr).widen(), + }, + ExprKind::Call { .. } | ExprKind::MethodCall { .. } | ExprKind::Error => Type::Unknown, + } + } + + fn name(&self, name: &Name) -> Type { + match self.ctx.resolution.resolve_at(name.span.start) { + Some(Resolved::Local(id)) => self.local_type(id), + _ => self.global_type(&name.text), + } + } + + pub fn global_type(&self, name: &str) -> Type { + if name == "exports" { + return Type::Exports(None); + } + let symbols = self.index.globals_named(name, self.ctx.file); + let known = symbols + .iter() + .map(|(_, s)| &s.ty) + .filter(|ty| !ty.is_unknown()) + .max_by_key(|ty| (matches!(ty, Type::GlobalTable(_) | Type::Named(..)), ty.specificity())); + if let Some(ty) = known.filter(|ty| !(matches!(ty, Type::Table) && self.index.has_members(name))) { + // `local lib = {}` published with `_ENV.lib = lib` and then extended as `function lib.x()` + // elsewhere keeps its members under two owners. + let aliased = matches!(ty, Type::GlobalTable(owner) if owner != name) && self.index.has_members(name); + return if aliased { Type::union([ty.clone(), Type::GlobalTable(SmolStr::new(name))]) } else { ty.clone() }; + } + if self.index.has_members(name) { + return Type::GlobalTable(SmolStr::new(name)); + } + match native(name) { + Some(native) => Type::Fun(Arc::new(native_fun_type(&native))), + None => Type::Unknown, + } + } + + pub fn local_type(&self, id: LocalId) -> Type { + if let Some(ty) = self.locals.borrow().get(&id) { + return ty.clone(); + } + if !self.in_progress.borrow_mut().insert(id) { + return Type::Unknown; + } + let ty = self.guarded(|| self.compute_local(id)); + self.in_progress.borrow_mut().remove(&id); + self.locals.borrow_mut().insert(id, ty.clone()); + ty + } + + fn compute_local(&self, id: LocalId) -> Type { + let local = self.ctx.resolution.local(id); + if local.kind == LocalKind::ImplicitSelf { + return match self.ctx.decl(local.decl.start) { + Some(Decl::SelfParam { name }) => self.func_name_owner_type(name), + _ => Type::Unknown, + }; + } + let Some(decl) = self.ctx.decl(local.decl.start) else { return Type::Unknown }; + match decl { + Decl::Local { stmt, index } => self.local_stmt_type(stmt, *index, local.func == 0), + Decl::LocalFunction { stmt, func } => { + Type::Fun(Arc::new(self.fun_type(func, Some(stmt.span.start), false))) + } + Decl::Param { func, index, doc_anchor, expected } => { + let name = &func.params[*index].text; + if let Some(anchor) = doc_anchor { + let doc = self.ctx.doc_at(*anchor); + if let Some(param) = doc.params.iter().find(|p| p.name == *name) { + return if param.optional { param.ty.clone().optional() } else { param.ty.clone() }; + } + } + expected.as_ref().and_then(|e| self.expected_param(e, *index)).unwrap_or_default() + } + Decl::SelfParam { name } => self.func_name_owner_type(name), + Decl::NumericFor => Type::Number, + Decl::GenericFor { stmt, index } => self.for_in_type(stmt, *index), + } + } + + fn local_stmt_type(&self, stmt: &Stmt, index: usize, top_level: bool) -> Type { + let StmtKind::Local { names, exprs, in_unpack } = &stmt.kind else { return Type::Unknown }; + let doc = self.ctx.doc_at(stmt.span.start); + if let Some(class) = doc.classes.last() { + return Type::Named(class.name.clone(), Vec::new()); + } + if let Some(ty) = &doc.ty { + return ty.clone(); + } + if *in_unpack { + let base = exprs.first().map(|e| self.expr(e)).unwrap_or_default(); + return self.member(&base, &names[index].name.text).map(|m| m.ty).unwrap_or_default(); + } + if let Some(expr) = exprs.get(index) { + let is_last = index + 1 == exprs.len(); + if top_level && table_fields(expr).is_some() { + return Type::GlobalTable(self.ctx.local_owner_key(names[index].name.span.start)); + } + let ty = + if is_last { self.expr_multi(expr).into_iter().next().unwrap_or_default() } else { self.expr(expr) }; + return ty.widen(); + } + match exprs.last() { + Some(last) if last.is_multi_value() => { + self.expr_multi(last).into_iter().nth(index + 1 - exprs.len()).unwrap_or_default().widen() + } + _ => Type::Unknown, + } + } + + /// `lib.onCache('vehicle', function(value, oldValue)`: both parameters are `cache.vehicle`. + fn on_cache_param(&self, expected: &Expected) -> Option { + let ExprKind::Call { callee, args, .. } = &expected.call.kind else { return None }; + if callee.dotted_path().as_deref() != Some("lib.onCache") { + return None; + } + let key = args.first()?.as_string()?; + self.member(&self.global_type("cache"), key).map(|m| m.ty) + } + + fn expected_param(&self, expected: &Expected, index: usize) -> Option { + if let Some(ty) = self.on_cache_param(expected).filter(|_| index < 2) { + return Some(ty); + } + let (fun, args, via_method) = match &expected.call.kind { + ExprKind::Call { callee, args, .. } => (self.expr(callee).as_fun().cloned(), args, false), + ExprKind::MethodCall { base, method, args, .. } => { + let member = self.member(&self.expr(base), &method.text); + (member.and_then(|m| m.ty.as_fun().cloned()), args, true) + } + _ => return None, + }; + let args = CallArgs::new(args); + let fun = self.signature_for(&fun?, &args, via_method); + let (skip_params, skip_args) = fun.call_offsets(via_method); + let param_index = (expected.arg_index + skip_params).checked_sub(skip_args)?; + let callback = fun.params.get(param_index)?.ty.as_fun()?.clone(); + let ty = &callback.params.get(index)?.ty; + Some(substitute(ty, &self.bind_generics(&fun, &args, via_method, false))) + } + + /// The type `self` has inside `function a.b:c()`, which is also the owner of `c`. + pub fn func_name_owner_type(&self, name: &FuncName) -> Type { + let mut ty = self.name(&name.base); + for segment in &name.path { + ty = self.member(&ty, &segment.text).map(|m| m.ty).unwrap_or_default(); + } + ty + } + + fn for_in_type(&self, stmt: &Stmt, index: usize) -> Type { + let StmtKind::GenericFor { exprs, .. } = &stmt.kind else { return Type::Unknown }; + let Some(first) = exprs.first() else { return Type::Unknown }; + let iterated = match &first.kind { + ExprKind::Call { callee, args, .. } => match (callee.dotted_path().as_deref(), args.first()) { + (Some(iterator @ ("pairs" | "ipairs" | "next")), Some(arg)) => Some((arg, iterator == "ipairs")), + _ => None, + }, + // `for k, v in next, t` + _ if first.dotted_path().as_deref() == Some("next") => exprs.get(1).map(|arg| (arg, false)), + _ => None, + }; + if let Some((arg, ipairs)) = iterated { + let (key, value) = match &arg.unparen().kind { + ExprKind::Table(fields) => self.inline_table_key_values(fields, ipairs), + _ => self.key_value_types(&self.expr(arg), ipairs), + }; + let key = if ipairs { Type::Integer } else { key }; + return if index == 0 { + key + } else if index == 1 { + value + } else { + Type::Unknown + }; + } + self.expr(first).as_fun().and_then(|f| f.returns.get(index).cloned()).unwrap_or_default() + } + + /// `pairs({ 'male', 'female' })`: nothing else can reach a table built in the call, so its keys + /// and values keep their literal types instead of widening to `string`. + fn inline_table_key_values(&self, fields: &[TableField], array_only: bool) -> (Type, Type) { + let fields = &fields[..fields.len().min(MAX_SHAPE_FIELDS)]; + let elements = table_elements(fields); + let mut keys = Vec::new(); + let mut values = Vec::new(); + for field in fields.iter().filter(|_| !array_only) { + let (key, value) = match field { + TableField::Named { name, value } => (Type::StringLit(name.text.clone()), self.expr(value)), + TableField::Keyed { key, value } if matches!(key.kind, ExprKind::String(_)) => { + (self.expr(key), self.expr(value)) + } + TableField::SetMember(name) => (Type::StringLit(name.text.clone()), Type::BooleanLit(true)), + TableField::Keyed { .. } | TableField::Positional(_) => continue, + }; + keys.push(key); + values.push(value); + } + for value in elements.array { + keys.push(Type::Integer); + values.push(self.expr(value)); + } + for (key, value) in elements.keyed.into_iter().filter(|_| !array_only) { + keys.push(self.expr(key)); + values.push(self.expr(value)); + } + (Type::union(keys), Type::union(values)) + } + + /// What `pairs` yields for a value of type `ty`, or with `array_only` what `ipairs` yields. + pub fn key_value_types(&self, ty: &Type, array_only: bool) -> (Type, Type) { + let resolved = self.resolve_alias(ty); + let (keys, values): (Vec, Vec) = match resolved { + Type::Array(inner) => return (Type::Integer, *inner), + Type::Tuple(items) => return (Type::Integer, Type::union(items)), + Type::Map(k, v) => return (*k, *v), + Type::Shape(shape) => { + let fields = shape.fields.iter().filter(|_| !array_only).map(|f| (Type::String, f.ty.clone())); + let array = shape.array.iter().map(|value| (Type::Integer, value.clone())); + let index = shape.index.iter().filter(|(key, _)| !array_only || is_integer_key(key)).cloned(); + fields.chain(array).chain(index).unzip() + } + Type::Named(ref name, _) => { + let index = self + .index + .class(name) + .and_then(|(_, c)| c.index.clone()) + .filter(|(key, _)| !array_only || is_integer_key(key)); + // An instance holds its data; the methods its class provides are not visited. + let fields = self + .members(&resolved) + .into_iter() + .filter(|m| !array_only && !matches!(m.kind, SymbolKind::Method | SymbolKind::Function)) + .map(|m| (Type::String, m.ty)); + let (keys, values): (Vec, Vec) = fields.chain(index).unzip(); + if keys.is_empty() { + return (Type::String, Type::Unknown); + } + (keys, values) + } + Type::GlobalTable(owner) => return self.global_table_key_values(&owner, array_only), + Type::Union(types) => types + .iter() + .filter(|t| !matches!(t, Type::Nil)) + .map(|t| self.guarded(|| self.key_value_types(t, array_only))) + .unzip(), + _ => return (Type::Unknown, Type::Unknown), + }; + (Type::union(keys), Type::union(values)) + } + + /// Keys and values of a table the index holds: its named fields are members, its array part and + /// other keys are elements. A top-level local of this file reads those from its constructor + /// instead, which is current even while the index still has the file's previous text. + fn global_table_key_values(&self, owner: &SmolStr, array_only: bool) -> (Type, Type) { + let mut keys = Vec::new(); + let mut values = Vec::new(); + if !array_only { + for member in self.members(&Type::GlobalTable(owner.clone())) { + keys.push(Type::String); + values.push(member.ty); + } + } + match self.local_table_fields(owner) { + Some(fields) => { + let elements = table_elements(&fields[..fields.len().min(MAX_SHAPE_FIELDS)]); + for value in elements.array { + keys.push(Type::Integer); + values.push(self.expr(value).widen()); + } + for (key, value) in elements.keyed.into_iter().filter(|_| !array_only) { + keys.push(self.expr(key).widen()); + values.push(self.expr(value).widen()); + } + } + None => { + for element in self.index.elements_of(owner, self.ctx.file) { + let key = match &element.key { + None => Type::Integer, + Some(_) if array_only => continue, + Some(key) => key.clone(), + }; + keys.push(key); + values.push(element.value.clone()); + } + } + } + if keys.is_empty() { + return (Type::String, Type::Unknown); + } + (Type::union(keys), Type::union(values)) + } + + fn local_table_fields(&self, owner: &str) -> Option<&'a [TableField]> { + let Decl::Local { stmt, index } = self.ctx.decl(self.ctx.local_owner_decl(owner)?)? else { return None }; + let StmtKind::Local { exprs, .. } = &stmt.kind else { return None }; + exprs.get(*index).and_then(table_fields) + } + + pub fn resolve_alias(&self, ty: &Type) -> Type { + let mut current = ty.clone(); + for _ in 0..8 { + match ¤t { + Type::Named(name, _) if self.index.class(name).is_none() => match self.index.alias(name) { + Some((_, alias)) => current = alias.ty.clone(), + None => break, + }, + Type::Require(path) => match self.module_type(path) { + Some(ty) => current = ty, + None => break, + }, + _ => break, + } + } + current + } + + fn module_type(&self, path: &str) -> Option { + let file = self.index.resolve_require(path, self.ctx.file)?; + self.index.file(file)?.index.module_return.clone() + } + + fn index_expr(&self, base: &Expr, index: &Expr) -> Type { + let base_ty = self.expr(base); + let key_ty = self.expr(index); + let mut keys = Vec::new(); + if self.literal_keys(&key_ty, 0, &mut keys) && !keys.is_empty() { + let fields: Option> = keys.iter().map(|key| self.member(&base_ty, key).map(|m| m.ty)).collect(); + if let Some(fields) = fields { + return Type::union(fields); + } + } + self.element_type(&base_ty.without_nil(), index, &key_ty, 0) + } + + /// What `base[index]` holds when the key is not a known field name. + fn element_type(&self, base: &Type, index: &Expr, key_ty: &Type, depth: u32) -> Type { + match self.resolve_alias(base) { + Type::Union(types) if depth < 8 => Type::union( + types + .iter() + .filter(|t| !matches!(t, Type::Nil)) + .map(|t| self.element_type(t, index, key_ty, depth + 1)), + ), + Type::Array(inner) => *inner, + Type::Map(_, value) => *value, + Type::Tuple(items) => match &index.kind { + ExprKind::Number(NumberValue::Int(i)) => { + items.get((*i as usize).wrapping_sub(1)).cloned().unwrap_or_default() + } + _ => Type::union(items), + }, + Type::Shape(shape) => Type::union(shape.array.iter().chain(shape.index.as_ref().map(|(_, v)| v)).cloned()), + Type::Named(name, _) => { + self.index.class(&name).and_then(|(_, c)| c.index.as_ref().map(|(_, v)| v.clone())).unwrap_or_default() + } + // `list[i]` on a table whose array part the index or a top-level local's constructor holds. + Type::GlobalTable(owner) + if matches!(key_ty.without_nil().widen(), Type::Integer | Type::Number | Type::Unknown) => + { + self.global_table_key_values(&owner, true).1 + } + Type::String | Type::StringLit(_) => Type::Unknown, + _ => Type::Unknown, + } + } + + /// The field names a key can be: `'male'`, or every name of a `"male"|"female"` loop variable or + /// alias. False when any part of the key is not a string literal. + fn literal_keys(&self, key: &Type, depth: u32, out: &mut Vec) -> bool { + if depth > 8 { + return false; + } + match self.resolve_alias(key) { + Type::StringLit(name) => { + out.push(name); + true + } + Type::Union(types) => { + types.iter().filter(|t| !matches!(t, Type::Nil)).all(|t| self.literal_keys(t, depth + 1, out)) + } + _ => false, + } + } + + fn table(&self, fields: &[TableField]) -> Type { + if fields.is_empty() { + return Type::Table; + } + let fields = &fields[..fields.len().min(MAX_SHAPE_FIELDS)]; + let mut shape = Shape::default(); + for field in fields { + let (name, ty) = match field { + TableField::Named { name, value } => (name.text.clone(), self.expr(value).widen()), + TableField::Keyed { key: Expr { kind: ExprKind::String(name), .. }, value } => { + (name.clone(), self.expr(value).widen()) + } + TableField::SetMember(name) => (name.text.clone(), Type::Boolean), + TableField::Keyed { .. } | TableField::Positional(_) => continue, + }; + shape.fields.push(ShapeField { name, ty, optional: false }); + } + let elements = table_elements(fields); + let array = Type::union(elements.array.iter().map(|value| self.expr(value).widen())); + if shape.fields.is_empty() && elements.keyed.is_empty() { + return Type::Array(Box::new(array)); + } + if !elements.array.is_empty() { + shape.array = Some(array); + } + if !elements.keyed.is_empty() { + let (keys, values): (Vec, Vec) = + elements.keyed.iter().map(|(key, value)| (self.expr(key).widen(), self.expr(value).widen())).unzip(); + shape.index = Some((Type::union(keys), Type::union(values))); + } + Type::Shape(Arc::new(shape)) + } + + fn binary(&self, op: BinOp, lhs: &Expr, rhs: &Expr) -> Type { + match op { + BinOp::Concat => Type::String, + BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge => Type::Boolean, + BinOp::And => Type::union([self.expr(rhs).widen(), Type::BooleanLit(false)]).widen(), + BinOp::Or => { + let left = self.expr(lhs).without_nil().widen(); + let left = match left { + Type::Boolean => Type::Unknown, + other => other, + }; + Type::union([left, self.expr(rhs).widen()]) + } + BinOp::BAnd | BinOp::BOr | BinOp::BXor | BinOp::Shl | BinOp::Shr | BinOp::IDiv => Type::Integer, + BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Mod | BinOp::Pow => { + let (left, right) = (self.expr(lhs).widen(), self.expr(rhs).widen()); + let is_vector = |t: &Type| matches!(t, Type::Named(n, _) if n.starts_with("vector") || n == "quat"); + if is_vector(&left) { + left + } else if is_vector(&right) { + right + } else if left == Type::Integer && right == Type::Integer && !matches!(op, BinOp::Div | BinOp::Pow) { + Type::Integer + } else { + Type::Number + } + } + } + } + + /// The callee's function type, taking `base:method` lookups into account. + pub fn callee_fun(&self, base: &Expr, method: Option<&Name>) -> Option<(Arc, Option)> { + match method { + Some(method) => { + let member = self.member(&self.expr(base), &method.text)?; + Some((member.ty.as_fun()?.clone(), Some(member))) + } + None => { + let ty = self.expr(base); + if let Some(fun) = ty.as_fun() { + return Some((fun.clone(), None)); + } + match self.resolve_alias(&ty) { + Type::Named(name, _) => { + self.index.class(&name).and_then(|(_, c)| c.call.clone()).map(|f| (f, None)) + } + _ => None, + } + } + } + } + + fn call(&self, base: &Expr, method: Option<&Name>, args: &[Expr]) -> Vec { + if method.is_none() { + match (base.dotted_path().as_deref(), args.first()) { + (Some("require" | "lib.require" | "lib.load"), Some(arg)) => { + if let Some(path) = arg.as_string() { + // `require 'glm'` returns the built-in library, not a file of the resource. + if path == "glm" { + return vec![self.global_type("glm")]; + } + return vec![Type::Require(path.clone())]; + } + } + (Some("setmetatable"), Some(arg)) => return vec![self.expr(arg)], + (Some("tostring"), _) => return vec![Type::String], + (Some("tonumber"), _) => return vec![Type::Number.optional()], + _ => {} + } + } + let Some((fun, _)) = self.callee_fun(base, method) else { return Vec::new() }; + let args = CallArgs::new(args); + let fun = self.signature_for(&fun, &args, method.is_some()); + let generics = self.bind_generics(&fun, &args, method.is_some(), true); + fun.returns.iter().map(|ret| substitute(ret, &generics)).collect() + } + + /// Binds the generics of `fun` from the arguments of a call. Function literals go last, and only + /// `with_callbacks`: their parameters are typed from what the other arguments bound, and their + /// returns bind the rest, as `RV` and `RK` in `fun(value: V, key: K): RV, RK`. + fn bind_generics( + &self, + fun: &FunType, + args: &CallArgs, + via_method: bool, + with_callbacks: bool, + ) -> Vec<(SmolStr, Type)> { + let mut bound = Vec::new(); + let (skip_params, skip_args) = fun.call_offsets(via_method); + let bindable: Vec<(&Param, usize)> = fun + .params + .iter() + .skip(skip_params) + .zip(skip_args..args.exprs.len()) + .filter(|(param, _)| self.can_bind(fun, ¶m.ty)) + .collect(); + let is_callback = |i: &usize| matches!(args.exprs[*i].unparen().kind, ExprKind::Function(_)); + for (param, i) in bindable.iter().filter(|(_, i)| !is_callback(i)) { + self.unify(fun, ¶m.ty, &args.ty(self, *i).widen(), &mut bound, 0); + } + if with_callbacks { + for (param, i) in bindable.iter().filter(|(_, i)| is_callback(i)) { + self.unify(fun, ¶m.ty, args.ty(self, *i), &mut bound, 0); + } + } + // A declared generic that no argument decides is unknown, not a type named `RV`. + for name in &fun.generics { + if !bound.iter().any(|(n, _)| n == name) { + bound.push((name.clone(), Type::Unknown)); + } + } + bound + } + + /// Matches a parameter type against the type of its argument, binding the generics in it. + fn unify(&self, fun: &FunType, param: &Type, arg: &Type, bound: &mut Vec<(SmolStr, Type)>, depth: u32) { + if depth > 8 || arg.is_unknown() { + return; + } + match param { + Type::Named(name, args) if args.is_empty() && self.is_generic(fun, name) => { + if !bound.iter().any(|(n, _)| n == name) { + bound.push((name.clone(), arg.clone())); + } + } + Type::Array(inner) => self.unify(fun, inner, &self.key_value_types(arg, true).1, bound, depth + 1), + Type::Map(key, value) => { + let (arg_key, arg_value) = self.key_value_types(arg, false); + self.unify(fun, key, &arg_key, bound, depth + 1); + self.unify(fun, value, &arg_value, bound, depth + 1); + } + Type::Union(types) => { + for part in types.iter().filter(|t| !matches!(t, Type::Nil)) { + self.unify(fun, part, &arg.without_nil(), bound, depth + 1); + } + } + Type::Fun(expected) => { + if let Some(given) = arg.as_fun() { + for (want, got) in expected.returns.iter().zip(&given.returns) { + self.unify(fun, want, got, bound, depth + 1); + } + } + } + _ => {} + } + } + + /// Whether an argument passed for `param` can bind a generic of `fun`, as far as `unify` looks. + fn can_bind(&self, fun: &FunType, param: &Type) -> bool { + match param { + Type::Named(name, args) => args.is_empty() && self.is_generic(fun, name), + Type::Array(inner) => self.can_bind(fun, inner), + Type::Map(key, value) => self.can_bind(fun, key) || self.can_bind(fun, value), + Type::Union(types) => types.iter().any(|t| self.can_bind(fun, t)), + Type::Fun(expected) => expected.returns.iter().any(|t| self.can_bind(fun, t)), + _ => false, + } + } + + /// The signature a call uses: the declared one, or else the first `@overload` that fits better, + /// so `fun(x, y, z): vector3` wins over a `vec(...)` that only takes three values through `...`. + fn signature_for(&self, fun: &Arc, args: &CallArgs, via_method: bool) -> Arc { + if fun.overloads.is_empty() { + return fun.clone(); + } + let declared = self.fit(fun, args, via_method); + fun.overloads.iter().find(|overload| self.fit(overload, args, via_method) > declared).unwrap_or(fun).clone() + } + + fn fit(&self, fun: &FunType, call: &CallArgs, via_method: bool) -> Fit { + let (skip_params, skip_args) = fun.call_offsets(via_method); + let params = fun.params.get(skip_params..).unwrap_or_default(); + let args = call.exprs.get(skip_args..).unwrap_or_default(); + let (fixed, variadic) = match params.split_last() { + Some((last, rest)) if last.name == "..." => (rest, true), + _ => (params, false), + }; + // A call ending in `f()` or `...` passes any number of values in its last argument. + let open_ended = args.last().is_some_and(Expr::is_multi_value); + let through_vararg = args.len() - usize::from(open_ended) > fixed.len(); + if through_vararg && !variadic { + return Fit::No; + } + let missing_required = + fixed.iter().skip(args.len()).any(|p| self.param_kinds(fun, p).is_some_and(|kinds| kinds & kind::NIL == 0)); + if missing_required && !open_ended { + return Fit::No; + } + let kinds_fit = fixed.iter().zip(args).enumerate().all(|(i, (param, arg))| { + // A function literal is not inferred here: its parameters may be typed from this very call. + let given = match arg.unparen().kind { + ExprKind::Function(_) => Some(kind::FUNCTION), + _ => self.value_kinds(fun, call.ty(self, skip_args + i), 0), + }; + match (self.param_kinds(fun, param), given) { + (Some(wanted), Some(given)) => wanted & given != 0, + _ => true, + } + }); + match (kinds_fit, through_vararg) { + (false, _) => Fit::No, + (true, true) => Fit::ThroughVararg, + (true, false) => Fit::Exact, + } + } + + /// The kinds of value a parameter takes: `id? integer` is stored as `integer`, and also takes `nil`. + fn param_kinds(&self, fun: &FunType, param: &Param) -> Option { + self.value_kinds(fun, ¶m.ty, 0).map(|kinds| if param.optional { kinds | kind::NIL } else { kinds }) + } + + /// The kinds of Lua value `ty` allows, as a set of `kind` bits, or `None` when it allows any. + fn value_kinds(&self, fun: &FunType, ty: &Type, depth: u32) -> Option { + if depth > 8 { + return None; + } + Some(match ty { + Type::Unknown | Type::Any => return None, + Type::Nil => kind::NIL, + Type::Boolean | Type::BooleanLit(_) => kind::BOOLEAN, + Type::Number | Type::Integer | Type::IntLit(_) | Type::Handle(_) => kind::NUMBER, + Type::String | Type::StringLit(_) => kind::STRING, + Type::Table + | Type::Array(_) + | Type::Map(..) + | Type::Tuple(_) + | Type::Shape(_) + | Type::GlobalTable(_) + | Type::Exports(_) + | Type::Require(_) => kind::TABLE, + Type::Function | Type::Fun(_) => kind::FUNCTION, + Type::Thread | Type::Userdata => kind::OTHER, + Type::Variadic(inner) => return self.value_kinds(fun, inner, depth + 1), + Type::Named(name, _) if self.is_generic(fun, name) || NATIVE_HANDLE_TYPES.contains(&name.as_str()) => { + return None + } + // Classes describe tables, and also userdata such as `vector3`. + Type::Named(name, _) if self.index.class(name).is_some() => kind::TABLE | kind::OTHER, + Type::Named(..) => match self.resolve_alias(ty) { + Type::Named(..) => return None, + resolved => return self.value_kinds(fun, &resolved, depth + 1), + }, + Type::Union(types) => { + let mut kinds = 0; + for part in types { + kinds |= self.value_kinds(fun, part, depth + 1)?; + } + kinds + } + }) + } + + /// A name `fun` declares with `@generic`, or a short one such as `T` that is neither a class nor + /// an alias. + fn is_generic(&self, fun: &FunType, name: &str) -> bool { + fun.generics.iter().any(|g| g == name) + || (name.len() <= 2 && self.index.class(name).is_none() && self.index.alias(name).is_none()) + } + + /// Builds the type of a function literal from its doc comment, inferring returns when undocumented. + pub fn fun_type(&self, func: &FuncBody, doc_anchor: Option, is_method: bool) -> FunType { + let names: Vec = func.params.iter().map(|p| p.text.clone()).collect(); + let doc = doc_anchor.map(|anchor| self.ctx.doc_at(anchor)); + let mut fun = match &doc { + Some(doc) => doc.fun_type(&names, func.vararg.is_some(), is_method), + None => DocGroup::default().fun_type(&names, func.vararg.is_some(), is_method), + }; + if fun.returns.is_empty() { + if let Some(exprs) = first_return(&func.body) { + let mut returns: Vec = Vec::new(); + for (i, expr) in exprs.iter().enumerate() { + if i + 1 == exprs.len() { + returns.extend(self.expr_multi(expr).into_iter().map(|t| t.widen())); + } else { + returns.push(self.expr(expr).widen()); + } + } + if returns.iter().any(|t| !t.is_unknown()) { + fun.returns = returns; + } + } + } + fun + } + + pub fn member(&self, ty: &Type, name: &str) -> Option { + self.guarded(|| { + let mut found = self.members_matching(ty, Some(name)); + let best = (0..found.len()).max_by_key(|i| (found[*i].ty.specificity(), std::cmp::Reverse(*i)))?; + Some(found.swap_remove(best)) + }) + } + + pub fn members(&self, ty: &Type) -> Vec { + let mut members = self.guarded(|| self.members_matching(ty, None)); + let mut seen = FxHashSet::default(); + members.retain(|m| seen.insert(m.name.clone())); + members + } + + fn members_matching(&self, ty: &Type, filter: Option<&str>) -> Vec { + let wanted = |name: &str| filter.is_none_or(|f| f == name); + let mut out = Vec::new(); + match ty { + Type::Union(types) => { + for part in types.iter().filter(|t| !matches!(t, Type::Nil)) { + out.extend(self.guarded(|| self.members_matching(part, filter))); + } + } + Type::Shape(shape) => { + for field in shape.fields.iter().filter(|f| wanted(&f.name)) { + out.push(MemberInfo { + name: field.name.clone(), + ty: if field.optional { field.ty.clone().optional() } else { field.ty.clone() }, + doc: None, + deprecated: false, + literal: None, + kind: SymbolKind::Field, + location: None, + }); + } + } + Type::Named(name, _) => self.class_members(name, filter, &mut out, 0), + Type::GlobalTable(owner) => self.owner_members(owner, filter, &mut out), + Type::String | Type::StringLit(_) => { + let library = self.global_type("string"); + if !matches!(library, Type::String | Type::StringLit(_)) { + out.extend(self.guarded(|| self.members_matching(&library, filter))); + } + } + Type::Require(_) => { + let resolved = self.resolve_alias(ty); + if resolved != *ty { + out.extend(self.guarded(|| self.members_matching(&resolved, filter))); + } + } + Type::Exports(None) => { + for resource in self.index.resources.iter().filter(|r| wanted(&r.name)) { + out.push(MemberInfo { + name: resource.name.clone(), + ty: Type::Exports(Some(resource.name.clone())), + doc: Some(Arc::from(format!("Exports of the `{}` resource.", resource.name))), + deprecated: false, + literal: None, + kind: SymbolKind::Table, + location: None, + }); + } + if let Some(name) = filter.filter(|_| out.is_empty()) { + out.push(MemberInfo { + name: SmolStr::new(name), + ty: Type::Exports(Some(SmolStr::new(name))), + doc: None, + deprecated: false, + literal: None, + kind: SymbolKind::Table, + location: None, + }); + } + } + Type::Exports(Some(resource)) => { + for (file, symbol) in self.index.exports_of(resource) { + if wanted(&symbol.name) { + out.push(member_from_symbol(file, symbol)); + } + } + } + _ => {} + } + out + } + + fn owner_members(&self, owner: &str, filter: Option<&str>, out: &mut Vec) { + for (file, symbol) in self.index.members_of(owner, self.ctx.file) { + if filter.is_none_or(|f| f == symbol.name) { + let mut member = member_from_symbol(file, symbol); + if matches!(member.ty, Type::Table | Type::Unknown) { + let nested = format!("{owner}.{}", symbol.name); + if self.index.has_members(&nested) { + member.ty = Type::GlobalTable(SmolStr::new(nested)); + } + } + out.push(member); + } + } + if let Some(name) = filter { + let nested = format!("{owner}.{name}"); + if out.is_empty() && self.index.has_members(&nested) { + out.push(MemberInfo { + name: SmolStr::new(name), + ty: Type::GlobalTable(SmolStr::new(nested)), + doc: None, + deprecated: false, + literal: None, + kind: SymbolKind::Table, + location: None, + }); + } + } + } + + fn class_members(&self, name: &str, filter: Option<&str>, out: &mut Vec, depth: u32) { + if depth > 8 { + return; + } + let defs = self.index.class_defs(name); + if defs.is_empty() { + if let Some((_, alias)) = self.index.alias(name) { + out.extend(self.guarded(|| self.members_matching(&alias.ty, filter))); + } + return; + } + for (file, class) in &defs { + for field in class.fields.iter().filter(|f| filter.is_none_or(|n| n == f.name)) { + out.push(member_from_symbol(*file, field)); + } + } + self.owner_members(name, filter, out); + for (_, class) in defs { + for parent in &class.parents { + self.class_members(parent, filter, out, depth + 1); + } + } + } +} + +fn member_from_symbol(file: FileId, symbol: &crate::index::Symbol) -> MemberInfo { + MemberInfo { + name: symbol.name.clone(), + ty: symbol.ty.clone(), + doc: symbol.doc.clone(), + deprecated: symbol.deprecated, + literal: symbol.literal.clone(), + kind: symbol.kind, + location: Some((file, symbol.range)), + } +} + +fn substitute(ty: &Type, generics: &[(SmolStr, Type)]) -> Type { + if generics.is_empty() { + return ty.clone(); + } + match ty { + Type::Named(name, args) if args.is_empty() => { + generics.iter().find(|(n, _)| n == name).map_or_else(|| ty.clone(), |(_, bound)| bound.clone()) + } + Type::Named(name, args) => Type::Named(name.clone(), args.iter().map(|t| substitute(t, generics)).collect()), + Type::Array(inner) => Type::Array(Box::new(substitute(inner, generics))), + Type::Tuple(items) => Type::Tuple(items.iter().map(|t| substitute(t, generics)).collect()), + Type::Variadic(inner) => Type::Variadic(Box::new(substitute(inner, generics))), + // `V?` with `V` unbound is unknown, not `nil`, while `V|string` is still a `string`. + Type::Union(types) => { + let parts: Vec = types.iter().map(|t| substitute(t, generics)).collect(); + if parts.iter().filter(|t| !matches!(t, Type::Nil)).all(Type::is_unknown) { + Type::Unknown + } else { + Type::union(parts) + } + } + Type::Map(k, v) => Type::Map(Box::new(substitute(k, generics)), Box::new(substitute(v, generics))), + Type::Fun(fun) => Type::Fun(Arc::new(FunType { + params: fun.params.iter().map(|p| Param { ty: substitute(&p.ty, generics), ..p.clone() }).collect(), + returns: fun.returns.iter().map(|t| substitute(t, generics)).collect(), + is_method: fun.is_method, + generics: Vec::new(), + overloads: Vec::new(), + })), + Type::Shape(shape) => Type::Shape(Arc::new(Shape { + fields: shape.fields.iter().map(|f| ShapeField { ty: substitute(&f.ty, generics), ..f.clone() }).collect(), + array: shape.array.as_ref().map(|t| substitute(t, generics)), + index: shape.index.as_ref().map(|(k, v)| (substitute(k, generics), substitute(v, generics))), + })), + other => other.clone(), + } +} + +/// The constructor behind a table-valued initialiser, looking through `setmetatable({...}, mt)`. +pub fn table_fields(expr: &Expr) -> Option<&[TableField]> { + match &expr.unparen().kind { + ExprKind::Table(fields) => Some(fields), + ExprKind::Call { callee, args, .. } if callee.dotted_path().as_deref() == Some("setmetatable") => { + args.first().and_then(table_fields) + } + _ => None, + } +} + +/// The entries of a table constructor that have no name: the array part `ipairs` visits, which +/// takes in `[n]` keys that continue it as in `{ [1] = 'a', [2] = 'b' }`, and the other `[key]`s. +pub struct TableElements<'a> { + pub array: Vec<&'a Expr>, + pub keyed: Vec<(&'a Expr, &'a Expr)>, +} + +pub fn table_elements(fields: &[TableField]) -> TableElements<'_> { + let mut elements = TableElements { array: Vec::new(), keyed: Vec::new() }; + let mut numbered = Vec::new(); + for field in fields { + match field { + TableField::Positional(value) => elements.array.push(value), + TableField::Keyed { key, value } => match &key.kind { + ExprKind::String(_) => {} + ExprKind::Number(NumberValue::Int(n)) => numbered.push((*n, key, value)), + _ => elements.keyed.push((key, value)), + }, + TableField::Named { .. } | TableField::SetMember(_) => {} + } + } + let mut length = elements.array.len() as i64; + while numbered.iter().any(|(n, ..)| *n == length + 1) { + length += 1; + } + for (n, key, value) in numbered { + if (1..=length).contains(&n) { + elements.array.push(value); + } else { + elements.keyed.push((key, value)); + } + } + elements +} + +fn is_integer_key(key: &Type) -> bool { + matches!(key.widen(), Type::Integer | Type::Number) +} + +/// The expressions of the first `return` that belongs to this function body itself. +fn first_return(block: &Block) -> Option<&[Expr]> { + for stmt in &block.stmts { + let found = match &stmt.kind { + StmtKind::Return(exprs) if !exprs.is_empty() => Some(exprs.as_slice()), + StmtKind::Do(body) | StmtKind::While { body, .. } | StmtKind::Repeat { body, .. } => first_return(body), + StmtKind::NumericFor { body, .. } | StmtKind::GenericFor { body, .. } => first_return(body), + StmtKind::If { branches, else_block } => branches + .iter() + .find_map(|b| first_return(&b.block)) + .or_else(|| else_block.as_ref().and_then(first_return)), + _ => None, + }; + if found.is_some() { + return found; + } + } + None +} diff --git a/crates/qbx_lua_ls/src/lib.rs b/crates/qbx_lua_ls/src/lib.rs new file mode 100644 index 0000000..d2c8201 --- /dev/null +++ b/crates/qbx_lua_ls/src/lib.rs @@ -0,0 +1,12 @@ +pub mod document; +pub mod features; +pub mod framework_callbacks; +pub mod index; +pub mod indexer; +pub mod infer; +pub mod locate; +pub mod luacats; +mod nui_callbacks; +pub mod server; +pub mod types; +pub mod workspace; diff --git a/crates/qbx_lua_ls/src/locate.rs b/crates/qbx_lua_ls/src/locate.rs new file mode 100644 index 0000000..cea6c33 --- /dev/null +++ b/crates/qbx_lua_ls/src/locate.rs @@ -0,0 +1,183 @@ +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::visit::{self, Visitor}; +use qbx_lua_syntax::Span; + +/// What the cursor is on, when it is on a name that belongs to a member access. +pub enum MemberAccess<'a> { + Field { base: &'a Expr, name: &'a Name }, + Method { base: &'a Expr, name: &'a Name }, + Index { base: &'a Expr, index: &'a Expr }, +} + +impl<'a> MemberAccess<'a> { + pub fn base(&self) -> &'a Expr { + match self { + MemberAccess::Field { base, .. } | MemberAccess::Method { base, .. } | MemberAccess::Index { base, .. } => { + base + } + } + } + + pub fn name(&self, source: &str) -> Option { + match self { + MemberAccess::Field { name, .. } | MemberAccess::Method { name, .. } => Some((*name).clone()), + MemberAccess::Index { index, .. } => { + Some(Name { text: index.as_string()?.clone(), span: string_content_span(index.span, source)? }) + } + } + } + + pub fn is_method(&self) -> bool { + matches!(self, MemberAccess::Method { .. }) + } +} + +/// The replaceable contents of a string token, retaining its quote style and long-string delimiter. +pub fn string_content_span(span: Span, source: &str) -> Option { + let raw = span.text(source); + let bytes = raw.as_bytes(); + let first = *bytes.first()?; + if matches!(first, b'\'' | b'"') && bytes.len() >= 2 && bytes.last() == Some(&first) { + return Some(Span::new(span.start + 1, span.end - 1)); + } + if first != b'[' { + return None; + } + let level = bytes[1..].iter().take_while(|&&b| b == b'=').count(); + let delimiter_len = level + 2; + if bytes.get(delimiter_len - 1) != Some(&b'[') + || raw.len() < delimiter_len * 2 + || !raw.ends_with(&format!("]{}]", "=".repeat(level))) + { + return None; + } + let body = &raw[delimiter_len..raw.len() - delimiter_len]; + let newline_len = if body.starts_with("\r\n") { 2 } else { usize::from(body.starts_with('\n')) }; + Some(Span::new(span.start + (delimiter_len + newline_len) as u32, span.end - delimiter_len as u32)) +} + +pub struct CallSite<'a> { + pub call: &'a Expr, + pub base: &'a Expr, + pub method: Option<&'a Name>, + pub args: &'a [Expr], + pub args_span: Span, +} + +impl CallSite<'_> { + /// The index of the argument the cursor is in, counting top-level commas before `offset`. + pub fn active_argument(&self, source: &str, offset: u32) -> usize { + let mut index = 0; + for (i, arg) in self.args.iter().enumerate() { + if arg.span.end >= offset { + break; + } + let until = self.args.get(i + 1).map_or(offset, |next| next.span.start.min(offset)); + if Span::new(arg.span.end, until.max(arg.span.end)).text(source).contains(',') { + index = i + 1; + } + } + index + } +} + +struct Locator<'a> { + offset: u32, + member: Option>, + call: Option>, + string: Option<(&'a Expr, Option<(&'a Expr, usize)>)>, + func_name: Option<(&'a FuncName, usize)>, + table_in_call: Option<(&'a Expr, usize, &'a Expr)>, +} + +impl<'a> Locator<'a> { + fn visit_args(&mut self, call: &'a Expr, args: &'a [Expr]) { + for (i, arg) in args.iter().enumerate() { + if !arg.span.contains_inclusive(self.offset) { + continue; + } + match &arg.kind { + ExprKind::String(_) => self.string = Some((arg, Some((call, i)))), + ExprKind::Table(_) => self.table_in_call = Some((call, i, arg)), + _ => {} + } + } + } +} + +impl<'a> Visitor<'a> for Locator<'a> { + fn visit_stmt(&mut self, stmt: &'a Stmt) { + if !stmt.span.contains_inclusive(self.offset) { + return; + } + if let StmtKind::Function { name, .. } = &stmt.kind { + let segments = std::iter::once(&name.base).chain(&name.path).chain(&name.method); + for (i, segment) in segments.enumerate() { + if segment.span.contains_inclusive(self.offset) { + self.func_name = Some((name, i)); + } + } + } + visit::walk_stmt(self, stmt); + } + + fn visit_expr(&mut self, expr: &'a Expr) { + if !expr.span.contains_inclusive(self.offset) { + return; + } + match &expr.kind { + ExprKind::Field { base, name, .. } if name.span.contains_inclusive(self.offset) => { + self.member = Some(MemberAccess::Field { base, name }); + } + ExprKind::Index { base, index, .. } + if index.as_string().is_some() && index.span.contains_inclusive(self.offset) => + { + self.member = Some(MemberAccess::Index { base, index }); + } + ExprKind::MethodCall { base, method, args, args_span, .. } => { + if method.span.contains_inclusive(self.offset) { + self.member = Some(MemberAccess::Method { base, name: method }); + } + if args_span.start < self.offset && self.offset <= args_span.end { + self.call = Some(CallSite { call: expr, base, method: Some(method), args, args_span: *args_span }); + } + self.visit_args(expr, args); + } + ExprKind::Call { callee, args, args_span, .. } => { + if args_span.start < self.offset && self.offset <= args_span.end { + self.call = Some(CallSite { call: expr, base: callee, method: None, args, args_span: *args_span }); + } + self.visit_args(expr, args); + } + ExprKind::String(_) if self.string.is_none_or(|(s, _)| !std::ptr::eq(s, expr)) => { + self.string = Some((expr, None)); + } + _ => {} + } + visit::walk_expr(self, expr); + } +} + +pub struct Located<'a> { + pub member: Option>, + /// The innermost call whose argument list contains the cursor. + pub call: Option>, + /// A string literal under the cursor, with the call and argument position it is passed to. + pub string: Option<(&'a Expr, Option<(&'a Expr, usize)>)>, + /// A segment of a `function a.b:c()` name under the cursor. + pub func_name: Option<(&'a FuncName, usize)>, + /// A table constructor under the cursor that is passed directly as a call argument. + pub table_in_call: Option<(&'a Expr, usize, &'a Expr)>, +} + +pub fn locate(chunk: &Chunk, offset: u32) -> Located<'_> { + let mut locator = Locator { offset, member: None, call: None, string: None, func_name: None, table_in_call: None }; + locator.visit_block(&chunk.block); + Located { + member: locator.member, + call: locator.call, + string: locator.string, + func_name: locator.func_name, + table_in_call: locator.table_in_call, + } +} diff --git a/crates/qbx_lua_ls/src/luacats.rs b/crates/qbx_lua_ls/src/luacats.rs new file mode 100644 index 0000000..45d2ca3 --- /dev/null +++ b/crates/qbx_lua_ls/src/luacats.rs @@ -0,0 +1,577 @@ +use std::sync::Arc; + +use smol_str::SmolStr; + +use crate::types::{FunType, Param, Type, TypeParser}; + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DocParam { + pub name: SmolStr, + pub ty: Type, + pub optional: bool, + pub description: String, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DocReturn { + pub ty: Type, + pub name: Option, + pub description: String, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DocField { + pub name: SmolStr, + pub ty: Type, + pub optional: bool, + pub description: String, + /// Index of the doc line the field was declared on, used to locate it in the source. + pub line: usize, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DocClass { + pub name: SmolStr, + pub parents: Vec, + pub fields: Vec, + pub index: Option<(Type, Type)>, + pub call: Option>, + pub description: String, + pub line: usize, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DocAlias { + pub name: SmolStr, + pub ty: Type, + pub description: String, + pub line: usize, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DocGroup { + pub description: String, + pub classes: Vec, + pub aliases: Vec, + pub params: Vec, + pub returns: Vec, + pub ty: Option, + pub enum_name: Option, + pub enum_keys: bool, + pub generics: Vec, + pub overloads: Vec>, + pub deprecated: Option, + pub is_async: bool, + pub nodiscard: bool, + pub is_meta: bool, +} + +impl DocGroup { + pub fn has_function_tags(&self) -> bool { + !self.params.is_empty() || !self.returns.is_empty() || !self.overloads.is_empty() + } + + /// Builds the function type from `@param`/`@return`, keeping the order of the real parameters. + pub fn fun_type(&self, param_names: &[SmolStr], has_vararg: bool, is_method: bool) -> FunType { + let mut params: Vec = param_names + .iter() + .map(|name| match self.params.iter().find(|p| p.name == *name) { + Some(doc) => Param { name: name.clone(), ty: doc.ty.clone(), optional: doc.optional }, + None => Param { name: name.clone(), ty: Type::Unknown, optional: false }, + }) + .collect(); + if has_vararg { + let ty = self.params.iter().find(|p| p.name == "...").map_or(Type::Any, |p| p.ty.clone()); + params.push(Param { name: "...".into(), ty, optional: false }); + } + // An overload shares the `@generic` names of its doc comment and is called the same way, + // unless it lists `self` itself like a `fun(self, ...)` field. + let overloads = self + .overloads + .iter() + .map(|overload| { + let lists_self = overload.params.first().is_some_and(|p| p.name == "self"); + let is_method = is_method && !lists_self; + Arc::new(FunType { is_method, generics: self.generics.clone(), ..(**overload).clone() }) + }) + .collect(); + FunType { + params, + returns: self.returns.iter().map(|r| r.ty.clone()).collect(), + is_method, + generics: self.generics.clone(), + overloads, + } + } + + pub fn param_description(&self, name: &str) -> Option<&str> { + self.params.iter().find(|p| p.name == name).map(|p| p.description.as_str()).filter(|d| !d.is_empty()) + } +} + +fn clean_description(text: &str) -> String { + let text = text.trim(); + let text = text.strip_prefix('#').or_else(|| text.strip_prefix("--")).unwrap_or(text); + text.trim().to_string() +} + +fn split_tag(line: &str) -> Option<(&str, &str)> { + let rest = line.trim_start().strip_prefix('@')?; + let end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len()); + Some((&rest[..end], rest[end..].trim_start())) +} + +/// Removes the `public`, `private`, `protected` and `package` keywords in front of a `@field` name. +fn strip_visibility(mut rest: &str) -> &str { + for scope in ["public ", "private ", "protected ", "package "] { + if let Some(stripped) = rest.strip_prefix(scope) { + rest = stripped.trim_start(); + } + } + rest +} + +/// The type on a `---| value` line that continues an `@alias`, without its `>` or `+` marker. +fn alias_member(line: &str) -> Option<&str> { + Some(line.trim_start().strip_prefix('|')?.trim_start_matches(['>', '+', ' '])) +} + +/// Splits attributes such as `(exact)`, `(partial)` or `(key)` off the front of a `@class` or `@enum`. +fn split_attributes(rest: &str) -> (&str, &str) { + match rest.strip_prefix('(').and_then(|inner| inner.split_once(')')) { + Some((attributes, rest)) => (attributes, rest.trim_start()), + None => ("", rest), + } +} + +/// Parses the `---` lines of one contiguous doc comment; every line has its `---` prefix removed. +pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { + let mut group = DocGroup::default(); + let mut description: Vec<&str> = Vec::new(); + let mut open_alias = false; + + for (index, raw) in lines.iter().enumerate() { + let line = raw.strip_prefix(' ').unwrap_or(raw); + if let Some(member) = alias_member(line) { + if open_alias { + if let Some(alias) = group.aliases.last_mut() { + let mut parser = TypeParser::new(member); + let ty = parser.parse(); + alias.ty = Type::union([std::mem::take(&mut alias.ty), ty]); + } + continue; + } + } + let Some((tag, rest)) = split_tag(line) else { + description.push(line); + continue; + }; + open_alias = false; + match tag { + "class" => { + let (_, rest) = split_attributes(rest); + let (head, parents) = rest.split_once(':').unwrap_or((rest, "")); + let name = head.split_whitespace().next().unwrap_or("").split('<').next().unwrap_or(""); + if name.is_empty() { + continue; + } + let parents = parents + .split(',') + .filter_map(|p| p.split_whitespace().next()) + .map(|p| SmolStr::new(p.split('<').next().unwrap_or(p))) + .collect(); + group.classes.push(DocClass { + name: SmolStr::new(name), + parents, + description: description.join("\n").trim().to_string(), + line: index, + ..DocClass::default() + }); + } + "field" => parse_field(rest, index, &mut group), + "overload" if !group.classes.is_empty() && !group.has_function_tags() => { + if let Type::Fun(fun) = TypeParser::new(rest).parse() { + if let Some(class) = group.classes.last_mut() { + class.call = Some(fun); + } + } + } + "overload" => { + if let Type::Fun(fun) = TypeParser::new(rest).parse() { + group.overloads.push(fun); + } + } + "alias" => { + let mut parser = TypeParser::new(rest); + let Some(name) = parser.ident() else { continue }; + parser.skip_ws(); + let ty = if parser.rest().trim().is_empty() { Type::Unknown } else { parser.parse() }; + group.aliases.push(DocAlias { + name: SmolStr::new(name), + ty, + description: description.join("\n").trim().to_string(), + line: index, + }); + open_alias = true; + } + "enum" => { + let (attributes, rest) = split_attributes(rest); + group.enum_name = rest.split_whitespace().next().map(SmolStr::new); + group.enum_keys = attributes.split(',').any(|attribute| attribute.trim() == "key"); + } + "param" => { + let mut parser = TypeParser::new(rest); + let name = if parser.rest().starts_with("...") { + parser = TypeParser::new(&rest[3..]); + "..." + } else { + match parser.ident() { + Some(name) => name, + None => continue, + } + }; + let optional = parser.rest().starts_with('?'); + let mut parser = TypeParser::new(parser.rest().trim_start_matches('?')); + let ty = parser.parse(); + group.params.push(DocParam { + name: SmolStr::new(name), + optional: optional || matches!(&ty, Type::Union(types) if types.contains(&Type::Nil)), + ty, + description: clean_description(parser.rest()), + }); + } + "return" => { + let mut parser = TypeParser::new(rest); + let ty = parser.parse(); + parser.skip_ws(); + let remainder = parser.rest(); + let (name, desc) = match remainder.split_whitespace().next() { + Some(word) if !word.starts_with('#') && word.chars().all(|c| c.is_alphanumeric() || c == '_') => { + (Some(SmolStr::new(word)), &remainder[word.len()..]) + } + _ => (None, remainder), + }; + group.returns.push(DocReturn { ty, name, description: clean_description(desc) }); + } + "type" => group.ty = Some(TypeParser::new(rest).parse()), + "generic" => { + let names = rest.split(',').filter_map(|g| g.trim().split([':', ' ']).next()).filter(|g| !g.is_empty()); + group.generics.extend(names.map(SmolStr::new)); + } + "vararg" => { + let ty = TypeParser::new(rest).parse(); + group.params.push(DocParam { name: "...".into(), ty, ..DocParam::default() }); + } + "deprecated" => group.deprecated = Some(rest.trim().to_string()), + "async" => group.is_async = true, + "nodiscard" => group.nodiscard = true, + "meta" => group.is_meta = true, + _ => {} + } + } + + group.description = description.join("\n").trim().to_string(); + group +} + +fn parse_field(rest: &str, line: usize, group: &mut DocGroup) { + let Some(class) = group.classes.last_mut() else { return }; + let rest = strip_visibility(rest); + if let Some(index) = rest.strip_prefix('[') { + let mut parser = TypeParser::new(index); + let key = parser.parse(); + let after = parser.rest().trim_start().strip_prefix(']').unwrap_or(parser.rest()); + let value = TypeParser::new(after).parse(); + match key { + Type::StringLit(name) => class.fields.push(DocField { name, ty: value, line, ..DocField::default() }), + key => class.index = Some((key, value)), + } + return; + } + let mut parser = TypeParser::new(rest); + let Some(name) = parser.ident() else { return }; + let optional = parser.rest().starts_with('?'); + let mut parser = TypeParser::new(parser.rest().trim_start_matches('?')); + let ty = parser.parse(); + class.fields.push(DocField { + name: SmolStr::new(name), + ty, + optional, + description: clean_description(parser.rest()), + line, + }); +} + +fn skip_name(rest: &str) -> Option<&str> { + let mut parser = TypeParser::new(rest); + parser.ident()?; + Some(parser.rest()) +} + +/// Collects the class and alias names of one doc line. Every `rest` passed in is a suffix of +/// `line`, so it starts at `line.len() - rest.len()`. +struct TypeNames<'a> { + line: &'a str, + found: Vec<(usize, &'a str)>, +} + +impl<'a> TypeNames<'a> { + /// Records the name a `@class`, `@alias` or `@enum` declares and returns the text after it. + fn declared(&mut self, rest: &'a str) -> Option<&'a str> { + let mut parser = TypeParser::new(rest); + let name = parser.ident()?; + self.found.push((self.line.len() - parser.rest().len() - name.len(), name)); + Some(parser.rest()) + } + + /// Records the names in the type `rest` starts with and returns the text after it. + fn ty(&mut self, rest: &'a str) -> &'a str { + let start = self.line.len() - rest.len(); + let mut parser = TypeParser::new(rest); + self.found.extend(parser.parse_names().into_iter().map(|(offset, name)| (start + offset, name))); + parser.rest() + } + + fn types(&mut self, rest: &'a str) { + self.list(rest, |names, rest| Some(names.ty(rest))); + } + + /// Walks a comma separated list, where `item` reads one entry and returns the text after it. + fn list(&mut self, rest: &'a str, item: impl Fn(&mut Self, &'a str) -> Option<&'a str>) { + let mut next = Some(rest); + while let Some(after) = next.and_then(|rest| item(self, rest.trim_start())) { + next = after.trim_start().strip_prefix(','); + } + } +} + +/// The class or alias name at byte `offset` of a doc line whose `---` prefix is removed, with the +/// byte it starts at. Parameter, field and return names, literals and built-in types do not count. +pub fn type_name_at(line: &str, offset: usize) -> Option<(usize, &str)> { + let (tag, rest) = match alias_member(line) { + Some(member) => ("|", member), + None => split_tag(line)?, + }; + let mut names = TypeNames { line, found: Vec::new() }; + match tag { + "class" => { + let (_, rest) = split_attributes(rest); + names.declared(rest); + // Parsing the head as a type skips generic parameters such as the `T` of `Child`. + let mut head = TypeParser::new(rest); + head.parse(); + if let Some(parents) = head.rest().trim_start().strip_prefix(':') { + names.types(parents); + } + } + "alias" => { + if let Some(rest) = names.declared(rest) { + names.types(rest); + } + } + "enum" => { + names.declared(split_attributes(rest).1); + } + "param" => { + if let Some(rest) = rest.strip_prefix("...").or_else(|| skip_name(rest)) { + names.types(rest.trim_start_matches('?')); + } + } + "field" => { + let rest = strip_visibility(rest); + let value = match rest.strip_prefix('[') { + Some(key) => names.ty(key).trim_start().strip_prefix(']'), + None => skip_name(rest), + }; + if let Some(value) = value { + names.types(value.trim_start_matches('?')); + } + } + "cast" => { + if let Some(rest) = skip_name(rest) { + names.list(rest, |names, rest| Some(names.ty(rest.trim_start_matches(['+', '-'])))); + } + } + "return" => names.list(rest, |names, rest| { + let after = names.ty(rest); + Some(skip_name(after).unwrap_or(after)) + }), + "operator" => { + if let Some(rest) = skip_name(rest).map(str::trim_start) { + let rest = match rest.strip_prefix('(') { + Some(operand) => names.ty(operand).trim_start().strip_prefix(')'), + None => Some(rest), + }; + if let Some(result) = rest.and_then(|rest| rest.trim_start().strip_prefix(':')) { + names.ty(result); + } + } + } + "generic" => names.list(rest, |names, rest| { + let rest = skip_name(rest)?.trim_start(); + Some(rest.strip_prefix(':').map_or(rest, |constraint| names.ty(constraint))) + }), + "see" => { + names.ty(rest); + } + "type" | "overload" | "vararg" | "as" | "|" => names.types(rest), + _ => {} + } + names.found.into_iter().find(|(start, name)| (*start..=start + name.len()).contains(&offset)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(text: &str) -> DocGroup { + let lines: Vec<&str> = text.lines().map(|l| l.trim_start().strip_prefix("---").unwrap_or(l)).collect(); + parse_doc_lines(&lines) + } + + #[test] + fn function_docs() { + let doc = parse( + "---Spawns a vehicle.\n---Second line.\n---@param model string|integer the model\n---@param coords? vector4\n---@param ... any extra\n---@return integer netId # network id\n---@return string? err\n---@deprecated use other\n---@async", + ); + assert_eq!(doc.description, "Spawns a vehicle.\nSecond line."); + assert_eq!(doc.params.len(), 3); + assert_eq!(doc.params[0].description, "the model"); + assert!(doc.params[1].optional); + assert_eq!(doc.params[2].name, "..."); + assert_eq!(doc.returns[0].name.as_deref(), Some("netId")); + assert_eq!(doc.returns[0].description, "network id"); + assert_eq!(doc.returns[1].ty.to_string(), "string?"); + assert_eq!(doc.deprecated.as_deref(), Some("use other")); + assert!(doc.is_async); + + let fun = doc.fun_type(&["model".into(), "coords".into()], true, false); + assert_eq!( + fun.signature("spawn"), + "function spawn(model: string|integer, coords?: vector4, ...: any): integer, string?" + ); + } + + #[test] + fn classes_fields_and_aliases() { + let doc = parse( + "---A player.\n---@class Player : Entity, Base\n---@field name string the name\n---@field private job? Job\n---@field [string] any\n---@field ['quoted-key'] number\n---@overload fun(id: integer): Player\n---@alias Side\n---| 'client' # runs on the client\n---| 'server'\n---@alias Id integer|string", + ); + let class = &doc.classes[0]; + assert_eq!(class.name, "Player"); + assert_eq!(class.parents, ["Entity", "Base"]); + assert_eq!(class.description, "A player."); + assert_eq!(class.fields.len(), 3); + assert!(class.fields[1].optional); + assert_eq!(class.fields[2].name, "quoted-key"); + assert!(class.index.is_some()); + assert!(class.call.is_some()); + assert_eq!(doc.aliases[0].ty.to_string(), "\"client\"|\"server\""); + assert_eq!(doc.aliases[1].ty.to_string(), "integer|string"); + } + + #[test] + fn class_and_enum_attributes() { + let doc = parse("---@class (partial) Player : Entity"); + assert_eq!(doc.classes[0].name, "Player"); + assert_eq!(doc.classes[0].parents, ["Entity"]); + let doc = parse("---@enum (key) Side"); + assert_eq!(doc.enum_name.as_deref(), Some("Side")); + assert!(doc.enum_keys); + assert!(!parse("---@enum Side").enum_keys); + } + + #[test] + fn bracketed_descriptions_inside_field_types() { + let doc = parse( + "---@class Config.Bleeding\n---@field items table [items]", + ); + assert_eq!(doc.classes[0].fields[0].name, "items"); + } + + #[test] + fn type_and_generics() { + let doc = parse("---@generic T: table, K\n---@type table"); + assert_eq!(doc.generics, ["T", "K"]); + assert_eq!(doc.ty.unwrap().to_string(), "table"); + } + + #[test] + fn finds_type_names_in_annotations() { + for line in [ + "@type Gar^age", + "@type Garage^", + "@type string, Gar^age", + "@type (string|Gar^age)[]?", + "@type table", + "@type fun(value: Gar^age): boolean", + "@type fun(): Gar^age", + "@type { value: Gar^age }", + "@type { [Gar^age]: boolean }", + "@type [string, Gar^age]", + "@param value? Gar^age", + "@param ... Gar^age", + "@return string, Gar^age", + "@return string name, Gar^age value", + "@field private value? Gar^age", + "@field [Gar^age] string", + "@field ['value'] Gar^age", + "@class Gar^age", + "@class (exact) Child: Parent, Gar^age", + "@class (partial) Gar^age", + "@class (partial) Child: Gar^age", + "@alias Gar^age string", + "@alias Value Gar^age", + "| > Gar^age # description", + "@enum Gar^age", + "@enum (key) Gar^age", + "@overload fun(): Gar^age", + "@operator add(Gar^age): Vector", + "@operator mul(number): Gar^age", + "@operator call(): Gar^age", + "@operator unm: Gar^age", + "@generic T, U: table", + "@generic T: string, U: Gar^age", + "@cast value +string, -Gar^age", + "@vararg Gar^age", + "@as Gar^age", + "@see Gar^age", + "@see Gar^age for details", + ] { + let offset = line.find('^').unwrap(); + let text = line.replace('^', ""); + assert_eq!(type_name_at(&text, offset), Some((text.find("Garage").unwrap(), "Garage")), "{line}"); + } + let line = "@type Garage.Point[]"; + assert_eq!(type_name_at(line, line.find("Point").unwrap()), Some((6, "Garage.Point"))); + } + + #[test] + fn ignores_non_type_names_in_annotations() { + for line in [ + "A Gar^age in the description", + "@pa^ram value Garage", + "@param Gar^age string", + "@field Gar^age string", + "@field ['Gar^age'] string", + "@field public^ value Garage", + "@return string Gar^age", + "@type string # Gar^age", + "@type string Gar^age", + "@type 'Gar^age'", + "@type fun(Gar^age: string): boolean", + "@type { Gar^age: string }", + "@type str^ing", + "@generic Gar^age: string", + "@class Child: Parent", + "@class (Gar^age) Child", + "@enum (Gar^age) Mode", + "@cast Gar^age string", + "@operator Gar^age(number): string", + "@see string, see Gar^age", + ] { + let offset = line.find('^').unwrap(); + assert_eq!(type_name_at(&line.replace('^', ""), offset), None, "{line}"); + } + } +} diff --git a/crates/qbx_lua_ls/src/main.rs b/crates/qbx_lua_ls/src/main.rs new file mode 100644 index 0000000..c923544 --- /dev/null +++ b/crates/qbx_lua_ls/src/main.rs @@ -0,0 +1,47 @@ +/// `qbx-lua-ls --index ` indexes a folder once and reports what it found, for benchmarking. +fn print_index_stats(dir: std::path::PathBuf) { + let mut workspace = qbx_lua_ls::workspace::Workspace::default(); + workspace.roots = vec![std::path::absolute(&dir).unwrap_or(dir)]; + workspace.load_stubs(); + let stats = workspace.scan(); + let (mut globals, mut members, mut classes, mut events) = (0, 0, 0, 0); + for (_, file) in workspace.index.files() { + globals += file.index.globals.len(); + members += file.index.members.len(); + classes += file.index.classes.len() + file.index.aliases.len(); + events += file.index.events.len(); + } + println!( + "{} files, {} resources in {} ms: {globals} globals, {members} members, {classes} types, {events} events", + stats.files, stats.resources, stats.millis + ); +} + +fn main() { + if std::env::args().any(|arg| arg == "--version" || arg == "-V") { + println!("qbx-lua-ls {}", env!("CARGO_PKG_VERSION")); + return; + } + if std::env::args().any(|arg| arg == "--help" || arg == "-h") { + println!( + "qbx-lua-ls {}\n\nUsage: qbx-lua-ls start the language server over stdio\n qbx-lua-ls --index index a folder once and print what was found\n qbx-lua-ls --version print the version", + env!("CARGO_PKG_VERSION") + ); + return; + } + if let Some(dir) = std::env::args().skip_while(|arg| arg != "--index").nth(1) { + print_index_stats(dir.into()); + return; + } + // Deeply nested Lua is walked recursively; the default main-thread stack is too small for that on Windows. + let server = std::thread::Builder::new().stack_size(32 * 1024 * 1024).spawn(qbx_lua_ls::server::run); + let outcome = server.expect("failed to start the server thread").join(); + match outcome { + Ok(Ok(())) => {} + Ok(Err(error)) => { + eprintln!("qbx-lua-ls: {error}"); + std::process::exit(1); + } + Err(_) => std::process::exit(101), + } +} diff --git a/crates/qbx_lua_ls/src/nui_callbacks.rs b/crates/qbx_lua_ls/src/nui_callbacks.rs new file mode 100644 index 0000000..d87adc1 --- /dev/null +++ b/crates/qbx_lua_ls/src/nui_callbacks.rs @@ -0,0 +1,36 @@ +//! Conservative recognition of the two Lua NUI registration globals. +use qbx_lua_analysis::scope::Resolved; +use qbx_lua_syntax::ast::{Expr, ExprKind}; + +use crate::framework_callbacks::global_field_redefined; +use crate::infer::FileContext; + +pub(crate) const REGISTRATION_GLOBALS: [&str; 2] = ["RegisterNUICallback", "RegisterNuiCallback"]; + +/// File-wide mutations are checked once while indexing, not once per callback or on requests. +pub(crate) struct NuiGlobals([bool; 2]); + +impl NuiGlobals { + pub(crate) fn of(ctx: &FileContext<'_>) -> Self { + if !REGISTRATION_GLOBALS.iter().any(|name| ctx.source.contains(name)) { + return Self([false; 2]); + } + let redefined = |name: &str| { + ctx.resolution.globals.iter().any(|global| global.name == name && global.is_definition()) + || global_field_redefined(ctx, name) + }; + if redefined("_ENV") { + return Self([false; 2]); + } + Self(REGISTRATION_GLOBALS.map(|name| !redefined(name))) + } + + pub(crate) fn registration<'a>(&self, ctx: &FileContext<'_>, callee: &'a Expr) -> Option<&'a str> { + let ExprKind::Name(name) = &callee.kind else { return None }; + let position = REGISTRATION_GLOBALS.iter().position(|global| *global == name.text)?; + (self.0[position] + && matches!(ctx.resolution.resolve_at(name.span.start), Some(Resolved::Global(_))) + && ctx.resolution.lookup_local_at("_ENV", name.span.start).is_none()) + .then_some(name.text.as_str()) + } +} diff --git a/crates/qbx_lua_ls/src/server.rs b/crates/qbx_lua_ls/src/server.rs new file mode 100644 index 0000000..159a5d0 --- /dev/null +++ b/crates/qbx_lua_ls/src/server.rs @@ -0,0 +1,834 @@ +use std::error::Error; +use std::path::PathBuf; + +use lsp_server::{Connection, ErrorCode, Message, Notification, Request, RequestId, Response}; +use lsp_types::notification::{self as notif, Notification as _}; +use lsp_types::request::{self as req, Request as _}; +use lsp_types::*; +use qbx_lua_analysis::project::is_manifest_file; +use qbx_lua_analysis::Level; +use rustc_hash::{FxHashMap, FxHashSet}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::document::Document; +use crate::features::{ + code_action, completion, definition, diagnostics, folding, hover, inlay, reference, references, semantic_tokens, + signature, symbols, +}; +use crate::index::FileOrigin; +use crate::workspace::{uri_to_path, Workspace}; + +pub type Documents = FxHashMap; + +/// Opening the file shows everything; the workspace overview only needs to say "look here". +const MAX_PROBLEMS_PER_CLOSED_FILE: usize = 100; + +type AnyResult = Result>; + +#[derive(Debug, Deserialize, Default, Clone)] +#[serde(rename_all = "camelCase", default)] +pub struct DiagnosticSettings { + pub enable: Option, + pub workspace: Option, + pub rules: FxHashMap, +} + +#[derive(Debug, Deserialize, Default, Clone)] +#[serde(rename_all = "camelCase", default)] +pub struct ToggleSettings { + pub enable: Option, +} + +#[derive(Debug, Deserialize, Default, Clone)] +#[serde(rename_all = "camelCase", default)] +pub struct Settings { + pub library: Vec, + pub diagnostics: DiagnosticSettings, + pub inlay_hints: ToggleSettings, + pub semantic_tokens: ToggleSettings, +} + +impl Settings { + fn rule_overrides(&self) -> Vec<(String, Level)> { + self.diagnostics + .rules + .iter() + .filter(|(code, _)| qbx_lua_analysis::rules::find(code).is_some()) + .filter_map(|(code, level)| { + let level = match level.as_str() { + "off" => Level::Off, + "hint" => Level::Hint, + "info" => Level::Info, + "warning" | "warn" => Level::Warning, + "error" => Level::Error, + _ => return None, + }; + Some((code.clone(), level)) + }) + .collect() + } +} + +pub struct Server { + connection: Connection, + ws: Workspace, + docs: Documents, + settings: Settings, + snippet_support: bool, + watched_files_registration: bool, + dirty: FxHashSet, + diagnostics_pending: bool, + /// Closed files that currently have diagnostics published, so they can be cleared again. + workspace_reported: FxHashSet, + workspace_stale: bool, + /// Resources to re-lint after a save or close, which is much cheaper than the whole workspace. + stale_resources: FxHashSet>, + next_request_id: i32, +} + +pub fn capabilities() -> ServerCapabilities { + ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions { + open_close: Some(true), + change: Some(TextDocumentSyncKind::INCREMENTAL), + save: Some(TextDocumentSyncSaveOptions::Supported(true)), + ..TextDocumentSyncOptions::default() + })), + completion_provider: Some(CompletionOptions { + resolve_provider: Some(true), + trigger_characters: Some(vec![".".into(), ":".into(), "'".into(), "\"".into(), "@".into(), "{".into()]), + ..CompletionOptions::default() + }), + hover_provider: Some(HoverProviderCapability::Simple(true)), + signature_help_provider: Some(SignatureHelpOptions { + trigger_characters: Some(vec!["(".into(), ",".into()]), + retrigger_characters: None, + work_done_progress_options: WorkDoneProgressOptions::default(), + }), + definition_provider: Some(OneOf::Left(true)), + references_provider: Some(OneOf::Left(true)), + document_highlight_provider: Some(OneOf::Left(true)), + document_symbol_provider: Some(OneOf::Left(true)), + workspace_symbol_provider: Some(OneOf::Left(true)), + rename_provider: Some(OneOf::Right(RenameOptions { + prepare_provider: Some(true), + work_done_progress_options: WorkDoneProgressOptions::default(), + })), + code_action_provider: Some(CodeActionProviderCapability::Simple(true)), + folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)), + inlay_hint_provider: Some(OneOf::Left(true)), + document_formatting_provider: Some(OneOf::Left(true)), + semantic_tokens_provider: Some(SemanticTokensServerCapabilities::SemanticTokensOptions( + SemanticTokensOptions { + legend: semantic_tokens::legend(), + full: Some(SemanticTokensFullOptions::Bool(true)), + range: Some(false), + work_done_progress_options: WorkDoneProgressOptions::default(), + }, + )), + ..ServerCapabilities::default() + } +} + +pub fn run() -> AnyResult<()> { + let (connection, io_threads) = Connection::stdio(); + run_connection(connection)?; + io_threads.join()?; + Ok(()) +} + +pub fn run_connection(connection: Connection) -> AnyResult<()> { + let (id, params) = connection.initialize_start()?; + let params: InitializeParams = serde_json::from_value(params)?; + let result = json!({ + "capabilities": capabilities(), + "serverInfo": { "name": "qbx-lua-ls", "version": env!("CARGO_PKG_VERSION") }, + }); + connection.initialize_finish(id, result)?; + + let mut server = Server::new(connection, params); + server.start(); + server.main_loop() +} + +#[allow(deprecated)] +fn workspace_roots(params: &InitializeParams) -> Vec { + let folders = params.workspace_folders.iter().flatten().filter_map(|f| uri_to_path(&f.uri)); + let mut roots: Vec = folders.collect(); + if roots.is_empty() { + roots.extend(params.root_uri.as_ref().and_then(uri_to_path)); + } + roots +} + +fn is_lua_ls_config(path: &std::path::Path) -> bool { + path.file_name().is_some_and(|name| qbx_lua_analysis::lua_ls_config::FILE_NAMES.iter().any(|n| name == *n)) +} + +impl Server { + pub fn new(connection: Connection, params: InitializeParams) -> Self { + let settings: Settings = + params.initialization_options.clone().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let mut ws = Workspace::default(); + ws.roots = workspace_roots(¶ms); + ws.library = settings.library.iter().map(PathBuf::from).collect(); + let snippet_support = params + .capabilities + .text_document + .as_ref() + .and_then(|text| text.completion.as_ref()) + .and_then(|completion| completion.completion_item.as_ref()) + .and_then(|item| item.snippet_support) + .unwrap_or(false); + let watched_files_registration = params + .capabilities + .workspace + .as_ref() + .and_then(|workspace| workspace.did_change_watched_files.as_ref()) + .and_then(|watched| watched.dynamic_registration) + .unwrap_or(false); + Self { + connection, + ws, + docs: Documents::default(), + settings, + snippet_support, + watched_files_registration, + dirty: FxHashSet::default(), + diagnostics_pending: false, + workspace_reported: FxHashSet::default(), + workspace_stale: true, + stale_resources: FxHashSet::default(), + next_request_id: 0, + } + } + + fn start(&mut self) { + self.ws.load_stubs(); + let stats = self.ws.scan(); + self.log(format!( + "indexed {} files in {} resources in {} ms ({} natives available)", + stats.files, + stats.resources, + stats.millis, + qbx_fivem_data::native_count() + )); + self.log_config_notes(); + self.register_watchers(); + } + + fn log(&self, message: String) { + self.notify::(LogMessageParams { typ: MessageType::INFO, message }); + } + + fn log_config_notes(&self) { + for note in &self.ws.lint_config.notes { + self.log(note.clone()); + } + } + + fn notify(&self, params: N::Params) { + let _ = self.connection.sender.send(Message::Notification(Notification::new(N::METHOD.to_string(), params))); + } + + fn register_watchers(&mut self) { + if !self.watched_files_registration { + return; + } + let config_files = qbx_lua_analysis::config::CONFIG_FILE_NAMES + .iter() + .chain(qbx_lua_analysis::lua_ls_config::FILE_NAMES) + .map(|name| format!("**/{name}")); + let watchers = ["**/*.lua", "**/locales/*.json", "**/*.cfg"] + .iter() + .map(|glob| glob.to_string()) + .chain(config_files) + .map(|glob| FileSystemWatcher { glob_pattern: GlobPattern::String(glob), kind: None }) + .collect(); + let registration = Registration { + id: "qbx-watch-lua".into(), + method: notif::DidChangeWatchedFiles::METHOD.into(), + register_options: serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).ok(), + }; + self.next_request_id += 1; + let request = Request::new( + RequestId::from(self.next_request_id), + req::RegisterCapability::METHOD.to_string(), + RegistrationParams { registrations: vec![registration] }, + ); + let _ = self.connection.sender.send(Message::Request(request)); + } + + fn main_loop(&mut self) -> AnyResult<()> { + while let Ok(message) = self.connection.receiver.recv() { + match message { + Message::Request(request) => { + if self.connection.handle_shutdown(&request)? { + return Ok(()); + } + let id = request.id.clone(); + let response = self.isolated(|server| { + server.flush_index(); + server.handle_request(request) + }); + let response = response.unwrap_or_else(|| { + Response::new_err(id, ErrorCode::InternalError as i32, "internal error".to_string()) + }); + self.connection.sender.send(Message::Response(response))?; + } + Message::Notification(notification) => { + self.isolated(|server| server.handle_notification(notification)); + } + Message::Response(_) => {} + } + if self.connection.receiver.is_empty() { + self.isolated(Self::publish_dirty); + self.isolated(Self::publish_workspace); + } + } + Ok(()) + } + + /// A bug in one request must not take the editor's language server down with it. + fn isolated(&mut self, work: impl FnOnce(&mut Self) -> T) -> Option { + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(self))); + if outcome.is_err() { + self.dirty.clear(); + self.diagnostics_pending = false; + self.log("recovered from an internal error; please report it with the file that triggered it".to_string()); + } + outcome.ok() + } + + /// Open documents are reparsed on every edit but only reindexed once something needs the index. + fn flush_index(&mut self) { + self.diagnostics_pending |= !self.dirty.is_empty(); + let mut relink = false; + for uri in std::mem::take(&mut self.dirty) { + if let Some(doc) = self.docs.get_mut(&uri) { + // A full scan reallocates file IDs, including the reserved slots for manifests. + doc.file = self.ws.index.allocate(&doc.path); + let is_source = !qbx_lua_analysis::project::is_not_source(doc.text.as_bytes()); + if !doc.is_manifest() && is_source && !self.ws.lint_config.is_excluded(&doc.path) { + relink |= self.ws.index.file(doc.file).is_none(); + doc.file = + self.ws.index_parsed(&doc.path, FileOrigin::Workspace, &doc.text, &doc.chunk, &doc.resolution); + } + } + } + if relink { + self.ws.link_imports(); + } + } + + fn publish_dirty(&mut self) { + self.flush_index(); + if !std::mem::take(&mut self.diagnostics_pending) { + return; + } + let crossrefs = self.ws.crossrefs(); + let uris: Vec = self.docs.keys().cloned().collect(); + for uri in uris { + self.publish(&uri, &crossrefs); + } + } + + /// Lints the files nobody has open, so the Problems panel covers the whole workspace. Each file + /// is parsed, checked and dropped again; only the diagnostics leave this function. + fn publish_workspace(&mut self) { + let everything = std::mem::take(&mut self.workspace_stale); + let scope = std::mem::take(&mut self.stale_resources); + if !everything && scope.is_empty() { + return; + } + let in_scope = |resource: Option| everything || scope.contains(&resource); + let settings = &self.settings.diagnostics; + let enabled = settings.enable.unwrap_or(true) && settings.workspace.unwrap_or(true); + let mut targets: Vec<(Url, PathBuf, Option)> = Vec::new(); + if enabled { + let in_workspace = |path: &std::path::Path| self.ws.roots.iter().any(|root| path.starts_with(root)); + let files = + self.ws.index.files().filter(|(_, f)| f.origin == FileOrigin::Workspace && in_scope(f.resource)); + for (id, file) in files { + targets.push((file.uri.clone(), file.path.clone(), Some(id))); + } + for (id, resource) in self.ws.index.resources.iter().enumerate() { + if in_workspace(&resource.manifest_path) && in_scope(Some(id as crate::index::ResourceId)) { + let uri = crate::workspace::path_to_uri(&resource.manifest_path); + targets.push((uri, resource.manifest_path.clone(), None)); + } + } + } + + let overrides = self.settings.rule_overrides(); + let checked: FxHashSet = targets.iter().map(|(uri, ..)| uri.clone()).collect(); + let mut reported: FxHashSet = if everything { + FxHashSet::default() + } else { + self.workspace_reported.iter().filter(|uri| !checked.contains(*uri)).cloned().collect() + }; + let crossrefs = self.ws.crossrefs(); + let mut locale_usage: FxHashMap> = + FxHashMap::default(); + for (uri, path, file) in targets { + let resource = file.and_then(|id| self.ws.index.file(id)).and_then(|f| f.resource); + if let Some(open) = self.docs.get(&uri) { + if let Some(resource) = resource { + locale_usage.entry(resource).or_default().push(qbx_lua_analysis::locale::locale_usage(&open.chunk)); + } + continue; + } + let Ok(text) = qbx_lua_analysis::project::read_source(&path) else { continue }; + let mut doc = Document::new(uri.clone(), path, 0, text); + doc.file = file.unwrap_or_else(|| self.ws.index.allocate(&doc.path)); + if let Some(resource) = resource { + locale_usage.entry(resource).or_default().push(qbx_lua_analysis::locale::locale_usage(&doc.chunk)); + } + let mut found = diagnostics::diagnostics(&self.ws, &doc, &overrides, &crossrefs); + found.retain(|d| d.severity != Some(DiagnosticSeverity::HINT)); + found.sort_by_key(|d| d.severity.map_or(4, |s| if s == DiagnosticSeverity::ERROR { 0 } else { 1 })); + found.truncate(MAX_PROBLEMS_PER_CLOSED_FILE); + if !found.is_empty() { + reported.insert(uri.clone()); + } + if !found.is_empty() || self.workspace_reported.contains(&uri) { + self.notify::(PublishDiagnosticsParams { + uri, + diagnostics: found, + version: None, + }); + } + } + for (resource, usages) in locale_usage { + let Some(entry) = self.ws.index.resource(resource) else { continue }; + // Encrypted scripts may use any key, so "unused" cannot be decided for such a resource. + if entry.escrowed { + continue; + } + let Some(locale) = qbx_lua_analysis::locale::LocaleFile::load(&entry.root) else { continue }; + if diagnostics::is_silenced(&self.ws, &locale.path) { + continue; + } + let mut config = self.ws.lint_config.for_file(&locale.path); + overrides.iter().for_each(|(code, level)| config.set(code, *level)); + let Some(severity) = config.severity(qbx_lua_analysis::rules::UNUSED_LOCALE_KEY) else { continue }; + let lines = qbx_lua_syntax::LineIndex::new(&locale.source); + let found: Vec = qbx_lua_analysis::lint::unused_locale_keys_from(&locale, usages.into_iter()) + .into_iter() + .map(|d| Diagnostic { + range: crate::indexer::span_to_range(&locale.source, &lines, d.span), + severity: Some(match severity { + qbx_lua_analysis::Severity::Error => DiagnosticSeverity::ERROR, + qbx_lua_analysis::Severity::Warning => DiagnosticSeverity::WARNING, + qbx_lua_analysis::Severity::Info => DiagnosticSeverity::INFORMATION, + qbx_lua_analysis::Severity::Hint => DiagnosticSeverity::HINT, + }), + code: Some(NumberOrString::String(d.code.to_string())), + source: Some(diagnostics::SOURCE.to_string()), + message: d.message, + tags: Some(vec![DiagnosticTag::UNNECESSARY]), + ..Diagnostic::default() + }) + .collect(); + let uri = crate::workspace::path_to_uri(&locale.path); + reported.remove(&uri); + if !found.is_empty() { + reported.insert(uri.clone()); + } + if !found.is_empty() || self.workspace_reported.contains(&uri) { + self.notify::(PublishDiagnosticsParams { + uri, + diagnostics: found, + version: None, + }); + } + } + for uri in self.workspace_reported.difference(&reported).filter(|uri| !self.docs.contains_key(*uri)) { + let cleared = PublishDiagnosticsParams { uri: uri.clone(), diagnostics: Vec::new(), version: None }; + self.notify::(cleared); + } + self.workspace_reported = reported; + } + + fn mark_resource_stale(&mut self, uri: &Url) { + let Some(path) = uri_to_path(uri) else { return }; + let resource = match self.ws.index.file_id(&path).and_then(|id| self.ws.index.file(id)) { + Some(file) => file.resource, + None => self.ws.index.resources.iter().position(|r| r.manifest_path == path).map(|id| id as u32), + }; + self.stale_resources.insert(resource); + } + + fn publish(&self, uri: &Url, crossrefs: &qbx_lua_analysis::crossref::CrossRefs) { + let Some(doc) = self.docs.get(uri) else { return }; + let diagnostics = if self.settings.diagnostics.enable.unwrap_or(true) { + diagnostics::diagnostics(&self.ws, doc, &self.settings.rule_overrides(), crossrefs) + } else { + Vec::new() + }; + self.notify::(PublishDiagnosticsParams { + uri: uri.clone(), + diagnostics, + version: Some(doc.version), + }); + } + + fn handle_notification(&mut self, notification: Notification) { + let Notification { method, params } = notification; + match method.as_str() { + notif::DidOpenTextDocument::METHOD => { + let Ok(params) = serde_json::from_value::(params) else { return }; + let item = params.text_document; + let Some(path) = uri_to_path(&item.uri) else { return }; + let mut doc = Document::new(item.uri.clone(), path, item.version, item.text); + if doc.is_manifest() { + self.ws.side_and_resource(&doc.path); + doc.file = self.ws.index.allocate(&doc.path); + } + self.dirty.insert(item.uri.clone()); + self.docs.insert(item.uri, doc); + } + notif::DidChangeTextDocument::METHOD => { + let Ok(params) = serde_json::from_value::(params) else { return }; + let uri = params.text_document.uri; + if let Some(doc) = self.docs.get_mut(&uri) { + doc.apply_changes(params.text_document.version, params.content_changes); + self.dirty.insert(uri); + } + } + notif::DidSaveTextDocument::METHOD => { + let Ok(params) = serde_json::from_value::(params) else { return }; + let uri = params.text_document.uri; + if let Some(path) = uri_to_path(&uri).filter(|p| is_manifest_file(p)) { + self.ws.reload_manifest(&path); + } + self.mark_resource_stale(&uri); + self.dirty.insert(uri); + } + notif::DidCloseTextDocument::METHOD => { + let Ok(params) = serde_json::from_value::(params) else { return }; + let uri = params.text_document.uri; + if let Some(doc) = self.docs.remove(&uri) { + if !doc.is_manifest() { + self.ws.index_path(&doc.path, FileOrigin::Workspace, None); + } + } + self.dirty.remove(&uri); + self.mark_resource_stale(&uri); + self.workspace_reported.insert(uri); + } + notif::DidChangeWatchedFiles::METHOD => { + let Ok(params) = serde_json::from_value::(params) else { return }; + self.watched_files_changed(params.changes); + } + notif::DidChangeConfiguration::METHOD => { + let Ok(params) = serde_json::from_value::(params) else { return }; + let section = params.settings.get("qbxLua").cloned().unwrap_or(params.settings); + if let Ok(settings) = serde_json::from_value::(section) { + self.settings = settings; + self.workspace_stale = true; + self.dirty.extend(self.docs.keys().cloned()); + } + } + _ => {} + } + } + + fn watched_files_changed(&mut self, changes: Vec) { + // Manifests, the configured imports and new files that a glob import names change which + // files each resource imports. + let mut relink = false; + let mut config_changed = false; + for change in changes { + let Some(path) = uri_to_path(&change.uri) else { continue }; + if path.extension().is_some_and(|e| e == "cfg") { + qbx_lua_analysis::startup::clear_cache(); + } else if path.extension().is_some_and(|e| e == "toml") || is_lua_ls_config(&path) { + config_changed = true; + } else if is_manifest_file(&path) { + // A manifest appearing or vanishing changes which resources count as installed. + qbx_lua_analysis::startup::clear_cache(); + self.ws.reload_manifest(&path); + relink = true; + } else if change.typ == FileChangeType::DELETED { + self.ws.index.remove_file(&path); + } else if path.extension().is_some_and(|e| e == "lua") { + if !self.docs.contains_key(&change.uri) { + self.ws.index_path(&path, FileOrigin::Workspace, None); + } + relink |= change.typ == FileChangeType::CREATED; + } + } + if config_changed { + if let Some(root) = self.ws.roots.first() { + self.ws.lint_config = qbx_lua_analysis::Config::discover(root).ok().flatten().unwrap_or_default(); + } + self.log_config_notes(); + } + if relink || config_changed { + self.ws.link_imports(); + } + self.workspace_stale = true; + self.dirty.extend(self.docs.keys().cloned()); + } + + fn handle_request(&mut self, request: Request) -> Response { + let id = request.id.clone(); + match self.dispatch(request) { + Ok(value) => Response { id, result: Some(value), error: None }, + Err(message) => Response::new_err(id, ErrorCode::InvalidParams as i32, message), + } + } + + fn doc(&self, uri: &Url) -> Result<&Document, String> { + self.docs.get(uri).ok_or_else(|| format!("document is not open: {uri}")) + } + + fn dispatch(&mut self, request: Request) -> Result { + fn params(value: Value) -> Result { + serde_json::from_value(value).map_err(|e| e.to_string()) + } + fn reply(value: T) -> Result { + serde_json::to_value(value).map_err(|e| e.to_string()) + } + + let Request { method, params: raw, .. } = request; + match method.as_str() { + req::Completion::METHOD => { + let p: CompletionParams = params(raw)?; + let doc = self.doc(&p.text_document_position.text_document.uri)?; + reply(completion::completion(&self.ws, doc, p.text_document_position.position, self.snippet_support)) + } + req::ResolveCompletionItem::METHOD => reply(completion::resolve(params(raw)?)), + req::HoverRequest::METHOD => { + let p: HoverParams = params(raw)?; + let doc = self.doc(&p.text_document_position_params.text_document.uri)?; + reply(hover::hover(&self.ws, doc, p.text_document_position_params.position)) + } + req::SignatureHelpRequest::METHOD => { + let p: SignatureHelpParams = params(raw)?; + let doc = self.doc(&p.text_document_position_params.text_document.uri)?; + reply(signature::signature_help(&self.ws, doc, p.text_document_position_params.position)) + } + req::GotoDefinition::METHOD => { + let p: GotoDefinitionParams = params(raw)?; + let doc = self.doc(&p.text_document_position_params.text_document.uri)?; + reply(definition::definition(&self.ws, doc, p.text_document_position_params.position)) + } + req::References::METHOD => { + let p: ReferenceParams = params(raw)?; + let doc = self.doc(&p.text_document_position.text_document.uri)?; + reply(references::references( + &self.ws, + &self.docs, + doc, + p.text_document_position.position, + p.context.include_declaration, + )) + } + req::DocumentHighlightRequest::METHOD => { + let p: DocumentHighlightParams = params(raw)?; + let doc = self.doc(&p.text_document_position_params.text_document.uri)?; + reply(references::highlights(&self.ws, doc, p.text_document_position_params.position)) + } + req::PrepareRenameRequest::METHOD => { + let p: TextDocumentPositionParams = params(raw)?; + reply(references::prepare_rename(&self.ws, self.doc(&p.text_document.uri)?, p.position)) + } + req::Rename::METHOD => { + let p: RenameParams = params(raw)?; + let doc = self.doc(&p.text_document_position.text_document.uri)?; + reply(references::rename(&self.ws, &self.docs, doc, p.text_document_position.position, &p.new_name)) + } + req::DocumentSymbolRequest::METHOD => { + let p: DocumentSymbolParams = params(raw)?; + reply(DocumentSymbolResponse::Nested(symbols::document_symbols(self.doc(&p.text_document.uri)?))) + } + req::WorkspaceSymbolRequest::METHOD => { + let p: WorkspaceSymbolParams = params(raw)?; + reply(symbols::workspace_symbols(&self.ws, &p.query)) + } + req::CodeActionRequest::METHOD => { + let p: CodeActionParams = params(raw)?; + reply(code_action::code_actions(self.doc(&p.text_document.uri)?, &p.context.diagnostics)) + } + req::FoldingRangeRequest::METHOD => { + let p: FoldingRangeParams = params(raw)?; + reply(folding::folding_ranges(self.doc(&p.text_document.uri)?)) + } + req::InlayHintRequest::METHOD => { + let p: InlayHintParams = params(raw)?; + if self.settings.inlay_hints.enable == Some(false) { + return reply(Vec::::new()); + } + reply(inlay::inlay_hints(&self.ws, self.doc(&p.text_document.uri)?, p.range)) + } + req::SemanticTokensFullRequest::METHOD => { + let p: SemanticTokensParams = params(raw)?; + if self.settings.semantic_tokens.enable == Some(false) { + return reply(SemanticTokens::default()); + } + reply(semantic_tokens::semantic_tokens(&self.ws, self.doc(&p.text_document.uri)?)) + } + req::Formatting::METHOD => { + let p: DocumentFormattingParams = params(raw)?; + let doc = self.doc(&p.text_document.uri)?; + let mut options = self.ws.lint_config.format.clone(); + // Without a qbxlint.toml the editor's own indentation settings decide. + if !self.ws.lint_config.format_configured { + options.indent_width = p.options.tab_size.max(1) as usize; + options.use_tabs = !p.options.insert_spaces; + } + match qbx_lua_fmt::format(&doc.text, &options) { + Ok(text) if text == doc.text => reply(Vec::::new()), + Ok(text) => { + let whole = Range::new(Position::new(0, 0), doc.position(doc.text.len() as u32)); + reply(vec![TextEdit::new(whole, text)]) + } + Err(error) => { + self.notify::(ShowMessageParams { + typ: MessageType::WARNING, + message: format!("Qbox Lua could not format this file: {error}"), + }); + Ok(Value::Null) + } + } + } + "qbx/status" => Ok(json!({ + "files": self.ws.index.file_count(), + "resources": self.ws.index.resources.len(), + "openDocuments": self.docs.len(), + "natives": qbx_fivem_data::native_count(), + })), + "qbx/referenceSearch" => { + if !raw.is_null() && !raw.is_object() { + return Err("reference search parameters must be an object or null".into()); + } + let p = if raw.is_null() { reference::SearchParams::default() } else { params(raw)? }; + reply(reference::search(p)?) + } + "qbx/referenceDetail" => { + if !raw.is_object() { + return Err("reference detail parameters must be an object".into()); + } + reply(reference::detail(params(raw)?)?) + } + "qbx/resourceDetails" => { + if !raw.is_object() { + return Err("resource details parameters must be an object containing a file URI".into()); + } + reply(crate::features::resource_details::details(&self.ws.index, params(raw)?)?) + } + "qbx/workspaceHealth" => { + if !raw.is_null() && !raw.as_object().is_some_and(|object| object.is_empty()) { + return Err("workspace health parameters must be null or an empty object".into()); + } + reply(crate::features::workspace_health::health(&self.ws.index)) + } + "qbx/nuiResource" => { + if !raw.is_object() { + return Err("NUI resource parameters must be an object containing a file URI".into()); + } + reply(crate::features::nui_resource::resource(&self.ws.index, params(raw)?)?) + } + "qbx/resourceAssets" => { + if !raw.is_object() { + return Err("resource asset parameters must be an object containing a file URI".into()); + } + reply(crate::features::resource_assets::resource(&self.ws.index, &self.docs, params(raw)?)?) + } + "qbx/resources" => { + if !raw.is_object() { + return Err("resource listing parameters must be an object".into()); + } + reply(crate::features::assistant::resources(&self.ws, params(raw)?)?) + } + "qbx/diagnostics" => { + if !raw.is_object() { + return Err("diagnostic snapshot parameters must be an object".into()); + } + reply(crate::features::assistant::diagnostic_snapshot( + &self.ws, + &self.docs, + params(raw)?, + self.settings.diagnostics.enable.unwrap_or(true), + &self.settings.rule_overrides(), + )?) + } + "qbx/symbolReferences" => { + if !raw.is_object() { + return Err("symbol reference parameters must be an object".into()); + } + reply(crate::features::assistant::symbol_references(&self.ws, &self.docs, params(raw)?)?) + } + "qbx/reindex" => { + qbx_lua_analysis::startup::clear_cache(); + let stats = self.ws.scan(); + self.log_config_notes(); + self.dirty.extend(self.docs.keys().cloned()); + // Restore unsaved text before any request or closed-file diagnostic can see the + // rebuilt index. A second pass settles types shared between open documents. + self.flush_index(); + self.ws.link_imports(); + self.dirty.extend(self.docs.keys().cloned()); + self.flush_index(); + self.stale_resources.clear(); + self.workspace_stale = true; + Ok(json!({ "files": stats.files, "resources": stats.resources, "millis": stats.millis as u64 })) + } + "qbx/fileInfo" => { + let p: TextDocumentIdentifier = params(raw)?; + let path = uri_to_path(&p.uri).ok_or("not a file uri")?; + let entry = self.ws.index.file_id(&path).and_then(|id| self.ws.index.file(id)); + let resource = entry.and_then(|f| f.resource).and_then(|id| self.ws.index.resource(id)); + let side = match (entry.and_then(|f| f.side), resource, is_manifest_file(&path)) { + (_, _, true) => "manifest", + (Some(side), _, _) => side.label(), + (None, Some(_), _) => "module", + (None, None, _) => "standalone", + }; + Ok(json!({ "side": side, "resource": resource.map(|r| r.name.to_string()) })) + } + "qbx/snippets" => { + let doc = + serde_json::from_value::(raw).ok().and_then(|p| self.docs.get(&p.uri)); + let snippets: Vec = completion::all_snippets(&self.ws, doc) + .into_iter() + .map(|s| { + let preview = completion::snippet_preview(&s.body); + json!({ "label": s.label, "description": s.description, "body": s.body, "preview": preview }) + }) + .collect(); + Ok(json!(snippets)) + } + other => Err(format!("unsupported request: {other}")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn requests_index_edits_once_and_idle_time_still_publishes_them() { + let (connection, client) = Connection::memory(); + let mut server = Server::new(connection, InitializeParams::default()); + let uri = Url::from_file_path(std::env::temp_dir().join("qbx-lua-ls-dirty.lua")).unwrap(); + let text_document = TextDocumentItem::new(uri, "lua".into(), 1, "Value = 1".into()); + server.handle_notification(Notification::new( + notif::DidOpenTextDocument::METHOD.into(), + DidOpenTextDocumentParams { text_document }, + )); + server.flush_index(); + assert!(server.dirty.is_empty()); + server.publish_dirty(); + let published = client + .receiver + .try_iter() + .filter(|m| matches!(m, Message::Notification(n) if n.method == notif::PublishDiagnostics::METHOD)) + .count(); + assert_eq!(published, 1); + } +} diff --git a/crates/qbx_lua_ls/src/types.rs b/crates/qbx_lua_ls/src/types.rs new file mode 100644 index 0000000..fc3e151 --- /dev/null +++ b/crates/qbx_lua_ls/src/types.rs @@ -0,0 +1,636 @@ +use std::fmt; +use std::sync::Arc; + +use smol_str::SmolStr; + +#[derive(Clone, Debug, Default, PartialEq)] +pub enum Type { + #[default] + Unknown, + Any, + Nil, + Boolean, + Number, + Integer, + String, + Table, + Function, + Thread, + Userdata, + BooleanLit(bool), + StringLit(SmolStr), + IntLit(i64), + /// A native handle such as `Vehicle` or `Hash`: an integer that keeps its name for display. + Handle(SmolStr), + Named(SmolStr, Vec), + Array(Box), + Map(Box, Box), + Tuple(Vec), + Union(Vec), + Fun(Arc), + Shape(Arc), + Variadic(Box), + /// A global table addressed by its dotted path, e.g. `lib.callback`; members live in the index. + GlobalTable(SmolStr), + /// The `exports` object, or one resource of it once indexed (`exports.qbx_core`). + Exports(Option), + /// The value returned by the module a `require` call names; resolved through the index on use. + Require(SmolStr), +} + +#[derive(Clone, Debug, PartialEq, Default)] +pub struct Param { + pub name: SmolStr, + pub ty: Type, + pub optional: bool, +} + +#[derive(Clone, Debug, PartialEq, Default)] +pub struct FunType { + pub params: Vec, + pub returns: Vec, + pub is_method: bool, + /// The names declared with `@generic`, bound from the arguments of each call. + pub generics: Vec, + /// The `@overload` signatures, for calls the declared one does not fit. + pub overloads: Vec>, +} + +#[derive(Clone, Debug, PartialEq, Default)] +pub struct ShapeField { + pub name: SmolStr, + pub ty: Type, + pub optional: bool, +} + +#[derive(Clone, Debug, PartialEq, Default)] +pub struct Shape { + pub fields: Vec, + /// The values of the array part, which `ipairs` visits, apart from the other `[key]` entries. + pub array: Option, + pub index: Option<(Type, Type)>, +} + +impl Type { + pub fn named(name: &str) -> Type { + match name { + "any" => Type::Any, + "nil" | "void" => Type::Nil, + "boolean" | "bool" => Type::Boolean, + "number" | "float" => Type::Number, + "integer" | "int" => Type::Integer, + "string" => Type::String, + "table" => Type::Table, + "function" => Type::Function, + "thread" => Type::Thread, + "userdata" | "lightuserdata" => Type::Userdata, + "true" => Type::BooleanLit(true), + "false" => Type::BooleanLit(false), + "unknown" => Type::Unknown, + _ => Type::Named(SmolStr::new(name), Vec::new()), + } + } + + /// How much a type tells us, used to pick the best of several declarations of one name. + pub fn specificity(&self) -> u8 { + match self { + Type::Unknown => 0, + Type::Any => 1, + Type::Table | Type::Function | Type::Nil => 2, + Type::Union(types) => types.iter().map(Type::specificity).max().unwrap_or(0), + Type::Boolean | Type::Number | Type::Integer | Type::String | Type::Thread | Type::Userdata => 3, + Type::BooleanLit(_) | Type::StringLit(_) | Type::IntLit(_) => 3, + _ => 4, + } + } + + pub fn is_unknown(&self) -> bool { + matches!(self, Type::Unknown) + } + + pub fn union(types: impl IntoIterator) -> Type { + let mut flat: Vec = Vec::new(); + for ty in types { + match ty { + Type::Union(inner) => inner.into_iter().for_each(|t| push_unique(&mut flat, t)), + other => push_unique(&mut flat, other), + } + } + if flat.iter().any(|t| matches!(t, Type::Any)) { + return Type::Any; + } + if flat.len() > 1 { + flat.retain(|t| !t.is_unknown()); + } + match flat.len() { + 0 => Type::Unknown, + 1 => flat.pop().unwrap_or_default(), + _ => Type::Union(flat), + } + } + + pub fn optional(self) -> Type { + Type::union([self, Type::Nil]) + } + + pub fn without_nil(&self) -> Type { + match self { + Type::Union(types) => Type::union(types.iter().filter(|t| !matches!(t, Type::Nil)).cloned()), + other => other.clone(), + } + } + + /// Widens literal types the way a variable initialised with a literal should be shown. + pub fn widen(&self) -> Type { + match self { + Type::BooleanLit(_) => Type::Boolean, + Type::StringLit(_) => Type::String, + Type::IntLit(_) => Type::Integer, + Type::Union(types) => Type::union(types.iter().map(Type::widen)), + other => other.clone(), + } + } + + pub fn as_fun(&self) -> Option<&Arc> { + match self { + Type::Fun(fun) => Some(fun), + Type::Union(types) => types.iter().find_map(Type::as_fun), + _ => None, + } + } + + pub fn first_return(&self) -> Type { + self.as_fun().and_then(|f| f.returns.first().cloned()).unwrap_or_default() + } +} + +fn push_unique(list: &mut Vec, ty: Type) { + if !list.contains(&ty) { + list.push(ty); + } +} + +impl FunType { + /// How a call lines up with `params`, as `(parameters to skip, arguments to skip)`. Functions + /// declared with `:` do not list `self`, while `fun(self, ...)` fields do. + pub fn call_offsets(&self, via_colon: bool) -> (usize, usize) { + let explicit_self = self.params.first().is_some_and(|p| p.name == "self"); + match (via_colon, self.is_method) { + (true, false) if explicit_self => (1, 0), + (false, true) => (0, 1), + _ => (0, 0), + } + } + + pub fn signature(&self, name: &str) -> String { + let params: Vec = self.params.iter().map(Param::to_string).collect(); + let mut out = format!("function {name}({})", params.join(", ")); + if !self.returns.is_empty() { + let returns: Vec = self.returns.iter().map(Type::to_string).collect(); + out.push_str(": "); + out.push_str(&returns.join(", ")); + } + out + } +} + +impl fmt::Display for Param { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let optional = if self.optional { "?" } else { "" }; + if self.ty.is_unknown() { + write!(f, "{}{optional}", self.name) + } else { + write!(f, "{}{optional}: {}", self.name, self.ty) + } + } +} + +impl fmt::Display for Type { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Type::Unknown => f.write_str("unknown"), + Type::Any => f.write_str("any"), + Type::Nil => f.write_str("nil"), + Type::Boolean => f.write_str("boolean"), + Type::Number => f.write_str("number"), + Type::Integer => f.write_str("integer"), + Type::String => f.write_str("string"), + Type::Table => f.write_str("table"), + Type::Function => f.write_str("function"), + Type::Thread => f.write_str("thread"), + Type::Userdata => f.write_str("userdata"), + Type::BooleanLit(b) => write!(f, "{b}"), + Type::StringLit(s) => write!(f, "\"{s}\""), + Type::IntLit(i) => write!(f, "{i}"), + Type::Handle(name) => f.write_str(name), + Type::Named(name, args) if args.is_empty() => f.write_str(name), + Type::Named(name, args) => { + let args: Vec = args.iter().map(Type::to_string).collect(); + write!(f, "{name}<{}>", args.join(", ")) + } + Type::Array(inner) => match **inner { + Type::Union(_) | Type::Fun(_) => write!(f, "({inner})[]"), + _ => write!(f, "{inner}[]"), + }, + Type::Map(k, v) => write!(f, "table<{k}, {v}>"), + Type::Tuple(items) => { + let items: Vec = items.iter().map(Type::to_string).collect(); + write!(f, "[{}]", items.join(", ")) + } + Type::Union(types) => { + let has_nil = types.iter().any(|t| matches!(t, Type::Nil)); + let rest: Vec = types + .iter() + .filter(|t| !matches!(t, Type::Nil)) + .filter(|t| !matches!(t, Type::GlobalTable(owner) if owner.starts_with('%')) || types.len() == 1) + .map(|t| if matches!(t, Type::Fun(_)) { format!("({t})") } else { t.to_string() }) + .collect(); + match (has_nil, rest.len()) { + (true, 1) => write!(f, "{}?", rest[0]), + (true, _) => write!(f, "{}|nil", rest.join("|")), + (false, _) => f.write_str(&rest.join("|")), + } + } + Type::Fun(fun) => { + let params: Vec = fun.params.iter().map(Param::to_string).collect(); + write!(f, "fun({})", params.join(", "))?; + if !fun.returns.is_empty() { + let returns: Vec = fun.returns.iter().map(Type::to_string).collect(); + write!(f, ": {}", returns.join(", "))?; + } + Ok(()) + } + Type::Shape(shape) => { + if shape.fields.is_empty() && shape.array.is_none() && shape.index.is_none() { + return f.write_str("table"); + } + let mut parts: Vec = shape + .fields + .iter() + .take(8) + .map(|field| format!("{}{}: {}", field.name, if field.optional { "?" } else { "" }, field.ty)) + .collect(); + if let Some(array) = &shape.array { + parts.push(format!("[integer]: {array}")); + } + if let Some((k, v)) = &shape.index { + parts.push(format!("[{k}]: {v}")); + } + if shape.fields.len() > 8 { + parts.push("...".into()); + } + write!(f, "{{ {} }}", parts.join(", ")) + } + Type::Variadic(inner) => write!(f, "...{inner}"), + Type::GlobalTable(path) if path.starts_with('%') => f.write_str("table"), + Type::GlobalTable(path) => f.write_str(path), + Type::Exports(None) => f.write_str("exports"), + Type::Exports(Some(resource)) => write!(f, "exports.{resource}"), + Type::Require(path) => write!(f, "module \"{path}\""), + } + } +} + +pub struct TypeParser<'a> { + src: &'a str, + pos: usize, + depth: u32, + /// Class and alias names with their byte offsets, collected only by `parse_names`. + names: Option>, +} + +impl<'a> TypeParser<'a> { + pub fn new(src: &'a str) -> Self { + Self { src, pos: 0, depth: 0, names: None } + } + + /// Parses a type and returns the class and alias names in it with their byte offsets, leaving + /// out literals, built-in types and the names of fields and parameters. + pub fn parse_names(&mut self) -> Vec<(usize, &'a str)> { + self.names = Some(Vec::new()); + self.parse(); + self.names.take().unwrap_or_default() + } + + pub fn rest(&self) -> &'a str { + &self.src[self.pos.min(self.src.len())..] + } + + pub fn position(&self) -> usize { + self.pos + } + + fn bytes(&self) -> &'a [u8] { + self.src.as_bytes() + } + + fn peek(&self) -> u8 { + self.bytes().get(self.pos).copied().unwrap_or(0) + } + + pub fn skip_ws(&mut self) { + while matches!(self.peek(), b' ' | b'\t') { + self.pos += 1; + } + } + + fn eat(&mut self, c: u8) -> bool { + self.skip_ws(); + if self.peek() == c { + self.pos += 1; + return true; + } + false + } + + pub fn ident(&mut self) -> Option<&'a str> { + self.skip_ws(); + let start = self.pos; + while matches!(self.peek(), b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'.' | b'-' | b'*') + && !self.rest().starts_with("...") + { + self.pos += 1; + } + (self.pos > start).then(|| &self.src[start..self.pos]) + } + + pub fn parse(&mut self) -> Type { + self.depth += 1; + if self.depth > 32 { + self.pos = self.src.len(); + return Type::Unknown; + } + let mut types = vec![self.postfix()]; + loop { + self.skip_ws(); + if self.peek() == b'|' { + self.pos += 1; + types.push(self.postfix()); + } else { + break; + } + } + self.depth -= 1; + Type::union(types) + } + + /// Comma separated types, as used by `@return` and function returns. + pub fn parse_list(&mut self) -> Vec { + let mut types = vec![self.parse()]; + while self.eat(b',') { + types.push(self.parse()); + } + types + } + + fn postfix(&mut self) -> Type { + let mut ty = self.primary(); + loop { + if self.rest().starts_with("[]") { + self.pos += 2; + ty = Type::Array(Box::new(ty)); + } else if self.peek() == b'?' { + self.pos += 1; + ty = ty.optional(); + } else { + return ty; + } + } + } + + fn primary(&mut self) -> Type { + self.skip_ws(); + match self.peek() { + b'(' => { + self.pos += 1; + let inner = self.parse(); + self.eat(b')'); + inner + } + b'{' => self.shape(), + b'[' => { + self.pos += 1; + Type::Tuple(self.list_until(b']')) + } + quote @ (b'"' | b'\'') => { + self.pos += 1; + let start = self.pos; + while self.pos < self.src.len() && self.peek() != quote { + self.pos += 1; + } + let value = SmolStr::new(&self.src[start..self.pos]); + self.pos = (self.pos + 1).min(self.src.len()); + Type::StringLit(value) + } + b'`' => { + self.pos += 1; + let name = self.ident().unwrap_or("T"); + self.eat(b'`'); + Type::Named(SmolStr::new(name), Vec::new()) + } + b'-' | b'0'..=b'9' => { + let start = self.pos; + self.pos += 1; + while self.peek().is_ascii_digit() { + self.pos += 1; + } + self.src[start..self.pos].parse().map(Type::IntLit).unwrap_or(Type::Integer) + } + _ if self.rest().starts_with("...") => { + self.pos += 3; + let inner = if self.ident_follows() { self.postfix() } else { Type::Any }; + Type::Variadic(Box::new(inner)) + } + _ => self.named(), + } + } + + fn list_until(&mut self, close: u8) -> Vec { + let mut items = Vec::new(); + while !self.eat(close) && self.pos < self.src.len() { + let before = self.pos; + let ty = self.parse(); + // A byte no type can start with, like the stray `}` in `table`, would be retried forever. + if self.pos == before { + break; + } + items.push(ty); + self.eat(b','); + } + items + } + + fn ident_follows(&self) -> bool { + matches!(self.peek(), b'a'..=b'z' | b'A'..=b'Z' | b'_' | b'{' | b'(') + } + + fn named(&mut self) -> Type { + let Some(name) = self.ident() else { + return Type::Unknown; + }; + if name == "fun" && self.peek() == b'(' { + return self.fun(); + } + if let Some(names) = &mut self.names { + if matches!(Type::named(name), Type::Named(..)) { + names.push((self.pos - name.len(), name)); + } + } + if self.peek() != b'<' { + return Type::named(name); + } + self.pos += 1; + let mut args = self.list_until(b'>'); + match (name, args.len()) { + ("table", 2) => { + let value = args.pop().unwrap_or_default(); + let key = args.pop().unwrap_or_default(); + Type::Map(Box::new(key), Box::new(value)) + } + ("table", 1) => Type::Array(Box::new(args.pop().unwrap_or_default())), + _ => Type::Named(SmolStr::new(name), args), + } + } + + fn fun(&mut self) -> Type { + self.pos += 1; + let mut params = Vec::new(); + loop { + self.skip_ws(); + if self.eat(b')') || self.pos >= self.src.len() { + break; + } + if self.rest().starts_with("...") { + self.pos += 3; + let ty = if self.eat(b':') { self.parse() } else { Type::Any }; + params.push(Param { name: "...".into(), ty, optional: false }); + } else if let Some(name) = self.ident() { + let optional = self.eat(b'?'); + let ty = if self.eat(b':') { self.parse() } else { Type::Unknown }; + params.push(Param { name: SmolStr::new(name), ty, optional }); + } else { + self.pos += 1; + } + self.eat(b','); + } + let returns = if self.eat(b':') { self.parse_return_list() } else { Vec::new() }; + Type::Fun(Arc::new(FunType { params, returns, is_method: false, generics: Vec::new(), overloads: Vec::new() })) + } + + fn parse_return_list(&mut self) -> Vec { + let mut types = vec![self.parse()]; + loop { + let checkpoint = self.pos; + if !self.eat(b',') { + break; + } + self.skip_ws(); + let before = self.pos; + let ty = self.parse(); + if self.pos == before || ty.is_unknown() { + self.pos = checkpoint; + break; + } + types.push(ty); + } + types + } + + fn shape(&mut self) -> Type { + self.pos += 1; + let mut shape = Shape::default(); + loop { + self.skip_ws(); + if self.eat(b'}') || self.pos >= self.src.len() { + break; + } + if self.eat(b'[') { + let key = self.parse(); + self.eat(b']'); + self.eat(b':'); + let value = self.parse(); + shape.index = Some((key, value)); + } else if let Some(name) = self.ident() { + let optional = self.eat(b'?'); + let ty = if self.eat(b':') { self.parse() } else { Type::Unknown }; + shape.fields.push(ShapeField { name: SmolStr::new(name), ty, optional }); + } else { + self.pos += 1; + } + if !self.eat(b',') { + self.eat(b';'); + } + } + Type::Shape(Arc::new(shape)) + } +} + +pub fn parse_type(text: &str) -> Type { + TypeParser::new(text).parse() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn roundtrip(text: &str) -> String { + parse_type(text).to_string() + } + + #[test] + fn parses_common_luacats_types() { + assert_eq!(roundtrip("string"), "string"); + assert_eq!(roundtrip("string|number"), "string|number"); + assert_eq!(roundtrip("string?"), "string?"); + assert_eq!(roundtrip("number[]"), "number[]"); + assert_eq!(roundtrip("(string|number)[]"), "(string|number)[]"); + assert_eq!(roundtrip("table"), "table"); + assert_eq!(roundtrip("table"), "Player[]"); + assert_eq!( + roundtrip("fun(a: string, b?: number): boolean, string"), + "fun(a: string, b?: number): boolean, string" + ); + assert_eq!(roundtrip("fun(...: any)"), "fun(...: any)"); + assert_eq!(roundtrip("{ name: string, age?: number }"), "{ name: string, age?: number }"); + assert_eq!(roundtrip("{ [string]: boolean }"), "{ [string]: boolean }"); + assert_eq!(roundtrip("'left'|'right'"), "\"left\"|\"right\""); + assert_eq!(roundtrip("[number, number]"), "[number, number]"); + assert_eq!(roundtrip("`T`"), "T"); + assert_eq!(roundtrip("vector3|vector4"), "vector3|vector4"); + assert_eq!(roundtrip("OxPlayer?"), "OxPlayer?"); + assert_eq!(roundtrip("1|2|3"), "1|2|3"); + assert_eq!(roundtrip("any|string"), "any"); + } + + #[test] + fn stops_at_the_description() { + let mut parser = TypeParser::new("string|number the value to use"); + assert_eq!(parser.parse().to_string(), "string|number"); + assert_eq!(parser.rest().trim(), "the value to use"); + } + + #[test] + fn malformed_types_do_not_hang() { + for text in [ + "fun(", + "{ a: ", + "table<", + "[", + "((((", + "fun(a: fun(b: fun(", + "|||", + "", + "table<}>", + "[}]", + "table", + ] { + let _ = parse_type(text); + } + } + + #[test] + fn widening_and_nil_removal() { + assert_eq!(parse_type("'a'|1|true").widen().to_string(), "string|integer|boolean"); + assert_eq!(parse_type("string?").without_nil().to_string(), "string"); + } +} diff --git a/crates/qbx_lua_ls/src/workspace.rs b/crates/qbx_lua_ls/src/workspace.rs new file mode 100644 index 0000000..ac35d16 --- /dev/null +++ b/crates/qbx_lua_ls/src/workspace.rs @@ -0,0 +1,344 @@ +use std::path::{Path, PathBuf}; + +use lsp_types::Url; +use qbx_fivem_data::{Side, STUBS}; +use qbx_lua_analysis::glob::{is_glob, manifest_glob_match}; +use qbx_lua_analysis::manifest::Manifest; +use qbx_lua_analysis::project::{ + find_manifest_dir, is_manifest_file, lua_files_under, manifest_path, read_source, relative_slash_path, + resource_imports, side_of, split_import, ResourceEnv, ResourceLocator, +}; +use qbx_lua_analysis::scope::resolve; +use qbx_lua_analysis::Config; +use qbx_lua_syntax::{parse, SmolStr}; +use rustc_hash::FxHashSet; + +use crate::index::{normalize_path, FileEntry, FileId, FileOrigin, Index, ResourceEntry, ResourceId}; +use crate::indexer::index_file; + +const MAX_INDEXED_FILE_BYTES: u64 = 2 * 1024 * 1024; + +#[derive(Default)] +pub struct Workspace { + pub index: Index, + pub roots: Vec, + pub library: Vec, + pub lint_config: Config, + /// Convars assigned with `set`, `setr` or `sets` in the workspace's .cfg files. + pub cfg_convars: Vec, + locator: ResourceLocator, +} + +fn cfg_convars(roots: &[PathBuf]) -> Vec { + let mut names: Vec = Vec::new(); + for root in roots { + let files = walkdir::WalkDir::new(root).max_depth(2).into_iter().flatten(); + for entry in files.filter(|e| e.path().extension().is_some_and(|ext| ext == "cfg")) { + let Ok(text) = read_source(entry.path()) else { continue }; + for line in text.lines() { + let mut words = line.split_whitespace(); + if let (Some("set" | "setr" | "sets"), Some(name)) = (words.next(), words.next()) { + let name = name.trim_matches(['"', '\'']); + if !name.is_empty() && !names.iter().any(|n| n == name) { + names.push(SmolStr::new(name)); + } + } + } + } + } + names +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct ScanStats { + pub files: usize, + pub resources: usize, + pub millis: u128, +} + +pub fn path_to_uri(path: &Path) -> Url { + Url::from_file_path(path).unwrap_or_else(|_| Url::parse("file:///invalid").expect("static url")) +} + +pub fn uri_to_path(uri: &Url) -> Option { + uri.to_file_path().ok() +} + +impl Workspace { + pub fn load_stubs(&mut self) { + for stub in STUBS { + let path = PathBuf::from(format!("/qbx-lua-ls/stubs/{}", stub.name)); + let id = self.index.allocate(&path); + let chunk = parse(stub.source); + let resolution = resolve(&chunk); + let uri = Url::parse(&format!("qbx-stub:///{}", stub.name)).expect("static url"); + let side = (stub.side != Side::Shared).then_some(stub.side); + let index = index_file(id, stub.source, &chunk, &resolution, &self.index, side); + self.index.set_file(id, FileEntry { path, uri, origin: FileOrigin::Stub, resource: None, side, index }); + } + } + + pub fn scan(&mut self) -> ScanStats { + let started = std::time::Instant::now(); + let mut stats = ScanStats::default(); + self.index.clear_workspace(); + self.locator = ResourceLocator::default(); + self.lint_config = + self.roots.first().and_then(|root| Config::discover(root).ok().flatten()).unwrap_or_default(); + let roots: Vec<(PathBuf, FileOrigin)> = self + .roots + .iter() + .map(|r| (r.clone(), FileOrigin::Workspace)) + .chain(self.library.iter().map(|r| (r.clone(), FileOrigin::Library))) + .collect(); + for (root, origin) in roots { + for path in lua_files_under(&root, &self.lint_config) { + if is_manifest_file(&path) { + if let Some(root) = path.parent() { + self.ensure_resource(root); + } + } else if self.index_path(&path, origin, None) { + stats.files += 1; + } + } + } + stats.files += self.index_dependencies(); + self.link_imports(); + self.reindex_all(); + self.cfg_convars = cfg_convars(&self.roots); + stats.resources = self.index.resources.len(); + stats.millis = started.elapsed().as_millis(); + stats + } + + /// Symbol types are inferred while indexing and may refer to files that were not indexed yet + /// (exports, imported globals), so a second pass settles them once every file is known. + fn reindex_all(&mut self) { + let files: Vec<(PathBuf, FileOrigin)> = self + .index + .files() + .filter(|(_, f)| f.origin != FileOrigin::Stub) + .map(|(_, f)| (f.path.clone(), f.origin)) + .collect(); + for (path, origin) in files { + self.index_path(&path, origin, None); + } + } + + /// Indexes resources that workspace manifests refer to but that live outside the workspace, + /// so opening a single resource folder still resolves `@ox_lib`, `@qbx_core` and friends. + fn index_dependencies(&mut self) -> usize { + let mut wanted: Vec<(PathBuf, SmolStr)> = Vec::new(); + for resource in &self.index.resources { + let configured = self.lint_config.imports_for(&resource.manifest_path).into_iter().map(|(p, _)| p); + let patterns = resource.manifest.imports().map(|s| s.pattern.as_str()).chain(configured); + let imports = patterns.filter_map(split_import).map(|(name, _)| name); + let dependencies = resource.manifest.dependencies.iter().map(|d| d.value.as_str()); + for name in imports.chain(dependencies) { + wanted.push((resource.root.clone(), SmolStr::new(name.trim_start_matches('/')))); + } + } + let mut seen = FxHashSet::default(); + let mut indexed = 0; + for (from, name) in wanted { + if !seen.insert(name.clone()) || self.index.resource_by_name(&name).is_some() { + continue; + } + let Some(root) = self.locator.locate(&from, &name) else { continue }; + self.ensure_resource(&root); + for path in lua_files_under(&root, &self.lint_config) { + if !is_manifest_file(&path) && self.index_path(&path, FileOrigin::Library, None) { + indexed += 1; + } + } + } + indexed + } + + fn ensure_resource(&mut self, root: &Path) -> Option { + if let Some(id) = self.index.resources.iter().position(|r| r.root == root) { + return Some(id as ResourceId); + } + let manifest_path = manifest_path(root)?; + let source = read_source(&manifest_path).ok()?; + let manifest = Manifest::from_chunk(&parse(&source)); + let name = SmolStr::new(root.file_name()?.to_string_lossy()); + self.index.resources.push(ResourceEntry { + name, + root: root.to_path_buf(), + manifest_path, + manifest, + files: Vec::new(), + imports: Vec::new(), + escrowed: qbx_lua_analysis::project::is_escrowed_resource(root), + }); + Some(self.index.resources.len() as ResourceId - 1) + } + + pub fn reload_manifest(&mut self, manifest_file: &Path) { + let Some(root) = manifest_file.parent() else { return }; + let Some(id) = self.index.resources.iter().position(|r| r.root == root) else { + self.ensure_resource(root); + return; + }; + let Ok(source) = read_source(manifest_file) else { return }; + self.index.resources[id].manifest = Manifest::from_chunk(&parse(&source)); + let files = self.index.resources[id].files.clone(); + for file in files { + if let Some(path) = self.index.file(file).map(|f| f.path.clone()) { + self.index_path(&path, FileOrigin::Workspace, None); + } + } + self.link_imports(); + } + + pub fn side_and_resource(&mut self, path: &Path) -> (Option, Option) { + let Some(root) = find_manifest_dir(path) else { return (None, None) }; + let Some(id) = self.ensure_resource(&root) else { return (None, None) }; + let relative = relative_slash_path(&root, path); + (Some(id), side_of(&self.index.resources[id as usize].manifest, &relative)) + } + + /// Indexes `path`, reading it from disk unless `text` (an open document) is given. + pub fn index_path(&mut self, path: &Path, origin: FileOrigin, text: Option<&str>) -> bool { + if self.lint_config.is_excluded(path) { + return false; + } + let owned; + let source = match text { + Some(text) => text, + None => { + let too_large = std::fs::metadata(path).is_ok_and(|m| m.len() > MAX_INDEXED_FILE_BYTES); + if too_large { + return false; + } + match read_source(path) { + Ok(text) => { + owned = text; + &owned + } + Err(_) => { + self.mark_escrowed(path); + return false; + } + } + } + }; + let chunk = parse(source); + let resolution = resolve(&chunk); + self.index_parsed(path, origin, source, &chunk, &resolution); + true + } + + fn mark_escrowed(&mut self, unreadable: &Path) { + if !unreadable.is_file() { + return; + } + let resource = find_manifest_dir(unreadable).and_then(|root| self.ensure_resource(&root)); + if let Some(id) = resource { + self.index.resources[id as usize].escrowed = true; + } + } + + pub fn index_parsed( + &mut self, + path: &Path, + origin: FileOrigin, + source: &str, + chunk: &qbx_lua_syntax::ast::Chunk, + resolution: &qbx_lua_analysis::scope::Resolution, + ) -> FileId { + let id = self.index.allocate(path); + let (resource, side) = self.side_and_resource(path); + let origin = self.index.file(id).map_or(origin, |f| f.origin); + let file_index = index_file(id, source, chunk, resolution, &self.index, side); + let entry = + FileEntry { path: path.to_path_buf(), uri: path_to_uri(path), origin, resource, side, index: file_index }; + self.index.set_file(id, entry); + id + } + + pub fn link_imports(&mut self) { + for id in 0..self.index.resources.len() { + let entry = &self.index.resources[id]; + let imports = resource_imports(&entry.manifest, &entry.manifest_path, &self.lint_config) + .into_iter() + .flat_map(|(pattern, side)| self.import_files(pattern).into_iter().map(move |file| (file, side))) + .collect(); + self.index.resources[id].imports = imports; + } + } + + /// The indexed files an `@resource/path` import names, where the path may be a manifest glob. + fn import_files(&self, pattern: &str) -> Vec { + let Some((name, file)) = split_import(pattern) else { return Vec::new() }; + let Some((_, target)) = self.index.resource_by_name(name) else { return Vec::new() }; + if !is_glob(file) { + let id = self.index.file_id(&target.root.join(file)); + return id.filter(|id| self.index.file(*id).is_some()).into_iter().collect(); + } + // Open documents keep the path spelling the editor sent, so compare normalized paths. + let root = normalize_path(&target.root); + let matches = |path: &Path| manifest_glob_match(file, &relative_slash_path(&root, &normalize_path(path))); + target.files.iter().copied().filter(|id| self.index.file(*id).is_some_and(|f| matches(&f.path))).collect() + } + + /// The lint environment of a resource, assembled from the per-file summaries in the index. + pub fn resource_env(&self, resource: ResourceId) -> ResourceEnv { + let mut env = ResourceEnv::default(); + let Some(entry) = self.index.resource(resource) else { return env }; + env.opaque = entry.escrowed; + for file in entry.files.iter().filter_map(|id| self.index.file(*id)) { + env.add_summary(&file.index.summary, file.side); + } + for (pattern, side) in resource_imports(&entry.manifest, &entry.manifest_path, &self.lint_config) { + let files = self.import_files(pattern).into_iter().filter_map(|id| self.index.file(id)); + env.add_import(pattern, side, files.map(|file| &file.index.summary)); + } + env + } +} + +impl Workspace { + /// Event handlers and exports of every indexed file, in the shape the cross-file lint rules use. + pub fn crossrefs(&self) -> qbx_lua_analysis::crossref::CrossRefs { + use qbx_lua_analysis::crossref::{Arity, CrossRefs}; + + use crate::index::EventKind; + use crate::types::FunType; + + fn arity(fun: &FunType) -> Arity { + let vararg = fun.params.last().is_some_and(|p| p.name == "..."); + Arity { params: fun.params.len() - usize::from(vararg), vararg } + } + + let mut refs = CrossRefs::default(); + for resource in &self.index.resources { + let hidden = resource.escrowed || resource.manifest.has_non_lua_scripts(); + if hidden { + refs.opaque_resources.insert(resource.name.clone()); + } + } + for (_, file) in self.index.files() { + let resource = file.resource.and_then(|id| self.index.resource(id)).map(|r| r.name.clone()); + if let (true, Some(name)) = (file.index.dynamic_exports, &resource) { + refs.opaque_resources.insert(name.clone()); + } + if let Some(name) = &resource { + refs.resources.insert(name.clone()); + } + for event in file.index.events.iter().filter(|e| matches!(e.kind, EventKind::NetEvent | EventKind::Handler)) + { + refs.add_event(event.name.clone(), event.side, event.handler.as_deref().map(arity), resource.clone()); + } + if let Some(resource) = resource { + for export in &file.index.exports { + let known = export.ty.as_fun().map(|f| arity(f)); + let arity = known.unwrap_or(Arity { params: 0, vararg: true }); + refs.exports.insert((resource.clone(), export.name.clone()), arity); + } + } + } + refs + } +} diff --git a/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/adapters/client.lua b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/adapters/client.lua new file mode 100644 index 0000000..0aa0335 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/adapters/client.lua @@ -0,0 +1,9 @@ +local Core = exports['qb-core']:GetCoreObject() +local Framework = exports.es_extended:getSharedObject() +local QB = Core +local ESX = Framework + +QB.Functions.TriggerCallback('shared:call', function(result) print(result) end, 'water', 2) +ESX.TriggerServerCallback('shared:call', function(result) print(result) end, 42, 'legion') +lib.callback.await('shared:call', false, 'ox') +TriggerServerEvent('shared:call', true) diff --git a/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/adapters/fxmanifest.lua b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/adapters/fxmanifest.lua new file mode 100644 index 0000000..fccd618 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/adapters/fxmanifest.lua @@ -0,0 +1,6 @@ +fx_version 'cerulean' +game 'gta5' +shared_script '@ox_lib/init.lua' +client_script 'client.lua' +server_scripts { 'server/*.lua' } +shared_script 'shared.lua' diff --git a/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/adapters/server/esx.lua b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/adapters/server/esx.lua new file mode 100644 index 0000000..fe1aad8 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/adapters/server/esx.lua @@ -0,0 +1,17 @@ +local Core = exports.es_extended:getSharedObject() +local ESX = Core + +---@param source integer +---@param cb function +---@param esxVehicle number +---@param esxDepot string +---@return boolean +local function garage(source, cb, esxVehicle, esxDepot) + cb(esxVehicle, esxDepot, source) + return true +end + +ESX.RegisterServerCallback('shared:call', garage) +ESX.RegisterServerCallback('esx:only', function(source, cb, esxUnique) + cb(esxUnique, source) +end) diff --git a/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/adapters/server/other.lua b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/adapters/server/other.lua new file mode 100644 index 0000000..1d00bde --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/adapters/server/other.lua @@ -0,0 +1,12 @@ +lib.callback.register('shared:call', function(source, oxPayload) + return oxPayload, source +end) +lib.callback.register('ox:only', function(source, oxUnique) + return oxUnique, source +end) +RegisterNetEvent('shared:call', function(nativePayload) + print(nativePayload) +end) +RegisterNetEvent('native:only', function(nativeUnique) + print(nativeUnique) +end) diff --git a/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/adapters/server/qb.lua b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/adapters/server/qb.lua new file mode 100644 index 0000000..5697308 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/adapters/server/qb.lua @@ -0,0 +1,17 @@ +local Core = exports['qb-core']:GetCoreObject() +local QB = Core + +---@param source integer +---@param cb function +---@param qbItem string +---@param qbAmount integer +---@return boolean +local function buy(source, cb, qbItem, qbAmount) + cb(qbItem, qbAmount, source) + return true +end + +QB.Functions.CreateCallback('shared:call', buy) +QB.Functions.CreateCallback('qb:only', function(source, cb, qbUnique) + cb(qbUnique, source) +end) diff --git a/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/adapters/shared.lua b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/adapters/shared.lua new file mode 100644 index 0000000..0253644 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/adapters/shared.lua @@ -0,0 +1,8 @@ +local QB = exports['qb-core']:GetCoreObject() +if IsDuplicityVersion() then + QB.Functions.CreateCallback('qb:guarded', function(source, cb, guardedPayload) + cb(guardedPayload, source) + end) +else + QB.Functions.TriggerCallback('qb:guarded', function(result) print(result) end, 'guarded') +end diff --git a/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/imported_esx/client.lua b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/imported_esx/client.lua new file mode 100644 index 0000000..b0873d8 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/imported_esx/client.lua @@ -0,0 +1 @@ +ESX.TriggerServerCallback('esx:imported', function(result) print(result) end, 'imported') diff --git a/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/imported_esx/fxmanifest.lua b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/imported_esx/fxmanifest.lua new file mode 100644 index 0000000..8561934 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/imported_esx/fxmanifest.lua @@ -0,0 +1,5 @@ +fx_version 'cerulean' +game 'gta5' +shared_script '@es_extended/imports.lua' +client_script 'client.lua' +server_script 'server.lua' diff --git a/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/imported_esx/server.lua b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/imported_esx/server.lua new file mode 100644 index 0000000..f23e785 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/imported_esx/server.lua @@ -0,0 +1,3 @@ +ESX.RegisterServerCallback('esx:imported', function(source, cb, importedPayload) + cb(importedPayload, source) +end) diff --git a/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/ox_lib/fxmanifest.lua b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/ox_lib/fxmanifest.lua new file mode 100644 index 0000000..44fa629 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/ox_lib/fxmanifest.lua @@ -0,0 +1,3 @@ +fx_version 'cerulean' +game 'gta5' +shared_script 'init.lua' diff --git a/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/ox_lib/init.lua b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/ox_lib/init.lua new file mode 100644 index 0000000..df1f5ab --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/framework_callbacks/ox_lib/init.lua @@ -0,0 +1,8 @@ +-- Minimal fixture signature; callback payloads still come from resource registrations. +lib = {} +lib.callback = {} + +---@param name string +---@param delay number|boolean +---@param ... any +function lib.callback.await(name, delay, ...) end diff --git a/crates/qbx_lua_ls/tests/fixtures/late.cfg b/crates/qbx_lua_ls/tests/fixtures/late.cfg new file mode 100644 index 0000000..48b3e92 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/late.cfg @@ -0,0 +1 @@ +ensure late diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/[core]/mylib/fxmanifest.lua b/crates/qbx_lua_ls/tests/fixtures/resources/[core]/mylib/fxmanifest.lua new file mode 100644 index 0000000..3017680 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/[core]/mylib/fxmanifest.lua @@ -0,0 +1,7 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +shared_script 'init.lua' +server_script 'server.lua' +files { 'modules/*.lua' } diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/[core]/mylib/init.lua b/crates/qbx_lua_ls/tests/fixtures/resources/[core]/mylib/init.lua new file mode 100644 index 0000000..c00affd --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/[core]/mylib/init.lua @@ -0,0 +1,54 @@ +---@class GaragePoint +---@field coords vector3 where the garage is +---@field label string +---@field slots? integer + +---@alias GarageKind 'public'|'job'|'gang' + +---Shared helpers for every resource. +MyLib = {} +MyLib.version = '1.2.0' +MyLib.math = {} + +---Rounds a number to the given number of decimals. +---@param value number the value to round +---@param decimals? integer +---@return number rounded +function MyLib.round(value, decimals) + local factor = 10 ^ (decimals or 0) + return math.floor(value * factor + 0.5) / factor +end + +---@param a number +---@param b number +---@return number +function MyLib.math.lerp(a, b) + return a + (b - a) * 0.5 +end + +---Creates a garage. +---@param kind GarageKind +---@param point GaragePoint +---@return Garage +function MyLib.createGarage(kind, point) + return setmetatable({ kind = kind, point = point }, Garage) +end + +---@class Garage +---@field kind GarageKind +---@field point GaragePoint +Garage = {} +Garage.__index = Garage + +---Returns how many vehicles are parked. +---@return integer count +function Garage:getVehicleCount() + return 0 +end + +---@param plate string +---@return boolean success +---@return string? reason +function Garage:store(plate) + return plate ~= '', nil +end diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/[core]/mylib/modules/settings.lua b/crates/qbx_lua_ls/tests/fixtures/resources/[core]/mylib/modules/settings.lua new file mode 100644 index 0000000..b6ca269 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/[core]/mylib/modules/settings.lua @@ -0,0 +1,6 @@ +return { + debug = false, + maxGarages = 12, + ---@type GarageKind + defaultKind = 'public', +} diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/[core]/mylib/server.lua b/crates/qbx_lua_ls/tests/fixtures/resources/[core]/mylib/server.lua new file mode 100644 index 0000000..8a12e8b --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/[core]/mylib/server.lua @@ -0,0 +1,14 @@ +---Looks a player up by server id. +---@param source integer +---@return { name: string, money: number }? player +local function getPlayer(source) + return { name = GetPlayerName(source), money = 0 } +end + +exports('GetPlayer', getPlayer) + +exports('Ping', function(message) + return 'pong: ' .. message +end) + +lib = lib or {} diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/late/bridge.lua b/crates/qbx_lua_ls/tests/fixtures/resources/late/bridge.lua new file mode 100644 index 0000000..c1ecfd2 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/late/bridge.lua @@ -0,0 +1,11 @@ +local selected = GetResourceKvpString('late:inventory') + +if selected == 'ox' then + exports.ox_inventory:AddItem(1, 'water', 1) +elseif selected == 'kartik' then + exports['kartik-evidence']:DropEvidence('fingerprint') +else + exports.some_fallback:Notify('none') +end + +exports.not_installed_anywhere:DoThing() diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/late/fxmanifest.lua b/crates/qbx_lua_ls/tests/fixtures/resources/late/fxmanifest.lua new file mode 100644 index 0000000..a039e26 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/late/fxmanifest.lua @@ -0,0 +1,5 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +server_scripts { 'server.lua', 'bridge.lua', 'guarded.lua', 'hidden.lua' } diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/late/guarded.lua b/crates/qbx_lua_ls/tests/fixtures/resources/late/guarded.lua new file mode 100644 index 0000000..b074316 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/late/guarded.lua @@ -0,0 +1,3 @@ +if GetResourceKvpString('late:framework') ~= 'esx' then return end + +exports.es_extended:getSharedObject() diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/late/hidden.lua b/crates/qbx_lua_ls/tests/fixtures/resources/late/hidden.lua new file mode 100644 index 0000000..22f7966 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/late/hidden.lua @@ -0,0 +1,2 @@ +exports.vault:CreateFingerPrint() +exports.mylib:Missing() diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/late/server.lua b/crates/qbx_lua_ls/tests/fixtures/resources/late/server.lua new file mode 100644 index 0000000..82a4b7d --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/late/server.lua @@ -0,0 +1 @@ +print(exports.mylib:Ping('hello')) diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/myresource/client/main.lua b/crates/qbx_lua_ls/tests/fixtures/resources/myresource/client/main.lua new file mode 100644 index 0000000..21a54d5 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/myresource/client/main.lua @@ -0,0 +1,18 @@ +local settings = require '@mylib.modules.settings' +local garage = MyLib.createGarage('public', { coords = vector3(0, 0, 0), label = 'Test' }) +local count = garage:getVehicleCount() +local ok, reason = garage:store('ABC123') +local rounded = MyLib.round(1.2345, 2) +local coords = GetEntityCoords(PlayerPedId()) +local distance = #(coords - garage.point.coords) + +RegisterNetEvent('myresource:client:notify', function(message, kind) + print(message, kind, count, ok, reason, rounded, distance, settings.maxGarages) +end) + +CreateThread(function() + while true do + Wait(Config.SpawnDistance > 10 and 0 or 500) + TriggerServerEvent('myresource:server:ping', Config.Garages.legion.label) + end +end) diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/myresource/fxmanifest.lua b/crates/qbx_lua_ls/tests/fixtures/resources/myresource/fxmanifest.lua new file mode 100644 index 0000000..0565cdf --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/myresource/fxmanifest.lua @@ -0,0 +1,12 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +shared_scripts { + '@mylib/init.lua', + 'shared/*.lua', +} +client_script 'client/main.lua' +server_script 'server/main.lua' +files { 'modules/*.lua' } +dependency 'mylib' diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/myresource/server/main.lua b/crates/qbx_lua_ls/tests/fixtures/resources/myresource/server/main.lua new file mode 100644 index 0000000..f0287a2 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/myresource/server/main.lua @@ -0,0 +1,11 @@ +RegisterNetEvent('myresource:server:ping', function(label) + local src = source + local player = exports.mylib:GetPlayer(src) + if player then + TriggerClientEvent('myresource:client:notify', src, player.name .. label, 'info') + end +end) + +lib.callback.register('myresource:getGarages', function(source) + return Config.Garages +end) diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/myresource/shared/config.lua b/crates/qbx_lua_ls/tests/fixtures/resources/myresource/shared/config.lua new file mode 100644 index 0000000..57a5046 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/myresource/shared/config.lua @@ -0,0 +1,11 @@ +Config = { + Debug = true, + SpawnDistance = 25.0, + Garages = { + legion = { label = 'Legion Square', slots = 10 }, + }, +} + +function Config.isDebug() + return Config.Debug +end diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/shop/client.lua b/crates/qbx_lua_ls/tests/fixtures/resources/shop/client.lua new file mode 100644 index 0000000..a3aec0e --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/shop/client.lua @@ -0,0 +1,10 @@ +local debug = GetConvar('shop_debug', 'false') +LocalPlayer.state.isShopping = true + +RegisterNetEvent('shop:bought', function(item) + print(locale('buy.success', item), locale('buy.missing'), debug, Shop.getPrice(item)) +end) + +TriggerServerEvent('shop:buy', 'water', 1, 'extra') +TriggerServerEvent('shop:bought', 'water') +print(exports.mylib:Ping('hello', 'world')) diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/shop/fxmanifest.lua b/crates/qbx_lua_ls/tests/fixtures/resources/shop/fxmanifest.lua new file mode 100644 index 0000000..181e74d --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/shop/fxmanifest.lua @@ -0,0 +1,8 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +shared_script 'shared.lua' +client_script 'client.lua' +server_script 'server.lua' +files { 'locales/*.json' } diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/shop/locales/en.json b/crates/qbx_lua_ls/tests/fixtures/resources/shop/locales/en.json new file mode 100644 index 0000000..6fb9bd0 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/shop/locales/en.json @@ -0,0 +1,7 @@ +{ + "buy": { + "success": "You bought %s", + "failed": "Purchase failed" + }, + "never_used": "Nobody reads this" +} diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/shop/server.lua b/crates/qbx_lua_ls/tests/fixtures/resources/shop/server.lua new file mode 100644 index 0000000..a0b47ca --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/shop/server.lua @@ -0,0 +1,11 @@ +RegisterNetEvent('shop:buy', function(item, amount) + if type(amount) ~= 'number' or amount < 1 then return end + local price = Shop.getPrice(item) + TriggerClientEvent('shop:bought', source, item, price) +end) + +RegisterNetEvent('shop:refund', function(src, amount) + local player = exports.mylib:GetPlayer(src) + player.Functions.AddMoney('cash', amount) + MySQL.query('UPDATE shop SET refunded = ' .. amount) +end) diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/shop/shared.lua b/crates/qbx_lua_ls/tests/fixtures/resources/shop/shared.lua new file mode 100644 index 0000000..463a2d4 --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/shop/shared.lua @@ -0,0 +1,16 @@ +function locale(key, ...) + return key:format(...) +end + +Shop = {} +Shop.items = {} + +function Shop.getPrice(item) + return Shop.items[item] +end + +Config = { + ShopName = 'General Store', + TaxRate = 0.21, + OpenAtNight = false, +} diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/vault/escrowed.lua b/crates/qbx_lua_ls/tests/fixtures/resources/vault/escrowed.lua new file mode 100644 index 0000000..c5bd4e4 Binary files /dev/null and b/crates/qbx_lua_ls/tests/fixtures/resources/vault/escrowed.lua differ diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/vault/fxmanifest.lua b/crates/qbx_lua_ls/tests/fixtures/resources/vault/fxmanifest.lua new file mode 100644 index 0000000..2ac8dce --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/vault/fxmanifest.lua @@ -0,0 +1,5 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +server_scripts { 'open.lua', 'escrowed.lua' } diff --git a/crates/qbx_lua_ls/tests/fixtures/resources/vault/open.lua b/crates/qbx_lua_ls/tests/fixtures/resources/vault/open.lua new file mode 100644 index 0000000..2dbecbf --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/resources/vault/open.lua @@ -0,0 +1 @@ +exports('Open', function() end) diff --git a/crates/qbx_lua_ls/tests/fixtures/server.cfg b/crates/qbx_lua_ls/tests/fixtures/server.cfg new file mode 100644 index 0000000..b7e57ae --- /dev/null +++ b/crates/qbx_lua_ls/tests/fixtures/server.cfg @@ -0,0 +1,6 @@ +ensure vault +# fixture start order +ensure shop +ensure [core] +exec late.cfg +ensure myresource diff --git a/crates/qbx_lua_ls/tests/lsp.rs b/crates/qbx_lua_ls/tests/lsp.rs new file mode 100644 index 0000000..f8dc5fe --- /dev/null +++ b/crates/qbx_lua_ls/tests/lsp.rs @@ -0,0 +1,2464 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::thread::JoinHandle; +use std::time::Duration; + +use lsp_server::{Connection, Message, Notification, Request, RequestId}; +use lsp_types::Url; +use serde_json::{json, Value}; + +struct Client { + connection: Connection, + server: Option>, + next_id: i32, + diagnostics: HashMap, + registrations: Vec, + logs: Vec, + root: PathBuf, +} + +fn fixture_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/resources") +} + +impl Client { + fn start(root: PathBuf) -> Self { + Self::start_with_capabilities( + root, + json!({ + "workspace": { "didChangeWatchedFiles": { "dynamicRegistration": true } }, + "textDocument": { "completion": { "completionItem": { "snippetSupport": true } } } + }), + ) + } + + fn start_with_capabilities(root: PathBuf, capabilities: Value) -> Self { + let (server_side, client_side) = Connection::memory(); + let server = std::thread::Builder::new() + .stack_size(16 * 1024 * 1024) + .spawn(move || qbx_lua_ls::server::run_connection(server_side).expect("server failed")) + .unwrap(); + let mut client = Client { + connection: client_side, + server: Some(server), + next_id: 0, + diagnostics: HashMap::new(), + registrations: Vec::new(), + logs: Vec::new(), + root, + }; + let root_uri = Url::from_file_path(&client.root).unwrap(); + let result = client.request( + "initialize", + json!({ "processId": null, "rootUri": root_uri, "capabilities": capabilities, "workspaceFolders": [{ "uri": root_uri, "name": "fixture" }] }), + ); + assert!(result["capabilities"]["completionProvider"].is_object()); + client.notify("initialized", json!({})); + client + } + + fn notify(&self, method: &str, params: Value) { + self.connection.sender.send(Message::Notification(Notification { method: method.into(), params })).unwrap(); + } + + fn handle_incoming(&mut self, message: Message) -> Option<(RequestId, Value)> { + match message { + Message::Response(response) => { + assert!(response.error.is_none(), "server returned an error: {:?}", response.error); + return Some((response.id, response.result.unwrap_or(Value::Null))); + } + Message::Notification(n) if n.method == "textDocument/publishDiagnostics" => { + let uri = n.params["uri"].as_str().unwrap().to_string(); + self.diagnostics.insert(uri, n.params["diagnostics"].clone()); + } + Message::Notification(n) if n.method == "window/logMessage" => { + self.logs.push(n.params["message"].as_str().unwrap_or_default().to_string()); + } + Message::Request(request) => { + if request.method == "client/registerCapability" { + self.registrations.extend(request.params["registrations"].as_array().unwrap().iter().cloned()); + } + let reply = lsp_server::Response { id: request.id, result: Some(Value::Null), error: None }; + self.connection.sender.send(Message::Response(reply)).unwrap(); + } + Message::Notification(_) => {} + } + None + } + + fn request(&mut self, method: &str, params: Value) -> Value { + self.next_id += 1; + let id = RequestId::from(self.next_id); + self.connection + .sender + .send(Message::Request(Request { id: id.clone(), method: method.into(), params })) + .unwrap(); + loop { + let message = + self.connection.receiver.recv_timeout(Duration::from_secs(20)).expect("server did not answer"); + if let Some((response_id, result)) = self.handle_incoming(message) { + if response_id == id { + return result; + } + } + } + } + + fn request_error(&mut self, method: &str, params: Value) -> lsp_server::ResponseError { + self.next_id += 1; + let id = RequestId::from(self.next_id); + self.connection + .sender + .send(Message::Request(Request { id: id.clone(), method: method.into(), params })) + .unwrap(); + loop { + let message = + self.connection.receiver.recv_timeout(Duration::from_secs(20)).expect("server did not answer"); + if let Message::Response(response) = &message { + if response.id == id { + return response.error.clone().expect("expected request validation error"); + } + } + self.handle_incoming(message); + } + } + + fn uri(&self, relative: &str) -> Url { + Url::from_file_path(self.root.join(relative)).unwrap() + } + + fn open(&mut self, relative: &str) -> String { + let text = std::fs::read_to_string(self.root.join(relative)).unwrap().replace("\r\n", "\n"); + self.open_with(relative, &text); + text + } + + fn open_with(&mut self, relative: &str, text: &str) { + let uri = self.uri(relative); + self.notify( + "textDocument/didOpen", + json!({ "textDocument": { "uri": uri, "languageId": "lua", "version": 1, "text": text } }), + ); + } + + fn change(&mut self, relative: &str, version: i32, text: &str) { + let uri = self.uri(relative); + self.notify( + "textDocument/didChange", + json!({ "textDocument": { "uri": uri, "version": version }, "contentChanges": [{ "text": text }] }), + ); + } + + /// Diagnostics are published once the server is idle, so round-trip a request first. + fn diagnostics_for(&mut self, relative: &str) -> Vec<(String, u64)> { + self.request("qbx/status", Value::Null); + while let Ok(message) = self.connection.receiver.recv_timeout(Duration::from_millis(300)) { + self.handle_incoming(message); + } + let uri = self.uri(relative).to_string(); + let list = self.diagnostics.get(&uri).cloned().unwrap_or(json!([])); + list.as_array() + .unwrap() + .iter() + .map(|d| (d["code"].as_str().unwrap().to_string(), d["range"]["start"]["line"].as_u64().unwrap())) + .collect() + } + + fn position_params(&self, relative: &str, line: u32, character: u32) -> Value { + json!({ "textDocument": { "uri": self.uri(relative) }, "position": { "line": line, "character": character } }) + } + + fn completion_labels(&mut self, relative: &str, line: u32, character: u32) -> Vec { + let result = self.request("textDocument/completion", self.position_params(relative, line, character)); + result["items"] + .as_array() + .map(|items| items.iter().map(|i| i["label"].as_str().unwrap().to_string()).collect()) + .unwrap_or_default() + } + + fn hover_text(&mut self, relative: &str, line: u32, character: u32) -> String { + let result = self.request("textDocument/hover", self.position_params(relative, line, character)); + result["contents"]["value"].as_str().unwrap_or_default().to_string() + } +} + +impl Drop for Client { + fn drop(&mut self) { + self.next_id += 1; + let shutdown = Request { id: RequestId::from(self.next_id), method: "shutdown".into(), params: Value::Null }; + let _ = self.connection.sender.send(Message::Request(shutdown)); + let _ = self.connection.receiver.recv_timeout(Duration::from_secs(5)); + self.notify("exit", Value::Null); + if let Some(server) = self.server.take() { + let _ = server.join(); + } + } +} + +/// Finds `needle` in `text` and returns the position `delta` characters into it. +fn pos(text: &str, needle: &str, delta: u32) -> (u32, u32) { + let offset = text.find(needle).unwrap_or_else(|| panic!("{needle:?} not found")); + let line = text[..offset].matches('\n').count() as u32; + let col = (offset - text[..offset].rfind('\n').map_or(0, |i| i + 1)) as u32; + (line, col + delta) +} + +const CLIENT: &str = "myresource/client/main.lua"; +const SERVER: &str = "myresource/server/main.lua"; + +#[test] +fn reference_search_is_offline_paginated_and_available_without_open_documents() { + let mut client = Client::start_with_capabilities(fixture_root(), json!({})); + let before = client.request("qbx/status", Value::Null); + let first = client.request("qbx/referenceSearch", Value::Null); + assert_eq!(first["items"].as_array().unwrap().len(), 50); + assert_eq!(first["offset"], 0); + assert_eq!(first["limit"], 50); + assert!(first["total"].as_u64().unwrap() > 7000); + let namespaces = first["namespaces"].as_array().unwrap(); + assert!(namespaces.iter().any(|value| value == "PAD")); + assert!(namespaces.windows(2).all(|pair| pair[0].as_str() < pair[1].as_str())); + assert!(first["items"].as_array().unwrap().iter().all(|item| item.get("documentation").is_none())); + assert_eq!(first, client.request("qbx/referenceSearch", json!({})), "default search order is deterministic"); + let second = client.request("qbx/referenceSearch", json!({ "offset": 50 })); + let first_ids: Vec<_> = first["items"].as_array().unwrap().iter().map(|item| &item["id"]).collect(); + assert!(second["items"].as_array().unwrap().iter().all(|item| !first_ids.contains(&&item["id"]))); + let capped = client.request("qbx/referenceSearch", json!({ "limit": 1000000 })); + assert_eq!(capped["items"].as_array().unwrap().len(), 100); + assert_eq!(capped["limit"], 100); + let minimum = client.request("qbx/referenceSearch", json!({ "limit": 0 })); + assert_eq!(minimum["limit"], 1); + assert_eq!(minimum["items"].as_array().unwrap().len(), 1); + let past_end = client.request("qbx/referenceSearch", json!({ "offset": u64::MAX })); + assert!(past_end["items"].as_array().unwrap().is_empty()); + assert_eq!(past_end["offset"], past_end["total"]); + let after = client.request("qbx/status", Value::Null); + assert_eq!(before, after, "reference requests do not open files or change the workspace index"); +} + +#[test] +fn reference_search_matches_names_hashes_aliases_ids_and_default_bindings() { + let mut client = Client::start(fixture_root()); + for (query, kind, expected) in [ + ("GetEntityCoords", "native", "native:GetEntityCoords"), + ("gEtEnTiTyCoOrDs", "native", "native:GetEntityCoords"), + ("SET_PED_CONFIG_FLAG", "native", "native:SetPedConfigFlag"), + ("0x3FEF770D40960D5A", "native", "native:GetEntityCoords"), + ("3fef770d40960d5a", "native", "native:GetEntityCoords"), + ("N_0x580417101DDB492F", "native", "native:IsControlJustPressed"), + ("N_0xe8a25867fba3b05e", "native", "native:SetControlNormal"), + ("GetLastInputMethod", "native", "native:IsUsingKeyboard"), + ("38", "control", "control:38"), + ("input_pickup", "control", "control:38"), + ("input pickup", "control", "control:38"), + ("pickup e", "control", "control:38"), + ("51 DPAD RIGHT", "control", "control:51"), + ("48", "pedFlag", "pedFlag:48"), + ("BlockWeaponSwitching", "pedFlag", "pedFlag:48"), + ] { + let result = client.request("qbx/referenceSearch", json!({ "query": query, "kind": kind })); + assert_eq!(result["items"][0]["id"], expected, "{query}: {result}"); + } + let result = client.request("qbx/referenceSearch", json!({ "query": "38" })); + assert_eq!(result["items"][0]["id"], "control:38", "exact IDs outrank substrings in hashes"); + assert_eq!(result["items"][1]["id"], "pedFlag:38"); + let result = client.request("qbx/referenceSearch", json!({ "query": "IsControl", "kind": "native" })); + assert!(result["items"][0]["name"].as_str().unwrap().starts_with("IsControl")); + let result = client.request("qbx/referenceSearch", json!({ "query": "not-a-real-native-or-control" })); + assert_eq!(result["total"], 0); + assert!(result["items"].as_array().unwrap().is_empty()); + let result = client.request("qbx/referenceSearch", json!({ "kind": "native" })); + let canonical_count = qbx_fivem_data::natives().filter(|native| native.alias_of.is_none()).count(); + assert_eq!(result["total"], canonical_count, "default listings deduplicate documented aliases"); +} + +#[test] +fn reference_search_filters_side_namespace_and_catalog_without_conflating_them() { + let mut client = Client::start(fixture_root()); + for side in ["client", "server", "shared"] { + let result = client.request("qbx/referenceSearch", json!({ "side": side, "limit": 100 })); + assert!(result["items"] + .as_array() + .unwrap() + .iter() + .all(|item| item["side"] == side || side != "shared" && item["side"] == "shared")); + let shared = client + .request("qbx/referenceSearch", json!({ "query": "GetEntityCoords", "side": side, "kind": "native" })); + assert_eq!(shared["items"][0]["id"], "native:GetEntityCoords", "shared native remains available on {side}"); + } + let result = + client.request("qbx/referenceSearch", json!({ "kind": "native", "namespace": "pad", "side": "server" })); + assert_eq!(result["total"], 0, "PAD natives are client-only"); + let result = client.request("qbx/referenceSearch", json!({ "kind": "native", "namespace": "PAD", "limit": 100 })); + assert!(result["total"].as_u64().unwrap() > 20); + assert!(result["items"].as_array().unwrap().iter().all(|item| item["namespace"] == "PAD")); + let all = client.request("qbx/referenceSearch", json!({ "namespace": "PAD" })); + assert_eq!( + all["total"].as_u64().unwrap(), + result["total"].as_u64().unwrap() + + qbx_fivem_data::controls().count() as u64 + + qbx_fivem_data::ped_config_flags().count() as u64, + "all-catalog search retains numeric catalogs when filtering native namespace" + ); + for (kind, count) in + [("control", qbx_fivem_data::controls().count()), ("pedFlag", qbx_fivem_data::ped_config_flags().count())] + { + let result = client.request("qbx/referenceSearch", json!({ "kind": kind, "namespace": "PED" })); + assert_eq!(result["total"], count, "native namespace filters do not hide other catalogs"); + assert!(result["items"] + .as_array() + .unwrap() + .iter() + .all(|item| item["kind"] == kind && item["side"] == "client" && item.get("namespace").is_none())); + let result = client.request("qbx/referenceSearch", json!({ "kind": kind, "side": "server" })); + assert_eq!(result["total"], 0); + } +} + +#[test] +fn reference_details_return_documentation_and_safe_lua_insertion_text() { + let mut client = Client::start(fixture_root()); + let detail = client.request("qbx/referenceDetail", json!({ "id": "native:GetEntityCoords" })); + assert_eq!(detail["id"], "native:GetEntityCoords"); + assert_eq!(detail["kind"], "native"); + assert_eq!(detail["side"], "shared"); + assert_eq!(detail["namespace"], "ENTITY"); + assert_eq!(detail["hash"], "0x3FEF770D40960D5A"); + assert_eq!(detail["signature"], "function GetEntityCoords(entity: Entity, alive: boolean): vector3"); + assert_eq!( + detail["parameters"], + json!([{ "name": "entity", "type": "Entity" }, { "name": "alive", "type": "boolean" }]) + ); + assert_eq!(detail["returns"], json!(["vector3"])); + assert!(!detail["documentation"].as_str().unwrap().is_empty()); + assert_eq!(detail["sourceUrl"], "https://docs.fivem.net/natives/?_0x3FEF770D40960D5A"); + assert_eq!(detail["copyText"], "GetEntityCoords"); + assert_eq!(detail["insertText"], "GetEntityCoords(entity, alive)"); + assert_eq!(detail["insertSnippet"], "GetEntityCoords(${1:entity}, ${2:alive})$0"); + let alias = client.request("qbx/referenceDetail", json!({ "id": "native:GetLastInputMethod" })); + assert_eq!(alias["id"], "native:IsUsingKeyboard"); + let hash = client.request("qbx/referenceDetail", json!({ "id": "native:N_0x580417101DDB492F" })); + assert_eq!(hash["id"], "native:IsControlJustPressed"); + let no_args = client.request("qbx/referenceDetail", json!({ "id": "native:PlayerPedId" })); + assert_eq!(no_args["insertText"], "PlayerPedId()"); + assert_eq!(no_args["insertSnippet"], "PlayerPedId()$0"); + + for (id, expected) in [ + ("control:38", "INPUT_PICKUP"), + ("control:243", "`` ~ / ` ``"), + ("control:360", "Not documented"), + ("pedFlag:48", "CPED_CONFIG_FLAG_BlockWeaponSwitching"), + ] { + let detail = client.request("qbx/referenceDetail", json!({ "id": id })); + assert_eq!(detail["id"], id); + assert!(detail["documentation"].as_str().unwrap().contains(expected), "{detail}"); + let number = id.split_once(':').unwrap().1; + assert_eq!(detail["copyText"], number); + assert_eq!(detail["insertText"], number); + assert!(detail.get("insertSnippet").is_none()); + assert!(detail.get("signature").is_none()); + if id.starts_with("pedFlag:") { + assert!(detail["documentation"].as_str().unwrap().contains("Behavior is not documented")); + assert!(detail["documentation"].as_str().unwrap().contains("potential names and hash collisions")); + } + } + for id in [ + "unknown:1", + "native:MissingNative", + "native:PED", + "control:9999", + "control:-1", + "control:038", + "pedFlag:4294967296", + "", + "control:38:other", + ] { + assert_eq!(client.request("qbx/referenceDetail", json!({ "id": id })), Value::Null, "{id}"); + } +} + +#[test] +fn reference_requests_reject_invalid_params_without_disrupting_lsp() { + let mut client = Client::start(fixture_root()); + for params in [ + json!({ "query": "a".repeat(257) }), + json!({ "namespace": "a".repeat(65) }), + json!({ "offset": -1 }), + json!({ "offset": 0.5 }), + json!({ "limit": -1 }), + json!({ "kind": "invalid" }), + json!({ "side": "invalid" }), + json!({ "query": 38 }), + json!([]), + ] { + let error = client.request_error("qbx/referenceSearch", params); + assert_eq!(error.code, -32602); + } + for params in + [json!({ "id": "a".repeat(513) }), json!({ "id": 38 }), Value::Null, json!(["native:GetEntityCoords"])] + { + let error = client.request_error("qbx/referenceDetail", params); + assert_eq!(error.code, -32602); + } + let text = "IsControlJustPressed(0, 38)"; + client.open_with(CLIENT, text); + let (line, character) = pos(text, "38", 0); + assert!(client.hover_text(CLIENT, line, character).contains("INPUT_PICKUP")); + assert!(client.request("qbx/status", Value::Null)["natives"].as_u64().unwrap() > 7000); +} + +#[test] +fn indexes_the_workspace_and_reports_status() { + let mut client = Client::start(fixture_root()); + let status = client.request("qbx/status", Value::Null); + assert_eq!(status["resources"], 5); + assert!(status["files"].as_u64().unwrap() >= 10, "{status}"); +} + +#[test] +fn hover_shows_types_docs_and_natives() { + let mut client = Client::start(fixture_root()); + let text = client.open(CLIENT); + + let (l, c) = pos(&text, "MyLib.round(1.2345", 7); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains("function MyLib.round(value: number, decimals?: integer): number"), "{hover}"); + assert!(hover.contains("Rounds a number"), "{hover}"); + assert!(hover.contains("`value`: the value to round"), "{hover}"); + + let (l, c) = pos(&text, "local garage", 7); + assert!(client.hover_text(CLIENT, l, c).contains("local garage: Garage")); + + let (l, c) = pos(&text, "local count", 7); + assert!(client.hover_text(CLIENT, l, c).contains("local count: integer")); + + let (l, c) = pos(&text, "ok, reason", 4); + assert!(client.hover_text(CLIENT, l, c).contains("local reason: string?")); + + let (l, c) = pos(&text, "local coords", 7); + assert!(client.hover_text(CLIENT, l, c).contains("local coords: vector3")); + + let (l, c) = pos(&text, "local distance", 7); + assert!(client.hover_text(CLIENT, l, c).contains("local distance: number")); + + let (l, c) = pos(&text, "GetEntityCoords", 3); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains("function GetEntityCoords(entity: Entity, alive: boolean): vector3"), "{hover}"); + assert!(hover.contains("native"), "{hover}"); + + let (l, c) = pos(&text, "garage:getVehicleCount", 8); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains("Garage:getVehicleCount"), "{hover}"); + assert!(hover.contains("how many vehicles"), "{hover}"); + + let (l, c) = pos(&text, "settings.maxGarages", 10); + assert!(client.hover_text(CLIENT, l, c).contains("maxGarages: integer")); + + let (l, c) = pos(&text, "Config.Garages.legion.label", 23); + assert!(client.hover_text(CLIENT, l, c).contains("label: string")); +} + +#[test] +fn native_argument_hovers_show_defaults_flags_and_exact_ranges() { + // Numeric hovers also work for clients that advertise no optional hover capabilities. + for capabilities in [json!({}), json!({ "textDocument": { "hover": { "contentFormat": ["markdown"] } } })] { + let mut client = Client::start_with_capabilities(fixture_root(), capabilities); + let text = "IsControlJustPressed(0, 38)\nSetPedConfigFlag(PlayerPedId(), 48, true)\nGetControlNormal(0, 360)\nGetControlNormal(0, 3)\nGetControlNormal(0, 243)\n"; + client.open_with(CLIENT, text); + for (literal, symbol) in [("38", "INPUT_PICKUP"), ("48", "CPED_CONFIG_FLAG_BlockWeaponSwitching")] { + let (line, character) = pos(text, literal, 0); + let hover = client.request("textDocument/hover", client.position_params(CLIENT, line, character)); + let content = hover["contents"]["value"].as_str().unwrap(); + assert!(content.contains(symbol), "{content}"); + assert!(content.contains("https://"), "reference should link its source: {content}"); + assert_eq!(hover["contents"]["kind"], "markdown"); + assert_eq!( + hover["range"], + json!({ + "start": { "line": line, "character": character }, + "end": { "line": line, "character": character + 2 } + }) + ); + if literal == "38" { + assert!(content.contains("Default keyboard (QWERTY): `E`"), "{content}"); + assert!(content.contains("Default Xbox controller: `LB`"), "{content}"); + assert!(content.contains("remapped"), "{content}"); + } else { + assert!(content.contains("Behavior is not documented"), "do not infer behavior from a name: {content}"); + assert!(content.contains("potential names and hash collisions"), "{content}"); + } + // Hovering the comma/space after a literal must not inherit its enum documentation. + assert!(client.hover_text(CLIENT, line, character + 2).is_empty()); + } + let hover = client.hover_text(CLIENT, 0, 3); + assert!(hover.contains("function IsControlJustPressed"), "ordinary native hover is preserved: {hover}"); + let (line, character) = pos(text, "360", 0); + let hover = client.hover_text(CLIENT, line, character); + assert!(hover.contains("INPUT_HUDMARKER_SELECT"), "{hover}"); + assert!(hover.contains("Default keyboard (QWERTY): Not documented"), "{hover}"); + assert!(hover.contains("Default Xbox controller: Not documented"), "{hover}"); + let (line, character) = pos(text, ", 3)", 2); + let hover = client.hover_text(CLIENT, line, character); + assert!(hover.contains("Default keyboard (QWERTY): `(NONE)`"), "explicit unbound values stay intact: {hover}"); + let (line, character) = pos(text, "243", 0); + let hover = client.hover_text(CLIENT, line, character); + assert!( + hover.contains("Default keyboard (QWERTY): `` ~ / ` ``"), + "backtick keys must remain valid Markdown: {hover}" + ); + } +} + +#[test] +fn native_argument_hovers_cover_control_variants_hashes_and_numeric_literals() { + let mut client = Client::start(fixture_root()); + let cases = [ + ("IsControlEnabled(0, 38)", "38", "INPUT_PICKUP"), + ("IsControlJustReleased(0, 38)", "38", "INPUT_PICKUP"), + ("IsControlPressed(0, 38)", "38", "INPUT_PICKUP"), + ("IsControlReleased(0, 38)", "38", "INPUT_PICKUP"), + ("IsDisabledControlJustPressed(0, 38)", "38", "INPUT_PICKUP"), + ("IsDisabledControlJustReleased(0, 51)", "51", "INPUT_CONTEXT"), + ("IsDisabledControlPressed(0, 38)", "38", "INPUT_PICKUP"), + ("IsDisabledControlReleased(0, 38)", "38", "INPUT_PICKUP"), + ("GetControlValue(0, 38)", "38", "INPUT_PICKUP"), + ("GetControlNormal(2, 0)", "0", "INPUT_NEXT_CAMERA"), + ("GetControlUnboundNormal(0, 38)", "38", "INPUT_PICKUP"), + ("GetDisabledControlNormal(0, 38)", "38", "INPUT_PICKUP"), + ("GetDisabledControlUnboundNormal(0, 38)", "38", "INPUT_PICKUP"), + ("GetControlInstructionalButton(0, 38, true)", "38", "INPUT_PICKUP"), + ("DisableControlAction(0, 38, true)", "38", "INPUT_PICKUP"), + ("EnableControlAction(0, 38, true)", "38", "INPUT_PICKUP"), + ("SetControlNormal(0, 38, 0.5)", "38", "INPUT_PICKUP"), + ("SetInputExclusive(0, 38)", "38", "INPUT_PICKUP"), + ("IsControlJustPressed(0, 0X26)", "0X26", "INPUT_PICKUP"), + ("IsControlJustPressed(0, 38.0)", "38.0", "INPUT_PICKUP"), + ("IsControlJustPressed(0, 3.8e1)", "3.8e1", "INPUT_PICKUP"), + ("IsControlJustPressed(0, 0x26p0)", "0x26p0", "INPUT_PICKUP"), + ("N_0xe8a25867fba3b05e(0, 38, 0.5)", "38", "INPUT_PICKUP"), + ("N_0x580417101DDB492F(0, 0x26)", "0x26", "INPUT_PICKUP"), + ("GetPedConfigFlag(PlayerPedId(), 32, true)", "32", "CPED_CONFIG_FLAG_WillFlyThroughWindscreen"), + ("N_0x1913FE4CBF41C463(PlayerPedId(), 0x30, true)", "0x30", "CPED_CONFIG_FLAG_BlockWeaponSwitching"), + ("N_0x7ee53118c892b513(PlayerPedId(), 48, true)", "48", "CPED_CONFIG_FLAG_BlockWeaponSwitching"), + ]; + client.open_with(CLIENT, cases[0].0); + for (version, (text, literal, symbol)) in cases.into_iter().enumerate() { + client.change(CLIENT, version as i32 + 2, text); + let (line, character) = pos(text, literal, 0); + let hover = client.request("textDocument/hover", client.position_params(CLIENT, line, character)); + assert!(hover["contents"]["value"].as_str().unwrap_or_default().contains(symbol), "{text}: {hover}"); + assert_eq!(hover["range"]["start"]["character"], character, "{text}: {hover}"); + assert_eq!(hover["range"]["end"]["character"], character + literal.len() as u32, "{text}: {hover}"); + } +} + +#[test] +fn native_argument_hovers_respect_ast_nesting_comments_and_argument_positions() { + let mut client = Client::start(fixture_root()); + let text = "\ +local label = 'é🎮'; Consume(IsControlJustPressed(0, 38)) +SetPedConfigFlag( + GetPed(48), -- a nested number is not a flag + ( -- flag 48 in a comment is not a literal + 0x30 -- end of actual flag + ), + true +) +IsControlJustPressed(38, 51) +GetControlGroupInstructionalButton(0, 38, true) +SetControlGroupColor(0, 38, 0, 0) +SetPedResetFlag(PlayerPedId(), 48, true) +SetControlNormal(0, 38, 51) +GetPedConfigFlag(32, 48, 32) +"; + client.open_with(CLIENT, text); + // LSP ranges use UTF-16, including when non-ASCII text precedes the number. + let first_line = text.lines().next().unwrap(); + let offset = first_line.find("38").unwrap(); + let character = first_line[..offset].encode_utf16().count() as u32; + let hover = client.request("textDocument/hover", client.position_params(CLIENT, 0, character)); + assert!(hover["contents"]["value"].as_str().unwrap().contains("INPUT_PICKUP"), "{hover}"); + assert_eq!( + hover["range"], + json!({ + "start": { "line": 0, "character": character }, + "end": { "line": 0, "character": character + 2 } + }) + ); + let (line, character) = pos(text, "0x30", 0); + let hover = client.request("textDocument/hover", client.position_params(CLIENT, line, character)); + assert!(hover["contents"]["value"].as_str().unwrap().contains("CPED_CONFIG_FLAG_BlockWeaponSwitching"), "{hover}"); + assert_eq!(hover["range"]["start"]["character"], character); + assert_eq!(hover["range"]["end"]["character"], character + 4); + for (needle, literal) in [ + ("GetPed(48)", "48"), + ("flag 48", "48"), + ("IsControlJustPressed(38", "38"), + ("GetControlGroupInstructionalButton(0, 38", "38"), + ("SetControlGroupColor(0, 38", "38"), + ("SetPedResetFlag(PlayerPedId(), 48", "48"), + ("SetControlNormal(0, 38, 51)", "51"), + ("GetPedConfigFlag(32", "32"), + ("48, 32)", "32"), + ] { + let (line, character) = pos(text, needle, needle.rfind(literal).unwrap() as u32); + assert!(client.hover_text(CLIENT, line, character).is_empty(), "unrelated argument/comment: {needle}"); + } + let (line, character) = pos(text, "38, 51)", 4); + assert!(client.hover_text(CLIENT, line, character).contains("INPUT_CONTEXT")); +} + +#[test] +fn native_argument_hovers_ignore_expressions_unknown_ids_and_unrelated_functions() { + let mut client = Client::start(fixture_root()); + let cases = [ + ("local value = 38", "38"), + ("CustomControl(0, 38)", "38"), + ("controls.IsControlJustPressed(0, 38)", "38"), + ("controls:IsControlJustPressed(0, 38)", "38"), + ("_G.IsControlJustPressed(0, 38)", "38"), + ("Citizen.InvokeNative(0x580417101DDB492F, 0, 38)", "38"), + ("IsControlJustPressed(0, 38 + 1)", "38"), + ("IsControlJustPressed(0, 38 | 1)", "38"), + ("IsControlJustPressed(0, -38)", "38"), + ("IsControlJustPressed(0, tonumber(38))", "38"), + ("IsControlJustPressed(0, {38})", "38"), + ("IsControlJustPressed(0, '38')", "38"), + ("IsControlJustPressed(0, 38.5)", "38.5"), + ("IsControlJustPressed(0, 9999)", "9999"), + ("IsControlJustPressed(0, 38oops)", "38oops"), + ("IsControlJustPressed(0, 0xZZ)", "0xZZ"), + ("GetPedResetFlag(PlayerPedId(), 48)", "48"), + ("SetPedConfigFlag(PlayerPedId(), 9999, true)", "9999"), + ("SetPedConfigFlag(PlayerPedId(), 48 + 1, true)", "48"), + ("N_0x0000000000000000(0, 38)", "38"), + ]; + client.open_with(CLIENT, cases[0].0); + for (version, (text, literal)) in cases.into_iter().enumerate() { + client.change(CLIENT, version as i32 + 2, text); + let (line, character) = pos(text, literal, 0); + assert!(client.hover_text(CLIENT, line, character).is_empty(), "must not label {text}"); + } + let text = "local control = 38\nIsControlJustPressed(0, control)"; + client.change(CLIENT, 100, text); + let (line, character) = pos(text, ", control", 2); + let hover = client.hover_text(CLIENT, line, character); + assert!(hover.contains("local control: integer"), "ordinary variable hover is preserved: {hover}"); + assert!(!hover.contains("INPUT_PICKUP"), "do not evaluate dynamic arguments: {hover}"); +} + +#[test] +fn native_argument_hovers_ignore_shadowed_and_redefined_natives() { + let mut client = Client::start(fixture_root()); + let cases = [ + "local IsControlJustPressed = function(...) end\nIsControlJustPressed(0, 38)", + "local function IsControlJustPressed(...) end\nIsControlJustPressed(0, 38)", + "local IsControlJustPressed = IsControlJustPressed\nIsControlJustPressed(0, 38)", + "function check(IsControlJustPressed)\nIsControlJustPressed(0, 38)\nend", + "IsControlJustPressed = unknown\nIsControlJustPressed(0, 38)", + "function IsControlJustPressed(...) end\nIsControlJustPressed(0, 38)", + "_G.IsControlJustPressed = function(...) end\nIsControlJustPressed(0, 38)", + "_ENV['IsControlJustPressed'] = function(...) end\nIsControlJustPressed(0, 38)", + "local _ENV = {}\nIsControlJustPressed(0, 38)", + "_ENV = {}\nIsControlJustPressed(0, 38)", + "local N_0x580417101DDB492F = function(...) end\nN_0x580417101DDB492F(0, 38)", + "local SetPedConfigFlag = function(...) end\nSetPedConfigFlag(ped, 38, true)", + ]; + client.open_with(CLIENT, cases[0]); + for (version, text) in cases.into_iter().enumerate() { + client.change(CLIENT, version as i32 + 2, text); + let (line, character) = pos(text, "38", 0); + assert!(client.hover_text(CLIENT, line, character).is_empty(), "must not label shadowed call: {text}"); + } + // A local declaration in a different scope must not hide the real native here. + let text = "do local IsControlJustPressed = function(...) end end\nIsControlJustPressed(0, 38)"; + client.change(CLIENT, 100, text); + let (line, character) = pos(text, "38", 0); + assert!(client.hover_text(CLIENT, line, character).contains("INPUT_PICKUP")); + + // A definition in a visible resource file must suppress the native annotation too. + let other = "myresource/shared/config.lua"; + client.open_with(other, "IsControlJustPressed = function(...) end"); + assert!(client.hover_text(CLIENT, line, character).is_empty()); + client.change(other, 2, ""); + assert!( + client.hover_text(CLIENT, line, character).contains("INPUT_PICKUP"), + "removing an override restores native hover" + ); +} + +#[test] +fn hover_keeps_literal_types_of_inline_loop_tables() { + let mut client = Client::start(fixture_root()); + let text = "\ +for _, sex in pairs({ 'male', 'female' }) do end +for i, n in ipairs({ 1, 2, extra = 'x' }) do end +for key, value in pairs({ a = true, [3] = 'c' }) do end +"; + client.open_with(CLIENT, text); + let cases = [ + ("sex", "sex: \"male\"|\"female\""), + ("i,", "i: integer"), + ("n in", "n: 1|2"), + ("key", "key: \"a\"|3"), + ("value", "value: true|\"c\""), + ]; + for (needle, expected) in cases { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + +#[test] +fn hover_indexes_fields_with_literal_typed_keys() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@type ['male', 'female'] +local sexes = { 'male', 'female' } + +---@param metadata { male: table, female: table, age: integer } +---@param field 'male'|'age' +---@param kind GarageKind +local function send(metadata, field, kind) + for _, sex in pairs({ 'male', 'female' }) do + local sexData = metadata[sex] + end + for _, listed in pairs(sexes) do + local listedData = metadata[listed] + end + local either = metadata[field] + local missing = metadata[kind] +end +"; + client.open_with(CLIENT, text); + let cases = [ + ("sexData", "sexData: table"), + ("listed in", "listed: \"male\"|\"female\""), + ("listedData", "listedData: table"), + ("either", "either: table|integer"), + ("missing", "missing: unknown"), + ]; + for (needle, expected) in cases { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + +#[test] +fn hover_infers_loop_variables_of_top_level_tables() { + let mut client = Client::start(fixture_root()); + let text = "\ +local list = { 'male', 'female' } +local mixed = { 'a', count = 1, [10] = true } +local garages = { legion = { label = 'Legion' }, pillbox = { label = 'Pillbox' } } +local first = list[1] +for _, item in pairs(list) do end +for k, v in pairs(mixed) do end +for i, element in ipairs(mixed) do end +for name, garage in pairs(garages) do end +local lookup = { [1] = 'one', [2] = 'two' } +local fromLookup = lookup[1] +for _, looked in ipairs(lookup) do end +local flagged = { 'a', [true] = 5 } +local sparse = { 'a', [10] = true } +"; + client.open_with(CLIENT, text); + let cases = [ + ("list", "local list: string[]"), + ("first", "first: string"), + ("item", "item: string"), + ("k, v", "k: string|integer"), + ("v in", "v: integer|string|boolean"), + // `ipairs` stops before `[10]`. + ("element", "element: string\n"), + ("name,", "name: string"), + ("garage in", "label: string"), + ("lookup =", "local lookup: string[]"), + ("fromLookup", "fromLookup: string"), + ("looked", "looked: string"), + ("flagged", "local flagged: table"), + ("sparse", "local sparse: (string|boolean)[]"), + ]; + for (needle, expected) in cases { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + +#[test] +fn hover_infers_loops_over_classes_unions_next_and_mixed_tables() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@param payload Garage +---@param both string[]|table +---@param t table +local function f(payload, both, t) + for k1, v1 in pairs(payload) do end + for k2, v2 in pairs(both) do end + for k3, v3 in next, t do end + local mixed = { 'a', x = 1 } + for k4, v4 in pairs(mixed) do end + local keyed = { [1] = 'a', [2] = 5 } + local fromKeyed = keyed[1] + local flags = { 'a', [true] = 5 } + for _, flagged in ipairs(flags) do end + for flag in pairs(flags) do end +end +"; + client.open_with(CLIENT, text); + let cases: &[(&str, &[&str])] = &[ + ("flags =", &["flags: { [integer]: string, [boolean]: integer }"]), + ("flagged", &["flagged: string\n"]), + ("flag in", &["flag: integer|boolean"]), + ("k1", &["k1: string"]), + ("v1", &["coords: vector3", "type GarageKind ="]), + ("k2", &["k2: integer|string"]), + ("v2", &["v2: string|integer"]), + ("k3", &["k3: string"]), + ("v3", &["v3: boolean"]), + ("k4", &["k4: string|integer"]), + ("v4", &["v4: integer|string"]), + ("fromKeyed", &["fromKeyed: string|integer"]), + ]; + for &(needle, expected) in cases { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + for part in expected { + assert!(hover.contains(part), "{needle}: expected {part:?} in {hover}"); + } + } +} + +#[test] +fn hover_indexes_global_arrays_and_unions() { + let mut client = Client::start(fixture_root()); + let text = "\ +TestShop = {} +TestShop.Items = { 'bread', 'water' } +TestShop.Lookup = { [1] = 'one', [2] = 2 } +local firstItem = TestShop.Items[1] +for _, item in ipairs(TestShop.Items) do end +for k, v in pairs(TestShop.Lookup) do end + +---@param either string[]|integer[] +local function pick(either) + local picked = either[1] +end + +local shelf = { 'bread', 'water' } + +---@param slot string +local function restock(index, slot) + local byIndex = shelf[index] + local byNumber = TestShop.Items[tonumber(slot)] + local bySlot = shelf[slot] +end +"; + client.open_with(CLIENT, text); + let cases = [ + ("Items[1]", "TestShop.Items: string[]"), + ("firstItem", "firstItem: string"), + ("item in", "item: string"), + ("k, v", "k: integer"), + ("v in", "v: string|integer"), + ("picked", "picked: string|integer"), + ("byIndex", "byIndex: string"), + ("byNumber", "byNumber: string"), + ("bySlot", "bySlot: unknown"), + ]; + for (needle, expected) in cases { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + +#[test] +fn hover_picks_the_overload_a_call_fits() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@overload fun(name: string): string +---@param id integer +---@return integer +local function find(id) end + +---@overload fun(): boolean +---@param x string +---@return string +local function arity(x) end + +---@overload fun(name: string, cb: fun(found: string)) +---@param id integer +---@param cb fun(found: integer) +local function lookup(id, cb) end + +---@overload fun(filter: table?, cb: fun(found: string)) +---@param id? integer +---@param cb fun(found: integer) +local function search(id, cb) end + +---@class OverloadedShop +local OverloadedShop = {} + +---@overload fun(self: OverloadedShop, label: string): string +---@param id integer +---@return integer +function OverloadedShop:price(id) end + +---@overload fun(label: string): string +---@param id integer +---@return integer +function OverloadedShop:stock(id) end + +local byId = find(1) +local byName = find('x') +local none = arity() +lookup('x', function(named) end) +lookup(1, function(numbered) end) +search(nil, function(byNil) end) +local priceByLabel = OverloadedShop:price('bread') +local priceById = OverloadedShop:price(1) +local stockByLabel = OverloadedShop:stock('bread') +local stockViaDot = OverloadedShop.stock(OverloadedShop, 'bread') +local priceViaDot = OverloadedShop.price(OverloadedShop, 'bread') +local flat = vec(1, 2) +local deep = vec(1, 2, 3) +"; + client.open_with(CLIENT, text); + let cases = [ + ("byId", "byId: integer"), + ("byName", "byName: string"), + ("none", "none: boolean"), + ("named", "named: string"), + ("numbered", "numbered: integer"), + // `nil` fills the optional `id`, so the declared signature still fits. + ("byNil", "byNil: integer"), + ("priceByLabel", "priceByLabel: string"), + ("priceById", "priceById: integer"), + ("stockByLabel", "stockByLabel: string"), + ("stockViaDot", "stockViaDot: string"), + ("priceViaDot", "priceViaDot: string"), + // `vec(...)` takes any number of values, but its overloads name the exact ones. + ("flat", "flat: vector2"), + ("deep", "deep: vector3"), + ]; + for (needle, expected) in cases { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + +#[test] +fn hover_binds_generics_from_arguments_and_callbacks() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@generic K, V, RK, RV +---@param tbl table +---@param fun fun(value: V, key: K): RV, RK +---@return table +function table.mapEntries(tbl, fun) + local result = {} + for key, value in pairs(tbl) do + local newValue, newKey = fun(value, key) + result[newKey or key] = newValue + end + return result +end + +local function normalize(step) + return step / 10 +end + +---@generic T +---@param value? T +---@return T|string +local function orName(value) end + +---@generic T +---@param value T +---@param onBox? fun(box: { value: T }) +---@return { value: T } +local function box(value, onBox) end + +---@param steps { [number]: number } +local function send(steps) + local mapped = table.mapEntries(steps, function(step, featureId) + return normalize(step), tostring(featureId) + end) + local fromList = table.mapEntries({ 'a', 'b' }, function(letter, position) + return position, letter + end) + local unbound = table.mapEntries(steps, function() end) + local named = orName() + local boxed = box(1, function(opened) end) +end +"; + client.open_with(CLIENT, text); + let cases = [ + ("mapped", "mapped: table"), + ("step, f", "step: number"), + ("featureId", "featureId: number"), + ("fromList", "fromList: table"), + ("letter,", "letter: string"), + ("position)", "position: integer"), + ("unbound", "unbound: table"), + ("named =", "named: string"), + ("boxed", "value: integer"), + ("opened", "value: integer"), + // Inside the generic function its parameters stay generic. + ("key, value", "key: K"), + ]; + for (needle, expected) in cases { + let (l, c) = pos(text, needle, 0); + let hover = client.hover_text(CLIENT, l, c); + assert!(hover.contains(expected), "{needle}: expected {expected:?} in {hover}"); + } +} + +#[test] +fn hover_expands_aliases_and_lists_members_only_for_tables() { + let mut client = Client::start(fixture_root()); + let text = "\ +---@alias Test.Name string +---@param kind GarageKind +---@param name Test.Name +---@param garage Garage|string +---@param kinds GarageKind[] +---@param owned Garage +local function describe(kind, name, garage, kinds, owned) + for _, each in ipairs(kinds) do end + print(owned.kind) +end +"; + client.open_with(CLIENT, text); + let garage_kind = "type GarageKind = \"public\"|\"job\"|\"gang\""; + let cases: &[(&str, u32, &[&str])] = &[ + ("(kind", 1, &["kind: GarageKind", garage_kind]), + ("name, garage", 0, &["name: Test.Name", "type Test.Name = string"]), + ("garage, kinds", 0, &["point: GaragePoint"]), + ("each", 0, &["each: GarageKind", garage_kind]), + ("owned.kind", 6, &["Garage.kind: GarageKind", garage_kind]), + ]; + for &(needle, delta, expected) in cases { + let (l, c) = pos(text, needle, delta); + let hover = client.hover_text(CLIENT, l, c); + for part in expected { + assert!(hover.contains(part), "{needle}: expected {part:?} in {hover}"); + } + assert!(!hover.contains("byte"), "{needle}: lists the string library in {hover}"); + } +} + +#[test] +fn hover_shows_annotation_type_details_and_ranges() { + let mut client = Client::start(fixture_root()); + let declarations = "\ +---A named parking spot. +---@class Test.Point: GaragePoint +---@field name string +---@field locate fun(): Test.Point + +---The result of a lookup. +---@alias Test.Result Test.Point|nil + +---@enum Test.Mode +local modes = { active = 'active', closed = 'closed' } +"; + client.open_with("myresource/types.lua", declarations); + let text = "local label = '🚗' ---@type Test.Point|Test.Result|Test.Mode|Test.Point\n"; + client.open_with(CLIENT, text); + let cases: &[(&str, &[&str])] = &[ + ( + "Test.Point", + &[ + "(class) Test.Point : GaragePoint", + "name: string", + "locate: fun(): Test.Point", + "coords: vector3", + "slots: integer?", + "A named parking spot.", + ], + ), + ("Test.Result", &["type Test.Result = Test.Point?", "The result of a lookup."]), + ("Test.Mode", &["type Test.Mode = \"active\"|\"closed\""]), + ]; + + // Columns count UTF-16 units past the emoji; the last `Test.Point` must get its own range. + let column = |name: &str| text[..text.rfind(name).unwrap()].encode_utf16().count() as u32; + + for &(name, expected) in cases { + let column = column(name); + let result = client.request("textDocument/hover", client.position_params(CLIENT, 0, column + 1)); + let hover = result["contents"]["value"].as_str().unwrap_or_default(); + for part in expected { + assert!(hover.contains(*part), "{name}: missing {part:?} in {result}"); + } + assert_eq!( + result["range"], + json!({ + "start": { "line": 0, "character": column }, + "end": { "line": 0, "character": column + name.len() as u32 } + }) + ); + } + + client.change("myresource/types.lua", 2, &declarations.replace("name string", "name integer")); + let hover = client.hover_text(CLIENT, 0, column("Test.Point") + 1); + assert!(hover.contains("name: integer"), "{hover}"); +} + +#[test] +fn key_enums_are_the_union_of_their_keys() { + let mut client = Client::start(fixture_root()); + let declarations = "---@enum (key) Test.Side\nlocal sides = { client = 1, ['server'] = 2 }\n"; + client.open_with("myresource/types.lua", declarations); + client.open_with(CLIENT, "---@type Test.Side\n"); + let hover = client.hover_text(CLIENT, 0, 12); + assert!(hover.contains("type Test.Side = \"client\"|\"server\""), "{hover}"); +} + +#[test] +fn hover_resolves_exports_across_resources() { + let mut client = Client::start(fixture_root()); + let text = client.open(SERVER); + let (l, c) = pos(&text, "GetPlayer(src)", 3); + let hover = client.hover_text(SERVER, l, c); + assert!(hover.contains("GetPlayer(source: integer)"), "{hover}"); + assert!(hover.contains("Looks a player up"), "{hover}"); + + let (l, c) = pos(&text, "player.name", 8); + assert!(client.hover_text(SERVER, l, c).contains("name: string")); +} + +#[test] +fn completes_members_globals_natives_and_events() { + let mut client = Client::start(fixture_root()); + let text = client.open(CLIENT); + let lines = text.lines().count() as u32; + + let with_line = |client: &mut Client, extra: &str, version: i32| { + let changed = format!("{text}{extra}"); + client.change(CLIENT, version, &changed); + (lines, extra.len() as u32) + }; + + let (l, c) = with_line(&mut client, "MyLib.", 2); + let labels = client.completion_labels(CLIENT, l, c); + for expected in ["round", "createGarage", "math", "version"] { + assert!(labels.contains(&expected.to_string()), "{expected} missing from {labels:?}"); + } + + let (l, c) = with_line(&mut client, "garage:", 3); + let labels = client.completion_labels(CLIENT, l, c); + assert!(labels.contains(&"getVehicleCount".to_string()) && labels.contains(&"store".to_string()), "{labels:?}"); + assert!(!labels.contains(&"kind".to_string()), "fields should not be offered after ':' {labels:?}"); + + let (l, c) = with_line(&mut client, "garage.point.", 4); + assert_eq!(client.completion_labels(CLIENT, l, c), ["coords", "label", "slots"]); + + let (l, c) = with_line(&mut client, "local s = ('x'):", 5); + assert!(client.completion_labels(CLIENT, l, c).contains(&"format".to_string())); + + let (l, c) = with_line(&mut client, "exports.", 6); + let mut resources = client.completion_labels(CLIENT, l, c); + resources.sort(); + assert_eq!(resources, ["late", "mylib", "myresource", "shop", "vault"]); + + let (l, c) = with_line(&mut client, "exports.mylib:", 7); + let labels = client.completion_labels(CLIENT, l, c); + assert!(labels.contains(&"GetPlayer".to_string()) && labels.contains(&"Ping".to_string()), "{labels:?}"); + + let (l, c) = with_line(&mut client, "GetEntityCo", 8); + let labels = client.completion_labels(CLIENT, l, c); + assert!(labels.contains(&"GetEntityCoords".to_string()), "{labels:?}"); + + let (l, c) = with_line(&mut client, "local z = Conf", 9); + assert!(client.completion_labels(CLIENT, l, c).contains(&"Config".to_string())); + + let (l, c) = with_line(&mut client, "local z = roun", 10); + assert!(client.completion_labels(CLIENT, l, c).contains(&"rounded".to_string())); + + let (l, _) = with_line(&mut client, "TriggerServerEvent('')", 11); + let labels = client.completion_labels(CLIENT, l, 20); + assert!(labels.contains(&"myresource:server:ping".to_string()), "{labels:?}"); + + let (l, _) = with_line(&mut client, "MyLib.createGarage('public', { })", 12); + let labels = client.completion_labels(CLIENT, l, 31); + assert_eq!(labels, ["coords", "label", "slots"]); + + let (l, c) = with_line(&mut client, "---@type Gar", 13); + let labels = client.completion_labels(CLIENT, l, c); + assert!(labels.contains(&"Garage".to_string()) && labels.contains(&"GarageKind".to_string()), "{labels:?}"); +} + +#[test] +fn server_side_completion_hides_client_natives() { + let mut client = Client::start(fixture_root()); + let text = client.open(SERVER); + let changed = format!("{text}PlayerPed"); + client.change(SERVER, 2, &changed); + let line = text.lines().count() as u32; + let labels = client.completion_labels(SERVER, line, 9); + assert!(!labels.contains(&"PlayerPedId".to_string()), "{labels:?}"); + + let changed = format!("{text}GetPlayerIdent"); + client.change(SERVER, 3, &changed); + assert!(client.completion_labels(SERVER, line, 14).contains(&"GetPlayerIdentifierByType".to_string())); + + let text = client.open(CLIENT); + let line = text.lines().count() as u32; + client.change(CLIENT, 2, &format!("{text}if IsDuplicityVersion() then\n GetPlayerIdent\nend")); + let labels = client.completion_labels(CLIENT, line + 1, 18); + assert!(labels.contains(&"GetPlayerIdentifierByType".to_string()), "server branch of a client file: {labels:?}"); + client.change(CLIENT, 3, &format!("{text}if IsDuplicityVersion() then\n TriggerClientEvent('a', -1)\nend")); + let found = client.diagnostics_for(CLIENT); + assert!(!found.iter().any(|(code, _)| code == "fivem/native-wrong-side"), "{found:?}"); +} + +#[test] +fn goes_to_definitions_across_files() { + let mut client = Client::start(fixture_root()); + let text = client.open(CLIENT); + + let (l, c) = pos(&text, "MyLib.round", 8); + let result = client.request("textDocument/definition", client.position_params(CLIENT, l, c)); + assert!(result[0]["uri"].as_str().unwrap().ends_with("mylib/init.lua"), "{result}"); + assert_eq!(result[0]["range"]["start"]["line"], 16); + + let (l, c) = pos(&text, "require '@mylib.modules.settings'", 12); + let result = client.request("textDocument/definition", client.position_params(CLIENT, l, c)); + assert!(result[0]["uri"].as_str().unwrap().ends_with("modules/settings.lua"), "{result}"); + + let (l, c) = pos(&text, "'myresource:server:ping'", 5); + let result = client.request("textDocument/definition", client.position_params(CLIENT, l, c)); + assert!(result[0]["uri"].as_str().unwrap().ends_with("server/main.lua"), "{result}"); + + let (l, c) = pos(&text, "print(message, kind, count", 21); + let result = client.request("textDocument/definition", client.position_params(CLIENT, l, c)); + assert_eq!(result[0]["range"]["start"]["line"], 2); +} + +#[test] +fn annotation_definitions_and_hovers_use_closed_files() { + let mut client = Client::start(fixture_root()); + let annotations = [ + ("---@type GaragePoint[]", "GaragePoint", 0), + ("---@type table", "Garage", 36), + ("---@param garage Garage", "Garage", 36), + ("---@field kind GarageKind", "GarageKind", 5), + ("---@class TestGarage: Garage", "Garage", 36), + ("---@alias GarageList Garage[]", "Garage", 36), + ("---@return Garage garage, GarageKind kind", "GarageKind", 5), + ("---@overload fun(point: GaragePoint): Garage", "Garage", 36), + ("---@operator add(GaragePoint): Garage", "GaragePoint", 0), + ("---@see Garage", "Garage", 36), + ("local garage = {} --[[@as Garage]]", "Garage", 36), + ]; + let text = annotations.iter().map(|(line, ..)| *line).collect::>().join("\n"); + client.open_with(CLIENT, &text); + + for (line, (annotation, name, definition_line)) in annotations.iter().enumerate() { + let character = annotation.rfind(*name).unwrap() as u32 + 1; + let result = client.request("textDocument/definition", client.position_params(CLIENT, line as u32, character)); + assert_eq!(result.as_array().map(Vec::len), Some(1), "{annotation}: {result}"); + assert_eq!(result[0]["uri"], client.uri("[core]/mylib/init.lua").as_str(), "{annotation}"); + assert_eq!(result[0]["range"]["start"]["line"], *definition_line, "{annotation}"); + let hover = client.hover_text(CLIENT, line as u32, character); + assert!(hover.contains(*name), "{annotation}: {hover}"); + } +} + +#[test] +fn goes_to_namespaced_types_and_enums_in_unsaved_files() { + let mut client = Client::start(fixture_root()); + let declarations = "---@class Test.Point\n\n---@alias Test.Result Test.Point\n\n---@enum Test.Mode\nlocal modes = { active = 'active' }\n"; + client.open_with("myresource/types.lua", declarations); + let text = "local label = '🚗' ---@type Test.Point|Test.Result|Test.Mode\n"; + client.open_with(CLIENT, text); + // One character into the name, counted in UTF-16 units past the emoji. + let column = |name: &str| text[..text.find(name).unwrap() + 1].encode_utf16().count() as u32; + + for (name, line, character) in [("Test.Point", 0, 0), ("Test.Result", 2, 0), ("Test.Mode", 5, 6)] { + let result = client.request("textDocument/definition", client.position_params(CLIENT, 0, column(name))); + assert_eq!(result.as_array().map(Vec::len), Some(1), "{name}: {result}"); + assert_eq!(result[0]["uri"], client.uri("myresource/types.lua").as_str()); + assert_eq!(result[0]["range"]["start"], json!({ "line": line, "character": character })); + } + + client.change("myresource/types.lua", 2, &format!("\n{declarations}")); + let result = client.request("textDocument/definition", client.position_params(CLIENT, 0, column("Test.Point"))); + assert_eq!(result[0]["range"]["start"]["line"], 1); + + client.open_with("myresource/extra-types.lua", "---@class Test.Point\n---@alias Test.Result string\n"); + for name in ["Test.Point", "Test.Result"] { + let result = client.request("textDocument/definition", client.position_params(CLIENT, 0, column(name))); + let locations = result.as_array().unwrap(); + assert_eq!(locations.len(), 2, "{name}: {result}"); + for file in ["myresource/types.lua", "myresource/extra-types.lua"] { + assert!(locations.iter().any(|location| location["uri"] == client.uri(file).as_str()), "{result}"); + } + } +} + +#[test] +fn annotation_features_ignore_names_outside_type_positions() { + let mut client = Client::start(fixture_root()); + let lines = [ + "---@param Garage string", + "---@field Garage string", + "---@return string Garage", + "---@type 'Garage'", + "---@type fun(Garage: string): boolean", + "---@type { Garage: string }", + "---@type string # Garage", + "---Garage is a class.", + "-- Garage", + "local text = '---@type Garage'", + "local text = [[---@type Garage]]", + "--[[---@type Garage]]", + "---@type MissingGarage", + ]; + client.open_with(CLIENT, &lines.join("\n")); + + for (line, text) in lines.iter().enumerate() { + let column = text.find("Garage").unwrap() as u32 + 1; + for method in ["textDocument/definition", "textDocument/hover"] { + let result = client.request(method, client.position_params(CLIENT, line as u32, column)); + assert!(result.is_null(), "{method} on {text}: {result}"); + } + } +} + +#[test] +fn signature_help_tracks_the_active_parameter() { + let mut client = Client::start(fixture_root()); + let text = client.open(CLIENT); + let (l, c) = pos(&text, "MyLib.round(1.2345, 2)", 20); + let result = client.request("textDocument/signatureHelp", client.position_params(CLIENT, l, c)); + assert_eq!(result["signatures"][0]["label"], "MyLib.round(value: number, decimals?: integer): number"); + assert_eq!(result["activeParameter"], 1); + + let (l, c) = pos(&text, "garage:store('ABC123')", 14); + let result = client.request("textDocument/signatureHelp", client.position_params(CLIENT, l, c)); + assert_eq!(result["signatures"][0]["label"], "store(plate: string): boolean, string?"); +} + +#[test] +fn trigger_calls_show_the_parameters_of_the_handler() { + let mut client = Client::start(fixture_root()); + let text = client.open(SHOP_CLIENT); + let (l, c) = pos(&text, "TriggerServerEvent('shop:buy', 'water'", 31); + let result = client.request("textDocument/signatureHelp", client.position_params(SHOP_CLIENT, l, c)); + let signature = &result["signatures"][0]; + assert_eq!(signature["label"], "TriggerServerEvent(eventName: string, item, amount)"); + assert_eq!(result["activeParameter"], 1); + let note = signature["documentation"]["value"].as_str().unwrap_or_default(); + assert!(note.contains("shop/server.lua:1"), "{note}"); + + let hints = client.request( + "textDocument/inlayHint", + json!({ "textDocument": { "uri": client.uri(SHOP_CLIENT) }, "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 40, "character": 0 } } }), + ); + let labels: Vec<&str> = hints.as_array().unwrap().iter().filter_map(|h| h["label"].as_str()).collect(); + assert!(labels.contains(&"item:") && labels.contains(&"amount:"), "{labels:?}"); +} + +#[test] +fn publishes_lint_diagnostics_with_resource_context() { + let mut client = Client::start(fixture_root()); + client.open(CLIENT); + assert_eq!(client.diagnostics_for(CLIENT), []); + + let text = client.open(SERVER); + let found = client.diagnostics_for(SERVER); + assert_eq!(found, [("fivem/import-not-declared".to_string(), 8), ("unused-argument".to_string(), 8)], "{found:?}"); + + let broken = format!("{text}\nlocal ped = PlayerPedId()\nprint(notDefinedAnywhere)\n"); + client.change(SERVER, 2, &broken); + let codes: Vec = client.diagnostics_for(SERVER).into_iter().map(|(code, _)| code).collect(); + assert!(codes.contains(&"fivem/native-wrong-side".to_string()), "{codes:?}"); + assert!(codes.contains(&"undefined-global".to_string()), "{codes:?}"); + assert!(codes.contains(&"unused-local".to_string()), "{codes:?}"); +} + +#[test] +fn references_rename_and_symbols() { + let mut client = Client::start(fixture_root()); + let text = client.open(CLIENT); + + let (l, c) = pos(&text, "local count", 7); + let mut params = client.position_params(CLIENT, l, c); + params["context"] = json!({ "includeDeclaration": true }); + let refs = client.request("textDocument/references", params); + assert_eq!(refs.as_array().unwrap().len(), 2); + + let (l, c) = pos(&text, "Config.SpawnDistance", 2); + let mut params = client.position_params(CLIENT, l, c); + params["context"] = json!({ "includeDeclaration": true }); + let refs = client.request("textDocument/references", params); + let files: Vec<&str> = refs.as_array().unwrap().iter().map(|r| r["uri"].as_str().unwrap()).collect(); + assert!( + files.iter().any(|f| f.ends_with("shared/config.lua")) && files.iter().any(|f| f.ends_with("server/main.lua")), + "{files:?}" + ); + + let mut params = client.position_params(CLIENT, l, c); + params["newName"] = json!("Settings"); + let edit = client.request("textDocument/rename", params); + assert!(edit["changes"].as_object().unwrap().len() >= 3, "{edit}"); + + let symbols = + client.request("textDocument/documentSymbol", json!({ "textDocument": { "uri": client.uri(CLIENT) } })); + let names: Vec<&str> = symbols.as_array().unwrap().iter().map(|s| s["name"].as_str().unwrap()).collect(); + assert!(names.contains(&"garage") && names.contains(&"RegisterNetEvent 'myresource:client:notify'"), "{names:?}"); + + let found = client.request("workspace/symbol", json!({ "query": "garage" })); + let names: Vec<&str> = found.as_array().unwrap().iter().map(|s| s["name"].as_str().unwrap()).collect(); + assert!(names.contains(&"MyLib.createGarage") && names.contains(&"Garage"), "{names:?}"); +} + +#[test] +fn code_actions_inlay_hints_tokens_and_folding() { + let mut client = Client::start(fixture_root()); + let text = "Citizen.CreateThread(function()\n local hash = GetHashKey('adder')\n SetEntityCoords(hash, 1.0, 2.0, 3.0, false, false, false, true)\nend)\n"; + client.open_with(CLIENT, text); + client.diagnostics_for(CLIENT); + let uri = client.uri(CLIENT).to_string(); + let diagnostics = client.diagnostics[&uri].clone(); + let actions = client.request( + "textDocument/codeAction", + json!({ "textDocument": { "uri": uri }, "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 3, "character": 0 } }, "context": { "diagnostics": diagnostics } }), + ); + let titles: Vec<&str> = actions.as_array().unwrap().iter().map(|a| a["title"].as_str().unwrap()).collect(); + assert!(titles.contains(&"Replace with 'CreateThread'"), "{titles:?}"); + assert!(titles.contains(&"Convert to a compile-time hash literal"), "{titles:?}"); + assert!(titles.iter().any(|t| t.starts_with("Disable fivem/hash-literal")), "{titles:?}"); + + let hints = client.request( + "textDocument/inlayHint", + json!({ "textDocument": { "uri": uri }, "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 4, "character": 0 } } }), + ); + let labels: Vec<&str> = hints.as_array().unwrap().iter().map(|h| h["label"].as_str().unwrap()).collect(); + assert_eq!(&labels[..3], ["string:", "xPos:", "yPos:"], "{labels:?}"); + + let tokens = client.request("textDocument/semanticTokens/full", json!({ "textDocument": { "uri": uri } })); + assert!(tokens["data"].as_array().unwrap().len() >= 5 * 6); + + let folds = client.request("textDocument/foldingRange", json!({ "textDocument": { "uri": uri } })); + assert_eq!(folds[0]["startLine"], 0); +} + +#[test] +fn survives_garbage_input_while_typing() { + let mut client = Client::start(fixture_root()); + let text = client.open(CLIENT); + for (version, cut) in (2..).zip((0..text.len()).step_by(37)) { + if !text.is_char_boundary(cut) { + continue; + } + client.change(CLIENT, version, &text[..cut]); + let line = text[..cut].matches('\n').count() as u32; + let col = (cut - text[..cut].rfind('\n').map_or(0, |i| i + 1)) as u32; + client.request("textDocument/completion", client.position_params(CLIENT, line, col)); + client.request("textDocument/hover", client.position_params(CLIENT, line, col.saturating_sub(1))); + client.request("textDocument/signatureHelp", client.position_params(CLIENT, line, col)); + } +} + +#[test] +fn reports_problems_for_files_that_are_not_open() { + let mut client = Client::start(fixture_root()); + let found = client.diagnostics_for(SERVER); + assert_eq!(found, [("fivem/import-not-declared".to_string(), 8)], "{found:?}"); + assert_eq!(client.diagnostics_for(CLIENT), []); +} + +#[test] +fn event_completion_follows_the_call_direction() { + let mut client = Client::start(fixture_root()); + let text = client.open(CLIENT); + let line = text.lines().count() as u32; + + client.change(CLIENT, 2, &format!("{text}TriggerServerEvent('')")); + let labels = client.completion_labels(CLIENT, line, 20); + assert_eq!(labels, ["myresource:server:ping", "shop:buy", "shop:refund"], "only server handlers are reachable"); + + client.change(CLIENT, 3, &format!("{text}TriggerEvent('')")); + let labels = client.completion_labels(CLIENT, line, 14); + assert_eq!(labels, ["myresource:client:notify", "shop:bought"]); +} + +#[test] +fn event_completion_replaces_the_whole_name_across_colons() { + for snippets in [false, true] { + let mut client = Client::start_with_capabilities( + fixture_root(), + json!({ "textDocument": { "completion": { "completionItem": { "snippetSupport": snippets } } } }), + ); + client.open_with(CLIENT, ""); + let mut version = 1; + for (call, name) in [ + ("TriggerServerEvent", "myresource:server:ping"), + ("TriggerLatentServerEvent", "myresource:server:ping"), + ("TriggerEvent", "shop:bought"), + ("RegisterNetEvent", "myresource:server:ping"), + ("lib.callback.await", "myresource:getGarages"), + ] { + for quote in ['\'', '"'] { + for suffix in ["", "stale"] { + for length in 0..=name.len() { + let prefix = &name[..length]; + let head = format!("local label = '🚗'; {call}({quote}"); + let tail = format!("{suffix}{quote}, 42)"); + let text = format!("{head}{prefix}{tail}"); + let start = head.encode_utf16().count() as u32; + let cursor = start + prefix.len() as u32; + version += 1; + client.change(CLIENT, version, &text); + let mut params = client.position_params(CLIENT, 0, cursor); + params["context"] = if prefix.ends_with(':') { + json!({ "triggerKind": 2, "triggerCharacter": ":" }) + } else { + json!({ "triggerKind": 1 }) + }; + let result = client.request("textDocument/completion", params); + let item = result["items"].as_array().unwrap().iter().find(|i| i["label"] == name).unwrap(); + assert_eq!( + item["textEdit"], + json!({ + "range": { "start": { "line": 0, "character": start }, + "end": { "line": 0, "character": cursor + suffix.len() as u32 } }, + "newText": name + }), + "{text} at {cursor}" + ); + assert_eq!(item["insertTextFormat"], Value::Null); + } + } + let head = format!("{call}({quote}"); + let prefix = name.rsplit_once(':').unwrap().0.to_string() + ":"; + let text = format!("{head}{prefix}"); + version += 1; + client.change(CLIENT, version, &text); + let result = + client.request("textDocument/completion", client.position_params(CLIENT, 0, text.len() as u32)); + let item = result["items"].as_array().unwrap().iter().find(|i| i["label"] == name).unwrap(); + assert_eq!( + item["textEdit"]["range"], + json!({ + "start": { "line": 0, "character": head.len() }, + "end": { "line": 0, "character": text.len() } + }), + "unterminated string: {text}" + ); + } + } + } +} + +#[test] +fn reports_the_side_of_a_file() { + let mut client = Client::start(fixture_root()); + let info = client.request("qbx/fileInfo", json!({ "uri": client.uri(CLIENT) })); + assert_eq!(info, json!({ "side": "client", "resource": "myresource" })); + let info = client.request("qbx/fileInfo", json!({ "uri": client.uri("myresource/shared/config.lua") })); + assert_eq!(info["side"], "shared"); + let info = client.request("qbx/fileInfo", json!({ "uri": client.uri("[core]/mylib/modules/settings.lua") })); + assert_eq!(info["side"], "module"); +} + +const SHOP_CLIENT: &str = "shop/client.lua"; +const SHOP_SERVER: &str = "shop/server.lua"; + +#[test] +fn cross_file_rules_run_in_the_editor() { + let mut client = Client::start(fixture_root()); + let found = client.diagnostics_for(SHOP_CLIENT); + let expected = [ + ("qbox/unknown-locale-key", 4), + ("fivem/event-argument-count", 7), + ("fivem/event-wrong-side", 8), + ("fivem/export-argument-count", 9), + ("manifest/missing-dependency", 9), + ]; + for (code, line) in expected { + assert!(found.contains(&(code.to_string(), line)), "{code} on line {line} missing from {found:?}"); + } + + let codes: Vec = client.diagnostics_for(SHOP_SERVER).into_iter().map(|(code, _)| code).collect(); + for code in ["security/client-supplied-source", "security/unvalidated-event-argument", "security/sql-concatenation"] + { + assert!(codes.contains(&code.to_string()), "{code} missing from {codes:?}"); + } + assert!(codes.contains(&"fivem/event-argument-count".to_string()), "shop:bought takes one argument: {codes:?}"); + + let unused = client.diagnostics_for("shop/locales/en.json"); + assert_eq!(unused, [("qbox/unused-locale-key".to_string(), 3), ("qbox/unused-locale-key".to_string(), 5)]); +} + +#[test] +fn completes_locale_keys_convars_and_state_bags() { + let mut client = Client::start(fixture_root()); + let text = client.open(SHOP_CLIENT); + let line = text.lines().count() as u32; + + client.change(SHOP_CLIENT, 2, &format!("{text}print(locale(''))")); + assert_eq!(client.completion_labels(SHOP_CLIENT, line, 14), ["buy.success", "buy.failed", "never_used"]); + + client.change(SHOP_CLIENT, 3, &format!("{text}print(GetConvarInt(''))")); + assert_eq!(client.completion_labels(SHOP_CLIENT, line, 20), ["shop_debug"]); + + client.change(SHOP_CLIENT, 4, &format!("{text}print(LocalPlayer.state.)")); + assert!(client.completion_labels(SHOP_CLIENT, line, 24).contains(&"isShopping".to_string())); + + let (l, c) = pos(&text, "'buy.success'", 3); + assert!(client.hover_text(SHOP_CLIENT, l, c).contains("You bought %s")); + let definition = client.request("textDocument/definition", client.position_params(SHOP_CLIENT, l, c)); + assert!(definition[0]["uri"].as_str().unwrap().ends_with("locales/en.json"), "{definition}"); + assert_eq!(definition[0]["range"]["start"]["line"], 2); +} + +#[test] +fn finds_and_renames_fields_across_files() { + let mut client = Client::start(fixture_root()); + let text = client.open(SHOP_CLIENT); + let (l, c) = pos(&text, "Shop.getPrice", 7); + + let mut params = client.position_params(SHOP_CLIENT, l, c); + params["context"] = json!({ "includeDeclaration": true }); + let refs = client.request("textDocument/references", params); + let mut files: Vec = refs + .as_array() + .unwrap() + .iter() + .map(|r| r["uri"].as_str().unwrap().rsplit('/').next().unwrap().to_string()) + .collect(); + files.sort(); + assert_eq!(files, ["client.lua", "server.lua", "shared.lua"], "{refs}"); + + let mut params = client.position_params(SHOP_CLIENT, l, c); + params["newName"] = json!("priceOf"); + let edit = client.request("textDocument/rename", params); + assert_eq!(edit["changes"].as_object().unwrap().len(), 3, "{edit}"); + + let (l, c) = pos(&text, "exports.mylib:Ping", 15); + let prepared = client.request("textDocument/prepareRename", client.position_params(SHOP_CLIENT, l, c)); + assert!(prepared.is_object(), "exports defined in the workspace can be renamed: {prepared}"); +} + +fn renamed_text(client: &mut Client, relative: &str, text: &str, needle: &str, delta: u32) -> String { + let (line, column) = pos(text, needle, delta); + let params = client.position_params(relative, line, column); + let prepared = client.request("textDocument/prepareRename", params.clone()); + assert!(prepared.is_object(), "rename should be available on {needle}: {prepared}"); + let mut params = params; + params["newName"] = json!("renamed"); + let result = client.request("textDocument/rename", params); + let edit: lsp_types::WorkspaceEdit = serde_json::from_value(result.clone()).expect("rename edit"); + let changes = edit.changes.unwrap(); + assert_eq!(changes.len(), 1, "unrelated objects must not be renamed: {result}"); + let mut edits = changes[&client.uri(relative)].clone(); + edits.sort_by_key(|edit| std::cmp::Reverse(edit.range.start)); + let lines = qbx_lua_syntax::LineIndex::new(text); + let mut output = text.to_string(); + let mut previous_start = text.len() as u32; + for edit in edits { + let start = lines.offset_utf16( + text, + qbx_lua_syntax::LineCol { line: edit.range.start.line, col: edit.range.start.character }, + ); + let end = lines + .offset_utf16(text, qbx_lua_syntax::LineCol { line: edit.range.end.line, col: edit.range.end.character }); + assert!(end <= previous_start, "rename edits must not overlap: {result}"); + previous_start = start; + output.replace_range(start as usize..end as usize, &edit.new_text); + } + assert!(qbx_lua_syntax::parse(&output).errors.is_empty(), "renamed Lua must parse: {output}"); + output +} + +#[test] +fn rename_static_string_reads_and_writes_preserves_delimiters() { + let mut client = Client::start(fixture_root()); + let text = "Audit = { foo = 1 }\nAudit['foo'] = 2\nprint(Audit.foo, Audit[\"foo\"], Audit[ [=[foo]=] ], Audit['f\\111o'])\nOther = { foo = 3 }\nprint(Other['foo'])\n"; + client.open_with(SHOP_CLIENT, text); + let expected = "Audit = { renamed = 1 }\nAudit['renamed'] = 2\nprint(Audit.renamed, Audit[\"renamed\"], Audit[ [=[renamed]=] ], Audit['renamed'])\nOther = { foo = 3 }\nprint(Other['foo'])\n"; + assert_eq!(renamed_text(&mut client, SHOP_CLIENT, text, "Audit.foo", 7), expected); + assert_eq!(renamed_text(&mut client, SHOP_CLIENT, text, "Audit['foo']", 8), expected); + let (line, column) = pos(text, "Audit['foo']", 8); + let mut params = client.position_params(SHOP_CLIENT, line, column); + params["context"] = json!({ "includeDeclaration": true }); + let refs = client.request("textDocument/references", params); + assert_eq!(refs.as_array().unwrap().len(), 6, "{refs}"); + let highlights = + client.request("textDocument/documentHighlight", client.position_params(SHOP_CLIENT, line, column)); + assert_eq!(highlights.as_array().unwrap().len(), 6, "{highlights}"); +} + +#[test] +fn rename_static_string_declarations_and_nested_paths() { + let mut client = Client::start(fixture_root()); + let cases = [ + ("Audit = { ['foo'] = 1 }\nprint(Audit.foo, Audit['foo'])\n", + "['foo']", 3, + "Audit = { ['renamed'] = 1 }\nprint(Audit.renamed, Audit['renamed'])\n"), + ("Audit = {}\nAudit['foo'] = 1\nprint(Audit.foo, Audit['foo'])\n", + "Audit.foo", 7, + "Audit = {}\nAudit['renamed'] = 1\nprint(Audit.renamed, Audit['renamed'])\n"), + ("Audit = { ['nested'] = { [ [=[\nfoo]=] ] = 1 } }\nprint(Audit['nested'].foo, Audit.nested['foo'])\n", + ".foo", 2, + "Audit = { ['nested'] = { [ [=[\nrenamed]=] ] = 1 } }\nprint(Audit['nested'].renamed, Audit.nested['renamed'])\n"), + ]; + for (version, (text, needle, delta, expected)) in (1..).zip(cases) { + if version == 1 { + client.open_with(SHOP_CLIENT, text); + } else { + client.change(SHOP_CLIENT, version, text); + } + assert_eq!(renamed_text(&mut client, SHOP_CLIENT, text, needle, delta), expected); + } +} + +#[test] +fn rename_annotation_fields_updates_declarations_and_typed_constructors() { + let mut client = Client::start(fixture_root()); + let text = "---@class RenameOptions\n---@field private foo? number foo description\nlocal audit = { ['foo'] = 1 }\n---@type RenameOptions\nlocal other = { foo = 2 }\nprint(audit.foo, other['foo'])\n"; + client.open_with(SHOP_CLIENT, text); + let expected = "---@class RenameOptions\n---@field private renamed? number foo description\nlocal audit = { ['renamed'] = 1 }\n---@type RenameOptions\nlocal other = { renamed = 2 }\nprint(audit.renamed, other['renamed'])\n"; + assert_eq!(renamed_text(&mut client, SHOP_CLIENT, text, "audit.foo", 7), expected); + assert_eq!(renamed_text(&mut client, SHOP_CLIENT, text, "private foo", 9), expected); + + let text = "---@class RenameOptions\n---@field ['foo'] number foo description\nlocal audit = { foo = 1 }\nprint(audit['foo'])\n"; + client.change(SHOP_CLIENT, 2, text); + let expected = "---@class RenameOptions\n---@field ['renamed'] number foo description\nlocal audit = { renamed = 1 }\nprint(audit['renamed'])\n"; + assert_eq!(renamed_text(&mut client, SHOP_CLIENT, text, "audit['foo']", 8), expected); +} + +#[test] +fn rename_finds_escaped_keys_in_closed_files_and_aborts_if_a_file_is_unreadable() { + struct Fixture(PathBuf); + impl Drop for Fixture { + fn drop(&mut self) { + if let (Ok(root), Ok(temp)) = (self.0.canonicalize(), std::env::temp_dir().canonicalize()) { + if root.parent() == Some(temp.as_path()) { + let _ = std::fs::remove_dir_all(root); + } + } + } + } + let fixture = Fixture(std::env::temp_dir().join(format!( + "qbx-rename-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(), + ))); + std::fs::create_dir(&fixture.0).unwrap(); + std::fs::write( + fixture.0.join("fxmanifest.lua"), + "fx_version 'cerulean'\ngame 'gta5'\nshared_scripts { 'main.lua', 'closed.lua' }\n", + ) + .unwrap(); + std::fs::write(fixture.0.join("main.lua"), "Audit = { foo = 1 }\nprint(Audit.foo)\n").unwrap(); + std::fs::write(fixture.0.join("closed.lua"), "print(Audit['\\102\\111\\111'])\n").unwrap(); + let mut client = Client::start(fixture.0.clone()); + let text = client.open("main.lua"); + let (line, column) = pos(&text, "Audit.foo", 7); + let mut params = client.position_params("main.lua", line, column); + params["newName"] = json!("renamed"); + let result = client.request("textDocument/rename", params.clone()); + let changes = result["changes"].as_object().expect("rename edit"); + assert_eq!(changes.len(), 2, "escaped references in closed files must be found: {result}"); + let edits = changes[client.uri("closed.lua").as_str()].as_array().unwrap(); + assert_eq!(edits.len(), 1, "{result}"); + assert_eq!(edits[0]["range"], json!({"start": {"line": 0, "character": 13}, "end": {"line": 0, "character": 25}})); + std::fs::remove_file(fixture.0.join("closed.lua")).unwrap(); + assert_eq!( + client.request("textDocument/rename", params), + Value::Null, + "an unreadable indexed file must not result in partial edits" + ); +} + +#[test] +fn formats_documents() { + let mut client = Client::start(fixture_root()); + client.open_with(SHOP_CLIENT, "local a=1\nif a then\nprint( a )\nend\n"); + let params = json!({ "textDocument": { "uri": client.uri(SHOP_CLIENT) }, "options": { "tabSize": 2, "insertSpaces": true } }); + let edits = client.request("textDocument/formatting", params); + assert_eq!(edits[0]["newText"], "local a = 1\nif a then\n print(a)\nend\n"); + + client.change(SHOP_CLIENT, 2, "local a = 1\n"); + let params = json!({ "textDocument": { "uri": client.uri(SHOP_CLIENT) }, "options": { "tabSize": 4, "insertSpaces": true } }); + assert_eq!(client.request("textDocument/formatting", params), json!([])); +} + +#[test] +fn lua_ls_config_supplies_lint_settings_but_not_formatting() { + struct Fixture(PathBuf); + impl Drop for Fixture { + fn drop(&mut self) { + if let (Ok(root), Ok(temp)) = (self.0.canonicalize(), std::env::temp_dir().canonicalize()) { + if root.parent() == Some(temp.as_path()) { + let _ = std::fs::remove_dir_all(root); + } + } + } + } + let fixture = Fixture(std::env::temp_dir().join(format!( + "qbx-luarc-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(), + ))); + std::fs::create_dir(&fixture.0).unwrap(); + std::fs::write(fixture.0.join("fxmanifest.lua"), "fx_version 'cerulean'\ngame 'gta5'\nclient_script 'main.lua'\n") + .unwrap(); + std::fs::write( + fixture.0.join(".luarc.json"), + r#"{ "diagnostics.globals": ["Config"], "diagnostics.disable": ["lowercase-global"] }"#, + ) + .unwrap(); + let text = "helper = function() return Config end\nif helper then\nprint( helper )\nend\n"; + std::fs::write(fixture.0.join("main.lua"), text).unwrap(); + let mut client = Client::start(fixture.0.clone()); + client.open_with("main.lua", text); + assert_eq!(client.diagnostics_for("main.lua"), []); + let fallback = + |logs: &[String]| logs.iter().filter(|l| l.contains("falling back") && l.contains(".luarc.json")).count(); + assert_eq!(fallback(&client.logs), 1, "{:?}", client.logs); + + let params = + json!({ "textDocument": { "uri": client.uri("main.lua") }, "options": { "tabSize": 2, "insertSpaces": true } }); + let edits = client.request("textDocument/formatting", params); + assert_eq!( + edits[0]["newText"], "helper = function() return Config end\nif helper then\n print(helper)\nend\n", + "the editor's indentation applies without a qbxlint.toml" + ); + + let watchers: Vec<&str> = client + .registrations + .iter() + .flat_map(|r| r["registerOptions"]["watchers"].as_array().into_iter().flatten()) + .filter_map(|w| w["globPattern"].as_str()) + .collect(); + assert!(watchers.contains(&"**/.luarc.json") && watchers.contains(&"**/.emmyrc.json"), "{watchers:?}"); + std::fs::write(fixture.0.join(".luarc.json"), r#"{ "diagnostics.globals": ["Config"] }"#).unwrap(); + client.notify( + "workspace/didChangeWatchedFiles", + json!({ "changes": [{ "uri": client.uri(".luarc.json"), "type": 2 }] }), + ); + assert_eq!( + client.diagnostics_for("main.lua"), + [("lowercase-global".to_string(), 0)], + "a changed .luarc.json applies" + ); + assert_eq!(fallback(&client.logs), 2, "a reloaded fallback is logged again: {:?}", client.logs); + + std::fs::write(fixture.0.join(".luarc.json"), r#"{ "diagnostics.globals": ["#).unwrap(); + client.notify( + "workspace/didChangeWatchedFiles", + json!({ "changes": [{ "uri": client.uri(".luarc.json"), "type": 2 }] }), + ); + let diagnostics = client.diagnostics_for("main.lua"); + assert!(diagnostics.iter().any(|(code, _)| code == "undefined-global"), "{diagnostics:?}"); + assert!(client.logs.iter().any(|l| l.starts_with("skipped") && l.contains(".luarc.json")), "{:?}", client.logs); +} + +#[test] +fn excluded_files_stay_out_and_ignored_files_stay_quiet() { + struct Fixture(PathBuf); + impl Drop for Fixture { + fn drop(&mut self) { + if let (Ok(root), Ok(temp)) = (self.0.canonicalize(), std::env::temp_dir().canonicalize()) { + if root.parent() == Some(temp.as_path()) { + let _ = std::fs::remove_dir_all(root); + } + } + } + } + let fixture = Fixture(std::env::temp_dir().join(format!( + "qbx-ignore-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(), + ))); + let write = |relative: &str, text: &str| { + let path = fixture.0.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + }; + write("qbxlint.toml", "exclude = ['skip/**']\nignore_diagnostics = ['vendor/']\n"); + write( + "fxmanifest.lua", + "fx_version 'cerulean'\ngame 'gta5'\nclient_scripts { 'vendor/*.lua', 'skip/*.lua', 'main.lua' }\n", + ); + write("vendor/lib.lua", "VendorApi = {}\nCitizen.Wait(0)\n"); + write("skip/old.lua", "SkippedApi = {}\nCitizen.Wait(0)\n"); + write("main.lua", "print(VendorApi, SkippedApi)\n"); + let mut client = Client::start(fixture.0.clone()); + let undefined = |client: &mut Client| { + client.diagnostics_for("main.lua"); + let list = client.diagnostics[&client.uri("main.lua").to_string()].as_array().unwrap().clone(); + list.iter().map(|d| d["message"].as_str().unwrap().to_string()).collect::>() + }; + + let messages = undefined(&mut client); + assert!(messages.len() == 1 && messages[0].contains("SkippedApi"), "{messages:?}"); + assert_eq!(client.diagnostics_for("vendor/lib.lua"), []); + assert_eq!(client.diagnostics_for("skip/old.lua"), []); + let symbols = client.request("workspace/symbol", json!({ "query": "VendorApi" })); + assert!(!symbols.as_array().unwrap().is_empty(), "ignored files are still indexed: {symbols}"); + + client.open("vendor/lib.lua"); + assert_eq!(client.diagnostics_for("vendor/lib.lua"), []); + client.open("skip/old.lua"); + assert_eq!(client.diagnostics_for("skip/old.lua"), []); + assert_eq!(undefined(&mut client).len(), 1, "an open excluded file is not indexed"); + client.notify("textDocument/didClose", json!({ "textDocument": { "uri": client.uri("skip/old.lua") } })); + assert_eq!(client.diagnostics_for("skip/old.lua"), [], "a closed excluded file stays out of the Problems panel"); + assert_eq!(undefined(&mut client).len(), 1); + + write("skip/new.lua", "NewApi = {}\nCitizen.Wait(0)\n"); + client.notify( + "workspace/didChangeWatchedFiles", + json!({ "changes": [{ "uri": client.uri("skip/new.lua"), "type": 1 }] }), + ); + assert_eq!(client.diagnostics_for("skip/new.lua"), []); + let symbols = client.request("workspace/symbol", json!({ "query": "NewApi" })); + assert!(symbols.as_array().unwrap().is_empty(), "{symbols}"); +} + +#[test] +fn server_cfg_start_order_settles_dependencies() { + let mut client = Client::start(fixture_root()); + let late = client.diagnostics_for("late/server.lua"); + assert_eq!(late, [], "server.cfg ensures [core] before late, so mylib is already running"); + + let uri = client.uri(SHOP_CLIENT).to_string(); + client.diagnostics_for(SHOP_CLIENT); + let messages: Vec = client.diagnostics[&uri] + .as_array() + .unwrap() + .iter() + .filter(|d| d["code"] == "manifest/missing-dependency") + .map(|d| d["message"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(messages.len(), 1, "shop is ensured before [core]: {messages:?}"); + assert!(messages[0].contains("server.cfg does not start it earlier"), "{messages:?}"); +} + +#[test] +fn bridge_code_is_not_a_dependency_but_missing_resources_are_reported() { + let mut client = Client::start(fixture_root()); + let bridge = client.diagnostics_for("late/bridge.lua"); + assert_eq!(bridge, [("fivem/resource-not-found".to_string(), 10)], "only the unconditional call matters"); + assert_eq!(client.diagnostics_for("late/guarded.lua"), [], "everything after the selector guard is optional"); +} + +#[test] +fn snippets_outrank_the_plain_name() { + let mut client = Client::start(fixture_root()); + let text = client.open(CLIENT); + let line = text.lines().count() as u32; + let mut version = 1; + let mut first_item = |typed: &str| { + version += 1; + client.change(CLIENT, version, &format!("{text}{typed}")); + let params = client.position_params(CLIENT, line, typed.len() as u32); + let result = client.request("textDocument/completion", params); + let mut items = result["items"].as_array().cloned().unwrap_or_default(); + items.sort_by_key(|i| i["sortText"].as_str().unwrap_or_default().to_string()); + items.into_iter().next().unwrap_or(Value::Null) + }; + + let thread = first_item("CreateThread"); + assert_eq!(thread["labelDetails"]["description"], "snippet", "{thread}"); + let preview = thread["documentation"]["value"].as_str().unwrap(); + assert!(preview.contains("Wait(0)") && !preview.contains('$'), "{preview}"); + let body = thread["insertText"].as_str().unwrap(); + assert!(body.contains("while true do") && body.contains("Wait(${1:0})"), "{body}"); + + let on_cache = first_item("oncache"); + let body = on_cache["insertText"].as_str().unwrap_or_default(); + assert!(body.starts_with("lib.onCache('${1|ped,"), "falls back to the usual keys without ox_lib: {on_cache}"); + + let member = first_item("lib.onCa"); + assert!(member["insertText"].as_str().unwrap_or_default().starts_with("onCache('${1|"), "{member}"); +} + +#[test] +fn minimal_clients_receive_plain_completions_and_no_dynamic_watch_registration() { + for capabilities in [ + json!({}), + json!({ + "workspace": { "didChangeWatchedFiles": { "dynamicRegistration": false } }, + "textDocument": { "completion": { "completionItem": { "snippetSupport": false } } } + }), + ] { + let mut client = Client::start_with_capabilities(fixture_root(), capabilities); + client.request("qbx/status", Value::Null); + assert!(client.registrations.is_empty(), "{:?}", client.registrations); + + let cases = [ + (CLIENT, "CreateThread", 0, 12, Some("CreateThread")), + (CLIENT, "local Useful = 1\nUse", 1, 3, Some("Useful")), + (CLIENT, "lib.onCa", 0, 8, None), + (CLIENT, "oncache", 0, 7, None), + (CLIENT, "---@par", 0, 7, Some("param")), + ("myresource/fxmanifest.lua", "fx_v", 0, 4, Some("fx_version")), + ]; + for (relative, text, line, column, expected) in cases { + client.open_with(relative, text); + let result = client.request("textDocument/completion", client.position_params(relative, line, column)); + let items = result["items"].as_array().unwrap(); + if let Some(label) = expected { + assert!(items.iter().any(|item| item["label"] == label), "{result}"); + } + for item in items { + assert_ne!(item["insertTextFormat"], 2, "{item}"); + assert_ne!(item["labelDetails"]["description"], "snippet", "{item}"); + assert!(!item["insertText"].as_str().unwrap_or_default().contains('$'), "{item}"); + } + if text == "---@par" { + assert_eq!(items.iter().find(|item| item["label"] == "param").unwrap()["insertText"], "param"); + } + client.notify("textDocument/didClose", json!({"textDocument": {"uri": client.uri(relative)}})); + } + } +} + +#[test] +fn capable_clients_keep_file_watches_and_annotation_and_manifest_snippets() { + let mut client = Client::start(fixture_root()); + client.request("qbx/status", Value::Null); + assert_eq!(client.registrations.len(), 1); + let registration = &client.registrations[0]; + assert_eq!(registration["method"], "workspace/didChangeWatchedFiles"); + assert!(registration["registerOptions"]["watchers"] + .as_array() + .unwrap() + .iter() + .any(|watch| watch["globPattern"] == "**/*.lua")); + + for (relative, text, label) in [(CLIENT, "---@par", "param"), ("myresource/fxmanifest.lua", "fx_v", "fx_version")] { + client.open_with(relative, text); + let result = client.request("textDocument/completion", client.position_params(relative, 0, text.len() as u32)); + let item = result["items"].as_array().unwrap().iter().find(|item| item["label"] == label).unwrap(); + assert_eq!(item["insertTextFormat"], 2, "{item}"); + assert!(item["insertText"].as_str().unwrap().contains("${1"), "{item}"); + } +} + +#[test] +fn knows_glm_and_keeps_native_handle_names() { + let mut client = Client::start(fixture_root()); + let text = client.open(CLIENT); + let line = text.lines().count() as u32; + let added = "local veh = GetVehiclePedIsIn(PlayerPedId(), false)\nlocal dir = glm.normalize(vector3(1, 2, 3))\nprint(veh, dir, glm.pi)\nglm.quatLook\nlocal g = require 'glm'\nlocal zone = g.polygon.new({ vector3(0, 0, 0) })\nprint(zone:contains(vector3(0, 0, 0), 2), g.tointeger(1.0))\nveh."; + client.change(CLIENT, 2, &format!("{text}{added}")); + + let hover = client.hover_text(CLIENT, line, 7); + assert!(hover.contains("local veh: Vehicle"), "{hover}"); + let native = client.hover_text(CLIENT, line, 16); + assert!(native.contains("ped: Ped") && native.contains("): Vehicle"), "{native}"); + assert_eq!(client.completion_labels(CLIENT, line + 7, 4), Vec::::new(), "a handle has no members"); + + let normalize = client.hover_text(CLIENT, line + 1, 20); + assert!(normalize.contains("glm.normalize") && normalize.contains("length 1"), "{normalize}"); + assert!(client.hover_text(CLIENT, line + 2, 22).contains("number")); + assert!(client.completion_labels(CLIENT, line + 3, 12).contains(&"quatLookAt".to_string())); + + let zone = client.hover_text(CLIENT, line + 5, 7); + assert!(zone.contains("local zone: glm.polygon"), "require 'glm' is the built-in library: {zone}"); + let contains = client.hover_text(CLIENT, line + 6, 13); + assert!(contains.contains("thickness?: number") && contains.contains("boolean"), "{contains}"); + + let found = client.diagnostics_for(CLIENT); + assert!(!found.iter().any(|(code, l)| code == "undefined-global" && *l >= u64::from(line)), "{found:?}"); +} + +#[test] +fn exports_of_escrowed_resources_are_not_second_guessed() { + let mut client = Client::start(fixture_root()); + assert_eq!( + client.diagnostics_for("late/hidden.lua"), + [("fivem/unknown-export".to_string(), 1)], + "vault has an encrypted file that may register anything; mylib is fully readable" + ); +} + +#[test] +fn completes_resources_and_exports_in_both_spellings() { + let mut client = Client::start(fixture_root()); + let text = client.open(CLIENT); + let line = text.lines().count() as u32; + + client.change(CLIENT, 2, &format!("{text}exports['']")); + let mut resources = client.completion_labels(CLIENT, line, 9); + resources.sort(); + assert_eq!(resources, ["late", "mylib", "myresource", "shop", "vault"]); + + client.change(CLIENT, 3, &format!("{text}exports['mylib']:")); + let labels = client.completion_labels(CLIENT, line, 17); + assert!(labels.contains(&"GetPlayer".to_string()) && labels.contains(&"Ping".to_string()), "{labels:?}"); + + client.change(CLIENT, 4, &format!("{text}exports.mylib:GetPlayer(1)")); + let hover = client.hover_text(CLIENT, line, 16); + assert!(hover.contains("GetPlayer(source: integer)") && hover.contains("Looks a player up"), "{hover}"); +} + +#[test] +fn table_hover_lists_only_the_fields_in_scope() { + let mut client = Client::start(fixture_root()); + let text = client.open(CLIENT); + let (l, c) = pos(&text, "Config.SpawnDistance", 2); + let hover = client.hover_text(CLIENT, l, c); + let expected = "```lua\n(global) Config: {\n Debug: boolean = true,\n SpawnDistance: number = 25.0,\n Garages: table,\n isDebug: function,\n}\n```"; + assert!(hover.starts_with(expected), "{hover}"); + assert!(!hover.contains("ShopName"), "the shop resource has its own Config: {hover}"); + assert!(hover.contains("myresource/shared/config.lua"), "{hover}"); + + let (l, c) = pos(&text, "Config.SpawnDistance", 10); + assert!(client.hover_text(CLIENT, l, c).contains("(field) Config.SpawnDistance: number = 25.0")); + + let shop = client.open(SHOP_CLIENT); + client.change(SHOP_CLIENT, 2, &format!("{shop}print(Config)")); + let hover = client.hover_text(SHOP_CLIENT, shop.lines().count() as u32, 8); + assert!( + hover.contains("ShopName: string = 'General Store'") && hover.contains("OpenAtNight: boolean = false"), + "{hover}" + ); + assert!(!hover.contains("SpawnDistance"), "{hover}"); +} + +#[test] +fn escrow_encrypted_files_are_ignored() { + let mut client = Client::start(fixture_root()); + assert_eq!(client.diagnostics_for("vault/escrowed.lua"), [], "closed escrowed files are not linted"); + + client.open_with("vault/escrowed.lua", "FXAP\u{1}\u{fffd}\u{fffd}garbage((("); + assert_eq!(client.diagnostics_for("vault/escrowed.lua"), [], "nor are they when opened in the editor"); + + client.open_with(SHOP_CLIENT, &"local = = =\n".repeat(200)); + let found = client.diagnostics_for(SHOP_CLIENT); + assert_eq!(found.len(), 11, "syntax errors are capped at ten plus a summary: {found:?}"); +} + +fn framework_fixture_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/framework_callbacks") +} + +const FRAMEWORK_CLIENT: &str = "adapters/client.lua"; +const FRAMEWORK_IMPORTS: &str = + "local Core = exports['qb-core']:GetCoreObject()\nlocal Framework = exports.es_extended:getSharedObject()\nlocal QB = Core\nlocal ESX = Framework\n"; + +fn framework_definitions(client: &mut Client, relative: &str, text: &str, needle: &str) -> Value { + let (line, column) = pos(text, needle, 2); + client.request("textDocument/definition", client.position_params(relative, line, column)) +} + +fn framework_hints(client: &mut Client, relative: &str, line: u32) -> Vec { + let hints = client.request( + "textDocument/inlayHint", + json!({ "textDocument": { "uri": client.uri(relative) }, "range": { + "start": { "line": line, "character": 0 }, "end": { "line": line + 1, "character": 0 } + } }), + ); + hints.as_array().unwrap().iter().filter_map(|hint| hint["label"].as_str().map(str::to_owned)).collect() +} + +#[test] +fn framework_callbacks_keep_completion_and_navigation_in_their_own_family() { + let mut client = Client::start(framework_fixture_root()); + client.open_with(FRAMEWORK_CLIENT, ""); + for (version, (call, expected, definition)) in (2..).zip([ + ("QB.Functions.TriggerCallback", vec!["qb:guarded", "qb:only", "shared:call"], "adapters/server/qb.lua"), + ("ESX.TriggerServerCallback", vec!["esx:imported", "esx:only", "shared:call"], "adapters/server/esx.lua"), + ("lib.callback.await", vec!["ox:only", "shared:call"], "adapters/server/other.lua"), + ("TriggerServerEvent", vec!["native:only", "shared:call"], "adapters/server/other.lua"), + ]) { + let text = format!("{FRAMEWORK_IMPORTS}{call}('')\n{call}('shared:call', function() end, 1, 2)\n"); + client.change(FRAMEWORK_CLIENT, version, &text); + let (line, column) = pos(&text, "('')", 2); + let mut labels = client.completion_labels(FRAMEWORK_CLIENT, line, column); + labels.sort(); + assert_eq!(labels, expected, "{call}"); + + let found = framework_definitions(&mut client, FRAMEWORK_CLIENT, &text, "shared:call"); + let locations = found.as_array().unwrap(); + assert_eq!(locations.len(), 1, "{call}: {found}"); + assert_eq!(locations[0]["uri"], client.uri(definition).as_str(), "{call}: {found}"); + let source = std::fs::read_to_string(client.root.join(definition)).unwrap(); + let registration = + if call == "TriggerServerEvent" { "RegisterNetEvent('shared:call'" } else { "'shared:call'" }; + let (registration_line, _) = pos(&source, registration, 0); + assert_eq!(locations[0]["range"]["start"]["line"], registration_line, "{call}: {found}"); + } +} + +#[test] +fn framework_callbacks_show_typed_payloads_without_source_response_or_async_returns() { + let mut client = Client::start(framework_fixture_root()); + let text = client.open(FRAMEWORK_CLIENT); + for (call, value, family, payload, other_payload, expected_label, handler_file) in [ + ( + "QB.Functions.TriggerCallback", + "'water'", + "QB-Core callback", + "qbItem", + "esxVehicle", + "QB.Functions.TriggerCallback(name: string, cb: function, qbItem: string, qbAmount: integer)", + "qb.lua", + ), + ( + "ESX.TriggerServerCallback", + "42", + "ESX callback", + "esxVehicle", + "qbItem", + "ESX.TriggerServerCallback(name: string, cb: function, esxVehicle: number, esxDepot: string)", + "esx.lua", + ), + ] { + let call_start = text.find(call).unwrap(); + let call_text = + &text[call_start..text[call_start..].find('\n').map(|end| call_start + end).unwrap_or(text.len())]; + let (line, column) = pos(&text, call_text, call_text.find(value).unwrap() as u32 + 1); + let result = + client.request("textDocument/signatureHelp", client.position_params(FRAMEWORK_CLIENT, line, column)); + assert_eq!(result["signatures"][0]["label"], expected_label, "{result}"); + assert_eq!(result["activeParameter"], 2, "{result}"); + let note = result["signatures"][0]["documentation"]["value"].as_str().unwrap_or_default(); + assert!(note.contains(handler_file), "{note}"); + let name_column = call_text.find("shared:call").unwrap() as u32 + 2; + let hover = client.hover_text(FRAMEWORK_CLIENT, line, name_column); + assert!(hover.contains(family) && hover.contains(payload) && hover.contains(handler_file), "{hover}"); + assert!(!hover.contains(other_payload) && !hover.contains("source: integer"), "{hover}"); + assert!(hover.to_lowercase().contains("asynchronous"), "{hover}"); + let hints = framework_hints(&mut client, FRAMEWORK_CLIENT, line); + assert!(hints.contains(&format!("{payload}:")), "{hints:?}"); + assert!(!hints.contains(&"source:".to_string()) && !hints.contains(&format!("{other_payload}:")), "{hints:?}"); + } + for (call, expected, forbidden) in + [("lib.callback.await", "oxPayload:", "nativePayload:"), ("TriggerServerEvent", "nativePayload:", "oxPayload:")] + { + let (line, _) = pos(&text, call, 0); + let hints = framework_hints(&mut client, FRAMEWORK_CLIENT, line); + assert!(hints.contains(&expected.to_string()) && !hints.contains(&forbidden.to_string()), "{hints:?}"); + assert!(!hints.iter().any(|hint| hint.starts_with("qb") || hint.starts_with("esx")), "{hints:?}"); + } +} + +#[test] +fn framework_callbacks_complete_whole_strings_for_minimal_and_snippet_clients() { + for snippets in [false, true] { + let mut client = Client::start_with_capabilities( + framework_fixture_root(), + json!({ "textDocument": { "completion": { "completionItem": { "snippetSupport": snippets } } } }), + ); + client.open_with(FRAMEWORK_CLIENT, ""); + for (version, (call, prefix, expected)) in (2..).zip([ + ("QB.Functions.TriggerCallback", "qb:", "qb:only"), + ("ESX.TriggerServerCallback", "esx:", "esx:only"), + ]) { + let head = format!("local emoji = '🚗'; {call}('"); + let text = format!("{FRAMEWORK_IMPORTS}{head}{prefix}stale', function() end)"); + let line = FRAMEWORK_IMPORTS.lines().count() as u32; + let start = head.encode_utf16().count() as u32; + client.change(FRAMEWORK_CLIENT, version, &text); + let result = client.request( + "textDocument/completion", + client.position_params(FRAMEWORK_CLIENT, line, start + prefix.len() as u32), + ); + let item = result["items"].as_array().unwrap().iter().find(|item| item["label"] == expected).unwrap(); + assert_eq!( + item["textEdit"], + json!({ "range": { + "start": { "line": line, "character": start }, + "end": { "line": line, "character": start + prefix.len() as u32 + 5 } + }, "newText": expected }), + "{result}" + ); + assert!(item["insertTextFormat"].is_null(), "literal event names are not snippets: {item}"); + } + } +} + +#[test] +fn framework_callbacks_require_proven_unmodified_roots_and_the_name_argument() { + let mut client = Client::start(framework_fixture_root()); + client.open_with(FRAMEWORK_CLIENT, ""); + let cases = [ + "local QB = {}\nQB.Functions.TriggerCallback('qb:only', function() end, 7)", + "local function demo(QB)\nQB.Functions.TriggerCallback('qb:only', function() end, 7)\nend", + "local QB = exports['qb-core']:GetCoreObject()\nQB = {}\nQB.Functions.TriggerCallback('qb:only', function() end, 7)", + "local QB = exports['qb-core']:GetCoreObject()\nQB.Functions.TriggerCallback = function() end\nQB.Functions.TriggerCallback('qb:only', function() end, 7)", + "local exports = {}\nlocal QB = exports['qb-core']:GetCoreObject()\nQB.Functions.TriggerCallback('qb:only', function() end, 7)", + "_G.exports = {}\nlocal QB = exports['qb-core']:GetCoreObject()\nQB.Functions.TriggerCallback('qb:only', function() end, 7)", + "_ENV['exports'] = {}\nlocal QB = exports['qb-core']:GetCoreObject()\nQB.Functions.TriggerCallback('qb:only', function() end, 7)", + "_G.exports['qb-core'].GetCoreObject = function() return {} end\nlocal QB = exports['qb-core']:GetCoreObject()\nQB.Functions.TriggerCallback('qb:only', function() end, 7)", + "local _ENV = {}\nlocal QB = exports['qb-core']:GetCoreObject()\nQB.Functions.TriggerCallback('qb:only', function() end, 7)", + "local QB = exports['qb-core']:GetCoreObject('subset')\nQB.Functions.TriggerCallback('qb:only', function() end, 7)", + "local QB = exports['qb-core']:GetCoreObject()\nQB.Functions:TriggerCallback('qb:only', function() end, 7)", + "local QB = exports['qb-core']:GetCoreObject()\nQB.Functions.TriggerCallback('missing', function() end, 'qb:only')", + "local QB = exports['qb-core']:GetCoreObject()\nlocal name = 'qb:only'\nQB.Functions.TriggerCallback(name, function() end, 7)", + "local ESX = {}\nESX.TriggerServerCallback('esx:only', function() end, 7)", + "local ESX = exports.es_extended:getSharedObject()\nESX.TriggerServerCallback = function() end\nESX.TriggerServerCallback('esx:only', function() end, 7)", + "local Core = exports.es_extended:getSharedObject()\nlocal ESX = Core\nCore.TriggerServerCallback = function() end\nESX.TriggerServerCallback('esx:only', function() end, 7)", + "local ESX = exports.es_extended:getSharedObject()\nESX:TriggerServerCallback('esx:only', function() end, 7)", + "print('qb:only')", + ]; + for (version, text) in (2..).zip(cases) { + client.change(FRAMEWORK_CLIENT, version, text); + let needle = if text.contains("qb:only") { "qb:only" } else { "esx:only" }; + let (line, column) = pos(text, needle, 2); + let labels = client.completion_labels(FRAMEWORK_CLIENT, line, column); + assert!(!labels.contains(&needle.to_string()), "{text}: {labels:?}"); + let found = framework_definitions(&mut client, FRAMEWORK_CLIENT, text, needle); + assert!(found.is_null() || found.as_array().is_some_and(Vec::is_empty), "{text}: {found}"); + let hover = client.hover_text(FRAMEWORK_CLIENT, line, column); + assert!(!hover.contains("QB-Core callback") && !hover.contains("ESX callback"), "{text}: {hover}"); + let signature = + client.request("textDocument/signatureHelp", client.position_params(FRAMEWORK_CLIENT, line, column)); + let rendered = signature.to_string(); + assert!(!rendered.contains("qbUnique") && !rendered.contains("esxUnique"), "{text}: {signature}"); + } + + client.open_with("imported_esx/client.lua", ""); + for (version, mutation) in (2..).zip([ + "_G.ESX = {}", + "_ENV['ESX'] = {}", + "_G.ESX.TriggerServerCallback = function() end", + "_ENV['ESX']['TriggerServerCallback'] = function() end", + ]) { + let text = format!("{mutation}\nESX.TriggerServerCallback('esx:imported', function() end, 7)"); + client.change("imported_esx/client.lua", version, &text); + let (line, column) = pos(&text, "esx:imported", 2); + let labels = client.completion_labels("imported_esx/client.lua", line, column); + assert!(!labels.contains(&"esx:imported".to_string()), "{text}: {labels:?}"); + let found = framework_definitions(&mut client, "imported_esx/client.lua", &text, "esx:imported"); + assert!(found.is_null() || found.as_array().is_some_and(Vec::is_empty), "{text}: {found}"); + } +} + +#[test] +fn framework_callbacks_honor_server_registration_client_trigger_and_shared_guards() { + let mut client = Client::start(framework_fixture_root()); + let shared = client.open("adapters/shared.lua"); + let (line, column) = pos(&shared, "'guarded'", 2); + let signature = + client.request("textDocument/signatureHelp", client.position_params("adapters/shared.lua", line, column)); + assert!(signature["signatures"][0]["label"].as_str().unwrap_or_default().contains("guardedPayload"), "{signature}"); + + let imported = client.open("imported_esx/client.lua"); + let definitions = framework_definitions(&mut client, "imported_esx/client.lua", &imported, "esx:imported"); + assert_eq!(definitions[0]["uri"], client.uri("imported_esx/server.lua").as_str(), "{definitions}"); + let (line, column) = pos(&imported, "'imported'", 2); + let signature = + client.request("textDocument/signatureHelp", client.position_params("imported_esx/client.lua", line, column)); + assert!( + signature["signatures"][0]["label"].as_str().unwrap_or_default().contains("importedPayload"), + "{signature}" + ); + + client.open_with(FRAMEWORK_CLIENT, ""); + let wrong_registration = format!("{FRAMEWORK_IMPORTS}QB.Functions.CreateCallback('qb:client-invalid', function(source, cb, badPayload) end)\nESX.RegisterServerCallback('esx:client-invalid', function(source, cb, badPayload) end)\nQB.Functions.TriggerCallback('')\nESX.TriggerServerCallback('')"); + client.change(FRAMEWORK_CLIENT, 2, &wrong_registration); + for call in ["QB.Functions.TriggerCallback('')", "ESX.TriggerServerCallback('')"] { + let (line, column) = pos(&wrong_registration, call, call.find("''").unwrap() as u32 + 1); + let labels = client.completion_labels(FRAMEWORK_CLIENT, line, column); + assert!(!labels.iter().any(|label| label.ends_with("client-invalid")), "{labels:?}"); + } + for (relative, text) in [ + ("adapters/server/wrongside.lua", format!("{FRAMEWORK_IMPORTS}QB.Functions.TriggerCallback('qb:only', function() end, 7)")), + ("adapters/shared.lua", format!("{FRAMEWORK_IMPORTS}QB.Functions.CreateCallback('qb:unguarded', function(source, cb, badPayload) end)\nQB.Functions.TriggerCallback('qb:only', function() end, 7)")), + ] { + if relative == "adapters/shared.lua" { client.change(relative, 2, &text); } else { client.open_with(relative, &text); } + let (line, column) = pos(&text, "qb:only", 2); + let labels = client.completion_labels(relative, line, column); + assert!(!labels.contains(&"qb:only".to_string()), "{relative}: {labels:?}"); + let found = framework_definitions(&mut client, relative, &text, "qb:only"); + assert!(found.is_null() || found.as_array().is_some_and(Vec::is_empty), "{relative}: {found}"); + let signature = client.request("textDocument/signatureHelp", client.position_params(relative, line, column)); + assert!(!signature.to_string().contains("qbUnique"), "{relative}: {signature}"); + } +} + +#[test] +fn framework_callbacks_refresh_unsaved_registration_names_and_payloads() { + let mut client = Client::start(framework_fixture_root()); + let original = client.open("adapters/server/qb.lua"); + let client_text = format!("{FRAMEWORK_IMPORTS}QB.Functions.TriggerCallback('qb:renamed', function() end, 'item', 3)\nQB.Functions.TriggerCallback('')"); + client.open_with(FRAMEWORK_CLIENT, &client_text); + let (line, column) = pos(&client_text, "('')", 2); + assert!(client.completion_labels(FRAMEWORK_CLIENT, line, column).contains(&"qb:only".to_string())); + let changed = original.replace("qb:only", "qb:renamed").replace("qbUnique", "freshPayload"); + client.change("adapters/server/qb.lua", 2, &changed); + let labels = client.completion_labels(FRAMEWORK_CLIENT, line, column); + assert!(labels.contains(&"qb:renamed".to_string()) && !labels.contains(&"qb:only".to_string()), "{labels:?}"); + let (call_line, call_column) = pos(&client_text, "'item'", 2); + let signature = + client.request("textDocument/signatureHelp", client.position_params(FRAMEWORK_CLIENT, call_line, call_column)); + assert!(signature["signatures"][0]["label"].as_str().unwrap_or_default().contains("freshPayload"), "{signature}"); + let definitions = framework_definitions(&mut client, FRAMEWORK_CLIENT, &client_text, "qb:renamed"); + assert_eq!(definitions[0]["uri"], client.uri("adapters/server/qb.lua").as_str(), "{definitions}"); + + client.change("adapters/server/qb.lua", 3, "local QB = exports['qb-core']:GetCoreObject()\nlocal name = 'qb:renamed'\nQB.Functions.CreateCallback(name, function(source, cb, shouldNotInfer) end)\n"); + let labels = client.completion_labels(FRAMEWORK_CLIENT, line, column); + assert!(!labels.contains(&"qb:renamed".to_string()) && !labels.contains(&"qb:only".to_string()), "{labels:?}"); + let signature = + client.request("textDocument/signatureHelp", client.position_params(FRAMEWORK_CLIENT, call_line, call_column)); + assert!( + !signature.to_string().contains("freshPayload") && !signature.to_string().contains("shouldNotInfer"), + "{signature}" + ); +} + +#[test] +fn framework_callbacks_do_not_choose_between_conflicting_handler_payloads() { + let mut client = Client::start(framework_fixture_root()); + client.open_with("adapters/server/conflict.lua", "local QB = exports['qb-core']:GetCoreObject()\nQB.Functions.CreateCallback('shared:call', function(source, cb, conflictingPayload) end)\n"); + let text = client.open(FRAMEWORK_CLIENT); + let definitions = framework_definitions(&mut client, FRAMEWORK_CLIENT, &text, "shared:call"); + assert_eq!(definitions.as_array().unwrap().len(), 2, "{definitions}"); + let (line, column) = pos(&text, "'water'", 2); + let signature = + client.request("textDocument/signatureHelp", client.position_params(FRAMEWORK_CLIENT, line, column)); + assert!( + !signature.to_string().contains("qbItem") && !signature.to_string().contains("conflictingPayload"), + "{signature}" + ); + let hints = framework_hints(&mut client, FRAMEWORK_CLIENT, line); + assert!( + !hints.contains(&"qbItem:".to_string()) && !hints.contains(&"conflictingPayload:".to_string()), + "{hints:?}" + ); + + client.change( + "adapters/server/conflict.lua", + 2, + "local QB = exports['qb-core']:GetCoreObject()\nQB.Functions.CreateCallback('shared:call', unknownHandler)\n", + ); + let definitions = framework_definitions(&mut client, FRAMEWORK_CLIENT, &text, "shared:call"); + assert_eq!( + definitions.as_array().unwrap().len(), + 2, + "an unresolved handler still has a registration: {definitions}" + ); + let signature = + client.request("textDocument/signatureHelp", client.position_params(FRAMEWORK_CLIENT, line, column)); + assert!( + !signature.to_string().contains("qbItem"), + "an unresolved duplicate must not select the known handler: {signature}" + ); + let hints = framework_hints(&mut client, FRAMEWORK_CLIENT, line); + assert!(!hints.contains(&"qbItem:".to_string()), "{hints:?}"); + + let (name_line, name_column) = pos(&text, "shared:call", 2); + let completion = + client.request("textDocument/completion", client.position_params(FRAMEWORK_CLIENT, name_line, name_column)); + let item = completion["items"].as_array().unwrap().iter().find(|item| item["label"] == "shared:call").unwrap(); + let detail = item["detail"].as_str().unwrap_or_default(); + assert!(!detail.contains("qbItem") && detail.contains("Multiple handlers"), "{item}"); +} diff --git a/crates/qbx_lua_ls/tests/nui_resource.rs b/crates/qbx_lua_ls/tests/nui_resource.rs new file mode 100644 index 0000000..3ce7947 --- /dev/null +++ b/crates/qbx_lua_ls/tests/nui_resource.rs @@ -0,0 +1,314 @@ +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::thread::JoinHandle; +use std::time::Duration; + +use lsp_server::{Connection, Message, Notification, Request, RequestId, Response}; +use lsp_types::Url; +use qbx_fivem_data::Side; +use qbx_lua_analysis::{manifest::Manifest, scope::resolve}; +use qbx_lua_ls::features::nui_resource::resource; +use qbx_lua_ls::features::resource_details::DetailsParams; +use qbx_lua_ls::index::{FileEntry, FileOrigin, Index, ResourceEntry}; +use qbx_lua_ls::indexer::index_file; +use qbx_lua_syntax::parse; +use serde_json::{json, Value}; + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let root = std::env::temp_dir().join(format!( + "qbx-nui-resource-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&root).unwrap(); + Self(root) + } + + fn write(&self, relative: &str, text: &str) { + let path = self.0.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + + fn manifest(&self, relative: &str, text: &str) { + self.write(&format!("{relative}/fxmanifest.lua"), &format!("fx_version 'cerulean'\ngame 'gta5'\n{text}")); + } + + fn uri(&self, relative: &str) -> Url { + Url::from_file_path(self.0.join(relative)).unwrap() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + assert!(self.0.starts_with(std::env::temp_dir())); + assert!(self.0.file_name().unwrap().to_string_lossy().starts_with("qbx-nui-resource-")); + std::fs::remove_dir_all(&self.0).unwrap(); + } +} + +struct Client { + connection: Connection, + server: Option>, + next: i32, +} + +impl Client { + fn start(root: &Path) -> Self { + let (server_connection, connection) = Connection::memory(); + let server = std::thread::Builder::new() + .stack_size(16 * 1024 * 1024) + .spawn(move || qbx_lua_ls::server::run_connection(server_connection).unwrap()) + .unwrap(); + let mut client = Self { connection, server: Some(server), next: 0 }; + let uri = Url::from_file_path(root).unwrap(); + client.request( + "initialize", + json!({"processId":null,"rootUri":uri,"capabilities":{}, + "initializationOptions":{"diagnostics":{"enable":false,"workspace":false}}, + "workspaceFolders":[{"uri":uri,"name":"NUI fixture"}]}), + ); + client.notify("initialized", json!({})); + client + } + + fn notify(&self, method: &str, params: Value) { + self.connection.sender.send(Message::Notification(Notification { method: method.into(), params })).unwrap(); + } + + fn response(&mut self, method: &str, params: Value) -> Response { + self.next += 1; + let id = RequestId::from(self.next); + self.connection + .sender + .send(Message::Request(Request { id: id.clone(), method: method.into(), params })) + .unwrap(); + loop { + match self.connection.receiver.recv_timeout(Duration::from_secs(20)).expect("server did not reply") { + Message::Response(response) if response.id == id => return response, + Message::Request(request) => self + .connection + .sender + .send(Message::Response(Response { id: request.id, result: Some(Value::Null), error: None })) + .unwrap(), + _ => {} + } + } + } + + fn request(&mut self, method: &str, params: Value) -> Value { + let response = self.response(method, params); + assert!(response.error.is_none(), "{:?}", response.error); + response.result.unwrap_or(Value::Null) + } + + fn nui(&mut self, uri: Url) -> Value { + self.request("qbx/nuiResource", json!({"uri":uri})) + } +} + +impl Drop for Client { + fn drop(&mut self) { + self.request("shutdown", Value::Null); + self.notify("exit", Value::Null); + self.server.take().unwrap().join().unwrap(); + } +} + +fn names(value: &Value) -> Vec<&str> { + value["callbacks"].as_array().unwrap().iter().map(|item| item["name"].as_str().unwrap()).collect() +} + +#[test] +fn protocol_lists_literal_callbacks_with_utf16_ranges_and_client_sides() { + let fixture = Fixture::new(); + fixture.manifest("app", "ui_page 'web/index.html'\nclient_script 'client.lua'\nshared_script 'shared.lua'\nserver_script 'server.lua'\nshared_script '@lib/shared.lua'\n"); + let source = "local marker = '😀'; RegisterNUICallback('get☃', function(data, cb) cb({}) end)\nRegisterNuiCallback('modern', function() end)\nRegisterNUICallback('duplicate', function() end)\nRegisterNUICallback('duplicate', function() end)\nRegisterNUICallback(dynamic, function() end)\nRegisterNUICallback('missingHandler')\nRegisterNetEvent('ordinary')\n"; + fixture.write("app/client.lua", source); + fixture.write("app/shared.lua", "RegisterNUICallback('shared', function() end)\nif IsDuplicityVersion() then\n RegisterNUICallback('wrongSharedSide', function() end)\nelse\n RegisterNUICallback('guardedClient', function() end)\nend\n"); + fixture.write("app/server.lua", "RegisterNUICallback = function() end\nRegisterNUICallback('wrongServerSide', function() end)\nif not IsDuplicityVersion() then RegisterNUICallback('unreachableClient', function() end) end\n"); + fixture.write("app/module.lua", "RegisterNUICallback('unknownSide', function() end)\n"); + fixture.manifest("lib", "shared_script 'shared.lua'\n"); + fixture.write("lib/shared.lua", "RegisterNUICallback('foreign', function() end)\n"); + let mut client = Client::start(&fixture.0); + let status = client.request("qbx/status", Value::Null); + let result = client.nui(fixture.uri("app")); + assert_eq!(result, client.nui(fixture.uri("app/fxmanifest.lua"))); + assert_eq!(result["uiPage"], "web/index.html"); + assert_eq!(result["resource"]["uri"], json!(fixture.uri("app"))); + assert_eq!(names(&result), ["duplicate", "duplicate", "get☃", "guardedClient", "modern", "shared"]); + let found = result["callbacks"].as_array().unwrap().iter().find(|item| item["name"] == "get☃").unwrap(); + let offset = source.find("'get☃'").unwrap(); + let start = source[..offset].encode_utf16().count(); + assert_eq!(found["location"]["uri"], json!(fixture.uri("app/client.lua"))); + assert_eq!( + found["location"]["range"], + json!({"start":{"line":0,"character":start},"end":{"line":0,"character":start+6}}) + ); + assert_eq!(result["truncated"], 0); + let details = client.request("qbx/resourceDetails", json!({"uri":fixture.uri("app")})); + assert_eq!(details["counts"]["events"], 1, "NUI callbacks must not become network events"); + assert_eq!(status, client.request("qbx/status", Value::Null)); + fixture.write("app/new.lua", "RegisterNUICallback('notYetIndexed', function() end)\n"); + assert_eq!(result, client.nui(fixture.uri("app")), "requests must not scan or parse new files"); +} + +#[test] +fn rejects_shadowed_redefined_qualified_and_dynamic_registrations() { + let rejected = [ + "local RegisterNUICallback = function() end; RegisterNUICallback('x', function() end)", + "local function RegisterNUICallback() end; RegisterNUICallback('x', function() end)", + "local function run(RegisterNUICallback) RegisterNUICallback('x', function() end) end", + "local _ENV = {}; RegisterNUICallback('x', function() end)", + "_ENV = {}; RegisterNUICallback('x', function() end)", + "RegisterNUICallback = function() end; RegisterNUICallback('x', function() end)", + "RegisterNUICallback('x', function() end); RegisterNUICallback = function() end", + "_G.RegisterNUICallback = function() end; RegisterNUICallback('x', function() end)", + "_ENV['RegisterNUICallback'] = function() end; RegisterNUICallback('x', function() end)", + "function _G.RegisterNUICallback() end; RegisterNUICallback('x', function() end)", + "_G[key] = function() end; RegisterNUICallback('x', function() end)", + "object.RegisterNUICallback('x', function() end)", + "object:RegisterNUICallback('x', function() end)", + "local callback = RegisterNUICallback; callback('x', function() end)", + "RegisterNUICallback('x' .. 'y', function() end)", + "RegisterNUICallback('', function() end)", + "local RegisterNuiCallback = function() end; RegisterNuiCallback('x', function() end)", + ]; + for source in rejected { + let chunk = parse(source); + assert!(chunk.errors.is_empty(), "{source}"); + let resolution = resolve(&chunk); + let file = index_file(0, source, &chunk, &resolution, &Index::default(), Some(Side::Client)); + assert!(file.nui_callbacks.is_empty(), "{source}"); + } + let source = "do local RegisterNUICallback = function() end end\nRegisterNUICallback('outside', function() end)"; + let chunk = parse(source); + let file = index_file(0, source, &chunk, &resolve(&chunk), &Index::default(), Some(Side::Client)); + assert_eq!(file.nui_callbacks[0].name, "outside"); +} + +#[test] +fn unsaved_callback_and_other_file_global_changes_refresh_without_rescanning() { + let fixture = Fixture::new(); + fixture.manifest("app", "client_scripts {'client.lua','override.lua'}\nui_page 'web/index.html'\n"); + fixture.write( + "app/client.lua", + "RegisterNUICallback('old', function() end)\nRegisterNuiCallback('modern', function() end)\n", + ); + fixture.write("app/override.lua", "-- empty\n"); + let mut client = Client::start(&fixture.0); + let target = fixture.uri("app"); + let uri = fixture.uri("app/override.lua"); + client.notify("textDocument/didOpen", json!({"textDocument":{"uri":uri,"languageId":"lua","version":1,"text":"_G.RegisterNUICallback = function() end\n"}})); + assert_eq!(names(&client.nui(target.clone())), ["modern"]); + client.notify( + "textDocument/didChange", + json!({"textDocument":{"uri":uri,"version":2},"contentChanges":[{"text":"_ENV = {}\n"}]}), + ); + assert!(names(&client.nui(target.clone())).is_empty()); + client.notify( + "textDocument/didChange", + json!({"textDocument":{"uri":uri,"version":3},"contentChanges":[{"text":"-- restored\n"}]}), + ); + assert_eq!(names(&client.nui(target.clone())), ["modern", "old"]); + let client_uri = fixture.uri("app/client.lua"); + client.notify("textDocument/didOpen", json!({"textDocument":{"uri":client_uri,"languageId":"lua","version":1,"text":"RegisterNUICallback('unsaved', function() end)\n"}})); + assert_eq!(names(&client.nui(target.clone())), ["unsaved"]); + client.notify("textDocument/didChange", json!({"textDocument":{"uri":client_uri,"version":2},"contentChanges":[{"text":"local RegisterNUICallback = function() end\nRegisterNUICallback('shadowed', function() end)"}]})); + assert!(names(&client.nui(target.clone())).is_empty()); + client.notify("textDocument/didClose", json!({"textDocument":{"uri":client_uri}})); + assert_eq!(names(&client.nui(target)), ["modern", "old"]); +} + +#[test] +fn literal_page_metadata_preserves_remote_paths_and_updates_only_after_save() { + let fixture = Fixture::new(); + fixture.manifest("app", "ui_page 'web/index.html'\n"); + let mut client = Client::start(&fixture.0); + let uri = fixture.uri("app/fxmanifest.lua"); + client.notify( + "textDocument/didOpen", + json!({"textDocument":{"uri":uri,"languageId":"lua","version":1,"text":"ui_page 'unsaved.html'\n"}}), + ); + assert_eq!(client.nui(fixture.uri("app"))["uiPage"], "web/index.html"); + for (manifest, expected) in [ + ("ui_page 'https://example.invalid/ui?view=full#app'", json!("https://example.invalid/ui?view=full#app")), + ("ui_page 'nui://another-resource/index.html'", json!("nui://another-resource/index.html")), + ("ui_page prefix .. '/index.html'", Value::Null), + ("-- no ui_page", Value::Null), + ] { + fixture.manifest("app", manifest); + client.notify("textDocument/didSave", json!({"textDocument":{"uri":uri}})); + assert_eq!(client.nui(fixture.uri("app"))["uiPage"], expected); + } +} + +#[test] +fn rejects_invalid_uris_and_selects_duplicate_names_by_folder_identity() { + let fixture = Fixture::new(); + fixture.manifest("[a]/duplicate", "ui_page 'a.html'"); + fixture.manifest("[b]/duplicate", "ui_page 'b.html'"); + fixture.write("[a]/duplicate/client.lua", "return {}\n"); + let mut client = Client::start(&fixture.0); + assert_eq!(client.nui(fixture.uri("[a]/duplicate"))["uiPage"], "a.html"); + assert_eq!(client.nui(fixture.uri("[b]/duplicate/fxmanifest.lua"))["uiPage"], "b.html"); + for params in [ + Value::Null, + json!([]), + json!({}), + json!({"uri":7}), + json!({"uri":"bad"}), + json!({"uri":"https://example.invalid/app"}), + json!({"uri":fixture.uri("unknown")}), + json!({"uri":fixture.uri("[a]/duplicate/client.lua")}), + json!({"uri":format!("{}#fragment",fixture.uri("[a]/duplicate"))}), + ] { + assert_eq!(client.response("qbx/nuiResource", params).error.unwrap().code, -32602); + } +} + +#[test] +fn caps_callback_rows_and_omits_oversized_action_values_without_modifying_them() { + let fixture = Fixture::new(); + let root = fixture.0.join("app"); + let mut index = Index::default(); + let page = "x".repeat(16_385); + index.resources.push(ResourceEntry { + name: "app".into(), + root: root.clone(), + manifest_path: root.join("fxmanifest.lua"), + manifest: Manifest::from_chunk(&parse(&format!("ui_page '{page}'"))), + files: Vec::new(), + imports: Vec::new(), + escrowed: false, + }); + let source = (0..503).map(|i| format!("RegisterNUICallback('cb{i:03}', function() end)\n")).collect::() + + &format!("RegisterNUICallback('{}', function() end)\n", "α".repeat(2049)); + let chunk = parse(&source); + let path = root.join("client.lua"); + let id = index.allocate(&path); + let callbacks = index_file(id, &source, &chunk, &resolve(&chunk), &index, Some(Side::Client)); + index.set_file( + id, + FileEntry { + uri: Url::from_file_path(&path).unwrap(), + path, + origin: FileOrigin::Workspace, + resource: Some(0), + side: Some(Side::Client), + index: callbacks, + }, + ); + let result = resource(&index, DetailsParams { uri: fixture.uri("app") }).unwrap(); + assert_eq!(result.callbacks.len(), 500); + assert_eq!(result.truncated, 4); + assert_eq!(result.callbacks[0].name, "cb000"); + assert_eq!(result.callbacks[499].name, "cb499"); + assert!(result.ui_page.is_none()); + assert!(result.notes.iter().any(|note| note.contains("16,384 bytes"))); + assert!(result.notes.iter().any(|note| note.contains("1 callback names longer"))); +} diff --git a/crates/qbx_lua_ls/tests/resource_details.rs b/crates/qbx_lua_ls/tests/resource_details.rs new file mode 100644 index 0000000..8ff2df1 --- /dev/null +++ b/crates/qbx_lua_ls/tests/resource_details.rs @@ -0,0 +1,392 @@ +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::thread::JoinHandle; +use std::time::Duration; + +use lsp_server::{Connection, Message, Notification, Request, RequestId, Response}; +use lsp_types::{Position, Range, Url}; +use qbx_lua_analysis::manifest::Manifest; +use qbx_lua_ls::features::resource_details::{details, DetailsParams}; +use qbx_lua_ls::index::{ + EventDef, EventFamily, EventKind, FileEntry, FileIndex, FileOrigin, Index, ResourceEntry, Symbol, SymbolKind, +}; +use qbx_lua_ls::types::Type; +use serde_json::{json, Value}; + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let root = std::env::temp_dir().join(format!( + "qbx-resource-details-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&root).unwrap(); + Self(root) + } + + fn write(&self, relative: &str, text: &str) { + let path = self.0.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + + fn resource(&self, relative: &str, manifest: &str) { + self.write(&format!("{relative}/fxmanifest.lua"), &format!("fx_version 'cerulean'\ngame 'gta5'\n{manifest}")); + } + + fn uri(&self, relative: &str) -> Url { + Url::from_file_path(self.0.join(relative)).unwrap() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + assert!(self.0.starts_with(std::env::temp_dir())); + assert!(self.0.file_name().unwrap().to_string_lossy().starts_with("qbx-resource-details-")); + std::fs::remove_dir_all(&self.0).unwrap(); + } +} + +struct Client { + connection: Connection, + server: Option>, + next: i32, +} + +impl Client { + fn start(root: &Path) -> Self { + let (server_connection, connection) = Connection::memory(); + let server = std::thread::Builder::new() + .stack_size(16 * 1024 * 1024) + .spawn(move || qbx_lua_ls::server::run_connection(server_connection).unwrap()) + .unwrap(); + let mut client = Self { connection, server: Some(server), next: 0 }; + let uri = Url::from_file_path(root).unwrap(); + client.request( + "initialize", + json!({ + "processId": null, "rootUri": uri, "capabilities": {}, + "initializationOptions": {"diagnostics":{"enable":false,"workspace":false}}, + "workspaceFolders": [{"uri":uri,"name":"details fixture"}] + }), + ); + client.notify("initialized", json!({})); + client + } + + fn notify(&self, method: &str, params: Value) { + self.connection.sender.send(Message::Notification(Notification { method: method.into(), params })).unwrap(); + } + + fn response(&mut self, method: &str, params: Value) -> Response { + self.next += 1; + let id = RequestId::from(self.next); + self.connection + .sender + .send(Message::Request(Request { id: id.clone(), method: method.into(), params })) + .unwrap(); + loop { + match self.connection.receiver.recv_timeout(Duration::from_secs(20)).expect("server did not reply") { + Message::Response(response) if response.id == id => return response, + Message::Request(request) => self + .connection + .sender + .send(Message::Response(Response { id: request.id, result: Some(Value::Null), error: None })) + .unwrap(), + _ => {} + } + } + } + + fn request(&mut self, method: &str, params: Value) -> Value { + let response = self.response(method, params); + assert!(response.error.is_none(), "{:?}", response.error); + response.result.unwrap_or(Value::Null) + } + + fn details(&mut self, uri: Url) -> Value { + self.request("qbx/resourceDetails", json!({"uri":uri})) + } +} + +impl Drop for Client { + fn drop(&mut self) { + self.request("shutdown", Value::Null); + self.notify("exit", Value::Null); + self.server.take().unwrap().join().unwrap(); + } +} + +fn sample() -> Fixture { + let fixture = Fixture::new(); + fixture.resource("app", "shared_scripts {'shared.lua','@lib/init.lua'}\nclient_script 'client.lua'\nserver_script 'server.lua'\ndependencies {'lib','missing','duplicate','/server:7290','/onesync'}\nexport 'ManifestOnly'\n"); + fixture.write("app/shared.lua", "Config = {}\n"); + fixture.write("app/module.lua", "return {}\n"); + fixture.write("app/client.lua", "RegisterNetEvent('app:client', function() end)\nTriggerServerEvent('app:trigger')\nexports('ClientThing', function(value) return value end)\n"); + fixture.write("app/server.lua", "RegisterNetEvent('app:server', function(source) end)\nAddEventHandler('app:local', function(value) end)\nlib.callback.register('app:ox', function(source, count) end)\nlocal QB = exports['qb-core']:GetCoreObject()\nQB.Functions.CreateCallback('app:qb', function(source, cb, item) end)\nlocal ESX = exports.es_extended:getSharedObject()\nESX.RegisterServerCallback('app:esx', function(source, cb, item) end)\nexports('ServerThing', function(source) return source end)\nexports(computed, function() end)\n"); + fixture.resource("lib", "shared_script 'init.lua'\n"); + fixture.write("lib/init.lua", "lib = {}\nexports('LibraryThing', function() end)\n"); + fixture.resource("[a]/duplicate", ""); + fixture.resource("[b]/duplicate", ""); + fixture.resource("consumer", "dependency 'app'\nshared_script '@app/shared.lua'\n"); + fixture.resource("replacement", "provide 'missing'\n"); + fixture +} + +#[test] +fn protocol_lists_owned_files_registrations_exports_and_relations() { + let fixture = sample(); + let mut client = Client::start(&fixture.0); + let before = client.request("qbx/status", Value::Null); + let result = client.details(fixture.uri("app")); + assert_eq!(result, client.details(fixture.uri("app/fxmanifest.lua"))); + assert_eq!(result["resource"]["name"], "app"); + assert_eq!(result["resource"]["uri"], json!(fixture.uri("app"))); + assert_eq!(result["files"], json!({"total":4,"client":1,"server":1,"shared":1,"module":1})); + assert_eq!(result["counts"], json!({"events":6,"exports":2})); + let event_names: Vec<_> = + result["events"].as_array().unwrap().iter().map(|row| row["name"].as_str().unwrap()).collect(); + assert_eq!(event_names, ["app:client", "app:esx", "app:local", "app:ox", "app:qb", "app:server"]); + assert!(!event_names.contains(&"app:trigger")); + assert_eq!(result["events"][1]["kind"], "ESX callback"); + assert_eq!(result["events"][4]["kind"], "QB-Core callback"); + assert_eq!(result["events"][4]["side"], "server"); + assert!(result["events"][4]["signature"].as_str().unwrap().contains("source")); + assert_eq!(result["exports"][0]["name"], "ClientThing"); + assert_eq!(result["exports"][0]["location"]["uri"], json!(fixture.uri("app/client.lua"))); + assert_eq!(result["exports"][0]["location"]["range"]["start"], json!({"line":2,"character":8})); + assert_eq!(result["exports"][1]["name"], "ServerThing"); + let dependencies = result["dependencies"].as_array().unwrap(); + assert_eq!(dependencies.len(), 3); + let lib = dependencies.iter().find(|row| row["name"] == "lib").unwrap(); + assert_eq!(lib["kinds"], json!(["dependency", "import"])); + assert_eq!(lib["status"], "resolved"); + assert_eq!(lib["targets"][0]["manifestUri"], json!(fixture.uri("lib/fxmanifest.lua"))); + let duplicate = dependencies.iter().find(|row| row["name"] == "duplicate").unwrap(); + assert_eq!(duplicate["status"], "ambiguous"); + assert_eq!(duplicate["targetCount"], 2); + let missing = dependencies.iter().find(|row| row["name"] == "missing").unwrap(); + assert_eq!(missing["status"], "missing"); + assert_eq!(missing["targets"], json!([])); + assert_eq!(result["constraints"], json!(["/onesync", "/server:7290"])); + assert_eq!(result["dependents"][0]["name"], "consumer"); + assert_eq!(result["dependents"][0]["kinds"], json!(["dependency", "import"])); + assert_eq!(result["dependents"][0]["targets"][0]["uri"], json!(fixture.uri("consumer"))); + assert_eq!(result["truncated"], json!({"events":0,"exports":0,"dependencies":0,"dependents":0})); + let notes = result["notes"].to_string(); + assert!(notes.contains("provide/provides") && notes.contains("computed") && notes.contains("Manifest export")); + assert_eq!(before, client.request("qbx/status", Value::Null), "requests do not change the index"); + fixture.write("app/not-notified.lua", "RegisterNetEvent('not-indexed')\n"); + assert_eq!(result, client.details(fixture.uri("app")), "details must not scan newly created files on demand"); + let ambiguous = client.details(fixture.uri("[a]/duplicate")); + assert_eq!(ambiguous["dependents"][0]["name"], "app"); + assert_eq!(ambiguous["dependents"][0]["status"], "ambiguous"); + assert_eq!(ambiguous["dependents"][0]["targetCount"], 1); +} + +#[test] +fn rejects_invalid_unknown_and_non_resource_uris() { + let fixture = sample(); + let mut client = Client::start(&fixture.0); + for params in [ + Value::Null, + json!([]), + json!({}), + json!({"uri":7}), + json!({"uri":"not a uri"}), + json!({"uri":"https://example.invalid/app"}), + json!({"uri":fixture.uri("app/client.lua")}), + json!({"uri":fixture.uri("unknown")}), + json!({"uri":format!("{}#fragment", fixture.uri("app"))}), + json!({"uri":format!("{}?query", fixture.uri("app"))}), + ] { + let error = client.response("qbx/resourceDetails", params.clone()).error.expect("expected InvalidParams"); + assert_eq!(error.code, -32602, "{params}"); + assert!(!error.message.is_empty()); + } + assert_eq!(client.details(fixture.uri("app")), client.details(fixture.uri("unused/../app"))); + #[cfg(windows)] + assert_eq!( + client.details(fixture.uri("app")), + client.details(Url::from_file_path(fixture.0.join("app").to_string_lossy().to_uppercase()).unwrap()) + ); +} + +#[test] +fn refresh_observes_unsaved_source_changes_and_saved_manifest_changes() { + let fixture = sample(); + let mut client = Client::start(&fixture.0); + let uri = fixture.uri("app/client.lua"); + client.notify("textDocument/didOpen", json!({"textDocument":{"uri":uri,"languageId":"lua","version":1,"text":"RegisterNetEvent('new:client')\nexports('Changed', function() end)\n"}})); + let changed = client.details(fixture.uri("app")); + assert!(!changed["events"].as_array().unwrap().iter().any(|row| row["name"] == "app:client")); + assert!(changed["events"].as_array().unwrap().iter().any(|row| row["name"] == "new:client")); + assert!(changed["exports"].as_array().unwrap().iter().any(|row| row["name"] == "Changed")); + client.notify( + "textDocument/didChange", + json!({"textDocument":{"uri":uri,"version":2},"contentChanges":[{"text":"-- empty\n"}]}), + ); + assert_eq!(client.details(fixture.uri("app"))["counts"], json!({"events":5,"exports":1})); + client.notify("textDocument/didClose", json!({"textDocument":{"uri":uri}})); + assert_eq!(client.details(fixture.uri("app"))["counts"], json!({"events":6,"exports":2})); + fixture.resource( + "app", + "server_scripts {'client.lua','server.lua'}\nshared_script 'shared.lua'\ndependency 'consumer'\n", + ); + client.notify("textDocument/didSave", json!({"textDocument":{"uri":fixture.uri("app/fxmanifest.lua")}})); + let saved = client.details(fixture.uri("app")); + assert_eq!(saved["files"], json!({"total":4,"client":0,"server":2,"shared":1,"module":1})); + assert_eq!(saved["dependencies"].as_array().unwrap().len(), 1); + assert_eq!(saved["dependencies"][0]["name"], "consumer"); + fixture.write("app/added.lua", "exports('Added', function() end)\n"); + client + .notify("workspace/didChangeWatchedFiles", json!({"changes":[{"uri":fixture.uri("app/added.lua"),"type":1}]})); + assert_eq!(client.details(fixture.uri("app"))["files"]["total"], 5); + client + .notify("workspace/didChangeWatchedFiles", json!({"changes":[{"uri":fixture.uri("app/added.lua"),"type":3}]})); + assert_eq!(client.details(fixture.uri("app"))["files"]["total"], 4); +} + +fn indexed_resource(index: &mut Index, root: PathBuf, name: &str, manifest: &str) { + index.resources.push(ResourceEntry { + name: name.into(), + manifest_path: root.join("fxmanifest.lua"), + root, + manifest: Manifest::from_chunk(&qbx_lua_syntax::parse(manifest)), + files: Vec::new(), + imports: Vec::new(), + escrowed: false, + }); +} + +#[test] +fn bounds_arrays_keeps_full_counts_and_preserves_duplicate_dependent_identities() { + let fixture = Fixture::new(); + let root = fixture.0.join("index-only"); + let mut index = Index::default(); + let manifest = (0..205).map(|i| format!("dependency 'dep{i:03}'\ndependency '/server:{i}'\n")).collect::() + + "dependency 'duplicate'\n"; + indexed_resource(&mut index, root.clone(), "focus", &manifest); + for i in 0..23 { + indexed_resource(&mut index, fixture.0.join(format!("copy{i}/duplicate")), "duplicate", ""); + } + for i in 0..230 { + indexed_resource(&mut index, fixture.0.join(format!("consumer{i}")), "same-name", "dependency 'focus'"); + } + let path = root.join("module.lua"); + let id = index.allocate(&path); + let range = Range::new(Position::new(0, 0), Position::new(0, 1)); + let mut file = FileIndex::default(); + for i in 0..503 { + file.events.push(EventDef { + name: format!("event{i:03}").into(), + kind: EventKind::NetEvent, + family: EventFamily::Native, + side: None, + handler: None, + range, + }); + } + for i in 0..501 { + file.exports.push(Symbol { + name: format!("export{i:03}").into(), + kind: SymbolKind::Export, + ty: Type::Unknown, + doc: None, + deprecated: false, + literal: None, + range, + }); + } + index.set_file( + id, + FileEntry { + uri: Url::from_file_path(&path).unwrap(), + path, + origin: FileOrigin::Workspace, + resource: Some(0), + side: None, + index: file, + }, + ); + let result = details(&index, DetailsParams { uri: Url::from_file_path(&root).unwrap() }).unwrap(); + assert_eq!(result.events.len(), 500); + assert_eq!(result.exports.len(), 500); + assert_eq!(result.dependencies.len(), 200); + assert_eq!(result.dependents.len(), 200); + assert_eq!(result.constraints.len(), 200); + assert_eq!((result.counts.events, result.counts.exports), (503, 501)); + assert_eq!( + (result.truncated.events, result.truncated.exports, result.truncated.dependencies, result.truncated.dependents), + (3, 1, 6, 30) + ); + assert_eq!(result.files.total, 1); + assert_eq!(result.files.module, 1); + let distinct: std::collections::BTreeSet<_> = + result.dependents.iter().map(|row| row.targets[0].uri.to_string()).collect(); + assert_eq!(distinct.len(), 200); + assert!(result.notes.iter().any(|note| note.contains("runtime constraints were omitted"))); + // Select a different resource with only the ambiguous dependency so its candidate row is visible. + indexed_resource(&mut index, fixture.0.join("target-cap"), "target-cap", "dependency 'duplicate'"); + let target_cap = details(&index, DetailsParams { uri: fixture.uri("target-cap") }).unwrap(); + assert_eq!(target_cap.dependencies[0].target_count, 23); + assert_eq!(target_cap.dependencies[0].targets.len(), 20); + assert_eq!(target_cap.dependencies[0].status, "ambiguous"); + assert!(target_cap.notes.iter().any(|note| note.contains("3 candidate resource folders"))); +} + +#[test] +fn long_display_strings_are_bounded_after_full_name_resolution() { + let fixture = Fixture::new(); + let root = fixture.0.join("focus"); + let long_name = "a".repeat(9000); + let constraint = format!("/gameBuild:{}", "1".repeat(3000)); + let mut index = Index::default(); + indexed_resource( + &mut index, + root.clone(), + "focus", + &format!("dependency '{long_name}'\ndependency '{constraint}'"), + ); + indexed_resource(&mut index, fixture.0.join("provider"), &long_name, ""); + let path = root.join("module.lua"); + let id = index.allocate(&path); + let mut file = FileIndex::default(); + file.events.push(EventDef { + name: long_name.clone().into(), + family: EventFamily::Native, + kind: EventKind::NetEvent, + side: None, + handler: Some(std::sync::Arc::new(qbx_lua_ls::types::FunType { + params: Vec::new(), + returns: Vec::new(), + is_method: false, + generics: Vec::new(), + overloads: Vec::new(), + })), + range: Range::new(Position::new(0, 0), Position::new(0, 1)), + }); + index.set_file( + id, + FileEntry { + uri: Url::from_file_path(&path).unwrap(), + path, + origin: FileOrigin::Workspace, + resource: Some(0), + side: None, + index: file, + }, + ); + let result = details(&index, DetailsParams { uri: fixture.uri("focus") }).unwrap(); + assert_eq!(result.dependencies[0].status, "resolved", "resolve the original name before shortening display text"); + assert_eq!(result.dependencies[0].targets[0].uri, fixture.uri("provider")); + assert_eq!(result.dependencies[0].name.chars().count(), 2048); + assert_eq!(result.events[0].name.chars().count(), 2048); + assert_eq!(result.events[0].signature.as_ref().unwrap().chars().count(), 8192); + assert_eq!(result.constraints[0].chars().count(), 2048); + assert!(result.events[0].name.ends_with('…')); + assert!(result.notes.iter().any(|note| note.contains("shortened for display"))); +} diff --git a/crates/qbx_lua_ls/tests/structured_tools.rs b/crates/qbx_lua_ls/tests/structured_tools.rs new file mode 100644 index 0000000..7c61486 --- /dev/null +++ b/crates/qbx_lua_ls/tests/structured_tools.rs @@ -0,0 +1,374 @@ +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::thread::JoinHandle; +use std::time::Duration; + +use lsp_server::{Connection, Message, Notification, Request, RequestId, Response}; +use lsp_types::Url; +use serde_json::{json, Value}; + +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let root = std::env::temp_dir().join(format!( + "qbx-structured-tools-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&root).unwrap(); + Self(root) + } + fn write(&self, relative: &str, text: &str) { + let path = self.0.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + fn manifest(&self, relative: &str, extra: &str) { + self.write(&format!("{relative}/fxmanifest.lua"), &format!("fx_version 'cerulean'\ngame 'gta5'\n{extra}")); + } + fn uri(&self, relative: &str) -> Url { + Url::from_file_path(self.0.join(relative)).unwrap() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + assert!(self.0.starts_with(std::env::temp_dir())); + assert!(self.0.file_name().unwrap().to_string_lossy().starts_with("qbx-structured-tools-")); + std::fs::remove_dir_all(&self.0).unwrap(); + } +} +struct Client { + connection: Connection, + server: Option>, + next: i32, +} +impl Client { + fn new(fixture: &Fixture, enabled: bool) -> Self { + let (server, connection) = Connection::memory(); + let thread = std::thread::Builder::new() + .stack_size(16 * 1024 * 1024) + .spawn(move || qbx_lua_ls::server::run_connection(server).unwrap()) + .unwrap(); + let mut client = Self { connection, server: Some(thread), next: 0 }; + let uri = fixture.uri(""); + client.request( + "initialize", + json!({"processId":null,"rootUri":uri,"capabilities":{},"workspaceFolders":[{"uri":uri,"name":"tools"}], + "initializationOptions":{"diagnostics":{"enable":enabled,"workspace":false}}}), + ); + client.notify("initialized", json!({})); + client + } + fn notify(&self, method: &str, params: Value) { + self.connection.sender.send(Message::Notification(Notification { method: method.into(), params })).unwrap(); + } + fn response(&mut self, method: &str, params: Value) -> Response { + self.next += 1; + let id = RequestId::from(self.next); + self.connection + .sender + .send(Message::Request(Request { id: id.clone(), method: method.into(), params })) + .unwrap(); + loop { + match self.connection.receiver.recv_timeout(Duration::from_secs(30)).unwrap() { + Message::Response(response) if response.id == id => return response, + Message::Request(request) => self + .connection + .sender + .send(Message::Response(Response { id: request.id, result: Some(Value::Null), error: None })) + .unwrap(), + _ => {} + } + } + } + fn request(&mut self, method: &str, params: Value) -> Value { + let response = self.response(method, params); + assert!(response.error.is_none(), "{:?}", response.error); + response.result.unwrap() + } + fn open(&self, uri: Url, text: &str) { + self.notify( + "textDocument/didOpen", + json!({"textDocument":{"uri":uri,"languageId":"lua","version":1,"text":text}}), + ); + } +} +impl Drop for Client { + fn drop(&mut self) { + self.request("shutdown", Value::Null); + self.notify("exit", Value::Null); + self.server.take().unwrap().join().unwrap(); + } +} + +#[test] +fn assets_extract_manifest_arguments_and_precise_native_references() { + let f = Fixture::new(); + f.manifest("app", "client_script 'client.lua'\nfiles {'web/index.html','web/*.png'}\ndata_file 'DLC_ITYP_REQUEST' 'stream/props.ytyp'\ndata_file('AUDIO_WAVEPACK', 'audio/waves')\nui_page 'web/index.html'\nloadscreen 'load.html'\nfiles {dynamic, ['key']='not-a-path'}\n"); + let source = "local marker = '😀'; RequestModel(`adder`)\nCreatePed(4, GetHashKey('sultan'), 0, 0, 0)\nCreatePedInsideVehicle(veh, 4, joaat('blista'), -1)\nCreateVehicle(0xB779A091, 0, 0, 0)\nCreateObject(-1216765807, 0, 0, 0)\nDrawSprite('inventory', 'water', 0, 0, 1, 1)\nRequestNamedPtfxAsset('core')\nRequestScriptAudioBank('DLC_AUDIO/test', false)\n"; + f.write("app/client.lua", source); + let mut client = Client::new(&f, false); + let value = client.request("qbx/resourceAssets", json!({"uri":f.uri("app")})); + let declarations = value["declarations"].as_array().unwrap(); + assert_eq!(declarations.len(), 7); + assert!(declarations.iter().any(|d| d["dataType"] == "AUDIO_WAVEPACK" && d["value"] == "audio/waves")); + let refs = value["references"].as_array().unwrap(); + assert_eq!(refs.len(), 9, "{value:#}"); + assert_eq!(refs[0]["value"], "adder"); + assert_eq!(refs[0]["hash"], 0xB779A091u32); + assert_eq!( + refs[0]["location"]["range"]["start"]["character"], + source[..source.find('`').unwrap()].encode_utf16().count() + ); + assert_eq!(refs[5]["kind"], "textureDictionary"); + assert_eq!(refs[6]["dictionary"], "inventory"); + assert_eq!(refs[6]["value"], "water"); + assert_eq!(refs[8]["kind"], "audioBank"); + assert!(refs[3].get("value").is_none()); +} + +#[test] +fn asset_lookup_uses_unsaved_sources_and_preserves_resource_ownership() { + let f = Fixture::new(); + f.manifest("app", "client_scripts {'client.lua', '@lib/shared.lua'}\nfiles {'old.png'}"); + f.manifest("lib", "shared_script 'shared.lua'"); + f.write("app/client.lua", "RequestModel('old_model')"); + f.write("lib/shared.lua", "RequestModel('external_model')"); + let mut client = Client::new(&f, false); + client.open( + f.uri("app/client.lua"), + "RequestModel('new_model')\ndo local RequestModel=function() end; RequestModel('shadowed') end\n", + ); + client.open( + f.uri("app/fxmanifest.lua"), + "fx_version 'cerulean'\ngame 'gta5'\nclient_script 'client.lua'\nfiles {'new.png'}", + ); + let value = client.request("qbx/resourceAssets", json!({"uri":f.uri("app/fxmanifest.lua")})); + assert_eq!(value["references"].as_array().unwrap().len(), 1, "{value:#}"); + assert_eq!(value["references"][0]["value"], "new_model"); + assert_eq!(value["declarations"][1]["value"], "new.png"); + for uri in [f.uri("app/client.lua").to_string(), "command:evil".into(), f.uri("absent").to_string()] { + assert!(client.response("qbx/resourceAssets", json!({"uri":uri})).error.is_some()); + } +} + +#[test] +fn asset_arguments_respect_shadowed_globals_and_bound_results_without_changing_names() { + let f = Fixture::new(); + f.manifest("app", "client_scripts {'client.lua','shadow.lua'}"); + f.write("app/client.lua", &"RequestModel('same')\n".repeat(2001)); + f.write("app/shadow.lua", "RequestModel(GetHashKey(123))\nRequestModel(GetHashKey(joaat('nested')))\ndo local _ENV = {}; RequestModel('shadowed') end\n"); + let mut client = Client::new(&f, false); + let value = client.request("qbx/resourceAssets", json!({"uri":f.uri("app")})); + assert_eq!(value["references"].as_array().unwrap().len(), 2000); + assert_eq!(value["truncated"]["references"], 1); + client.open(f.uri("app/client.lua"), "_G.RequestModel = function() end\nRequestModel('replacement')"); + assert!(client.request("qbx/resourceAssets", json!({"uri":f.uri("app")}))["references"] + .as_array() + .unwrap() + .is_empty()); +} + +#[test] +fn resource_listing_is_paginated_and_keeps_duplicate_names_distinct() { + let f = Fixture::new(); + f.manifest("one/same", ""); + f.manifest("two/same", ""); + f.manifest("other", ""); + let mut client = Client::new(&f, false); + let a = client.request("qbx/resources", json!({"query":"same","limit":1})); + let b = client.request("qbx/resources", json!({"query":"same","offset":1,"limit":1})); + assert_eq!(a["total"], 2); + assert_eq!(b["total"], 2); + assert_ne!(a["items"][0]["uri"], b["items"][0]["uri"]); + assert_eq!(client.request("qbx/resources", json!({"offset":100}))["items"], json!([])); + for params in [ + Value::Null, + json!({"limit":101}), + json!({"limit":0}), + json!({"offset":-1}), + json!({"query":"x".repeat(257)}), + json!({"command":"start"}), + ] { + assert!(client.response("qbx/resources", params).error.is_some()); + } +} + +#[test] +fn diagnostic_snapshots_include_closed_sources_unsaved_edits_and_rule_settings() { + let f = Fixture::new(); + f.manifest("app", "client_script 'client.lua'"); + f.write("app/client.lua", "UnknownAssistantCall()\n"); + let mut client = Client::new(&f, true); + let uri = f.uri("app/client.lua"); + let before = client.request("qbx/diagnostics", json!({"uri":uri,"limit":1})); + assert!(before["total"].as_u64().unwrap() > 0); + assert_eq!(before["items"].as_array().unwrap().len(), 1); + assert_eq!(before["items"][0]["uri"], uri.as_str()); + assert!(before["items"][0].get("data").is_none()); + client.open(uri.clone(), "print('clean')\n"); + assert_eq!(client.request("qbx/diagnostics", json!({"uri":uri}))["total"], 0); + assert!(client.response("qbx/diagnostics", json!({"uri":f.uri("unindexed.lua")})).error.is_some()); + client.notify( + "workspace/didChangeConfiguration", + json!({"settings":{"qbxLua":{"diagnostics":{"enable":false,"workspace":false}}}}), + ); + let disabled = client.request("qbx/diagnostics", json!({})); + assert_eq!(disabled["total"], 0); + assert!(disabled["notes"][0].as_str().unwrap().contains("disabled")); +} + +#[test] +fn symbol_references_work_on_closed_and_unsaved_sources_and_reject_invalid_positions() { + let f = Fixture::new(); + f.manifest("app", "client_script 'client.lua'"); + f.write("app/client.lua", "local item = 42\nprint(item)\n"); + let mut client = Client::new(&f, false); + let uri = f.uri("app/client.lua"); + let result = client.request("qbx/symbolReferences", json!({"uri":uri,"line":1,"character":7})); + assert_eq!(result["total"], 2, "{result}"); + assert_eq!( + client.request("qbx/symbolReferences", json!({"uri":uri,"line":1,"character":7,"includeDeclaration":false})) + ["total"], + 1 + ); + client.open(uri.clone(), "local item = 42\nprint(item,item)\n"); + assert_eq!(client.request("qbx/symbolReferences", json!({"uri":uri,"line":1,"character":7}))["total"], 3); + for params in [ + json!({"uri":uri,"line":999,"character":0}), + json!({"uri":uri,"line":0,"character":999}), + json!({"uri":"https://example.invalid/code.lua","line":0,"character":0}), + json!({"uri":uri,"line":0,"character":0,"limit":201}), + ] { + assert!(client.response("qbx/symbolReferences", params).error.is_some()); + } +} + +#[test] +fn assistant_references_bound_related_sources_for_globals_and_members() { + let f = Fixture::new(); + f.manifest("app", "client_scripts {'a.lua','b.lua'}"); + f.write("app/a.lua", "Shared = {field = 1}\nprint(Shared.field)\n"); + f.write("app/b.lua", "print(Shared.field)\n"); + let mut client = Client::new(&f, false); + let query = |character| json!({"uri":f.uri("app/a.lua"),"line":1,"character":character}); + for character in [8, 15] { + let complete = client.request("qbx/symbolReferences", query(character)); + assert!( + complete["items"].as_array().unwrap().iter().any(|item| item["uri"] == f.uri("app/b.lua").as_str()), + "{complete:#}" + ); + } + // The indexed related file changes after initialization. A bounded reference request must + // inspect its size before parsing it, including the member-reference path. + f.write("app/b.lua", &format!("--{}\nprint(Shared.field)\n", "x".repeat(2 * 1024 * 1024))); + for character in [8, 15] { + let partial = client.request("qbx/symbolReferences", query(character)); + assert!(partial["total"].as_u64().unwrap() > 0, "{partial:#}"); + assert!(partial["items"].as_array().unwrap().iter().all(|item| item["uri"] != f.uri("app/b.lua").as_str())); + assert!( + partial["notes"].as_array().unwrap().iter().any(|note| note.as_str().unwrap().contains("2 MiB")), + "{partial:#}" + ); + } + assert!(client + .response("qbx/symbolReferences", json!({"uri":f.uri("app/b.lua"),"line":1,"character":8})) + .error + .is_some()); +} + +#[test] +fn assistant_reference_result_caps_cover_global_local_and_member_paths() { + let f = Fixture::new(); + f.manifest("app", "client_script 'a.lua'"); + f.write("app/a.lua", "Shared = {field = 1}\n"); + let mut client = Client::new(&f, false); + f.write("app/a.lua", &format!("Shared = {{field = 1}}\n{}", "print(Shared.field)\n".repeat(20_005))); + for character in [2, 12] { + let result = client.request( + "qbx/symbolReferences", + json!({"uri":f.uri("app/a.lua"),"line":0,"character":character,"limit":1}), + ); + assert_eq!(result["total"], 20_000, "{result:#}"); + assert!( + result["notes"].as_array().unwrap().iter().any(|note| note.as_str().unwrap().contains("20,000")), + "{result:#}" + ); + } + f.write("app/a.lua", &format!("local item = 1\n{}", "print(item)\n".repeat(20_005))); + let result = + client.request("qbx/symbolReferences", json!({"uri":f.uri("app/a.lua"),"line":0,"character":8,"limit":1})); + assert_eq!(result["total"], 20_000, "{result:#}"); + assert!(!result["notes"].as_array().unwrap().is_empty()); +} + +#[test] +fn diagnostic_snapshots_bound_locale_reads_and_report_the_final_locale_result_cap() { + let f = Fixture::new(); + f.manifest("app", "client_script 'a.lua'"); + f.write("app/a.lua", "locale('used')\nlocale('missing')\n"); + f.write("app/locales/en.json", r#"{"used":"Used","unused":"Unused"}"#); + let mut client = Client::new(&f, true); + let before = client.request("qbx/diagnostics", json!({"limit":200})); + let has_code = |result: &Value, code| result["items"].as_array().unwrap().iter().any(|item| item["code"] == code); + assert!(has_code(&before, "qbox/unknown-locale-key"), "{before:#}"); + assert!(has_code(&before, "qbox/unused-locale-key"), "{before:#}"); + f.write("app/locales/en.json", &format!(r#"{{"used":"{}"}}"#, "x".repeat(2 * 1024 * 1024))); + let partial = client.request("qbx/diagnostics", json!({"limit":200})); + assert!(!has_code(&partial, "qbox/unknown-locale-key"), "{partial:#}"); + assert!(!has_code(&partial, "qbox/unused-locale-key"), "{partial:#}"); + assert!( + partial["notes"].as_array().unwrap().iter().any(|note| note.as_str().unwrap().contains("2 MiB")), + "{partial:#}" + ); + + // This limit is reached in the final unused-locale pass, after ordinary source diagnostics. + let values: serde_json::Map = (0..20_005) + .map(|index| (format!("unused{index}"), json!("text"))) + .chain([(String::from("used"), json!("Used"))]) + .collect(); + f.write("app/locales/en.json", &serde_json::to_string(&values).unwrap()); + let capped = client.request("qbx/diagnostics", json!({"limit":1})); + assert_eq!(capped["total"], 20_000, "{capped:#}"); + assert!( + capped["notes"].as_array().unwrap().iter().any(|note| note.as_str().unwrap().contains("20,000")), + "{capped:#}" + ); +} + +#[test] +fn diagnostic_snapshots_preserve_bounded_start_order_and_skip_incomplete_config_checks() { + let f = Fixture::new(); + f.manifest("resources/app", "client_script 'a.lua'"); + f.manifest("resources/lib", "client_script 'a.lua'"); + f.write("resources/app/a.lua", "exports.lib:run()\nUnknownAssistantCall()\n"); + f.write("resources/lib/a.lua", "exports('run', function() end)\n"); + f.write("server.cfg", "exec ordering.cfg\nensure app\n"); + f.write("ordering.cfg", "ensure lib\n"); + let mut client = Client::new(&f, true); + let query = json!({"uri":f.uri("resources/app/a.lua"),"limit":200}); + let before = client.request("qbx/diagnostics", query.clone()); + assert!(before["notes"].as_array().unwrap().is_empty(), "{before:#}"); + assert!( + before["items"].as_array().unwrap().iter().all(|item| item["code"] != "manifest/missing-dependency"), + "{before:#}" + ); + f.write("ordering.cfg", "# lib deliberately not started\n"); + let changed = client.request("qbx/diagnostics", query.clone()); + assert!( + changed["items"].as_array().unwrap().iter().any(|item| item["code"] == "manifest/missing-dependency"), + "{changed:#}" + ); + f.write("ordering.cfg", &"x".repeat(2 * 1024 * 1024 + 1)); + let partial = client.request("qbx/diagnostics", query); + assert!( + partial["items"].as_array().unwrap().iter().all(|item| item["code"] != "manifest/missing-dependency"), + "{partial:#}" + ); + assert!(partial["items"].as_array().unwrap().iter().any(|item| item["code"] == "undefined-global"), "{partial:#}"); + assert!( + partial["notes"].as_array().unwrap().iter().any(|note| note.as_str().unwrap().contains("Start-order")), + "{partial:#}" + ); +} diff --git a/crates/qbx_lua_ls/tests/workspace_health.rs b/crates/qbx_lua_ls/tests/workspace_health.rs new file mode 100644 index 0000000..8a8a966 --- /dev/null +++ b/crates/qbx_lua_ls/tests/workspace_health.rs @@ -0,0 +1,301 @@ +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::thread::JoinHandle; +use std::time::Duration; + +use lsp_server::{Connection, Message, Notification, Request, RequestId, Response}; +use lsp_types::Url; +use qbx_lua_analysis::manifest::Manifest; +use qbx_lua_ls::features::workspace_health::health; +use qbx_lua_ls::index::{FileEntry, FileIndex, FileOrigin, Index, ResourceEntry}; +use serde_json::{json, Value}; + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let root = std::env::temp_dir().join(format!( + "qbx-workspace-health-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&root).unwrap(); + Self(root) + } + + fn write(&self, relative: &str, text: &str) { + let path = self.0.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + + fn resource(&self, relative: &str, manifest: &str) { + self.write(&format!("{relative}/fxmanifest.lua"), &format!("fx_version 'cerulean'\ngame 'gta5'\n{manifest}")); + } + + fn uri(&self, relative: &str) -> Url { + Url::from_file_path(self.0.join(relative)).unwrap() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + assert!(self.0.starts_with(std::env::temp_dir())); + assert!(self.0.file_name().unwrap().to_string_lossy().starts_with("qbx-workspace-health-")); + std::fs::remove_dir_all(&self.0).unwrap(); + } +} + +struct Client { + connection: Connection, + server: Option>, + next: i32, +} + +impl Client { + fn start(root: &Path) -> Self { + let (server_connection, connection) = Connection::memory(); + let server = std::thread::Builder::new() + .stack_size(16 * 1024 * 1024) + .spawn(move || qbx_lua_ls::server::run_connection(server_connection).unwrap()) + .unwrap(); + let mut client = Self { connection, server: Some(server), next: 0 }; + let uri = Url::from_file_path(root).unwrap(); + client.request( + "initialize", + json!({ + "processId": null, "rootUri": uri, "capabilities": {}, + "initializationOptions": {"diagnostics":{"enable":false,"workspace":false}}, + "workspaceFolders": [{"uri":uri,"name":"health fixture"}] + }), + ); + client.notify("initialized", json!({})); + client + } + + fn notify(&self, method: &str, params: Value) { + self.connection.sender.send(Message::Notification(Notification { method: method.into(), params })).unwrap(); + } + + fn response(&mut self, method: &str, params: Value) -> Response { + self.next += 1; + let id = RequestId::from(self.next); + self.connection + .sender + .send(Message::Request(Request { id: id.clone(), method: method.into(), params })) + .unwrap(); + loop { + match self.connection.receiver.recv_timeout(Duration::from_secs(20)).expect("server did not reply") { + Message::Response(response) if response.id == id => return response, + Message::Request(request) => self + .connection + .sender + .send(Message::Response(Response { id: request.id, result: Some(Value::Null), error: None })) + .unwrap(), + _ => {} + } + } + } + + fn request(&mut self, method: &str, params: Value) -> Value { + let response = self.response(method, params); + assert!(response.error.is_none(), "{:?}", response.error); + response.result.unwrap_or(Value::Null) + } + + fn health(&mut self) -> Value { + self.request("qbx/workspaceHealth", Value::Null) + } +} + +impl Drop for Client { + fn drop(&mut self) { + self.request("shutdown", Value::Null); + self.notify("exit", Value::Null); + self.server.take().unwrap().join().unwrap(); + } +} + +fn sample() -> Fixture { + let fixture = Fixture::new(); + fixture.resource("app", "dependencies {'LIB','missing','MISSING','duplicate','/server:7290','/onesync'}\nshared_scripts {'main.lua','@lib/init.lua','@missing/init.lua','@duplicate/init.lua'}\n"); + fixture.write("app/main.lua", "return {}\n"); + fixture.resource("lib", "shared_script 'init.lua'\n"); + fixture.write("lib/init.lua", "return {}\n"); + fixture.resource("[a]/duplicate", ""); + fixture.resource("[b]/duplicate", ""); + fixture.resource("consumer", "dependency 'missing'\n"); + fixture.resource("replacement", "provide 'missing'\n"); + fixture.write("standalone.lua", "return {}\n"); + fixture +} + +#[test] +fn protocol_reports_grouped_dependencies_and_duplicates_without_rescanning() { + let fixture = sample(); + let mut client = Client::start(&fixture.0); + let status = client.request("qbx/status", Value::Null); + let result = client.health(); + assert_eq!(result, client.request("qbx/workspaceHealth", json!({}))); + assert_eq!(result["files"], 3); + assert_eq!(result["resources"], 6); + assert_eq!(result["counts"], json!({"duplicates":1,"missing":2,"ambiguous":1})); + assert_eq!(result["truncated"], 0); + let issues = result["issues"].as_array().unwrap(); + assert_eq!(issues.len(), 4); + assert_eq!(issues[0]["kind"], "duplicate"); + assert_eq!(issues[0]["name"], "duplicate"); + assert!(issues[0].get("resource").is_none()); + assert_eq!(issues[0]["kinds"], json!([])); + assert_eq!(issues[0]["targetCount"], 2); + assert_eq!(issues[0]["targets"][0]["uri"], json!(fixture.uri("[a]/duplicate"))); + let ambiguous = issues.iter().find(|issue| issue["kind"] == "ambiguous").unwrap(); + assert_eq!(ambiguous["resource"]["uri"], json!(fixture.uri("app"))); + assert_eq!(ambiguous["kinds"], json!(["dependency", "import"])); + assert_eq!(ambiguous["targetCount"], 2); + let missing = issues.iter().find(|issue| issue["kind"] == "missing" && issue["resource"]["name"] == "app").unwrap(); + assert_eq!(missing["name"], "missing"); + assert_eq!(missing["kinds"], json!(["dependency", "import"])); + assert_eq!(missing["targets"], json!([])); + assert_eq!(missing["targetCount"], 0); + assert!(issues.iter().all(|issue| !issue["name"].as_str().unwrap().starts_with('/'))); + assert!(result["notes"].to_string().contains("provide/provides")); + assert_eq!(status, client.request("qbx/status", Value::Null), "health is read-only"); + fixture.resource("missing", ""); + assert_eq!(result, client.health(), "new resources are not discovered by a health request"); +} + +#[test] +fn rejects_nonempty_or_nonobject_parameters_and_accepts_empty_workspace() { + let fixture = Fixture::new(); + let mut client = Client::start(&fixture.0); + let empty = client.health(); + assert_eq!(empty["files"], 0); + assert_eq!(empty["resources"], 0); + assert_eq!(empty["counts"], json!({"duplicates":0,"missing":0,"ambiguous":0})); + assert_eq!(empty["issues"], json!([])); + for params in [json!([]), json!(false), json!(3), json!(""), json!({"uri":"file:///unused"})] { + let response = client.response("qbx/workspaceHealth", params); + let error = response.error.expect("expected InvalidParams"); + assert_eq!(error.code, -32602); + assert!(error.message.contains("empty object")); + } +} + +#[test] +fn reflects_saved_manifest_and_watched_source_refresh() { + let fixture = sample(); + let mut client = Client::start(&fixture.0); + let before = client.health(); + let uri = fixture.uri("app/fxmanifest.lua"); + client.notify( + "textDocument/didOpen", + json!({"textDocument":{"uri":uri,"languageId":"lua","version":1,"text":"dependency 'lib'\n"}}), + ); + assert_eq!(before["counts"], client.health()["counts"], "manifest metadata uses saved text"); + fixture.resource("app", "dependency 'lib'\n"); + client.notify("textDocument/didSave", json!({"textDocument":{"uri":uri}})); + assert_eq!(client.health()["counts"], json!({"duplicates":1,"missing":1,"ambiguous":0})); + fixture.write("app/added.lua", "return {}\n"); + client + .notify("workspace/didChangeWatchedFiles", json!({"changes":[{"uri":fixture.uri("app/added.lua"),"type":1}]})); + assert_eq!(client.health()["files"], 4); + client + .notify("workspace/didChangeWatchedFiles", json!({"changes":[{"uri":fixture.uri("app/added.lua"),"type":3}]})); + assert_eq!(client.health()["files"], 3); + fixture.resource("missing", ""); + client.request("qbx/reindex", Value::Null); + let refreshed = client.health(); + assert_eq!(refreshed["resources"], 7); + assert_eq!(refreshed["counts"], json!({"duplicates":1,"missing":0,"ambiguous":0})); +} + +fn indexed_resource(index: &mut Index, root: PathBuf, name: &str, manifest: &str) { + index.resources.push(ResourceEntry { + name: name.into(), + manifest_path: root.join("fxmanifest.lua"), + root, + manifest: Manifest::from_chunk(&qbx_lua_syntax::parse(manifest)), + files: Vec::new(), + imports: Vec::new(), + escrowed: false, + }); +} + +#[test] +fn bounds_issues_and_candidates_without_losing_full_counts_or_identity() { + let fixture = Fixture::new(); + let mut index = Index::default(); + let manifest = + (0..520).map(|i| format!("dependency 'missing{i:03}'\n")).collect::() + "dependency 'duplicate'\n"; + indexed_resource(&mut index, fixture.0.join("app"), "app", &manifest); + for i in 0..23 { + indexed_resource(&mut index, fixture.0.join(format!("copy{i:02}/duplicate")), "duplicate", ""); + } + // A repeated index entry for the same lexical root must not add another provider or dependent. + indexed_resource(&mut index, fixture.0.join("app/unused/.."), "app", &manifest); + indexed_resource(&mut index, fixture.0.join("copy00/duplicate"), "duplicate", ""); + let result = health(&index); + assert_eq!(result.resources, 24); + assert_eq!((result.counts.duplicates, result.counts.missing, result.counts.ambiguous), (1, 520, 1)); + assert_eq!(result.issues.len(), 500); + assert_eq!(result.truncated, 22); + for issue in result.issues.iter().take(2) { + assert_eq!(issue.targets.len(), 20); + assert_eq!(issue.target_count, 23); + } + assert_eq!(result.issues[0].kind, "duplicate"); + assert_eq!(result.issues[1].kind, "ambiguous"); + assert!(result.notes.iter().any(|note| note.contains("6 candidate resource folders"))); + assert!(result.notes.iter().any(|note| note.contains("Some issue rows were omitted"))); + // Input ordering does not change the visible report for repeated equivalent entries. + index.resources.reverse(); + assert!( + serde_json::to_value(result).unwrap() == serde_json::to_value(health(&index)).unwrap(), + "reordering equivalent index entries must not change the report" + ); +} + +#[test] +fn resolves_full_names_before_shortening_and_excludes_stubs_and_manifests() { + let fixture = Fixture::new(); + let mut index = Index::default(); + let long = "α".repeat(4000); + indexed_resource(&mut index, fixture.0.join("app"), "app", &format!("dependency '{long}'\ndependency ''\n")); + indexed_resource(&mut index, fixture.0.join("one"), &long, ""); + indexed_resource(&mut index, fixture.0.join("two"), &long, ""); + for (name, origin) in [ + ("module.lua", FileOrigin::Workspace), + ("lib.lua", FileOrigin::Library), + ("stub.lua", FileOrigin::Stub), + ("fxmanifest.lua", FileOrigin::Workspace), + ] { + let path = fixture.0.join(name); + let id = index.allocate(&path); + index.set_file( + id, + FileEntry { + uri: Url::from_file_path(&path).unwrap(), + path, + origin, + resource: None, + side: None, + index: FileIndex::default(), + }, + ); + } + let result = health(&index); + assert_eq!(result.files, 2); + assert_eq!((result.counts.duplicates, result.counts.missing, result.counts.ambiguous), (1, 0, 1)); + assert_eq!(result.issues[1].target_count, 2, "resolution must use the unshortened name"); + for issue in &result.issues { + assert_eq!(issue.name.chars().count(), 2048); + assert!(issue.name.ends_with('…')); + for target in &issue.targets { + assert_eq!(target.name.chars().count(), 2048); + } + } + assert!(result.notes.iter().any(|note| note.contains("shortened for display"))); + assert!(result.notes.iter().any(|note| note.starts_with("1 empty dependency"))); +} diff --git a/crates/qbx_lua_ls/tests/workspace_refresh.rs b/crates/qbx_lua_ls/tests/workspace_refresh.rs new file mode 100644 index 0000000..c69fa03 --- /dev/null +++ b/crates/qbx_lua_ls/tests/workspace_refresh.rs @@ -0,0 +1,388 @@ +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::thread::JoinHandle; +use std::time::Duration; + +use lsp_server::{Connection, Message, Notification, Request, RequestId, Response}; +use lsp_types::{CompletionResponse, NumberOrString, Url}; +use qbx_fivem_data::Side; +use qbx_lua_analysis::crossref::CrossRefs; +use qbx_lua_ls::document::Document; +use qbx_lua_ls::features::{completion, diagnostics, event_call}; +use qbx_lua_ls::index::FileOrigin; +use qbx_lua_ls::types::FunType; +use qbx_lua_ls::workspace::{path_to_uri, Workspace}; +use serde_json::{json, Value}; + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let root = std::env::temp_dir().join(format!( + "qbx-lua-ls-refresh-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&root).unwrap(); + Self(root) + } + + fn write(&self, relative: &str, text: &str) { + let path = self.0.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + + fn workspace(&self, root: &str) -> Workspace { + let mut ws = Workspace::default(); + ws.roots.push(self.0.join(root)); + ws.load_stubs(); + ws.scan(); + ws + } + + fn document(&self, ws: &mut Workspace, relative: &str, text: &str) -> Document { + let path = self.0.join(relative); + let mut doc = Document::new(path_to_uri(&path), path, 1, text.to_string()); + doc.file = ws.index_parsed(&doc.path, FileOrigin::Workspace, &doc.text, &doc.chunk, &doc.resolution); + doc + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + if let (Ok(root), Ok(temp)) = (self.0.canonicalize(), std::env::temp_dir().canonicalize()) { + if root.parent() == Some(temp.as_path()) { + let _ = std::fs::remove_dir_all(root); + } + } + } +} + +const GUARDED: &str = "if IsDuplicityVersion() then\n\ + RegisterNetEvent('demo:server', function(serverValue) end)\n\ + RegisterNetEvent('demo:both', function(serverValue) end)\n\ + lib.callback.register('demo:callback', function(source, serverValue) end)\n\ +else\n\ + AddEventHandler('demo:client', function(clientValue) end)\n\ + AddEventHandler('demo:both', function(clientValue) end)\n\ + lib.callback.register('demo:callback', function(clientValue) end)\n\ +end\n"; + +fn event_workspace() -> (Fixture, Workspace) { + let fixture = Fixture::new(); + fixture.write("demo/fxmanifest.lua", "shared_script 'shared.lua'\nclient_script 'client.lua'\n"); + fixture.write("demo/shared.lua", GUARDED); + fixture.write("demo/client.lua", ""); + let ws = fixture.workspace("demo"); + (fixture, ws) +} + +#[test] +fn guarded_event_diagnostics_match_the_cli() { + let (fixture, mut ws) = event_workspace(); + let refs = ws.crossrefs(); + let mut cli = CrossRefs::default(); + cli.collect(&qbx_lua_syntax::parse(GUARDED), Some(Side::Shared), Some("demo")); + for (name, expected) in cli.events { + let actual = &refs.events[&name]; + assert_eq!(actual.len(), expected.len(), "{name}"); + for (actual, expected) in actual.iter().zip(expected) { + assert_eq!(actual.side, expected.side, "{name}"); + assert_eq!(actual.handler, expected.handler, "{name}"); + } + } + let doc = fixture.document(&mut ws, "demo/client.lua", "TriggerEvent('demo:server', 1)\n"); + let found = diagnostics::diagnostics(&ws, &doc, &[], &ws.crossrefs()); + assert!(found.iter().any(|d| d.code == Some(NumberOrString::String("fivem/event-wrong-side".into())))); +} + +fn labels(ws: &Workspace, doc: &Document) -> Vec { + let offset = doc.text.rfind("''").unwrap() + 1; + let result = completion::completion(ws, doc, doc.position(offset as u32), true).unwrap(); + let items = match result { + CompletionResponse::List(list) => list.items, + CompletionResponse::Array(items) => items, + }; + items.into_iter().map(|item| item.label).collect() +} + +fn payload(ws: &Workspace, doc: &Document) -> Option> { + let offset = doc.text.rfind("1)").unwrap(); + let site = qbx_lua_ls::locate::locate(&doc.chunk, offset as u32).call.unwrap(); + let event = qbx_lua_ls::features::with_infer(ws, doc, |infer| { + event_call::event_call(ws, doc, infer, site.base, site.args, Some(&FunType::default())) + })?; + Some(event.fun.params.into_iter().map(|p| p.name.to_string()).collect()) +} + +#[test] +fn guarded_event_completion_and_signatures_follow_both_sides() { + let (fixture, mut ws) = event_workspace(); + for (guard, wanted, absent) in [ + ("IsDuplicityVersion()", "demo:server", "demo:client"), + ("not IsDuplicityVersion()", "demo:client", "demo:server"), + ] { + let text = format!("if {guard} then\nTriggerEvent('')\nend"); + let doc = fixture.document(&mut ws, "demo/caller.lua", &text); + let found = labels(&ws, &doc); + assert!(found.iter().any(|name| name == wanted), "{found:?}"); + assert!(!found.iter().any(|name| name == absent), "{found:?}"); + } + for (call, expected) in [("TriggerServerEvent", "serverValue"), ("TriggerEvent", "clientValue")] { + let text = format!("{call}('demo:both', 1)"); + let doc = fixture.document(&mut ws, "demo/client.lua", &text); + assert_eq!(payload(&ws, &doc).unwrap().last().unwrap(), expected); + } + // A client callback has no implicit player/source argument, even in a shared file. + for (guard, expected) in [("IsDuplicityVersion()", "clientValue"), ("not IsDuplicityVersion()", "serverValue")] { + let text = format!("if {guard} then\nlib.callback.await('demo:callback', false, 1)\nend"); + let doc = fixture.document(&mut ws, "demo/caller.lua", &text); + let params = payload(&ws, &doc).unwrap(); + assert_eq!(params.len(), 3, "{params:?}"); + assert_eq!(params[2], expected); + } + let doc = fixture.document(&mut ws, "demo/client.lua", "TriggerEvent('demo:server', 1)"); + assert!(payload(&ws, &doc).is_none(), "an unreachable handler must not supply a signature"); +} + +#[test] +fn scan_reloads_manifests_and_removes_deleted_and_excluded_state() { + let fixture = Fixture::new(); + fixture.write("demo/fxmanifest.lua", "client_script '*.lua'"); + fixture.write("demo/main.lua", "Current = 1"); + fixture.write("demo/deleted.lua", "Deleted = 1"); + fixture.write("demo/skip/old.lua", "Excluded = 1"); + fixture.write("empty/fxmanifest.lua", "fx_version 'cerulean'"); + let mut ws = fixture.workspace(""); + assert!(ws.index.resource_by_name("empty").is_some()); + let main = fixture.0.join("demo/main.lua"); + assert_eq!(ws.index.file(ws.index.file_id(&main).unwrap()).unwrap().side, Some(Side::Client)); + fixture.write("demo/fxmanifest.lua", "server_script '*.lua'"); + fixture.write("qbxlint.toml", "exclude = ['demo/skip/**']"); + std::fs::remove_file(fixture.0.join("demo/deleted.lua")).unwrap(); + std::fs::remove_file(fixture.0.join("empty/fxmanifest.lua")).unwrap(); + ws.scan(); + assert_eq!(ws.index.file(ws.index.file_id(&main).unwrap()).unwrap().side, Some(Side::Server)); + assert!(ws.index.file_id(&fixture.0.join("demo/deleted.lua")).is_none()); + assert!(ws.index.file_id(&fixture.0.join("demo/skip/old.lua")).is_none()); + assert!(ws.index.resource_by_name("empty").is_none()); + std::fs::remove_file(fixture.0.join("demo/fxmanifest.lua")).unwrap(); + ws.scan(); + assert!(ws.index.resources.is_empty()); + let entry = ws.index.file(ws.index.file_id(&main).unwrap()).unwrap(); + assert!(entry.resource.is_none()); + assert!(entry.side.is_none()); +} + +#[test] +fn configured_imports_define_globals_by_side_and_follow_the_config() { + let fixture = Fixture::new(); + fixture.write( + "qbxlint.toml", + "[[overrides]]\nfiles = ['[[]lib[]]/**']\n\ + [overrides.imports]\nshared = ['@lib/shared/**.lua']\nclient = ['@lib/client/*.lua']\n", + ); + fixture.write("lib/fxmanifest.lua", "files { 'shared/**.lua', 'client/*.lua' }"); + fixture.write("lib/shared/deep/api.lua", "SharedApi = {}\n"); + fixture.write("lib/client/api.lua", "function ClientApi() end\n"); + fixture.write("[lib]/shop/fxmanifest.lua", "client_script 'client.lua'\nserver_script 'server.lua'\n"); + fixture.write("other/fxmanifest.lua", "client_script 'client.lua'\n"); + let undefined = |ws: &mut Workspace, relative: &str| -> Vec { + let doc = fixture.document(ws, relative, "print(SharedApi, ClientApi)\n"); + let found = diagnostics::diagnostics(ws, &doc, &[], &ws.crossrefs()); + let undefined = found.into_iter().filter(|d| d.code == Some(NumberOrString::String("undefined-global".into()))); + undefined.map(|d| d.message.split('\'').nth(1).unwrap().to_string()).collect() + }; + + let mut ws = fixture.workspace(""); + assert_eq!(undefined(&mut ws, "[lib]/shop/client.lua"), Vec::::new()); + assert_eq!(undefined(&mut ws, "[lib]/shop/server.lua"), ["ClientApi"]); + assert_eq!(undefined(&mut ws, "other/client.lua"), ["SharedApi", "ClientApi"]); + let file = |ws: &Workspace, relative: &str| ws.index.file_id(&fixture.0.join(relative)).unwrap(); + let client = file(&ws, "[lib]/shop/client.lua"); + assert_eq!(ws.index.globals_named("ClientApi", client).len(), 1, "definitions reach the imported file"); + assert_eq!(ws.index.globals_named("ClientApi", file(&ws, "[lib]/shop/server.lua")).len(), 0); + + fixture.write("qbxlint.toml", ""); + ws.scan(); + assert_eq!(undefined(&mut ws, "[lib]/shop/client.lua"), ["SharedApi", "ClientApi"]); + assert_eq!(ws.index.globals_named("ClientApi", file(&ws, "[lib]/shop/client.lua")).len(), 0); +} + +#[test] +fn scan_rediscovers_missing_external_dependencies_and_prunes_removed_libraries() { + let fixture = Fixture::new(); + fixture.write("demo/fxmanifest.lua", "shared_script '@external/init.lua'"); + fixture.write("demo/main.lua", "print(External)"); + let mut ws = fixture.workspace("demo"); + assert!(ws.index.resource_by_name("external").is_none()); + fixture.write("external/fxmanifest.lua", "shared_script 'init.lua'"); + fixture.write("external/init.lua", "External = true"); + ws.scan(); + let (_, resource) = ws.index.resource_by_name("demo").unwrap(); + assert_eq!(resource.imports.len(), 1); + assert!(ws.index.resource_by_name("external").is_some()); + fixture.write("library/fxmanifest.lua", "shared_script 'library.lua'"); + fixture.write("library/library.lua", "Library = true"); + ws.library.push(fixture.0.join("library")); + ws.scan(); + assert!(ws.index.resource_by_name("library").is_some()); + ws.library.clear(); + fixture.write("demo/fxmanifest.lua", "shared_script 'main.lua'"); + ws.scan(); + assert!(ws.index.resource_by_name("external").is_none()); + assert!(ws.index.resource_by_name("library").is_none()); +} + +struct Client { + connection: Connection, + server: Option>, + next: i32, +} + +impl Client { + fn start(root: &Path) -> Self { + let (server, connection) = Connection::memory(); + let server = std::thread::Builder::new() + .stack_size(16 * 1024 * 1024) + .spawn(move || { + qbx_lua_ls::server::run_connection(server).unwrap(); + }) + .unwrap(); + let mut client = Self { connection, server: Some(server), next: 0 }; + client.request("initialize", json!({"processId": null, "rootUri": path_to_uri(root), "capabilities": {}})); + client.notify("initialized", json!({})); + client + } + + fn notify(&self, method: &str, params: Value) { + self.connection.sender.send(Message::Notification(Notification::new(method.to_string(), params))).unwrap(); + } + + fn request(&mut self, method: &str, params: Value) -> Value { + self.next += 1; + let id = RequestId::from(self.next); + self.connection.sender.send(Message::Request(Request::new(id.clone(), method.to_string(), params))).unwrap(); + loop { + match self.connection.receiver.recv_timeout(Duration::from_secs(20)).expect("server did not respond") { + Message::Response(response) if response.id == id => { + assert!(response.error.is_none(), "{:?}", response.error); + return response.result.unwrap_or(Value::Null); + } + Message::Request(request) => { + self.connection.sender.send(Message::Response(Response::new_ok(request.id, Value::Null))).unwrap(); + } + _ => {} + } + } + } + + fn open(&self, uri: &Url, text: &str) { + self.notify( + "textDocument/didOpen", + json!({"textDocument": { + "uri": uri, "languageId": "lua", "version": 1, "text": text + }}), + ); + } +} + +impl Drop for Client { + fn drop(&mut self) { + self.request("shutdown", Value::Null); + self.notify("exit", Value::Null); + self.server.take().unwrap().join().unwrap(); + } +} + +#[test] +fn new_glob_imports_are_linked_for_closed_open_and_unsaved_documents() { + for (open, saved, watched) in [(false, true, true), (true, true, true), (true, false, false)] { + let fixture = Fixture::new(); + fixture.write("qbxlint.toml", "[imports]\nclient = ['@lib/*.lua']\n"); + fixture.write("lib/fxmanifest.lua", "files { '*.lua' }\n"); + fixture.write("lib/old.lua", "OldApi = true\n"); + fixture.write("shop/fxmanifest.lua", "client_script 'client.lua'\n"); + fixture.write("shop/client.lua", "print(NewApi)\n"); + let mut client = Client::start(&fixture.0); + // Finish the initial scan before introducing the imported file. + client.request("qbx/status", Value::Null); + let caller = path_to_uri(&fixture.0.join("shop/client.lua")); + let imported = path_to_uri(&fixture.0.join("lib/new.lua")); + client.open(&caller, "print(NewApi)\n"); + if saved { + fixture.write("lib/new.lua", "NewApi = true\n"); + } + if open { + client.open(&imported, "NewApi = true\n"); + } + if watched { + client.notify("workspace/didChangeWatchedFiles", json!({"changes": [{"uri": imported, "type": 1}]})); + } + let definitions = client.request( + "textDocument/definition", + json!({ + "textDocument": {"uri": caller}, "position": {"line": 0, "character": 8} + }), + ); + let definitions = definitions.as_array().expect("new glob import must have a definition"); + assert!(definitions.iter().any(|definition| definition["uri"] == imported.as_str()), "{definitions:?}"); + } +} + +#[test] +fn configured_exact_imports_respect_exclusions() { + let fixture = Fixture::new(); + fixture.write("qbxlint.toml", "exclude = ['lib/api.lua']\n[imports]\nclient = ['@lib/api.lua']\n"); + fixture.write("lib/fxmanifest.lua", "files { 'api.lua' }\n"); + fixture.write("lib/api.lua", "ExcludedApi = true\n"); + fixture.write("shop/fxmanifest.lua", "client_script 'client.lua'\n"); + fixture.write("shop/client.lua", "print(ExcludedApi)\n"); + let ws = fixture.workspace(""); + let caller = ws.index.file_id(&fixture.0.join("shop/client.lua")).unwrap(); + let resource = ws.index.file(caller).unwrap().resource.unwrap(); + assert!(!ws.resource_env(resource).defines("ExcludedApi", Some(Side::Client))); + assert!(ws.index.globals_named("ExcludedApi", caller).is_empty()); +} + +#[test] +fn manual_reindex_restores_unsaved_documents_and_their_new_file_ids() { + let fixture = Fixture::new(); + fixture.write("demo/fxmanifest.lua", "client_script '*.lua'"); + fixture.write("demo/a_deleted.lua", "Old = true"); + fixture.write("demo/main.lua", "DiskOnly = 1"); + let mut client = Client::start(&fixture.0); + let main_uri = path_to_uri(&fixture.0.join("demo/main.lua")); + let scratch_uri = path_to_uri(&fixture.0.join("demo/scratch.lua")); + let manifest_uri = path_to_uri(&fixture.0.join("demo/fxmanifest.lua")); + client.open(&main_uri, "UnsavedOnly = 42\nprint(UnsavedOnly)"); + client.open(&scratch_uri, "ScratchOnly = true"); + client.open(&manifest_uri, "client_script '*.lua'"); + client.request("qbx/status", Value::Null); + std::fs::remove_file(fixture.0.join("demo/a_deleted.lua")).unwrap(); + fixture.write("demo/fxmanifest.lua", "server_script '*.lua'"); + client.request("qbx/reindex", Value::Null); + assert_eq!(client.request("qbx/fileInfo", json!({"uri": main_uri}))["side"], "server"); + assert_eq!(client.request("qbx/fileInfo", json!({"uri": scratch_uri}))["side"], "server"); + for name in ["UnsavedOnly", "ScratchOnly"] { + let symbols = client.request("workspace/symbol", json!({"query": name})); + assert!(symbols.as_array().unwrap().iter().any(|s| s["name"] == name), "{symbols}"); + } + for name in ["DiskOnly", "Old"] { + let symbols = client.request("workspace/symbol", json!({"query": name})); + assert!(symbols.as_array().unwrap().is_empty(), "{symbols}"); + } + let hover = client.request( + "textDocument/hover", + json!({ + "textDocument": {"uri": main_uri}, "position": {"line": 1, "character": 9} + }), + ); + assert!(hover["contents"]["value"].as_str().unwrap().contains("42"), "{hover}"); + client.notify("textDocument/didClose", json!({"textDocument": {"uri": scratch_uri}})); + client.request("qbx/reindex", Value::Null); + let symbols = client.request("workspace/symbol", json!({"query": "ScratchOnly"})); + assert!(symbols.as_array().unwrap().is_empty(), "{symbols}"); +} diff --git a/docs/repository-migration.md b/docs/repository-migration.md new file mode 100644 index 0000000..88660e9 --- /dev/null +++ b/docs/repository-migration.md @@ -0,0 +1,61 @@ +# Combined Lua tooling workspace + +`Qbox-project/qbx-lint` contains the linter, language server, and their shared Rust crates. +The editor adapters remain in `Qbox-project/qbx-editor`. A change to shared analysis and LSP +behavior now belongs in one pull request, checked by the workspace CI on Windows and Linux. + +## Preserved history + +The language server was imported into `crates/qbx_lua_ls` with a full-history subtree merge, +without squashing or rewriting its commits. The import joins the two existing commit graphs: + +- Linter source tip: `6cdc955`. +- Language-server source tip: `3957972`. + +All commits reachable from those tips retain their original IDs, authors, committers, +timestamps and messages. Workspace integration is recorded in subsequent commits. Historical +server commits still describe paths at their original repository root; use `git log --follow -m` +for the history of an individual moved file. + +The migration pull request must be merged with **Create a merge commit**. Squashing or rebasing +the migration would not retain the imported commit graph on the default branch. + +The original repositories both used `v1.0.x` tags. Keep the linter tags unchanged and preserve +the server's historical tag objects under `lua-ls/v1.0.x`, avoiding collisions: + +```sh +git fetch --no-tags https://github.com/Qbox-project/qbx-lua-ls.git \ + 'refs/tags/*:refs/tags/lua-ls/*' +git push origin 'refs/tags/lua-ls/*:refs/tags/lua-ls/*' +``` + +These names do not trigger the release workflow's `v*` tag filter. Old GitHub release pages +and any closed issues or pull requests remain available in the original server repository. + +## Build and release compatibility + +One root `Cargo.toml` controls versions and dependencies, with one `Cargo.lock` and `target/` +directory. The executables remain `qbx-lint` and `qbx-lua-ls`. + +The release workflow builds each tool separately for the existing five platforms, using the +same archive names as before. CLI builds use `lint-release` with abort-on-panic and without +the server's documentation features. Server builds use `release` with panic unwinding, +preserving LSP request recovery. A new `vX.Y.Z` release contains both sets of archives. + +The root `action.yml` and `Qbox-project/qbx-lint@` action address remain available. +Existing linter tags and their release assets are unchanged. Existing server releases remain +downloadable from `Qbox-project/qbx-lua-ls`; future releases are published here. + +## Cutover order + +1. Merge the workspace migration with a merge commit and publish the namespaced historical + server tags. Do not recreate or move existing `v1.0.x` tags. +2. Bump the workspace to the next unused version and publish a new shared release, such as + `v1.0.5`. Check that it contains both tools for all five platforms and their checksums. +3. Merge the companion `qbx-editor` migration. Its CI and development scripts use this + workspace, its release builds use the matching workspace tag, and Zed downloads server + archives from this repository. Publish the editor using the new shared server version. +4. Add a migration notice to `Qbox-project/qbx-lua-ls` and archive it after the editor cutover. + Keep its historical releases available for existing editor versions and download links. + +Do not publish an editor release using an old linter tag: those tags predate the server import.