From 0bda3b822e797594e39602cead22b1e84d2466fe Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:01:25 +0200 Subject: [PATCH 01/57] feat: add low-memory FiveM Lua language server Closed files are reduced to a compact symbol summary and only open documents keep a syntax tree, which keeps the resident set around 10 MB for a handful of resources. Types come from LuaCATS annotations, table shapes, natives and manifest-aware globals; the lint rules are shared with qbx-lint so the editor and CI agree. --- .gitattributes | 1 + .gitignore | 1 + Cargo.lock | 742 ++++++++++++++++ Cargo.toml | 33 + LICENSE | 674 ++++++++++++++ rustfmt.toml | 2 + scripts/bench.mjs | 93 ++ src/document.rs | 65 ++ src/features/code_action.rs | 48 + src/features/completion.rs | 487 ++++++++++ src/features/definition.rs | 61 ++ src/features/diagnostics.rs | 88 ++ src/features/folding.rs | 91 ++ src/features/hover.rs | 195 ++++ src/features/inlay.rs | 78 ++ src/features/mod.rs | 29 + src/features/references.rs | 136 +++ src/features/semantic_tokens.rs | 194 ++++ src/features/signature.rs | 63 ++ src/features/symbols.rs | 179 ++++ src/index.rs | 365 ++++++++ src/indexer.rs | 531 +++++++++++ src/infer.rs | 832 ++++++++++++++++++ src/lib.rs | 10 + src/locate.rs | 148 ++++ src/luacats.rs | 318 +++++++ src/main.rs | 40 + src/server.rs | 448 ++++++++++ src/types.rs | 573 ++++++++++++ src/workspace.rs | 235 +++++ .../resources/[core]/mylib/fxmanifest.lua | 7 + .../fixtures/resources/[core]/mylib/init.lua | 54 ++ .../[core]/mylib/modules/settings.lua | 6 + .../resources/[core]/mylib/server.lua | 14 + .../resources/myresource/client/main.lua | 18 + .../resources/myresource/fxmanifest.lua | 12 + .../resources/myresource/server/main.lua | 11 + .../resources/myresource/shared/config.lua | 11 + tests/lsp.rs | 429 +++++++++ 39 files changed, 7322 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 rustfmt.toml create mode 100644 scripts/bench.mjs create mode 100644 src/document.rs create mode 100644 src/features/code_action.rs create mode 100644 src/features/completion.rs create mode 100644 src/features/definition.rs create mode 100644 src/features/diagnostics.rs create mode 100644 src/features/folding.rs create mode 100644 src/features/hover.rs create mode 100644 src/features/inlay.rs create mode 100644 src/features/mod.rs create mode 100644 src/features/references.rs create mode 100644 src/features/semantic_tokens.rs create mode 100644 src/features/signature.rs create mode 100644 src/features/symbols.rs create mode 100644 src/index.rs create mode 100644 src/indexer.rs create mode 100644 src/infer.rs create mode 100644 src/lib.rs create mode 100644 src/locate.rs create mode 100644 src/luacats.rs create mode 100644 src/main.rs create mode 100644 src/server.rs create mode 100644 src/types.rs create mode 100644 src/workspace.rs create mode 100644 tests/fixtures/resources/[core]/mylib/fxmanifest.lua create mode 100644 tests/fixtures/resources/[core]/mylib/init.lua create mode 100644 tests/fixtures/resources/[core]/mylib/modules/settings.lua create mode 100644 tests/fixtures/resources/[core]/mylib/server.lua create mode 100644 tests/fixtures/resources/myresource/client/main.lua create mode 100644 tests/fixtures/resources/myresource/fxmanifest.lua create mode 100644 tests/fixtures/resources/myresource/server/main.lua create mode 100644 tests/fixtures/resources/myresource/shared/config.lua create mode 100644 tests/lsp.rs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b2ccdb5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,742 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "qbx_fivem_data" +version = "0.1.0" + +[[package]] +name = "qbx_lua_analysis" +version = "0.1.0" +dependencies = [ + "globset", + "qbx_fivem_data", + "qbx_lua_syntax", + "rayon", + "rustc-hash", + "serde", + "smol_str", + "toml", + "walkdir", +] + +[[package]] +name = "qbx_lua_ls" +version = "0.1.0" +dependencies = [ + "crossbeam-channel", + "lsp-server", + "lsp-types", + "qbx_fivem_data", + "qbx_lua_analysis", + "qbx_lua_syntax", + "rustc-hash", + "serde", + "serde_json", + "smol_str", + "walkdir", +] + +[[package]] +name = "qbx_lua_syntax" +version = "0.1.0" +dependencies = [ + "smol_str", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..bc12b91 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "qbx_lua_ls" +description = "A low-memory language server for FiveM Lua" +version = "0.1.0" +edition = "2021" +license = "GPL-3.0-or-later" +repository = "https://github.com/Qbox-project/qbx-lua-ls" +rust-version = "1.85" + +[[bin]] +name = "qbx-lua-ls" +path = "src/main.rs" + +[dependencies] +# Switch these to git dependencies on Qbox-project/qbx-lint once that repository is published. +qbx_lua_syntax = { path = "../qbx-lint/crates/qbx_lua_syntax" } +qbx_lua_analysis = { path = "../qbx-lint/crates/qbx_lua_analysis", features = ["docs"] } +qbx_fivem_data = { path = "../qbx-lint/crates/qbx_fivem_data", features = ["docs"] } +lsp-server = "0.7" +lsp-types = "0.95" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +crossbeam-channel = "0.5" +rustc-hash = "2" +smol_str = "0.3" +walkdir = "2" + +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +strip = true +panic = "abort" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/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/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..c775577 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 120 +use_small_heuristics = "Max" diff --git a/scripts/bench.mjs b/scripts/bench.mjs new file mode 100644 index 0000000..213501f --- /dev/null +++ b/scripts/bench.mjs @@ -0,0 +1,93 @@ +// 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 { pathToFileURL } from 'node:url'; + +const [workspace, fileToOpen, binary = 'target/release/qbx-lua-ls'] = 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/src/document.rs b/src/document.rs new file mode 100644 index 0000000..92aec0a --- /dev/null +++ b/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/src/features/code_action.rs b/src/features/code_action.rs new file mode 100644 index 0000000..95b32f2 --- /dev/null +++ b/src/features/code_action.rs @@ -0,0 +1,48 @@ +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/src/features/completion.rs b/src/features/completion.rs new file mode 100644 index 0000000..385dc49 --- /dev/null +++ b/src/features/completion.rs @@ -0,0 +1,487 @@ +use lsp_types::{ + CompletionItem, CompletionItemKind, CompletionItemTag, CompletionList, CompletionResponse, Documentation, + InsertTextFormat, Position, +}; +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, TokenKind}; +use rustc_hash::FxHashSet; +use serde_json::json; + +use super::{lua_block, markdown, with_infer}; +use crate::document::Document; +use crate::index::{EventKind, FileOrigin, SymbolKind}; +use crate::infer::{Infer, MemberInfo}; +use crate::locate::locate; +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 SNIPPETS: &[(&str, &str, &str)] = &[ + ("CreateThread", "CreateThread(function()\n\t$0\nend)", "Start a new thread"), + ("thread loop", "CreateThread(function()\n\twhile true do\n\t\t$0\n\t\tWait(${1:0})\n\tend\nend)", "Thread with a loop that yields every iteration"), + ("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 EVENT_NAME_CALLS: &[&str] = &[ + "TriggerEvent", + "TriggerServerEvent", + "TriggerClientEvent", + "TriggerLatentServerEvent", + "TriggerLatentClientEvent", + "AddEventHandler", + "RegisterNetEvent", + "RegisterServerEvent", +]; +const CALLBACK_NAME_CALLS: &[&str] = &["lib.callback", "lib.callback.await", "lib.callback.register"]; +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() } +} + +fn member_item(member: &MemberInfo) -> CompletionItem { + let mut out = item(&member.name, kind_of(member.kind, &member.ty), 0); + out.detail = detail_of(&member.name, &member.ty); + 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) -> 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), false)); + } + + let in_string = doc + .chunk + .tokens + .iter() + .position(|t| matches!(t.kind, TokenKind::String | TokenKind::LongString) && t.span.start < offset && offset < t.span.end.max(t.span.start + 1)); + 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 items = with_infer(ws, doc, |infer| member_items(infer, doc, offset, head, via_colon)); + return Some(respond(items, false)); + } + + if doc.is_manifest() { + return Some(respond(manifest_items(before, prefix), 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)); + 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) -> 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(snippet.to_string()); + 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 Some(path) = path else { return Vec::new() }; + let path = path.as_str(); + + if arg_index == 0 && (EVENT_NAME_CALLS.contains(&path) || CALLBACK_NAME_CALLS.contains(&path)) { + let wants_callbacks = CALLBACK_NAME_CALLS.contains(&path); + let mut seen = FxHashSet::default(); + return ws + .index + .events() + .filter(|(_, e)| (e.kind == EventKind::Callback) == wants_callbacks || e.kind == EventKind::Trigger) + .filter(|(_, e)| seen.insert(e.name.clone())) + .map(|(file, event)| { + let mut out = item(&event.name, CompletionItemKind::EVENT, 0); + let origin = ws.index.file(file).and_then(|f| f.resource).and_then(|r| ws.index.resource(r)); + out.detail = match (&event.handler, origin) { + (Some(handler), Some(resource)) => Some(format!("{} · {}", resource.name, handler.signature(""))), + (None, Some(resource)) => Some(resource.name.to_string()), + (Some(handler), None) => Some(handler.signature("")), + (None, None) => None, + }; + out + }) + .collect(); + } + 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() +} + +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) -> Vec { + if before.trim_start().len() != prefix.len() { + return Vec::new(); + } + KNOWN_DIRECTIVES + .iter() + .map(|directive| { + let mut out = item(directive, CompletionItemKind::PROPERTY, 0); + 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) -> 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 + }) + .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) -> (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)); + } + for (label, body, description) in SNIPPETS.iter().filter(|(label, ..)| matches(label)) { + let mut out = item(label, CompletionItemKind::SNIPPET, 4); + out.insert_text = Some(body.to_string()); + out.insert_text_format = Some(InsertTextFormat::SNIPPET); + out.detail = Some(description.to_string()); + out.filter_text = Some(label.to_string()); + items.push(out); + } + + let mut incomplete = false; + if prefix.len() >= 3 { + let side = ws.index.file(doc.file).and_then(|f| f.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/src/features/definition.rs b/src/features/definition.rs new file mode 100644 index 0000000..8b8e1e8 --- /dev/null +++ b/src/features/definition.rs @@ -0,0 +1,61 @@ +use lsp_types::{GotoDefinitionResponse, Location, Position, Range}; +use qbx_lua_syntax::ast::ExprKind; + +use super::hover::{target_at, Target}; +use super::with_infer; +use crate::document::Document; +use crate::index::{EventKind, FileId, FileOrigin}; +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, 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().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(); + } + ws.index + .events() + .filter(|(_, e)| e.name == *value && e.kind != EventKind::Trigger) + .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() + } + None => string_definition(ws, doc, offset), + }); + (!locations.is_empty()).then_some(GotoDefinitionResponse::Array(locations)) +} diff --git a/src/features/diagnostics.rs b/src/features/diagnostics.rs new file mode 100644 index 0000000..0888f6c --- /dev/null +++ b/src/features/diagnostics.rs @@ -0,0 +1,88 @@ +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 diagnostics(ws: &Workspace, doc: &Document, rule_overrides: &[(String, Level)]) -> Vec { + let mut config = ws.lint_config.for_file(&doc.path); + for (code, level) in rule_overrides { + config.set(code, *level); + } + 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); + check_manifest(&ManifestInput { + source: &doc.text, + chunk: &doc.chunk, + manifest: &manifest, + config: &config, + resource_files: &all_files(&resource.root), + has_lua_scripts: !resource.files.is_empty(), + }) + } else { + let summary = summarize(&doc.chunk, &doc.resolution); + let env = resource_id.map(|id| ws.resource_env(id)); + let resource_input = match (resource, &env) { + (Some(resource), Some(env)) => Some(ResourceInput { name: &resource.name, env, manifest: &resource.manifest }), + _ => None, + }; + check_file(&FileInput { + source: &doc.text, + chunk: &doc.chunk, + resolution: &doc.resolution, + summary: &summary, + config: &config, + side: entry.and_then(|f| f.side), + resource: resource_input, + }) + }; + + found + .into_iter() + .map(|d| { + let fix = d.fix.as_ref().map(|fix| FixData { + title: fix.title.clone(), + edits: fix.edits.iter().map(|e| (doc.range(e.span), e.new_text.clone())).collect(), + }); + Diagnostic { + range: doc.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/src/features/folding.rs b/src/features/folding.rs new file mode 100644 index 0000000..616713b --- /dev/null +++ b/src/features/folding.rs @@ -0,0 +1,91 @@ +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/src/features/hover.rs b/src/features/hover.rs new file mode 100644 index 0000000..5bd67eb --- /dev/null +++ b/src/features/hover.rs @@ -0,0 +1,195 @@ +use lsp_types::{Hover, HoverContents, Position}; +use qbx_fivem_data::{native, native_docs}; +use qbx_lua_analysis::scope::{LocalId, LocalKind, Resolved}; +use qbx_lua_syntax::ast::ExprKind; +use qbx_lua_syntax::{SmolStr, Span}; + +use super::{lua_block, markdown, with_infer}; +use crate::document::Document; +use crate::index::{FileOrigin, SymbolKind}; +use crate::indexer::render_doc; +use crate::infer::{Decl, Infer, MemberInfo}; +use crate::locate::locate; +use crate::types::Type; +use crate::workspace::Workspace; + +pub enum Target { + Local(LocalId, Span), + Global(SmolStr, Span), + Member { info: MemberInfo, owner: Type, span: Span }, +} + +impl Target { + pub fn span(&self) -> Span { + match self { + Target::Local(_, span) | Target::Global(_, span) | Target::Member { span, .. } => *span, + } + } +} + +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(); + 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 }); + } + None +} + +fn describe_value(prefix: &str, name: &str, ty: &Type) -> String { + match ty.as_fun() { + Some(fun) if matches!(ty, Type::Fun(_)) => format!("{prefix}{}", fun.signature(name)), + _ => format!("{prefix}{name}: {ty}"), + } +} + +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(prefix, &local.name, &ty)); + 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 Some((file, symbol)) = symbols.iter().find(|(_, s)| !s.ty.is_unknown()).or(symbols.first()) 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("", name, &ty))); + }; + let ty = if symbol.ty.is_unknown() { infer.global_type(name) } else { symbol.ty.clone() }; + let mut out = lua_block(&describe_value("", name, &ty)); + 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(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(prefix, &qualified, &info.ty)); + 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 string_hover(ws: &Workspace, doc: &Document, offset: u32) -> Option<(String, Span)> { + let located = locate(&doc.chunk, offset); + let (string, _) = located.string?; + let ExprKind::String(value) = &string.kind else { return None }; + let registrations: Vec<_> = + ws.index.events().filter(|(_, e)| e.name == *value && e.kind != crate::index::EventKind::Trigger).collect(); + if registrations.is_empty() { + return None; + } + let mut out = format!("event `{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.as_ref().map(|h| format!(" `{}`", h.signature(""))).unwrap_or_default(); + out.push_str(&format!("\n- {side} `{name}:{}`{handler}", event.range.start.line + 1)); + } + 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 string_hover(ws, 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(info, owner)), + }; + text.map(|t| (t, target.span())) + })?; + Some(Hover { contents: HoverContents::Markup(markdown(text)), range: Some(doc.range(span)) }) +} diff --git a/src/features/inlay.rs b/src/features/inlay.rs new file mode 100644 index 0000000..5b91029 --- /dev/null +++ b/src/features/inlay.rs @@ -0,0 +1,78 @@ +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::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>, + 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 Some((fun, _)) = self.infer.callee_fun(base, method) 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, range: span, out: Vec::new() }; + hints.visit_block(&doc.chunk.block); + hints.out + }) +} diff --git a/src/features/mod.rs b/src/features/mod.rs new file mode 100644 index 0000000..6b6d941 --- /dev/null +++ b/src/features/mod.rs @@ -0,0 +1,29 @@ +pub mod code_action; +pub mod completion; +pub mod definition; +pub mod diagnostics; +pub mod folding; +pub mod hover; +pub mod inlay; +pub mod references; +pub mod semantic_tokens; +pub mod signature; +pub mod symbols; + +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/src/features/references.rs b/src/features/references.rs new file mode 100644 index 0000000..8c181e5 --- /dev/null +++ b/src/features/references.rs @@ -0,0 +1,136 @@ +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 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)> { + 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 let Some(open) = docs.get(&entry.uri) { + for global in open.resolution.globals.iter().filter(|g| g.name == name) { + out.push((entry.uri.clone(), open.range(global.span), global.kind != GlobalRefKind::Read)); + } + continue; + } + let Ok(source) = read_source(&entry.path) else { continue }; + if !mentions(&source) { + continue; + } + let resolution = resolve(&parse(&source)); + let lines = LineIndex::new(&source); + for global in resolution.globals.iter().filter(|g| g.name == name) { + out.push((entry.uri.clone(), span_to_range(&source, &lines, global.span), global.kind != GlobalRefKind::Read)); + } + } + out +} + +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 { return Vec::new() }; + 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(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 { return Vec::new() }; + 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(doc: &Document, position: Position) -> Option { + let offset = doc.offset(position); + let (resolved, span) = doc.resolution.resolved_at_offset(offset)?; + 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 { + let 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())); + } + } + Some(WorkspaceEdit { changes: Some(changes), ..WorkspaceEdit::default() }) +} diff --git a/src/features/semantic_tokens.rs b/src/features/semantic_tokens.rs new file mode 100644 index 0000000..61c91fe --- /dev/null +++ b/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/src/features/signature.rs b/src/features/signature.rs new file mode 100644 index 0000000..8e8feef --- /dev/null +++ b/src/features/signature.rs @@ -0,0 +1,63 @@ +use lsp_types::{Documentation, ParameterInformation, ParameterLabel, Position, SignatureHelp, SignatureInformation}; +use qbx_fivem_data::native_docs; +use qbx_lua_syntax::ast::ExprKind; + +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 (fun, member) = infer.callee_fun(site.base, site.method)?; + 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)); + } + } + + 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/src/features/symbols.rs b/src/features/symbols.rs new file mode 100644 index 0000000..1dfe65c --- /dev/null +++ b/src/features/symbols.rs @@ -0,0 +1,179 @@ +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/src/index.rs b/src/index.rs new file mode 100644 index 0000000..e6580fa --- /dev/null +++ b/src/index.rs @@ -0,0 +1,365 @@ +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, + pub range: Range, +} + +#[derive(Clone, Debug)] +pub struct Member { + pub owner: SmolStr, + pub symbol: Symbol, +} + +#[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, Debug)] +pub struct EventDef { + pub name: SmolStr, + pub kind: EventKind, + pub handler: Option>, + pub range: Range, +} + +#[derive(Clone, Debug, Default)] +pub struct FileIndex { + pub globals: Vec, + pub members: Vec, + pub classes: Vec, + pub aliases: Vec, + pub exports: Vec, + pub events: Vec, + pub module_return: Option, + 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)>, +} + +type Slot = (FileId, u32); + +#[derive(Debug, Default)] +pub struct Index { + files: Vec>, + by_path: FxHashMap, + pub resources: Vec, + globals: FxHashMap>, + members: 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 { + 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, 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.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)> { + let Some(slots) = slots else { return Vec::new() }; + let resolve = |(file, i): &Slot| Some((*file, get(self.file(*file)?, *i)?)); + let visible: Vec<_> = slots.iter().filter(|(f, _)| self.is_visible(from, *f)).filter_map(resolve).collect(); + if !visible.is_empty() { + return visible; + } + slots.iter().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)) + } + + pub fn members_of(&self, owner: &str, from: FileId) -> Vec<(FileId, &Symbol)> { + self.visible_first(self.members.get(owner), from, |f, i| f.index.members.get(i as usize).map(|m| &m.symbol)) + } + + 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 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/src/indexer.rs b/src/indexer.rs new file mode 100644 index 0000000..29ff1dd --- /dev/null +++ b/src/indexer.rs @@ -0,0 +1,531 @@ +use std::fmt::Write as _; +use std::sync::Arc; + +use lsp_types::{Position, Range}; +use qbx_lua_analysis::scope::{Resolution, Resolved}; +use qbx_lua_analysis::summary::summarize; +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::{Comment, LineIndex, SmolStr, Span}; + +use crate::index::{AliasDef, ClassDef, EventDef, EventKind, FileId, FileIndex, Index, Member, Symbol, SymbolKind}; +use crate::infer::{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) -> 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, 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>, + out: FileIndex, + depth: u32, +} + +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, + 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()), + _ => 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(Expr { kind: ExprKind::Table(fields), .. }) = value { + 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((ExprKind::Table(fields), _)) if table_depth < MAX_TABLE_DEPTH => { + kind = SymbolKind::Table; + if let Some(enum_name) = &doc.enum_name { + self.enum_class(enum_name.clone(), 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(), + range: self.range(name.span), + } + } + + fn enum_class(&mut self, name: SmolStr, fields: &[TableField], span: Span) { + let values: Vec = fields + .iter() + .filter_map(|f| match f { + 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 table_members(&mut self, owner: SmolStr, fields: &[TableField], depth: u32) { + for field in fields.iter().take(MAX_TABLE_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) + } + _ => 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); + } + } + + 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(Expr { kind: ExprKind::Table(fields), .. }) = exprs.get(i) { + 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(), 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)); + } + 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(), + 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() => { + 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 &first.kind { + ExprKind::Table(fields) => { + let owner = SmolStr::new(format!("%mod{}", self.file)); + self.table_members(owner.clone(), fields, 1); + Type::GlobalTable(owner) + } + _ => 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 { 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))), + _ => 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(), + 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 event(&mut self, path: &str, args: &[Expr]) { + let kind = if NET_EVENT_CALLS.contains(&path) { + EventKind::NetEvent + } else if HANDLER_CALLS.contains(&path) { + EventKind::Handler + } else if CALLBACK_CALLS.contains(&path) { + EventKind::Callback + } else if TRIGGER_CALLS.contains(&path) { + 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 = 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, handler, range: self.range(name_arg.span) }); + } + + fn expr(&mut self, expr: &Expr) { + match &expr.kind { + ExprKind::Function(func) => self.func_body(func), + ExprKind::Call { callee, args, .. } => { + if let Some(path) = callee.dotted_path() { + self.event(&path, args); + } + self.expr(callee); + args.iter().for_each(|a| self.expr(a)); + } + ExprKind::MethodCall { base, args, .. } => { + self.expr(base); + args.iter().for_each(|a| self.expr(a)); + } + ExprKind::Index { base, index, .. } => { + self.expr(base); + self.expr(index); + } + ExprKind::Field { base, .. } => 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/src/infer.rs b/src/infer.rs new file mode 100644 index 0000000..1502ee8 --- /dev/null +++ b/src/infer.rs @@ -0,0 +1,832 @@ +use std::cell::{Cell, 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; + +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)) + } +} + +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 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, +} + +fn native_type(name: &str) -> Type { + match name { + "vector3" => Type::named("vector3"), + other => Type::named(other), + } +} + +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, + } +} + +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, .. } => { + 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).find(|ty| !ty.is_unknown()); + if let Some(ty) = known.filter(|ty| !(matches!(ty, Type::Table) && self.index.has_members(name))) { + return 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 && matches!(expr.kind, ExprKind::Table(_)) { + 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, + } + } + + fn expected_param(&self, expected: &Expected, index: usize) -> Option { + let fun = match &expected.call.kind { + ExprKind::Call { callee, .. } => self.expr(callee).as_fun().cloned(), + ExprKind::MethodCall { base, method, .. } => { + self.member(&self.expr(base), &method.text).and_then(|m| m.ty.as_fun().cloned()) + } + _ => None, + }?; + let (skip_params, skip_args) = fun.call_offsets(matches!(expected.call.kind, ExprKind::MethodCall { .. })); + let param_index = (expected.arg_index + skip_params).checked_sub(skip_args)?; + let callback = fun.params.get(param_index)?.ty.as_fun()?.clone(); + callback.params.get(index).map(|p| p.ty.clone()) + } + + /// 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 }; + if let ExprKind::Call { callee, args, .. } = &first.kind { + let iterator = callee.dotted_path(); + if let (Some("pairs" | "ipairs" | "next"), Some(arg)) = (iterator.as_deref(), args.first()) { + let (key, value) = self.key_value_types(&self.expr(arg)); + let key = if iterator.as_deref() == Some("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() + } + + fn key_value_types(&self, ty: &Type) -> (Type, Type) { + match self.resolve_alias(ty) { + Type::Array(inner) => (Type::Integer, *inner), + Type::Map(k, v) => (*k, *v), + Type::Shape(shape) => match &shape.index { + Some((k, v)) => (k.clone(), v.clone()), + None => (Type::String, Type::union(shape.fields.iter().map(|f| f.ty.clone()))), + }, + Type::Named(name, _) => match self.index.class(&name).and_then(|(_, c)| c.index.clone()) { + Some((k, v)) => (k, v), + None => (Type::String, Type::Unknown), + }, + Type::GlobalTable(_) => (Type::String, Type::Unknown), + Type::Union(types) => { + types.iter().filter(|t| !matches!(t, Type::Nil)).map(|t| self.key_value_types(t)).next().unwrap_or_default() + } + _ => (Type::Unknown, Type::Unknown), + } + } + + 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); + if let ExprKind::String(key) = &index.kind { + if let Some(member) = self.member(&base_ty, key) { + return member.ty; + } + } + match self.resolve_alias(&base_ty.without_nil()) { + 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) => shape.index.as_ref().map(|(_, v)| v.clone()).unwrap_or_default(), + Type::Named(name, _) => { + self.index.class(&name).and_then(|(_, c)| c.index.as_ref().map(|(_, v)| v.clone())).unwrap_or_default() + } + Type::String | Type::StringLit(_) => Type::Unknown, + _ => Type::Unknown, + } + } + + fn table(&self, fields: &[TableField]) -> Type { + let mut shape = Shape::default(); + let mut positional = Vec::new(); + for field in fields.iter().take(MAX_SHAPE_FIELDS) { + match field { + TableField::Named { name, value } => { + shape.fields.push(ShapeField { name: name.text.clone(), ty: self.expr(value).widen(), optional: false }) + } + TableField::Keyed { key, value } => match &key.kind { + ExprKind::String(name) => { + shape.fields.push(ShapeField { name: name.clone(), ty: self.expr(value).widen(), optional: false }) + } + _ => shape.index = Some((self.expr(key).widen(), self.expr(value).widen())), + }, + TableField::SetMember(name) => { + shape.fields.push(ShapeField { name: name.text.clone(), ty: Type::Boolean, optional: false }) + } + TableField::Positional(value) => positional.push(self.expr(value).widen()), + } + } + if shape.fields.is_empty() && shape.index.is_none() && !positional.is_empty() { + return Type::Array(Box::new(Type::union(positional))); + } + if fields.is_empty() { + return Type::Table; + } + 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() { + 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 generics = self.bind_generics(&fun, args, method.is_some()); + fun.returns.iter().map(|ret| substitute(ret, &generics)).collect() + } + + fn bind_generics(&self, fun: &FunType, args: &[Expr], via_method: bool) -> Vec<(SmolStr, Type)> { + let mut bound = Vec::new(); + let (skip_params, skip_args) = fun.call_offsets(via_method); + for (param, arg) in fun.params.iter().skip(skip_params).zip(args.iter().skip(skip_args)) { + let target = match ¶m.ty { + Type::Named(name, args) if args.is_empty() => Some((name, false)), + Type::Array(inner) => match &**inner { + Type::Named(name, args) if args.is_empty() => Some((name, true)), + _ => None, + }, + _ => None, + }; + let Some((name, in_array)) = target else { continue }; + if self.index.class(name).is_some() || self.index.alias(name).is_some() || name.len() > 2 { + continue; + } + let arg_ty = self.expr(arg).widen(); + let arg_ty = match (in_array, arg_ty) { + (true, Type::Array(inner)) => *inner, + (true, _) => continue, + (false, ty) => ty, + }; + if !arg_ty.is_unknown() && !bound.iter().any(|(n, _)| n == name) { + bound.push((name.clone(), arg_ty)); + } + } + bound + } + + /// 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 = found.iter().position(|m| !m.ty.is_unknown()).unwrap_or(0); + (!found.is_empty()).then(|| 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, + 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, + 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, + 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) { + out.push(member_from_symbol(file, symbol)); + } + } + 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, + 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, + 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::Array(inner) => Type::Array(Box::new(substitute(inner, generics))), + Type::Union(types) => Type::union(types.iter().map(|t| substitute(t, generics))), + Type::Map(k, v) => Type::Map(Box::new(substitute(k, generics)), Box::new(substitute(v, generics))), + other => other.clone(), + } +} + +/// 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/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c6eb38c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,10 @@ +pub mod document; +pub mod features; +pub mod index; +pub mod indexer; +pub mod infer; +pub mod locate; +pub mod luacats; +pub mod server; +pub mod types; +pub mod workspace; diff --git a/src/locate.rs b/src/locate.rs new file mode 100644 index 0000000..f9d6a9b --- /dev/null +++ b/src/locate.rs @@ -0,0 +1,148 @@ +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 }, +} + +impl<'a> MemberAccess<'a> { + pub fn base(&self) -> &'a Expr { + match self { + MemberAccess::Field { base, .. } | MemberAccess::Method { base, .. } => base, + } + } + + pub fn name(&self) -> &'a Name { + match self { + MemberAccess::Field { name, .. } | MemberAccess::Method { name, .. } => name, + } + } + + pub fn is_method(&self) -> bool { + matches!(self, MemberAccess::Method { .. }) + } +} + +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::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/src/luacats.rs b/src/luacats.rs new file mode 100644 index 0000000..71ac75f --- /dev/null +++ b/src/luacats.rs @@ -0,0 +1,318 @@ +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 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 }); + } + FunType { params, returns: self.returns.iter().map(|r| r.ty.clone()).collect(), is_method } + } + + 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())) +} + +/// 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) = line.trim_start().strip_prefix('|') { + if open_alias { + if let Some(alias) = group.aliases.last_mut() { + let mut parser = TypeParser::new(member.trim_start_matches(['>', '+', ' '])); + 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 = rest.strip_prefix("(exact)").map_or(rest, str::trim_start); + 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" => group.enum_name = rest.split_whitespace().next().map(SmolStr::new), + "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 mut rest = rest; + 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('[') { + 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, + }); +} + +#[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 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"); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0337bd6 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,40 @@ +/// `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 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/src/server.rs b/src/server.rs new file mode 100644 index 0000000..8c6c0ef --- /dev/null +++ b/src/server.rs @@ -0,0 +1,448 @@ +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, references, semantic_tokens, signature, symbols, +}; +use crate::index::FileOrigin; +use crate::workspace::{uri_to_path, Workspace}; + +pub type Documents = FxHashMap; + +type AnyResult = Result>; + +#[derive(Debug, Deserialize, Default, Clone)] +#[serde(rename_all = "camelCase", default)] +pub struct DiagnosticSettings { + pub enable: 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, + dirty: 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)), + 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 +} + +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(); + Self { connection, ws, docs: Documents::default(), settings, dirty: 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.register_watchers(); + } + + fn log(&self, message: String) { + self.notify::(LogMessageParams { typ: MessageType::INFO, message }); + } + + 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) { + let watchers = ["**/*.lua", "**/qbxlint.toml", "**/.qbxlint.toml"] + .iter() + .map(|glob| FileSystemWatcher { glob_pattern: GlobPattern::String(glob.to_string()), 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(()); + } + self.flush_index(); + let response = self.handle_request(request); + self.connection.sender.send(Message::Response(response))?; + } + Message::Notification(notification) => self.handle_notification(notification), + Message::Response(_) => {} + } + if self.connection.receiver.is_empty() { + self.publish_dirty(); + } + } + Ok(()) + } + + /// Open documents are reparsed on every edit but only reindexed once something needs the index. + fn flush_index(&mut self) { + for uri in &self.dirty { + if let Some(doc) = self.docs.get_mut(uri) { + if !doc.is_manifest() { + doc.file = self.ws.index_parsed(&doc.path, FileOrigin::Workspace, &doc.text, &doc.chunk, &doc.resolution); + } + } + } + } + + fn publish_dirty(&mut self) { + if self.dirty.is_empty() { + return; + } + self.flush_index(); + self.dirty.clear(); + let uris: Vec = self.docs.keys().cloned().collect(); + for uri in uris { + self.publish(&uri); + } + } + + fn publish(&self, uri: &Url) { + let Some(doc) = self.docs.get(uri) else { return }; + let enabled = self.settings.diagnostics.enable.unwrap_or(true); + let diagnostics = + if enabled { diagnostics::diagnostics(&self.ws, doc, &self.settings.rule_overrides()) } 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.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.notify::(PublishDiagnosticsParams { uri, diagnostics: Vec::new(), version: None }); + } + 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.dirty.extend(self.docs.keys().cloned()); + } + } + _ => {} + } + } + + fn watched_files_changed(&mut self, changes: Vec) { + let mut manifests_changed = false; + for change in changes { + let Some(path) = uri_to_path(&change.uri) else { continue }; + if path.extension().is_some_and(|e| e == "toml") { + if let Some(root) = self.ws.roots.first() { + self.ws.lint_config = qbx_lua_analysis::Config::discover(root).ok().flatten().unwrap_or_default(); + } + } else if is_manifest_file(&path) { + self.ws.reload_manifest(&path); + manifests_changed = true; + } else if change.typ == FileChangeType::DELETED { + self.ws.index.remove_file(&path); + } else if !self.docs.contains_key(&change.uri) { + self.ws.index_path(&path, FileOrigin::Workspace, None); + } + } + if manifests_changed { + self.ws.link_imports(); + } + 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)) + } + 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(doc, p.text_document_position_params.position)) + } + req::PrepareRenameRequest::METHOD => { + let p: TextDocumentPositionParams = params(raw)?; + reply(references::prepare_rename(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)?)) + } + "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/reindex" => { + let stats = self.ws.scan(); + self.dirty.extend(self.docs.keys().cloned()); + Ok(json!({ "files": stats.files, "resources": stats.resources, "millis": stats.millis as u64 })) + } + other => Err(format!("unsupported request: {other}")), + } + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..207bb14 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,573 @@ +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), + 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, +} + +#[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, + 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()), + } + } + + 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::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)) + .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.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((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) => 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, +} + +impl<'a> TypeParser<'a> { + pub fn new(src: &'a str) -> Self { + Self { src, pos: 0, depth: 0 } + } + + 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; + let mut items = Vec::new(); + while !self.eat(b']') && self.pos < self.src.len() { + items.push(self.parse()); + self.eat(b','); + } + Type::Tuple(items) + } + 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 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 self.peek() != b'<' { + return Type::named(name); + } + self.pos += 1; + let mut args = Vec::new(); + while !self.eat(b'>') && self.pos < self.src.len() { + args.push(self.parse()); + self.eat(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 })) + } + + 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(", "|||", ""] { + 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/src/workspace.rs b/src/workspace.rs new file mode 100644 index 0000000..397a01a --- /dev/null +++ b/src/workspace.rs @@ -0,0 +1,235 @@ +use std::path::{Path, PathBuf}; + +use lsp_types::Url; +use qbx_fivem_data::{known_import, Side, STUBS}; +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, side_of, + split_import, ResourceEnv, ResourceLocator, UnresolvedImport, +}; +use qbx_lua_analysis::scope::resolve; +use qbx_lua_analysis::Config; +use qbx_lua_syntax::{parse, SmolStr}; +use rustc_hash::FxHashSet; + +use crate::index::{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, + locator: ResourceLocator, +} + +#[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 index = index_file(id, stub.source, &chunk, &resolution, &self.index); + let uri = Url::parse(&format!("qbx-stub:///{}", stub.name)).expect("static url"); + let side = (stub.side != Side::Shared).then_some(stub.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(); + if let Some(root) = self.roots.first() { + self.lint_config = 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) && self.index_path(&path, origin, None) { + stats.files += 1; + } + } + } + stats.files += self.index_dependencies(); + self.link_imports(); + stats.resources = self.index.resources.len(); + stats.millis = started.elapsed().as_millis(); + stats + } + + /// 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 imports = resource.manifest.imports().filter_map(|s| split_import(&s.pattern)).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 }; + 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(), + }); + 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 { + 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(_) => return false, + } + } + }; + let chunk = parse(source); + let resolution = resolve(&chunk); + self.index_parsed(path, origin, source, &chunk, &resolution); + 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); + 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 mut imports = Vec::new(); + for script in self.index.resources[id].manifest.imports() { + let Some((resource, file)) = split_import(&script.pattern) else { continue }; + let Some((_, target)) = self.index.resource_by_name(resource) else { continue }; + if let Some(file_id) = self.index.file_id(&target.root.join(file)) { + imports.push((file_id, script.side)); + } + } + self.index.resources[id].imports = imports; + } + } + + /// 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 }; + for file in entry.files.iter().filter_map(|id| self.index.file(*id)) { + env.add_summary(&file.index.summary, file.side); + } + for script in entry.manifest.imports().filter(|s| s.pattern.ends_with(".lua")) { + let resolved = split_import(&script.pattern) + .and_then(|(name, file)| Some(self.index.resource_by_name(name)?.1.root.join(file))) + .and_then(|path| self.index.file(self.index.file_id(&path)?)); + if let Some(file) = resolved { + env.add_summary(&file.index.summary, Some(script.side)); + } + match known_import(&script.pattern) { + Some(known) => known.globals.iter().for_each(|g| env.add_global(&SmolStr::new(g), Some(script.side))), + None if resolved.is_none() => { + env.unresolved_imports.push(UnresolvedImport { path: script.pattern.clone(), side: script.side }) + } + None => {} + } + } + env + } +} diff --git a/tests/fixtures/resources/[core]/mylib/fxmanifest.lua b/tests/fixtures/resources/[core]/mylib/fxmanifest.lua new file mode 100644 index 0000000..3017680 --- /dev/null +++ b/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/tests/fixtures/resources/[core]/mylib/init.lua b/tests/fixtures/resources/[core]/mylib/init.lua new file mode 100644 index 0000000..c00affd --- /dev/null +++ b/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/tests/fixtures/resources/[core]/mylib/modules/settings.lua b/tests/fixtures/resources/[core]/mylib/modules/settings.lua new file mode 100644 index 0000000..b6ca269 --- /dev/null +++ b/tests/fixtures/resources/[core]/mylib/modules/settings.lua @@ -0,0 +1,6 @@ +return { + debug = false, + maxGarages = 12, + ---@type GarageKind + defaultKind = 'public', +} diff --git a/tests/fixtures/resources/[core]/mylib/server.lua b/tests/fixtures/resources/[core]/mylib/server.lua new file mode 100644 index 0000000..8a12e8b --- /dev/null +++ b/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/tests/fixtures/resources/myresource/client/main.lua b/tests/fixtures/resources/myresource/client/main.lua new file mode 100644 index 0000000..21a54d5 --- /dev/null +++ b/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/tests/fixtures/resources/myresource/fxmanifest.lua b/tests/fixtures/resources/myresource/fxmanifest.lua new file mode 100644 index 0000000..0565cdf --- /dev/null +++ b/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/tests/fixtures/resources/myresource/server/main.lua b/tests/fixtures/resources/myresource/server/main.lua new file mode 100644 index 0000000..f0287a2 --- /dev/null +++ b/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/tests/fixtures/resources/myresource/shared/config.lua b/tests/fixtures/resources/myresource/shared/config.lua new file mode 100644 index 0000000..57a5046 --- /dev/null +++ b/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/tests/lsp.rs b/tests/lsp.rs new file mode 100644 index 0000000..b96a994 --- /dev/null +++ b/tests/lsp.rs @@ -0,0 +1,429 @@ +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, + root: PathBuf, +} + +fn fixture_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/resources") +} + +impl Client { + fn start(root: PathBuf) -> 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(), root }; + let root_uri = Url::from_file_path(&client.root).unwrap(); + let result = client.request( + "initialize", + json!({ "processId": null, "rootUri": root_uri, "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::Request(request) => { + 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 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 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"], 2); + 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 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); + assert_eq!(client.completion_labels(CLIENT, l, c), ["mylib", "myresource"]); + + 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())); +} + +#[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 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 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)); + } +} From 027e7e8b1c2729b21c9a0eb27dbe44c9ff485f0b Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:08:40 +0200 Subject: [PATCH 02/57] fix(index): resolve tables built with setmetatable, _ENV aliases and lazy library members Probing against ox_lib and qbx_core showed that their main tables were invisible: they are created through setmetatable, published with _ENV.name, extended from lazily loaded files and, for the ox_lib cache, filled through cache:set. A second index pass also settles types that depend on files indexed later. --- scripts/probe.mjs | 55 +++++++++++++++++++++++++++++++++++++++++++ src/features/hover.rs | 5 +++- src/index.rs | 25 +++++++++++++++++++- src/indexer.rs | 46 +++++++++++++++++++++++++++++------- src/infer.rs | 44 +++++++++++++++++++++++++++++----- src/types.rs | 15 ++++++++++++ src/workspace.rs | 15 ++++++++++++ 7 files changed, 189 insertions(+), 16 deletions(-) create mode 100644 scripts/probe.mjs diff --git a/scripts/probe.mjs b/scripts/probe.mjs new file mode 100644 index 0000000..0c017ee --- /dev/null +++ b/scripts/probe.mjs @@ -0,0 +1,55 @@ +// 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') diagnostics = message.params.diagnostics; + } +}); +let diagnostics = []; +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 = 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(' | ')}`); + } +} +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}`).join(', ') || 'none'); +await request('shutdown', null); +send({ jsonrpc: '2.0', method: 'exit' }); diff --git a/src/features/hover.rs b/src/features/hover.rs index 5bd67eb..4d40868 100644 --- a/src/features/hover.rs +++ b/src/features/hover.rs @@ -109,7 +109,10 @@ fn native_hover(name: &str) -> Option { fn global_hover(ws: &Workspace, infer: &Infer, name: &str) -> Option { let symbols = ws.index.globals_named(name, infer.ctx.file); - let Some((file, symbol)) = symbols.iter().find(|(_, s)| !s.ty.is_unknown()).or(symbols.first()) else { + 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); } diff --git a/src/index.rs b/src/index.rs index e6580fa..687022f 100644 --- a/src/index.rs +++ b/src/index.rs @@ -298,8 +298,31 @@ impl Index { 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.visible_first(self.members.get(owner), from, |f, i| f.index.members.get(i as usize).map(|m| &m.symbol)) + let Some(slots) = self.members.get(owner) else { return Vec::new() }; + let resolve = |(file, i): &Slot| Some((*file, &self.file(*file)?.index.members.get(*i as usize)?.symbol)); + 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)) + }; + let visible: Vec<_> = slots.iter().filter(|(f, _)| reachable(*f)).filter_map(resolve).collect(); + if !visible.is_empty() { + return visible; + } + slots.iter().filter_map(resolve).collect() + } + + 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 { diff --git a/src/indexer.rs b/src/indexer.rs index 29ff1dd..33f49a5 100644 --- a/src/indexer.rs +++ b/src/indexer.rs @@ -8,7 +8,7 @@ use qbx_lua_syntax::ast::*; use qbx_lua_syntax::{Comment, LineIndex, SmolStr, Span}; use crate::index::{AliasDef, ClassDef, EventDef, EventKind, FileId, FileIndex, Index, Member, Symbol, SymbolKind}; -use crate::infer::{FileContext, Infer}; +use crate::infer::{table_fields, FileContext, Infer}; use crate::luacats::{parse_doc_lines, DocGroup}; use crate::types::Type; @@ -158,6 +158,10 @@ impl<'a> Indexer<'a> { 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, } } @@ -183,7 +187,7 @@ impl<'a> Indexer<'a> { 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(Expr { kind: ExprKind::Table(fields), .. }) = value { + if let Some(fields) = value.and_then(table_fields) { self.table_members(class.name.clone(), fields, table_depth + 1); } kind = SymbolKind::Table; @@ -192,7 +196,12 @@ impl<'a> Indexer<'a> { ty.clone() } else { match value.map(|v| (&v.kind, v)) { - Some((ExprKind::Table(fields), _)) if table_depth < MAX_TABLE_DEPTH => { + 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(), fields, name.span); @@ -273,7 +282,7 @@ impl<'a> Indexer<'a> { StmtKind::Local { names, exprs, .. } => { if self.depth == 0 { for (i, name) in names.iter().enumerate() { - if let Some(Expr { kind: ExprKind::Table(fields), .. }) = exprs.get(i) { + 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(), @@ -416,13 +425,13 @@ impl<'a> Indexer<'a> { fn module_return(&mut self, exprs: &[Expr]) { let Some(first) = exprs.first() else { return }; - self.out.module_return = Some(match &first.kind { - ExprKind::Table(fields) => { + 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) } - _ => self.infer.expr(first).widen(), + None => self.infer.expr(first).widen(), }); } @@ -436,6 +445,10 @@ impl<'a> Indexer<'a> { 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); @@ -468,6 +481,22 @@ impl<'a> Indexer<'a> { } } + /// 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, + range: self.range(key.span), + }; + self.push_member(owner, symbol); + } + fn event(&mut self, path: &str, args: &[Expr]) { let kind = if NET_EVENT_CALLS.contains(&path) { EventKind::NetEvent @@ -499,7 +528,8 @@ impl<'a> Indexer<'a> { self.expr(callee); args.iter().for_each(|a| self.expr(a)); } - ExprKind::MethodCall { base, args, .. } => { + ExprKind::MethodCall { base, method, args, .. } => { + self.keyed_setter(base, method, args); self.expr(base); args.iter().for_each(|a| self.expr(a)); } diff --git a/src/infer.rs b/src/infer.rs index 1502ee8..983aa4b 100644 --- a/src/infer.rs +++ b/src/infer.rs @@ -285,6 +285,13 @@ impl<'a> Infer<'a> { 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() } @@ -316,9 +323,16 @@ impl<'a> Infer<'a> { return Type::Exports(None); } let symbols = self.index.globals_named(name, self.ctx.file); - let known = symbols.iter().map(|(_, s)| &s.ty).find(|ty| !ty.is_unknown()); + 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))) { - return ty.clone(); + // `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)); @@ -385,7 +399,7 @@ impl<'a> Infer<'a> { } if let Some(expr) = exprs.get(index) { let is_last = index + 1 == exprs.len(); - if top_level && matches!(expr.kind, ExprKind::Table(_)) { + 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) }; @@ -658,8 +672,8 @@ impl<'a> Infer<'a> { pub fn member(&self, ty: &Type, name: &str) -> Option { self.guarded(|| { let mut found = self.members_matching(ty, Some(name)); - let best = found.iter().position(|m| !m.ty.is_unknown()).unwrap_or(0); - (!found.is_empty()).then(|| found.swap_remove(best)) + let best = (0..found.len()).max_by_key(|i| (found[*i].ty.specificity(), std::cmp::Reverse(*i)))?; + Some(found.swap_remove(best)) }) } @@ -742,7 +756,14 @@ impl<'a> Infer<'a> { 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) { - out.push(member_from_symbol(file, symbol)); + 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 { @@ -811,6 +832,17 @@ fn substitute(ty: &Type, generics: &[(SmolStr, Type)]) -> Type { } } +/// 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 expressions of the first `return` that belongs to this function body itself. fn first_return(block: &Block) -> Option<&[Expr]> { for stmt in &block.stmts { diff --git a/src/types.rs b/src/types.rs index 207bb14..e44903e 100644 --- a/src/types.rs +++ b/src/types.rs @@ -83,6 +83,19 @@ impl Type { } } + /// 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) } @@ -220,6 +233,7 @@ impl fmt::Display for Type { 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()) { @@ -256,6 +270,7 @@ impl fmt::Display for Type { 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}"), diff --git a/src/workspace.rs b/src/workspace.rs index 397a01a..4958928 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -76,11 +76,26 @@ impl Workspace { } stats.files += self.index_dependencies(); self.link_imports(); + self.reindex_all(); 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 { From 1ac8b530233c61b7def400f4d1eaa71438677cf9 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:15:32 +0200 Subject: [PATCH 03/57] docs: add readme with measured comparison, benchmark scripts and ci --- .github/workflows/ci.yml | 32 +++++++++ README.md | 126 ++++++++++++++++++++++++++++++++++++ scripts/bench-luals.mjs | 111 +++++++++++++++++++++++++++++++ src/features/code_action.rs | 5 +- src/features/completion.rs | 83 ++++++++++++++++++------ src/features/diagnostics.rs | 7 +- src/features/folding.rs | 21 +++++- src/features/hover.rs | 4 +- src/features/inlay.rs | 7 +- src/features/references.rs | 41 ++++++++++-- src/features/symbols.rs | 51 ++++++++++++--- src/index.rs | 12 +++- src/indexer.rs | 21 ++++-- src/infer.rs | 48 ++++++++++---- src/luacats.rs | 5 +- src/server.rs | 26 +++++--- src/types.rs | 5 +- tests/lsp.rs | 21 ++++-- 18 files changed, 546 insertions(+), 80 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 README.md create mode 100644 scripts/bench-luals.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..781fb86 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI +on: + push: + branches: [main] + pull_request: + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + path: qbx-lua-ls + - uses: actions/checkout@v4 + with: + repository: Qbox-project/qbx-lint + path: qbx-lint + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + with: + workspaces: qbx-lua-ls + - run: cargo fmt --check + working-directory: qbx-lua-ls + - run: cargo clippy --all-targets -- -D warnings + working-directory: qbx-lua-ls + - run: cargo test + working-directory: qbx-lua-ls diff --git a/README.md b/README.md new file mode 100644 index 0000000..d7562af --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# qbx-lua-ls + +A language server for FiveM Lua that stays small. It understands CfxLua 5.4 syntax, reads +`fxmanifest.lua` to know which globals and natives exist on which side, takes its types from +LuaCATS annotations, and shares its diagnostics with [`qbx-lint`](../qbx-lint). + +> Status: proof of concept. It is an alternative to running `lua-language-server` with the cfxlua +> add-on, not a drop-in clone of it: the type checker is deliberately much simpler. + +## Numbers + +Same machine (Windows 11), same workspace: qbx_core, qbx_police, qbx_vehicleshop, ox_lib and +ox_inventory, 224 Lua files. + +| | Workspace loaded | Resident memory | +| --- | --- | --- | +| lua-language-server 3.19.1 | 4.7 s | 309 MB | +| lua-language-server 3.19.1 + cfxlua natives library | 6.5 s | 458 MB | +| qbx-lua-ls | 0.21 s | 12 MB | + +Requests against a 2,600 line file (`ox_inventory/modules/inventory/server.lua`): hover, +completion and definition answer in well under a millisecond, semantic tokens in ~2 ms, document +symbols in ~7 ms. + +Reproduce with: + +```bash +cargo build --release +node scripts/bench.mjs [file-to-open] +node scripts/bench-luals.mjs [library-dir ...] +``` + +### Why it is small + +- **Closed files keep no syntax tree.** Each file is parsed once and reduced to a summary: global + symbols, table members, classes and aliases, exports, events and the module return type. Only + open documents keep their AST, and references in closed files are found by re-parsing on demand + (the parser handles ~30 MB/s, so that is cheap). +- **Natives are not Lua files.** The ~9,900 native signatures and their documentation are a + sorted table embedded in the binary and binary-searched in place; nothing is parsed or copied + at startup. +- **Types are resolved lazily.** Symbols store references such as "class `Player`", "table + `lib.callback`" or "module `config.shared`", which are looked up when a request needs them. +- **Dependency driven indexing.** Opening one resource indexes only that resource plus the + resources its manifest refers to (`@ox_lib/init.lua`, `dependencies { ... }`), found by walking + up to the surrounding `resources` folder. + +## Features + +| | | +| --- | --- | +| Completion | locals, side-aware globals and natives, members through classes/tables/modules, `exports.resource:Fn`, event and callback names, `require` paths, expected table fields, LuaCATS tags and types, manifest directives and paths, FiveM snippets | +| Hover | signatures, LuaCATS docs, native docs with examples and side, event handler locations | +| Navigation | definition (incl. `require` targets, event registrations, exports), references, rename, document highlight, document and workspace symbols | +| Editing help | signature help, inlay parameter hints, folding, semantic tokens | +| Diagnostics | every `qbx-lint` rule with manifest context, quick fixes, "disable for this line" actions | + +Type sources: `---@class/@field/@alias/@enum/@type/@param/@return/@generic/@overload`, table +constructors (also behind `setmetatable`), `function Table.name()` / `Table.name = ...` anywhere +in the resource, `_ENV.name = value`, module returns through `require`/`lib.load`, exports +registered with `exports('Name', fn)`, native signatures, and callback parameter types taken from +the function being called. + +### Known limits + +- No type *checking* (no "cannot assign string to number"); types drive completion, hover and + navigation only. +- No control-flow narrowing, no full generics (only simple `T` substitution), no operator + metamethod lookup apart from vectors. +- References and rename cover locals and globals, not table fields. +- No formatter. + +## Configuration + +Sent as `initializationOptions` and through `workspace/didChangeConfiguration` under `qbxLua`: + +```jsonc +{ + "library": ["C:/server/resources"], // extra folders to index + "diagnostics": { "enable": true, "rules": { "unused-argument": "off" } }, + "inlayHints": { "enable": true }, + "semanticTokens": { "enable": true } +} +``` + +A `qbxlint.toml` in the workspace root is honoured for diagnostics, so the editor shows what CI +will report. + +Custom requests: `qbx/status` (index statistics) and `qbx/reindex`. + +## Editors + +- VS Code: [`qbx-vscode`](../qbx-vscode). +- Anything that speaks LSP over stdio, for example Neovim: + + ```lua + vim.lsp.start({ name = 'qbx-lua-ls', cmd = { 'qbx-lua-ls' }, root_dir = vim.fs.root(0, { 'fxmanifest.lua', '.git' }) }) + ``` + +## Layout + +| Module | Purpose | +| --- | --- | +| `types`, `luacats` | type model and LuaCATS annotation parser | +| `indexer`, `index` | per-file summaries and the workspace index with visibility rules | +| `infer` | on-demand expression typing for open documents and for the indexer | +| `workspace` | resource discovery, sides, imports, dependency indexing, lint environment | +| `features/*` | one module per LSP feature | +| `server` | stdio main loop; diagnostics are published when the message queue is idle | + +The parser, scope resolver, manifest model, natives data and lint rules come from the `qbx-lint` +workspace through path dependencies; switch them to git dependencies once both are published. + +## Development + +```bash +cargo test # unit tests plus end-to-end LSP tests over an in-memory connection +node scripts/probe.mjs target/release/qbx-lua-ls < snippet.lua +``` + +`probe.mjs` opens a virtual document and prints completions (`--^` at the end of a line) and +hovers (`--?`), which is handy for checking behaviour against real resources. + +## License + +GPL-3.0-or-later diff --git a/scripts/bench-luals.mjs b/scripts/bench-luals.mjs new file mode 100644 index 0000000..155a7a4 --- /dev/null +++ b/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/src/features/code_action.rs b/src/features/code_action.rs index 95b32f2..50ef149 100644 --- a/src/features/code_action.rs +++ b/src/features/code_action.rs @@ -1,6 +1,9 @@ use std::collections::HashMap; -use lsp_types::{CodeAction, CodeActionKind, CodeActionOrCommand, Diagnostic, NumberOrString, Position, Range, TextEdit, WorkspaceEdit}; +use lsp_types::{ + CodeAction, CodeActionKind, CodeActionOrCommand, Diagnostic, NumberOrString, Position, Range, TextEdit, + WorkspaceEdit, +}; use super::diagnostics::{FixData, SOURCE}; use crate::document::Document; diff --git a/src/features/completion.rs b/src/features/completion.rs index 385dc49..5d16423 100644 --- a/src/features/completion.rs +++ b/src/features/completion.rs @@ -29,11 +29,27 @@ const KEYWORDS: &[&str] = &[ const SNIPPETS: &[(&str, &str, &str)] = &[ ("CreateThread", "CreateThread(function()\n\t$0\nend)", "Start a new thread"), - ("thread loop", "CreateThread(function()\n\twhile true do\n\t\t$0\n\t\tWait(${1:0})\n\tend\nend)", "Thread with a loop that yields every iteration"), - ("RegisterNetEvent", "RegisterNetEvent('${1:resource}:${2:event}', function(${3})\n\t$0\nend)", "Register a network event with a handler"), + ( + "thread loop", + "CreateThread(function()\n\twhile true do\n\t\t$0\n\t\tWait(${1:0})\n\tend\nend)", + "Thread with a loop that yields every iteration", + ), + ( + "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"), + ( + "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"), @@ -63,8 +79,19 @@ const DOC_TAGS: &[(&str, &str)] = &[ ]; const PRIMITIVE_TYPES: &[&str] = &[ - "any", "nil", "boolean", "number", "integer", "string", "table", "function", "thread", "userdata", "unknown", - "fun()", "table", + "any", + "nil", + "boolean", + "number", + "integer", + "string", + "table", + "function", + "thread", + "userdata", + "unknown", + "fun()", + "table", ]; const EVENT_NAME_CALLS: &[&str] = &[ @@ -79,7 +106,14 @@ const EVENT_NAME_CALLS: &[&str] = &[ ]; const CALLBACK_NAME_CALLS: &[&str] = &["lib.callback", "lib.callback.await", "lib.callback.register"]; const REQUIRE_CALLS: &[&str] = &["require", "lib.require", "lib.load"]; -const RESOURCE_NAME_CALLS: &[&str] = &["GetResourceState", "StartResource", "StopResource", "GetResourcePath", "GetResourceMetadata", "LoadResourceFile"]; +const RESOURCE_NAME_CALLS: &[&str] = &[ + "GetResourceState", + "StartResource", + "StopResource", + "GetResourcePath", + "GetResourceMetadata", + "LoadResourceFile", +]; fn kind_of(kind: SymbolKind, ty: &Type) -> CompletionItemKind { match kind { @@ -104,7 +138,12 @@ fn detail_of(name: &str, ty: &Type) -> Option { } 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() } + CompletionItem { + label: label.to_string(), + kind: Some(kind), + sort_text: Some(format!("{sort_group}{label}")), + ..CompletionItem::default() + } } fn member_item(member: &MemberInfo) -> CompletionItem { @@ -122,7 +161,8 @@ fn member_item(member: &MemberInfo) -> CompletionItem { 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 == '_') + 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 { @@ -140,11 +180,11 @@ pub fn completion(ws: &Workspace, doc: &Document, position: Position) -> Option< return is_doc.then(|| respond(doc_comment_items(ws, before), false)); } - let in_string = doc - .chunk - .tokens - .iter() - .position(|t| matches!(t.kind, TokenKind::String | TokenKind::LongString) && t.span.start < offset && offset < t.span.end.max(t.span.start + 1)); + let in_string = doc.chunk.tokens.iter().position(|t| { + matches!(t.kind, TokenKind::String | TokenKind::LongString) + && t.span.start < offset + && offset < t.span.end.max(t.span.start + 1) + }); if let Some(token_index) = in_string { return Some(respond(string_items(ws, doc, offset, token_index), false)); } @@ -205,7 +245,8 @@ fn doc_comment_items(ws: &Workspace, before: &str) -> Vec { if !takes_type { return Vec::new(); } - let mut items: Vec = PRIMITIVE_TYPES.iter().map(|t| item(t, CompletionItemKind::KEYWORD, 1)).collect(); + 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)); @@ -347,9 +388,7 @@ fn member_items(infer: &Infer, doc: &Document, offset: u32, head: &str, via_colo /// 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 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) { @@ -397,7 +436,13 @@ fn expected_field_items(infer: &Infer, doc: &Document, offset: u32) -> Vec (Vec, bool) { +fn scope_items( + ws: &Workspace, + infer: &Infer, + doc: &Document, + offset: u32, + prefix: &str, +) -> (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(); diff --git a/src/features/diagnostics.rs b/src/features/diagnostics.rs index 0888f6c..d9b2d19 100644 --- a/src/features/diagnostics.rs +++ b/src/features/diagnostics.rs @@ -26,7 +26,8 @@ pub fn diagnostics(ws: &Workspace, doc: &Document, rule_overrides: &[(String, Le 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 { + 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); @@ -42,7 +43,9 @@ pub fn diagnostics(ws: &Workspace, doc: &Document, rule_overrides: &[(String, Le let summary = summarize(&doc.chunk, &doc.resolution); let env = resource_id.map(|id| ws.resource_env(id)); let resource_input = match (resource, &env) { - (Some(resource), Some(env)) => Some(ResourceInput { name: &resource.name, env, manifest: &resource.manifest }), + (Some(resource), Some(env)) => { + Some(ResourceInput { name: &resource.name, env, manifest: &resource.manifest }) + } _ => None, }; check_file(&FileInput { diff --git a/src/features/folding.rs b/src/features/folding.rs index 616713b..5d121ef 100644 --- a/src/features/folding.rs +++ b/src/features/folding.rs @@ -17,7 +17,12 @@ impl Folds<'_> { 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() }); + self.out.push(FoldingRange { + start_line: start, + end_line: end_line.max(start), + kind, + ..FoldingRange::default() + }); } } } @@ -75,7 +80,12 @@ pub fn folding_ranges(doc: &Document) -> Vec { 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() }); + folds.out.push(FoldingRange { + start_line: start, + end_line: end, + kind: Some(FoldingRangeKind::Comment), + ..FoldingRange::default() + }); } Some((first, last)) } @@ -83,7 +93,12 @@ pub fn folding_ranges(doc: &Document) -> Vec { }; } 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.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); diff --git a/src/features/hover.rs b/src/features/hover.rs index 4d40868..3e45b88 100644 --- a/src/features/hover.rs +++ b/src/features/hover.rs @@ -77,7 +77,9 @@ fn local_hover(infer: &Infer, id: LocalId) -> String { }; let mut out = lua_block(&describe_value(prefix, &local.name, &ty)); 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::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) } diff --git a/src/features/inlay.rs b/src/features/inlay.rs index 5b91029..e38ca02 100644 --- a/src/features/inlay.rs +++ b/src/features/inlay.rs @@ -34,7 +34,8 @@ impl Hints<'_, '_> { let Some((fun, _)) = self.infer.callee_fun(base, method) 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) { + if !is_literal(arg) || param.name == "..." || param.name.is_empty() || !self.range.contains(arg.span.start) + { continue; } self.out.push(InlayHint { @@ -61,7 +62,9 @@ impl<'ast> Visitor<'ast> for Hints<'_, '_> { 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), + ExprKind::MethodCall { base, method, args, style: CallStyle::Paren, .. } => { + self.call(base, Some(method), args) + } _ => {} } visit::walk_expr(self, expr); diff --git a/src/features/references.rs b/src/features/references.rs index 8c181e5..db535bb 100644 --- a/src/features/references.rs +++ b/src/features/references.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; -use lsp_types::{DocumentHighlight, DocumentHighlightKind, Location, Position, PrepareRenameResponse, TextEdit, Url, WorkspaceEdit}; +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}; @@ -36,7 +38,12 @@ fn global_name_at(doc: &Document, offset: u32) -> Option<&str> { /// 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)> { +fn global_occurrences( + ws: &Workspace, + docs: &Documents, + doc: &Document, + name: &str, +) -> 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) { @@ -56,13 +63,23 @@ fn global_occurrences(ws: &Workspace, docs: &Documents, doc: &Document, name: &s let resolution = resolve(&parse(&source)); let lines = LineIndex::new(&source); for global in resolution.globals.iter().filter(|g| g.name == name) { - out.push((entry.uri.clone(), span_to_range(&source, &lines, global.span), global.kind != GlobalRefKind::Read)); + out.push(( + entry.uri.clone(), + span_to_range(&source, &lines, global.span), + global.kind != GlobalRefKind::Read, + )); } } out } -pub fn references(ws: &Workspace, docs: &Documents, doc: &Document, position: Position, include_declaration: bool) -> Vec { +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 { @@ -87,7 +104,10 @@ pub fn highlights(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(); + 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 { return Vec::new() }; doc.resolution @@ -106,7 +126,8 @@ pub fn prepare_rename(doc: &Document, position: Position) -> Option Option Option { +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(); diff --git a/src/features/symbols.rs b/src/features/symbols.rs index 1dfe65c..81e0a17 100644 --- a/src/features/symbols.rs +++ b/src/features/symbols.rs @@ -9,7 +9,15 @@ 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 { +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 }, @@ -52,7 +60,15 @@ fn block_symbols(doc: &Document, block: &Block, out: &mut Vec) { 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)); + 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() { @@ -67,7 +83,9 @@ fn block_symbols(doc: &Document, block: &Block, out: &mut Vec) { } } 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::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)); @@ -90,7 +108,13 @@ fn value_symbol(doc: &Document, name: String, selection: Span, full: Span, value 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))); + children.push(value_symbol( + doc, + name.text.to_string(), + name.span, + name.span.to(value.span), + Some(value), + )); } } (LspKind::OBJECT, None) @@ -109,8 +133,13 @@ fn call_symbols(doc: &Document, expr: &Expr, full: Span, out: &mut Vec { + "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}'") } @@ -160,7 +189,12 @@ pub fn workspace_symbols(ws: &Workspace, query: &str) -> Vec _ 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())); + 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); @@ -168,7 +202,8 @@ pub fn workspace_symbols(ws: &Workspace, query: &str) -> Vec 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)) { + 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 { diff --git a/src/index.rs b/src/index.rs index 687022f..8341988 100644 --- a/src/index.rs +++ b/src/index.rs @@ -256,7 +256,9 @@ impl Index { 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))) + 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, @@ -308,7 +310,10 @@ impl Index { (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)) + sides_match + && (self.is_visible(from, target) + || self.is_related(from, target) + || self.imports_resource_of(from, target)) }; let visible: Vec<_> = slots.iter().filter(|(f, _)| reachable(*f)).filter_map(resolve).collect(); if !visible.is_empty() { @@ -318,7 +323,8 @@ impl Index { } 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)) + let (Some(resource), Some(target_resource)) = + (self.resource_of(from), self.file(target).and_then(|f| f.resource)) else { return false; }; diff --git a/src/indexer.rs b/src/indexer.rs index 33f49a5..443d253 100644 --- a/src/indexer.rs +++ b/src/indexer.rs @@ -46,7 +46,8 @@ pub fn index_file(file: FileId, source: &str, chunk: &Chunk, resolution: &Resolu 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, out: FileIndex::default(), depth: 0 }; + let mut indexer = + Indexer { file, source, lines: &lines, ctx: &ctx, infer: &infer, 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() { @@ -183,7 +184,14 @@ impl<'a> Indexer<'a> { } /// 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 { + 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() { @@ -391,7 +399,10 @@ impl<'a> Indexer<'a> { 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)) { + match own_class + .map(|class| Type::Named(class, Vec::new())) + .unwrap_or_else(|| self.infer.expr(base)) + { Type::Named(class, _) => class, _ => SmolStr::new(path), } @@ -472,7 +483,9 @@ impl<'a> Indexer<'a> { 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, + crate::infer::Decl::LocalFunction { stmt, .. } | crate::infer::Decl::Local { stmt, .. } => { + stmt.span.start + } _ => return None, }; render_doc(&self.ctx.doc_at(anchor)) diff --git a/src/infer.rs b/src/infer.rs index 983aa4b..baeda12 100644 --- a/src/infer.rs +++ b/src/infer.rs @@ -367,7 +367,9 @@ impl<'a> Infer<'a> { 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::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 { @@ -402,7 +404,8 @@ impl<'a> Infer<'a> { 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) }; + 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() { @@ -444,7 +447,13 @@ impl<'a> Infer<'a> { if let (Some("pairs" | "ipairs" | "next"), Some(arg)) = (iterator.as_deref(), args.first()) { let (key, value) = self.key_value_types(&self.expr(arg)); let key = if iterator.as_deref() == Some("ipairs") { Type::Integer } else { key }; - return if index == 0 { key } else if index == 1 { value } else { Type::Unknown }; + 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() @@ -463,9 +472,12 @@ impl<'a> Infer<'a> { None => (Type::String, Type::Unknown), }, Type::GlobalTable(_) => (Type::String, Type::Unknown), - Type::Union(types) => { - types.iter().filter(|t| !matches!(t, Type::Nil)).map(|t| self.key_value_types(t)).next().unwrap_or_default() - } + Type::Union(types) => types + .iter() + .filter(|t| !matches!(t, Type::Nil)) + .map(|t| self.key_value_types(t)) + .next() + .unwrap_or_default(), _ => (Type::Unknown, Type::Unknown), } } @@ -504,7 +516,9 @@ impl<'a> Infer<'a> { 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(), + ExprKind::Number(NumberValue::Int(i)) => { + items.get((*i as usize).wrapping_sub(1)).cloned().unwrap_or_default() + } _ => Type::union(items), }, Type::Shape(shape) => shape.index.as_ref().map(|(_, v)| v.clone()).unwrap_or_default(), @@ -521,13 +535,17 @@ impl<'a> Infer<'a> { let mut positional = Vec::new(); for field in fields.iter().take(MAX_SHAPE_FIELDS) { match field { - TableField::Named { name, value } => { - shape.fields.push(ShapeField { name: name.text.clone(), ty: self.expr(value).widen(), optional: false }) - } + TableField::Named { name, value } => shape.fields.push(ShapeField { + name: name.text.clone(), + ty: self.expr(value).widen(), + optional: false, + }), TableField::Keyed { key, value } => match &key.kind { - ExprKind::String(name) => { - shape.fields.push(ShapeField { name: name.clone(), ty: self.expr(value).widen(), optional: false }) - } + ExprKind::String(name) => shape.fields.push(ShapeField { + name: name.clone(), + ty: self.expr(value).widen(), + optional: false, + }), _ => shape.index = Some((self.expr(key).widen(), self.expr(value).widen())), }, TableField::SetMember(name) => { @@ -588,7 +606,9 @@ impl<'a> Infer<'a> { 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)), + Type::Named(name, _) => { + self.index.class(&name).and_then(|(_, c)| c.call.clone()).map(|f| (f, None)) + } _ => None, } } diff --git a/src/luacats.rs b/src/luacats.rs index 71ac75f..6be4ab6 100644 --- a/src/luacats.rs +++ b/src/luacats.rs @@ -288,7 +288,10 @@ mod tests { 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?"); + assert_eq!( + fun.signature("spawn"), + "function spawn(model: string|integer, coords?: vector4, ...: any): integer, string?" + ); } #[test] diff --git a/src/server.rs b/src/server.rs index 8c6c0ef..b40c239 100644 --- a/src/server.rs +++ b/src/server.rs @@ -13,7 +13,8 @@ use serde_json::{json, Value}; use crate::document::Document; use crate::features::{ - code_action, completion, definition, diagnostics, folding, hover, inlay, references, semantic_tokens, signature, symbols, + code_action, completion, definition, diagnostics, folding, hover, inlay, references, semantic_tokens, signature, + symbols, }; use crate::index::FileOrigin; use crate::workspace::{uri_to_path, Workspace}; @@ -105,12 +106,14 @@ pub fn capabilities() -> ServerCapabilities { code_action_provider: Some(CodeActionProviderCapability::Simple(true)), folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)), inlay_hint_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(), - })), + 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() } } @@ -222,7 +225,8 @@ impl Server { for uri in &self.dirty { if let Some(doc) = self.docs.get_mut(uri) { if !doc.is_manifest() { - doc.file = self.ws.index_parsed(&doc.path, FileOrigin::Workspace, &doc.text, &doc.chunk, &doc.resolution); + doc.file = + self.ws.index_parsed(&doc.path, FileOrigin::Workspace, &doc.text, &doc.chunk, &doc.resolution); } } } @@ -292,7 +296,11 @@ impl Server { } } self.dirty.remove(&uri); - self.notify::(PublishDiagnosticsParams { uri, diagnostics: Vec::new(), version: None }); + self.notify::(PublishDiagnosticsParams { + uri, + diagnostics: Vec::new(), + version: None, + }); } notif::DidChangeWatchedFiles::METHOD => { let Ok(params) = serde_json::from_value::(params) else { return }; diff --git a/src/types.rs b/src/types.rs index e44903e..26dd4db 100644 --- a/src/types.rs +++ b/src/types.rs @@ -553,7 +553,10 @@ mod tests { 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(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 }"); diff --git a/tests/lsp.rs b/tests/lsp.rs index b96a994..c4a7f28 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -64,9 +64,13 @@ impl Client { 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(); + 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"); + 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; @@ -122,7 +126,10 @@ impl Client { 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() + 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 { @@ -364,14 +371,18 @@ fn references_rename_and_symbols() { 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:?}"); + 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 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:?}"); From b38df9c683698466d4a7b38347563dac16719933 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:17:35 +0200 Subject: [PATCH 04/57] fix(diagnostics): skip global checks in mapmanager map files --- src/features/diagnostics.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/features/diagnostics.rs b/src/features/diagnostics.rs index d9b2d19..6c96130 100644 --- a/src/features/diagnostics.rs +++ b/src/features/diagnostics.rs @@ -41,6 +41,12 @@ pub fn diagnostics(ws: &Workspace, doc: &Document, rule_overrides: &[(String, Le }) } 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 resource_input = match (resource, &env) { (Some(resource), Some(env)) => { From 767a9cc7e807900f04b0a391955035e9ac3b9b8e Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:19:59 +0200 Subject: [PATCH 05/57] fix(server): isolate panics per request and add a stress script The release profile aborted on panic, so a single bug would have killed the editor's server. Requests and notifications now run behind catch_unwind and answer with an internal error instead. scripts/stress.mjs fires every feature at random positions of every file in a workspace to find such bugs. --- Cargo.toml | 1 - scripts/stress.mjs | 124 +++++++++++++++++++++++++++++++++++++++++++++ src/server.rs | 26 ++++++++-- 3 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 scripts/stress.mjs diff --git a/Cargo.toml b/Cargo.toml index bc12b91..dee579f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,4 +30,3 @@ opt-level = 3 lto = "fat" codegen-units = 1 strip = true -panic = "abort" diff --git a/scripts/stress.mjs b/scripts/stress.mjs new file mode 100644 index 0000000..74d2401 --- /dev/null +++ b/scripts/stress.mjs @@ -0,0 +1,124 @@ +// 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 { pathToFileURL } from 'node:url'; + +const [workspace, binary = 'target/release/qbx-lua-ls', 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/src/server.rs b/src/server.rs index b40c239..161bf49 100644 --- a/src/server.rs +++ b/src/server.rs @@ -206,20 +206,38 @@ impl Server { if self.connection.handle_shutdown(&request)? { return Ok(()); } - self.flush_index(); - let response = self.handle_request(request); + 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.handle_notification(notification), + Message::Notification(notification) => { + self.isolated(|server| server.handle_notification(notification)); + } Message::Response(_) => {} } if self.connection.receiver.is_empty() { - self.publish_dirty(); + self.isolated(Self::publish_dirty); } } 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.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) { for uri in &self.dirty { From 6c84bf7bed5facaa2d9b26c6f2fd7fec933d5d95 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:37:21 +0200 Subject: [PATCH 06/57] feat: lint the whole workspace, follow event direction and complete ox_lib cache keys Closed files are linted one at a time when the server is idle so the Problems panel covers the workspace, and a save or close only re-lints the resource it belongs to. Event name completion now only offers handlers the call can reach, lib.onCache and an onCache snippet list the cache keys found in the indexed ox_lib source, and qbx/fileInfo tells editors which side a file runs on. --- README.md | 9 ++- src/features/completion.rs | 107 ++++++++++++++++++++++++++++------- src/server.rs | 113 +++++++++++++++++++++++++++++++++++-- tests/lsp.rs | 34 +++++++++++ 4 files changed, 235 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index d7562af..bc20a75 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,9 @@ node scripts/bench-luals.mjs [library- | Hover | signatures, LuaCATS docs, native docs with examples and side, event handler locations | | Navigation | definition (incl. `require` targets, event registrations, exports), references, rename, document highlight, document and workspace symbols | | Editing help | signature help, inlay parameter hints, folding, semantic tokens | -| Diagnostics | every `qbx-lint` rule with manifest context, quick fixes, "disable for this line" actions | +| Diagnostics | every `qbx-lint` rule with manifest context, for the whole workspace (closed files are linted one at a time and dropped again; a save only re-lints the affected resource), quick fixes, "disable for this line" actions | +| Side awareness | natives and globals filtered by client/server, wrong-side errors, event name completion that follows the call direction (`TriggerServerEvent` only offers events handled on the server), `qbx/fileInfo` for editors | +| ox_lib | `onCache` snippet and `lib.onCache('…')` completion whose key list is read from the indexed ox_lib source | Type sources: `---@class/@field/@alias/@enum/@type/@param/@return/@generic/@overload`, table constructors (also behind `setmetatable`), `function Table.name()` / `Table.name = ...` anywhere @@ -86,7 +88,10 @@ Sent as `initializationOptions` and through `workspace/didChangeConfiguration` u A `qbxlint.toml` in the workspace root is honoured for diagnostics, so the editor shows what CI will report. -Custom requests: `qbx/status` (index statistics) and `qbx/reindex`. +`diagnostics.workspace` (default `true`) controls whether files that are not open are reported. + +Custom requests: `qbx/status` (index statistics), `qbx/reindex` and `qbx/fileInfo` +(`{ uri }` → `{ side: client|server|shared|module|manifest|standalone, resource }`). ## Editors diff --git a/src/features/completion.rs b/src/features/completion.rs index 5d16423..1042d22 100644 --- a/src/features/completion.rs +++ b/src/features/completion.rs @@ -181,9 +181,11 @@ pub fn completion(ws: &Workspace, doc: &Document, position: Position) -> Option< } let in_string = doc.chunk.tokens.iter().position(|t| { - matches!(t.kind, TokenKind::String | TokenKind::LongString) + 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.max(t.span.start + 1) + && (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)); @@ -277,24 +279,52 @@ fn string_items(ws: &Workspace, doc: &Document, offset: u32, token_index: usize) if arg_index == 0 && (EVENT_NAME_CALLS.contains(&path) || CALLBACK_NAME_CALLS.contains(&path)) { let wants_callbacks = CALLBACK_NAME_CALLS.contains(&path); - let mut seen = FxHashSet::default(); - return ws - .index - .events() - .filter(|(_, e)| (e.kind == EventKind::Callback) == wants_callbacks || e.kind == EventKind::Trigger) - .filter(|(_, e)| seen.insert(e.name.clone())) - .map(|(file, event)| { - let mut out = item(&event.name, CompletionItemKind::EVENT, 0); - let origin = ws.index.file(file).and_then(|f| f.resource).and_then(|r| ws.index.resource(r)); - out.detail = match (&event.handler, origin) { - (Some(handler), Some(resource)) => Some(format!("{} · {}", resource.name, handler.signature(""))), - (None, Some(resource)) => Some(resource.name.to_string()), - (Some(handler), None) => Some(handler.signature("")), - (None, None) => None, - }; - out - }) - .collect(); + let own_side = ws.index.file(doc.file).and_then(|f| f.side); + // Where the handler has to live for this call to reach it. + let target_side = match path { + "TriggerServerEvent" | "TriggerLatentServerEvent" => Some(Side::Server), + "TriggerClientEvent" | "TriggerLatentClientEvent" => Some(Side::Client), + "TriggerEvent" => own_side, + "lib.callback" | "lib.callback.await" => match own_side { + Some(Side::Client) => Some(Side::Server), + Some(Side::Server) => Some(Side::Client), + _ => None, + }, + _ => None, + }; + let handled_on_target = |file| { + let side = ws.index.file(file).and_then(|f| f.side); + !matches!((target_side, side), (Some(target), Some(side)) if !side.is_available_on(target)) + }; + let candidates = |strict: bool| { + let mut seen = FxHashSet::default(); + ws.index + .events() + .filter(|(_, e)| (e.kind == EventKind::Callback) == wants_callbacks || e.kind == EventKind::Trigger) + .filter(|(file, e)| !strict || (e.kind != EventKind::Trigger && handled_on_target(*file))) + .filter(|(_, e)| seen.insert(e.name.clone())) + .map(|(file, event)| { + let mut out = item(&event.name, CompletionItemKind::EVENT, 0); + let entry = ws.index.file(file); + let origin = entry.and_then(|f| f.resource).and_then(|r| ws.index.resource(r)); + let side = entry.and_then(|f| f.side).map_or(String::new(), |s| format!(" ({})", s.label())); + out.detail = match (&event.handler, origin) { + (Some(handler), Some(resource)) => { + Some(format!("{}{side} · {}", resource.name, handler.signature(""))) + } + (None, Some(resource)) => Some(format!("{}{side}", resource.name)), + (Some(handler), None) => Some(handler.signature("")), + (None, None) => None, + }; + out + }) + .collect::>() + }; + let strict = if target_side.is_some() { candidates(true) } else { Vec::new() }; + return if strict.is_empty() { candidates(false) } else { strict }; + } + if arg_index == 0 && matches!(path, "lib.onCache") { + return cache_key_items(ws, doc); } if arg_index == 0 && REQUIRE_CALLS.contains(&path) { return module_items(ws, doc); @@ -305,6 +335,40 @@ fn string_items(ws: &Workspace, doc: &Document, offset: u32, token_index: usize) Vec::new() } +/// 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() + }) +} + +fn on_cache_snippet(infer: &Infer) -> Option { + let keys: Vec = cache_keys(infer).iter().map(|k| k.name.to_string()).collect(); + if keys.is_empty() { + return None; + } + let mut out = item("onCache", CompletionItemKind::SNIPPET, 4); + out.insert_text = + Some(format!("lib.onCache('${{1|{}|}}', function(${{2:value}}, ${{3:oldValue}})\n\t$0\nend)", keys.join(","))); + out.insert_text_format = Some(InsertTextFormat::SNIPPET); + out.detail = Some(format!("React to an ox_lib cache change ({})", keys.join(", "))); + Some(out) +} + fn resource_items(ws: &Workspace) -> Vec { ws.index.resources.iter().map(|r| item(&r.name, CompletionItemKind::MODULE, 0)).collect() } @@ -489,6 +553,9 @@ fn scope_items( out.filter_text = Some(label.to_string()); items.push(out); } + if matches("onCache") { + items.extend(on_cache_snippet(infer)); + } let mut incomplete = false; if prefix.len() >= 3 { diff --git a/src/server.rs b/src/server.rs index 161bf49..f5295bd 100644 --- a/src/server.rs +++ b/src/server.rs @@ -27,6 +27,7 @@ type AnyResult = Result>; #[serde(rename_all = "camelCase", default)] pub struct DiagnosticSettings { pub enable: Option, + pub workspace: Option, pub rules: FxHashMap, } @@ -72,6 +73,11 @@ pub struct Server { docs: Documents, settings: Settings, dirty: FxHashSet, + /// 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, } @@ -156,7 +162,17 @@ impl Server { let mut ws = Workspace::default(); ws.roots = workspace_roots(¶ms); ws.library = settings.library.iter().map(PathBuf::from).collect(); - Self { connection, ws, docs: Documents::default(), settings, dirty: FxHashSet::default(), next_request_id: 0 } + Self { + connection, + ws, + docs: Documents::default(), + settings, + dirty: FxHashSet::default(), + workspace_reported: FxHashSet::default(), + workspace_stale: true, + stale_resources: FxHashSet::default(), + next_request_id: 0, + } } fn start(&mut self) { @@ -223,6 +239,7 @@ impl Server { } if self.connection.receiver.is_empty() { self.isolated(Self::publish_dirty); + self.isolated(Self::publish_workspace); } } Ok(()) @@ -262,6 +279,76 @@ impl Server { } } + /// 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() + }; + for (uri, path, file) in targets { + if self.docs.contains_key(&uri) { + 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)); + let mut found = diagnostics::diagnostics(&self.ws, &doc, &overrides); + found.retain(|d| d.severity != Some(DiagnosticSeverity::HINT)); + 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) { let Some(doc) = self.docs.get(uri) else { return }; let enabled = self.settings.diagnostics.enable.unwrap_or(true); @@ -303,6 +390,7 @@ impl Server { 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 => { @@ -314,11 +402,8 @@ impl Server { } } self.dirty.remove(&uri); - self.notify::(PublishDiagnosticsParams { - uri, - diagnostics: Vec::new(), - version: None, - }); + self.mark_resource_stale(&uri); + self.workspace_reported.insert(uri); } notif::DidChangeWatchedFiles::METHOD => { let Ok(params) = serde_json::from_value::(params) else { return }; @@ -329,6 +414,7 @@ impl Server { 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()); } } @@ -356,6 +442,7 @@ impl Server { if manifests_changed { self.ws.link_imports(); } + self.workspace_stale = true; self.dirty.extend(self.docs.keys().cloned()); } @@ -466,8 +553,22 @@ impl Server { "qbx/reindex" => { let stats = self.ws.scan(); self.dirty.extend(self.docs.keys().cloned()); + 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()) })) + } other => Err(format!("unsupported request: {other}")), } } diff --git a/tests/lsp.rs b/tests/lsp.rs index c4a7f28..87661cb 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -438,3 +438,37 @@ fn survives_garbage_input_while_typing() { 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"], "client code can only reach server handlers"); + + client.change(CLIENT, 3, &format!("{text}TriggerEvent('')")); + let labels = client.completion_labels(CLIENT, line, 14); + assert_eq!(labels, ["myresource:client:notify"]); +} + +#[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"); +} From caaa586d64c8a7b836de55ee729743232722e185 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:14:00 +0200 Subject: [PATCH 07/57] feat: add formatting, cross-file diagnostics, field references and new completions The server now formats documents with qbx_lua_fmt, feeds the cross-file lint rules from its index (event and export arity, wrong side, security, dependencies, locale keys including unused keys reported on the JSON file), finds and renames fields and methods across related files by resolving each candidate's owner type, and completes locale keys, convars from code and .cfg files, and state bag keys. --- Cargo.lock | 10 ++ Cargo.toml | 1 + src/features/completion.rs | 47 +++++- src/features/definition.rs | 14 ++ src/features/diagnostics.rs | 9 +- src/features/hover.rs | 13 +- src/features/member_refs.rs | 137 ++++++++++++++++++ src/features/mod.rs | 1 + src/features/references.rs | 41 +++++- src/index.rs | 2 + src/indexer.rs | 48 +++++- src/server.rs | 97 +++++++++++-- src/workspace.rs | 58 ++++++++ tests/fixtures/resources/shop/client.lua | 10 ++ tests/fixtures/resources/shop/fxmanifest.lua | 8 + tests/fixtures/resources/shop/locales/en.json | 7 + tests/fixtures/resources/shop/server.lua | 11 ++ tests/fixtures/resources/shop/shared.lua | 10 ++ tests/lsp.rs | 100 ++++++++++++- 19 files changed, 598 insertions(+), 26 deletions(-) create mode 100644 src/features/member_refs.rs create mode 100644 tests/fixtures/resources/shop/client.lua create mode 100644 tests/fixtures/resources/shop/fxmanifest.lua create mode 100644 tests/fixtures/resources/shop/locales/en.json create mode 100644 tests/fixtures/resources/shop/server.lua create mode 100644 tests/fixtures/resources/shop/shared.lua diff --git a/Cargo.lock b/Cargo.lock index b2ccdb5..aec924e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -332,6 +332,7 @@ version = "0.1.0" dependencies = [ "globset", "qbx_fivem_data", + "qbx_lua_fmt", "qbx_lua_syntax", "rayon", "rustc-hash", @@ -341,6 +342,14 @@ dependencies = [ "walkdir", ] +[[package]] +name = "qbx_lua_fmt" +version = "0.1.0" +dependencies = [ + "qbx_lua_syntax", + "serde", +] + [[package]] name = "qbx_lua_ls" version = "0.1.0" @@ -350,6 +359,7 @@ dependencies = [ "lsp-types", "qbx_fivem_data", "qbx_lua_analysis", + "qbx_lua_fmt", "qbx_lua_syntax", "rustc-hash", "serde", diff --git a/Cargo.toml b/Cargo.toml index dee579f..0f2b2c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" [dependencies] # Switch these to git dependencies on Qbox-project/qbx-lint once that repository is published. qbx_lua_syntax = { path = "../qbx-lint/crates/qbx_lua_syntax" } +qbx_lua_fmt = { path = "../qbx-lint/crates/qbx_lua_fmt" } qbx_lua_analysis = { path = "../qbx-lint/crates/qbx_lua_analysis", features = ["docs"] } qbx_fivem_data = { path = "../qbx-lint/crates/qbx_fivem_data", features = ["docs"] } lsp-server = "0.7" diff --git a/src/features/completion.rs b/src/features/completion.rs index 1042d22..c8145ef 100644 --- a/src/features/completion.rs +++ b/src/features/completion.rs @@ -199,7 +199,11 @@ pub fn completion(ws: &Workspace, doc: &Document, position: Position) -> Option< if (head.ends_with('.') && !head.ends_with("..")) || (head.ends_with(':') && !head.ends_with("::")) { let via_colon = head.ends_with(':'); - let items = with_infer(ws, doc, |infer| member_items(infer, doc, offset, head, via_colon)); + let mut items = with_infer(ws, doc, |infer| member_items(infer, doc, offset, head, via_colon)); + 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)); } @@ -326,6 +330,32 @@ fn string_items(ws: &Workspace, doc: &Document, offset: u32, token_index: usize) 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); } @@ -335,6 +365,21 @@ fn string_items(ws: &Workspace, doc: &Document, offset: u32, token_index: usize) 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 = diff --git a/src/features/definition.rs b/src/features/definition.rs index 8b8e1e8..d43bfad 100644 --- a/src/features/definition.rs +++ b/src/features/definition.rs @@ -24,6 +24,20 @@ fn string_definition(ws: &Workspace, doc: &Document, offset: u32) -> Vec 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 diff --git a/src/features/diagnostics.rs b/src/features/diagnostics.rs index 6c96130..e664379 100644 --- a/src/features/diagnostics.rs +++ b/src/features/diagnostics.rs @@ -16,7 +16,12 @@ pub struct FixData { pub edits: Vec<(lsp_types::Range, String)>, } -pub fn diagnostics(ws: &Workspace, doc: &Document, rule_overrides: &[(String, Level)]) -> Vec { +pub fn diagnostics( + ws: &Workspace, + doc: &Document, + rule_overrides: &[(String, Level)], + crossrefs: &qbx_lua_analysis::crossref::CrossRefs, +) -> Vec { let mut config = ws.lint_config.for_file(&doc.path); for (code, level) in rule_overrides { config.set(code, *level); @@ -62,6 +67,8 @@ pub fn diagnostics(ws: &Workspace, doc: &Document, rule_overrides: &[(String, Le config: &config, side: entry.and_then(|f| f.side), resource: resource_input, + crossrefs: Some(crossrefs), + locale: resource.and_then(|r| qbx_lua_analysis::locale::LocaleFile::load(&r.root)).as_ref(), }) }; diff --git a/src/features/hover.rs b/src/features/hover.rs index 3e45b88..c81fe75 100644 --- a/src/features/hover.rs +++ b/src/features/hover.rs @@ -167,8 +167,19 @@ pub fn member_hover(info: &MemberInfo, owner: &Type) -> String { fn string_hover(ws: &Workspace, doc: &Document, offset: u32) -> Option<(String, Span)> { let located = locate(&doc.chunk, offset); - let (string, _) = located.string?; + 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 registrations: Vec<_> = ws.index.events().filter(|(_, e)| e.name == *value && e.kind != crate::index::EventKind::Trigger).collect(); if registrations.is_empty() { diff --git a/src/features/member_refs.rs b/src/features/member_refs.rs new file mode 100644 index 0000000..1b5a70c --- /dev/null +++ b/src/features/member_refs.rs @@ -0,0 +1,137 @@ +use lsp_types::{Range, Url}; +use qbx_lua_analysis::project::read_source; +use qbx_lua_syntax::ast::*; +use qbx_lua_syntax::visit::{self, Visitor}; +use qbx_lua_syntax::SmolStr; + +use super::hover::{target_at, Target}; +use super::with_infer; +use crate::document::Document; +use crate::index::{FileId, FileOrigin}; +use crate::infer::Infer; +use crate::server::Documents; +use crate::workspace::Workspace; + +pub struct MemberTarget { + pub name: SmolStr, + pub file: FileId, + pub range: 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 { + with_infer(ws, doc, |infer| match target_at(infer, doc, offset)? { + Target::Member { info, .. } => { + let (file, range) = info.location?; + Some(MemberTarget { name: info.name, file, range }) + } + _ => None, + }) +} + +struct Finder<'a, 'b> { + doc: &'a Document, + infer: &'a Infer<'b>, + target: &'a MemberTarget, + out: Vec, +} + +impl Finder<'_, '_> { + fn check(&mut self, owner: &crate::types::Type, name: &Name) { + let same = self.infer.member(owner, &name.text).and_then(|m| m.location) + == Some((self.target.file, self.target.range)); + if same { + self.out.push(self.doc.range(name.span)); + } + } +} + +impl<'ast> Visitor<'ast> for Finder<'_, '_> { + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + 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); + } + owner = self.infer.member(&owner, &segment.text).map(|m| m.ty).unwrap_or_default(); + } + } + } + visit::walk_stmt(self, stmt); + } + + fn visit_expr(&mut self, expr: &'ast Expr) { + 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); + } + _ => {} + } + visit::walk_expr(self, expr); + } +} + +fn occurrences_in(ws: &Workspace, doc: &Document, target: &MemberTarget) -> Vec { + with_infer(ws, doc, |infer| { + let mut finder = Finder { doc, infer, target, out: Vec::new() }; + finder.visit_block(&doc.chunk.block); + finder.out + }) +} + +/// Every use of the member across the files that can reach its definition. Closed files are parsed +/// on demand, and only when they mention the name at all. +pub fn member_occurrences( + ws: &Workspace, + docs: &Documents, + doc: &Document, + target: &MemberTarget, +) -> Vec<(Url, Range)> { + let mut out: Vec<(Url, Range)> = Vec::new(); + for (id, entry) in ws.index.files() { + let reachable = id == target.file || 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 Ok(source) = read_source(&entry.path) else { continue }; + if !source.contains(target.name.as_str()) { + continue; + } + 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))); + } + if let Some(definition) = ws.index.file(target.file) { + let declared = (definition.uri.clone(), target.range); + if !out.contains(&declared) { + out.push(declared); + } + } + out +} + +pub fn in_document(ws: &Workspace, doc: &Document, target: &MemberTarget) -> Vec { + let mut ranges = occurrences_in(ws, doc, target); + if target.file == doc.file && !ranges.contains(&target.range) { + ranges.push(target.range); + } + ranges +} + +/// Members of the runtime stubs and of indexed libraries cannot be renamed from here. +pub fn is_renamable(ws: &Workspace, target: &MemberTarget) -> bool { + ws.index.file(target.file).is_some_and(|f| f.origin == FileOrigin::Workspace) +} diff --git a/src/features/mod.rs b/src/features/mod.rs index 6b6d941..397a712 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -5,6 +5,7 @@ pub mod diagnostics; pub mod folding; pub mod hover; pub mod inlay; +pub mod member_refs; pub mod references; pub mod semantic_tokens; pub mod signature; diff --git a/src/features/references.rs b/src/features/references.rs index db535bb..21672d8 100644 --- a/src/features/references.rs +++ b/src/features/references.rs @@ -7,6 +7,7 @@ use qbx_lua_analysis::project::read_source; use qbx_lua_analysis::scope::{resolve, GlobalRefKind, Resolved}; use qbx_lua_syntax::{parse, LineIndex, Span}; +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; @@ -92,7 +93,13 @@ pub fn references( .map(|o| Location::new(doc.uri.clone(), doc.range(o.span))) .collect(); } - let Some(name) = global_name_at(doc, offset) else { return Vec::new() }; + 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) + .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) @@ -100,7 +107,7 @@ pub fn references( .collect() } -pub fn highlights(doc: &Document, position: Position) -> Vec { +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) { @@ -109,7 +116,13 @@ pub fn highlights(doc: &Document, position: Position) -> Vec .map(|o| DocumentHighlight { range: doc.range(o.span), kind: kind(o.write) }) .collect(); } - let Some(name) = global_name_at(doc, offset) else { return Vec::new() }; + 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() @@ -118,9 +131,13 @@ pub fn highlights(doc: &Document, position: Position) -> Vec .collect() } -pub fn prepare_rename(doc: &Document, position: Position) -> Option { +pub fn prepare_rename(ws: &Workspace, doc: &Document, position: Position) -> Option { let offset = doc.offset(position); - let (resolved, span) = doc.resolution.resolved_at_offset(offset)?; + 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; } @@ -153,11 +170,21 @@ pub fn rename( 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 { - let name = global_name_at(doc, offset)?; + } 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))?; + // A member declared by a `---@field` line is located by the whole comment, which must not + // be replaced; only ranges that cover exactly the name are edited. + let covers_name = |range: &lsp_types::Range| { + range.start.line == range.end.line + && (range.end.character - range.start.character) as usize == target.name.len() + }; + for (uri, range) in member_occurrences(ws, docs, doc, &target).into_iter().filter(|(_, r)| covers_name(r)) { + changes.entry(uri).or_default().push(TextEdit::new(range, new_name.to_string())); + } } Some(WorkspaceEdit { changes: Some(changes), ..WorkspaceEdit::default() }) } diff --git a/src/index.rs b/src/index.rs index 8341988..a98d970 100644 --- a/src/index.rs +++ b/src/index.rs @@ -85,6 +85,8 @@ pub struct FileIndex { pub exports: Vec, pub events: Vec, pub module_return: Option, + pub convars: Vec, + pub state_keys: Vec, pub summary: FileSummary, } diff --git a/src/indexer.rs b/src/indexer.rs index 443d253..176b2c0 100644 --- a/src/indexer.rs +++ b/src/indexer.rs @@ -68,6 +68,25 @@ struct Indexer<'a> { 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"]; @@ -308,6 +327,7 @@ impl<'a> Indexer<'a> { 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)); } @@ -494,6 +514,13 @@ impl<'a> Indexer<'a> { } } + 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"); @@ -537,20 +564,39 @@ impl<'a> Indexer<'a> { ExprKind::Call { callee, args, .. } => { if let Some(path) = callee.dotted_path() { self.event(&path, args); + 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, .. } => self.expr(base), + 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); diff --git a/src/server.rs b/src/server.rs index f5295bd..646ea32 100644 --- a/src/server.rs +++ b/src/server.rs @@ -112,6 +112,7 @@ pub fn capabilities() -> ServerCapabilities { 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(), @@ -197,7 +198,7 @@ impl Server { } fn register_watchers(&mut self) { - let watchers = ["**/*.lua", "**/qbxlint.toml", "**/.qbxlint.toml"] + let watchers = ["**/*.lua", "**/qbxlint.toml", "**/.qbxlint.toml", "**/locales/*.json"] .iter() .map(|glob| FileSystemWatcher { glob_pattern: GlobPattern::String(glob.to_string()), kind: None }) .collect(); @@ -273,9 +274,10 @@ impl Server { } self.flush_index(); self.dirty.clear(); + let crossrefs = self.ws.crossrefs(); let uris: Vec = self.docs.keys().cloned().collect(); for uri in uris { - self.publish(&uri); + self.publish(&uri, &crossrefs); } } @@ -313,14 +315,24 @@ impl Server { } 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 { - if self.docs.contains_key(&uri) { + 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)); - let mut found = diagnostics::diagnostics(&self.ws, &doc, &overrides); + 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)); if !found.is_empty() { reported.insert(uri.clone()); @@ -333,6 +345,43 @@ impl Server { }); } } + for (resource, usages) in locale_usage { + let Some(entry) = self.ws.index.resource(resource) else { continue }; + let Some(locale) = qbx_lua_analysis::locale::LocaleFile::load(&entry.root) else { 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); @@ -349,11 +398,13 @@ impl Server { self.stale_resources.insert(resource); } - fn publish(&self, uri: &Url) { + fn publish(&self, uri: &Url, crossrefs: &qbx_lua_analysis::crossref::CrossRefs) { let Some(doc) = self.docs.get(uri) else { return }; - let enabled = self.settings.diagnostics.enable.unwrap_or(true); - let diagnostics = - if enabled { diagnostics::diagnostics(&self.ws, doc, &self.settings.rule_overrides()) } else { Vec::new() }; + 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, @@ -435,7 +486,7 @@ impl Server { manifests_changed = true; } else if change.typ == FileChangeType::DELETED { self.ws.index.remove_file(&path); - } else if !self.docs.contains_key(&change.uri) { + } else if path.extension().is_some_and(|e| e == "lua") && !self.docs.contains_key(&change.uri) { self.ws.index_path(&path, FileOrigin::Workspace, None); } } @@ -503,11 +554,11 @@ impl Server { req::DocumentHighlightRequest::METHOD => { let p: DocumentHighlightParams = params(raw)?; let doc = self.doc(&p.text_document_position_params.text_document.uri)?; - reply(references::highlights(doc, p.text_document_position_params.position)) + 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.doc(&p.text_document.uri)?, p.position)) + reply(references::prepare_rename(&self.ws, self.doc(&p.text_document.uri)?, p.position)) } req::Rename::METHOD => { let p: RenameParams = params(raw)?; @@ -544,6 +595,30 @@ impl Server { } 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.root.as_os_str().is_empty() { + 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(), diff --git a/src/workspace.rs b/src/workspace.rs index 4958928..fdccec6 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -23,9 +23,31 @@ pub struct Workspace { 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, @@ -77,6 +99,7 @@ impl Workspace { 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 @@ -248,3 +271,38 @@ impl Workspace { 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 (_, file) in self.index.files() { + let resource = file.resource.and_then(|id| self.index.resource(id)).map(|r| r.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(), file.side, event.handler.as_deref().map(arity)); + } + 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/tests/fixtures/resources/shop/client.lua b/tests/fixtures/resources/shop/client.lua new file mode 100644 index 0000000..a3aec0e --- /dev/null +++ b/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/tests/fixtures/resources/shop/fxmanifest.lua b/tests/fixtures/resources/shop/fxmanifest.lua new file mode 100644 index 0000000..181e74d --- /dev/null +++ b/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/tests/fixtures/resources/shop/locales/en.json b/tests/fixtures/resources/shop/locales/en.json new file mode 100644 index 0000000..6fb9bd0 --- /dev/null +++ b/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/tests/fixtures/resources/shop/server.lua b/tests/fixtures/resources/shop/server.lua new file mode 100644 index 0000000..a0b47ca --- /dev/null +++ b/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/tests/fixtures/resources/shop/shared.lua b/tests/fixtures/resources/shop/shared.lua new file mode 100644 index 0000000..b741075 --- /dev/null +++ b/tests/fixtures/resources/shop/shared.lua @@ -0,0 +1,10 @@ +function locale(key, ...) + return key:format(...) +end + +Shop = {} +Shop.items = {} + +function Shop.getPrice(item) + return Shop.items[item] +end diff --git a/tests/lsp.rs b/tests/lsp.rs index 87661cb..72238df 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -166,7 +166,7 @@ const SERVER: &str = "myresource/server/main.lua"; 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"], 2); + assert_eq!(status["resources"], 3); assert!(status["files"].as_u64().unwrap() >= 10, "{status}"); } @@ -256,7 +256,7 @@ fn completes_members_globals_natives_and_events() { assert!(client.completion_labels(CLIENT, l, c).contains(&"format".to_string())); let (l, c) = with_line(&mut client, "exports.", 6); - assert_eq!(client.completion_labels(CLIENT, l, c), ["mylib", "myresource"]); + assert_eq!(client.completion_labels(CLIENT, l, c), ["mylib", "myresource", "shop"]); let (l, c) = with_line(&mut client, "exports.mylib:", 7); let labels = client.completion_labels(CLIENT, l, c); @@ -455,11 +455,11 @@ fn event_completion_follows_the_call_direction() { client.change(CLIENT, 2, &format!("{text}TriggerServerEvent('')")); let labels = client.completion_labels(CLIENT, line, 20); - assert_eq!(labels, ["myresource:server:ping"], "client code can only reach server handlers"); + 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"]); + assert_eq!(labels, ["myresource:client:notify", "shop:bought"]); } #[test] @@ -472,3 +472,95 @@ fn reports_the_side_of_a_file() { 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}"); +} + +#[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!([])); +} From ca2cc71ac1ba61ec38b2c3b8f8fae03f16a2a2ee Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:15:23 +0200 Subject: [PATCH 08/57] docs: describe formatting, field references and project knowledge completion --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index bc20a75..7d1dcb3 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,9 @@ node scripts/bench-luals.mjs [library- | --- | --- | | Completion | locals, side-aware globals and natives, members through classes/tables/modules, `exports.resource:Fn`, event and callback names, `require` paths, expected table fields, LuaCATS tags and types, manifest directives and paths, FiveM snippets | | Hover | signatures, LuaCATS docs, native docs with examples and side, event handler locations | -| Navigation | definition (incl. `require` targets, event registrations, exports), references, rename, document highlight, document and workspace symbols | -| Editing help | signature help, inlay parameter hints, folding, semantic tokens | +| Navigation | definition (incl. `require` targets, event registrations, exports, locale keys), references and rename for locals, globals **and fields/methods** (each candidate's owner type is resolved, so `a.name` and `b.name` are not confused), document highlight, document and workspace symbols | +| Editing help | signature help, inlay parameter hints, folding, semantic tokens, **document formatting** through `qbx_lua_fmt` (options from `[format]` in `qbxlint.toml`, otherwise the editor's indentation) | +| Project knowledge | `locale('…')` keys with their text from `locales/en.json`, convar names from `GetConvar*` calls and `set`/`setr`/`sets` lines in `.cfg` files, state bag keys after `.state.` and in `AddStateBagChangeHandler` | | Diagnostics | every `qbx-lint` rule with manifest context, for the whole workspace (closed files are linted one at a time and dropped again; a save only re-lints the affected resource), quick fixes, "disable for this line" actions | | Side awareness | natives and globals filtered by client/server, wrong-side errors, event name completion that follows the call direction (`TriggerServerEvent` only offers events handled on the server), `qbx/fileInfo` for editors | | ox_lib | `onCache` snippet and `lib.onCache('…')` completion whose key list is read from the indexed ox_lib source | @@ -69,8 +70,10 @@ the function being called. navigation only. - No control-flow narrowing, no full generics (only simple `T` substitution), no operator metamethod lookup apart from vectors. -- References and rename cover locals and globals, not table fields. -- No formatter. +- Field references are as good as the inferred owner type: a field reached through a value whose + type is unknown is not found, and fields declared only by a `---@field` comment are found but + the comment itself is not rewritten on rename. +- Range formatting is not implemented, only whole documents. ## Configuration From e1270fcfbf1176ae44dbbc2c32e74f72f0546037 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:36:20 +0200 Subject: [PATCH 09/57] fix: ignore escrowed files opened in the editor and cap problems per closed file --- .gitattributes | 2 ++ src/features/diagnostics.rs | 4 ++++ src/server.rs | 8 +++++++- tests/fixtures/resources/shop/escrowed.lua | Bin 0 -> 28 bytes tests/lsp.rs | 13 +++++++++++++ 5 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/resources/shop/escrowed.lua diff --git a/.gitattributes b/.gitattributes index 6313b56..e6a5221 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,3 @@ * text=auto eol=lf +*.lua text eol=lf +**/escrowed.lua binary diff --git a/src/features/diagnostics.rs b/src/features/diagnostics.rs index e664379..9024056 100644 --- a/src/features/diagnostics.rs +++ b/src/features/diagnostics.rs @@ -22,6 +22,10 @@ pub fn diagnostics( rule_overrides: &[(String, Level)], crossrefs: &qbx_lua_analysis::crossref::CrossRefs, ) -> 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()) { + return Vec::new(); + } let mut config = ws.lint_config.for_file(&doc.path); for (code, level) in rule_overrides { config.set(code, *level); diff --git a/src/server.rs b/src/server.rs index 646ea32..cbdee41 100644 --- a/src/server.rs +++ b/src/server.rs @@ -21,6 +21,9 @@ 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)] @@ -260,7 +263,8 @@ impl Server { fn flush_index(&mut self) { for uri in &self.dirty { if let Some(doc) = self.docs.get_mut(uri) { - if !doc.is_manifest() { + let is_source = !qbx_lua_analysis::project::is_not_source(doc.text.as_bytes()); + if !doc.is_manifest() && is_source { doc.file = self.ws.index_parsed(&doc.path, FileOrigin::Workspace, &doc.text, &doc.chunk, &doc.resolution); } @@ -334,6 +338,8 @@ impl Server { } 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()); } diff --git a/tests/fixtures/resources/shop/escrowed.lua b/tests/fixtures/resources/shop/escrowed.lua new file mode 100644 index 0000000000000000000000000000000000000000..c5bd4e40929ccf8dd41e2cc8266b2c3077b51a8c GIT binary patch literal 28 jcmZ>Za13B%VCb4CeB?g^Q+i@iQet}Q90o=v4Gj$ddSM6z literal 0 HcmV?d00001 diff --git a/tests/lsp.rs b/tests/lsp.rs index 72238df..d6745c6 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -564,3 +564,16 @@ fn formats_documents() { let params = json!({ "textDocument": { "uri": client.uri(SHOP_CLIENT) }, "options": { "tabSize": 4, "insertSpaces": true } }); assert_eq!(client.request("textDocument/formatting", params), json!([])); } + +#[test] +fn escrow_encrypted_files_are_ignored() { + let mut client = Client::start(fixture_root()); + assert_eq!(client.diagnostics_for("shop/escrowed.lua"), [], "closed escrowed files are not linted"); + + client.open_with("shop/escrowed.lua", "FXAP\u{1}\u{fffd}\u{fffd}garbage((("); + assert_eq!(client.diagnostics_for("shop/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:?}"); +} From d9c6faf3e2d6016ff5df24ece1223ffdc8ed0211 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:51:59 +0200 Subject: [PATCH 10/57] feat(hover): show table overviews with values and scope tables to the current resource Hovering a table now lists its fields with types and the short literal values remembered by the index. Global lookups no longer fall back to symbols of unrelated resources, and a table the resource fills itself is no longer merged with same-named tables of imported libraries, so Config means this resource's Config. --- README.md | 3 +- src/features/completion.rs | 5 +- src/features/hover.rs | 63 +++++++++++++++++++----- src/index.rs | 28 ++++++++--- src/indexer.rs | 18 +++++++ src/infer.rs | 6 +++ tests/fixtures/resources/shop/shared.lua | 6 +++ tests/lsp.rs | 24 +++++++++ 8 files changed, 134 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 7d1dcb3..44b077a 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,8 @@ node scripts/bench-luals.mjs [library- | | | | --- | --- | | Completion | locals, side-aware globals and natives, members through classes/tables/modules, `exports.resource:Fn`, event and callback names, `require` paths, expected table fields, LuaCATS tags and types, manifest directives and paths, FiveM snippets | -| Hover | signatures, LuaCATS docs, native docs with examples and side, event handler locations | +| Hover | signatures, LuaCATS docs, native docs with examples and side, event handler locations, and for tables an overview of their fields with types and literal values (`Debug: boolean = true`) | +| Scoping | a global table only shows what the current file can really see: `Config` means the `Config` of this resource (plus files its manifest imports), never the merged `Config` of every resource in the workspace; library tables such as `lib` are completed from the imported library | | Navigation | definition (incl. `require` targets, event registrations, exports, locale keys), references and rename for locals, globals **and fields/methods** (each candidate's owner type is resolved, so `a.name` and `b.name` are not confused), document highlight, document and workspace symbols | | Editing help | signature help, inlay parameter hints, folding, semantic tokens, **document formatting** through `qbx_lua_fmt` (options from `[format]` in `qbxlint.toml`, otherwise the editor's indentation) | | Project knowledge | `locale('…')` keys with their text from `locales/en.json`, convar names from `GetConvar*` calls and `set`/`setr`/`sets` lines in `.cfg` files, state bag keys after `.state.` and in `AddStateBagChangeHandler` | diff --git a/src/features/completion.rs b/src/features/completion.rs index c8145ef..ab8fd57 100644 --- a/src/features/completion.rs +++ b/src/features/completion.rs @@ -148,7 +148,10 @@ fn item(label: &str, kind: CompletionItemKind, sort_group: u8) -> CompletionItem fn member_item(member: &MemberInfo) -> CompletionItem { let mut out = item(&member.name, kind_of(member.kind, &member.ty), 0); - out.detail = detail_of(&member.name, &member.ty); + 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]); diff --git a/src/features/hover.rs b/src/features/hover.rs index c81fe75..c4d75d9 100644 --- a/src/features/hover.rs +++ b/src/features/hover.rs @@ -59,11 +59,48 @@ pub fn target_at(infer: &Infer, doc: &Document, offset: u32) -> Option { None } -fn describe_value(prefix: &str, name: &str, ty: &Type) -> String { - match ty.as_fun() { - Some(fun) if matches!(ty, Type::Fun(_)) => format!("{prefix}{}", fun.signature(name)), - _ => format!("{prefix}{name}: {ty}"), +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. +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 bare = ty.without_nil(); + let is_table = matches!( + bare, + Type::GlobalTable(_) + | Type::Named(..) + | Type::Shape(_) + | Type::Require(_) + | Type::Union(_) + | Type::Exports(Some(_)) + ); + let members = if is_table { infer.members(&bare) } else { Vec::new() }; + if members.is_empty() { + let value = literal.map(|l| format!(" = {l}")).unwrap_or_default(); + return format!("{prefix}{name}: {ty}{value}"); + } + 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 } fn local_hover(infer: &Infer, id: LocalId) -> String { @@ -75,7 +112,7 @@ fn local_hover(infer: &Infer, id: LocalId) -> String { LocalKind::LoopVar => "(loop variable) ", LocalKind::Local | LocalKind::LocalFunction => "local ", }; - let mut out = lua_block(&describe_value(prefix, &local.name, &ty)); + 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)) @@ -119,10 +156,14 @@ fn global_hover(ws: &Workspace, infer: &Infer, name: &str) -> Option { return Some(hover); } let ty = infer.global_type(name); - return (!ty.is_unknown()).then(|| lua_block(&describe_value("", name, &ty))); + 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 ty = if symbol.ty.is_unknown() { infer.global_type(name) } else { symbol.ty.clone() }; - let mut out = lua_block(&describe_value("", name, &ty)); + 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); @@ -142,7 +183,7 @@ fn global_hover(ws: &Workspace, infer: &Infer, name: &str) -> Option { Some(out) } -pub fn member_hover(info: &MemberInfo, owner: &Type) -> String { +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(), @@ -154,7 +195,7 @@ pub fn member_hover(info: &MemberInfo, owner: &Type) -> String { (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(prefix, &qualified, &info.ty)); + 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**"); } @@ -203,7 +244,7 @@ pub fn hover(ws: &Workspace, doc: &Document, position: Position) -> Option Some(local_hover(infer, *id)), Target::Global(name, _) => global_hover(ws, infer, name), - Target::Member { info, owner, .. } => Some(member_hover(info, owner)), + Target::Member { info, owner, .. } => Some(member_hover(infer, info, owner)), }; text.map(|t| (t, target.span())) })?; diff --git a/src/index.rs b/src/index.rs index a98d970..3156290 100644 --- a/src/index.rs +++ b/src/index.rs @@ -32,6 +32,8 @@ pub struct Symbol { 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, } @@ -289,13 +291,10 @@ impl Index { 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)?)); - let visible: Vec<_> = slots.iter().filter(|(f, _)| self.is_visible(from, *f)).filter_map(resolve).collect(); - if !visible.is_empty() { - return visible; - } - slots.iter().filter_map(resolve).collect() + 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)> { @@ -317,8 +316,25 @@ impl Index { || 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.iter().filter_map(resolve).collect(); + } + // 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<&Slot> = slots.iter().filter(|(f, _)| self.is_visible(from, *f)).collect(); + if in_scope.iter().any(|(f, _)| is_own(*f)) { + return in_scope.into_iter().filter_map(resolve).collect(); + } let visible: Vec<_> = slots.iter().filter(|(f, _)| reachable(*f)).filter_map(resolve).collect(); - if !visible.is_empty() { + // 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.iter().filter_map(resolve).collect() diff --git a/src/indexer.rs b/src/indexer.rs index 176b2c0..2800ff4 100644 --- a/src/indexer.rs +++ b/src/indexer.rs @@ -149,6 +149,7 @@ impl<'a> Indexer<'a> { 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, }) @@ -253,10 +254,24 @@ impl<'a> Indexer<'a> { 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, fields: &[TableField], span: Span) { let values: Vec = fields .iter() @@ -375,6 +390,7 @@ impl<'a> Indexer<'a> { 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() { @@ -492,6 +508,7 @@ impl<'a> Indexer<'a> { ty, doc: symbol_doc, deprecated: doc.deprecated.is_some(), + literal: None, range: self.range(name_arg.span), }); } @@ -532,6 +549,7 @@ impl<'a> Indexer<'a> { ty: self.infer.expr(value).widen(), doc: None, deprecated: false, + literal: None, range: self.range(key.span), }; self.push_member(owner, symbol); diff --git a/src/infer.rs b/src/infer.rs index baeda12..4cd94d8 100644 --- a/src/infer.rs +++ b/src/infer.rs @@ -208,6 +208,7 @@ pub struct MemberInfo { pub ty: Type, pub doc: Option>, pub deprecated: bool, + pub literal: Option, pub kind: SymbolKind, pub location: Option<(FileId, Range)>, } @@ -720,6 +721,7 @@ impl<'a> Infer<'a> { ty: if field.optional { field.ty.clone().optional() } else { field.ty.clone() }, doc: None, deprecated: false, + literal: None, kind: SymbolKind::Field, location: None, }); @@ -746,6 +748,7 @@ impl<'a> Infer<'a> { 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, }); @@ -756,6 +759,7 @@ impl<'a> Infer<'a> { ty: Type::Exports(Some(SmolStr::new(name))), doc: None, deprecated: false, + literal: None, kind: SymbolKind::Table, location: None, }); @@ -794,6 +798,7 @@ impl<'a> Infer<'a> { ty: Type::GlobalTable(SmolStr::new(nested)), doc: None, deprecated: false, + literal: None, kind: SymbolKind::Table, location: None, }); @@ -832,6 +837,7 @@ fn member_from_symbol(file: FileId, symbol: &crate::index::Symbol) -> MemberInfo ty: symbol.ty.clone(), doc: symbol.doc.clone(), deprecated: symbol.deprecated, + literal: symbol.literal.clone(), kind: symbol.kind, location: Some((file, symbol.range)), } diff --git a/tests/fixtures/resources/shop/shared.lua b/tests/fixtures/resources/shop/shared.lua index b741075..463a2d4 100644 --- a/tests/fixtures/resources/shop/shared.lua +++ b/tests/fixtures/resources/shop/shared.lua @@ -8,3 +8,9 @@ Shop.items = {} function Shop.getPrice(item) return Shop.items[item] end + +Config = { + ShopName = 'General Store', + TaxRate = 0.21, + OpenAtNight = false, +} diff --git a/tests/lsp.rs b/tests/lsp.rs index d6745c6..47b32b4 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -565,6 +565,30 @@ fn formats_documents() { assert_eq!(client.request("textDocument/formatting", params), json!([])); } +#[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()); From 3cc2119464095ecde286ef5a869119f24ee154ef Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:09:55 +0200 Subject: [PATCH 11/57] feat(diagnostics): honour server.cfg start order and escrowed resources --- src/features/diagnostics.rs | 13 +++++++--- src/server.rs | 10 ++++++-- src/workspace.rs | 1 + tests/fixtures/late.cfg | 1 + tests/fixtures/resources/late/fxmanifest.lua | 5 ++++ tests/fixtures/resources/late/server.lua | 1 + tests/fixtures/server.cfg | 5 ++++ tests/lsp.rs | 25 ++++++++++++++++++-- 8 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/late.cfg create mode 100644 tests/fixtures/resources/late/fxmanifest.lua create mode 100644 tests/fixtures/resources/late/server.lua create mode 100644 tests/fixtures/server.cfg diff --git a/src/features/diagnostics.rs b/src/features/diagnostics.rs index 9024056..0016949 100644 --- a/src/features/diagnostics.rs +++ b/src/features/diagnostics.rs @@ -57,10 +57,17 @@ pub fn diagnostics( config.set(qbx_lua_analysis::rules::UNDEFINED_GLOBAL, Level::Off); } let env = resource_id.map(|id| ws.resource_env(id)); + let started_before = resource.and_then(|r| { + let order = qbx_lua_analysis::startup::StartOrder::discover(&r.root)?; + Some(order.started_before(&r.name)) + }); let resource_input = match (resource, &env) { - (Some(resource), Some(env)) => { - Some(ResourceInput { name: &resource.name, env, manifest: &resource.manifest }) - } + (Some(resource), Some(env)) => Some(ResourceInput { + name: &resource.name, + env, + manifest: &resource.manifest, + started_before: started_before.as_ref(), + }), _ => None, }; check_file(&FileInput { diff --git a/src/server.rs b/src/server.rs index cbdee41..a9a10cb 100644 --- a/src/server.rs +++ b/src/server.rs @@ -201,7 +201,7 @@ impl Server { } fn register_watchers(&mut self) { - let watchers = ["**/*.lua", "**/qbxlint.toml", "**/.qbxlint.toml", "**/locales/*.json"] + let watchers = ["**/*.lua", "**/qbxlint.toml", "**/.qbxlint.toml", "**/locales/*.json", "**/*.cfg"] .iter() .map(|glob| FileSystemWatcher { glob_pattern: GlobPattern::String(glob.to_string()), kind: None }) .collect(); @@ -353,6 +353,10 @@ impl Server { } 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 qbx_lua_analysis::project::is_escrowed_resource(&entry.root) { + continue; + } let Some(locale) = qbx_lua_analysis::locale::LocaleFile::load(&entry.root) else { continue }; let mut config = self.ws.lint_config.for_file(&locale.path); overrides.iter().for_each(|(code, level)| config.set(code, *level)); @@ -483,7 +487,9 @@ impl Server { let mut manifests_changed = false; for change in changes { let Some(path) = uri_to_path(&change.uri) else { continue }; - if path.extension().is_some_and(|e| e == "toml") { + 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") { if let Some(root) = self.ws.roots.first() { self.ws.lint_config = qbx_lua_analysis::Config::discover(root).ok().flatten().unwrap_or_default(); } diff --git a/src/workspace.rs b/src/workspace.rs index fdccec6..ea14bd7 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -250,6 +250,7 @@ impl Workspace { 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 = qbx_lua_analysis::project::is_escrowed_resource(&entry.root); for file in entry.files.iter().filter_map(|id| self.index.file(*id)) { env.add_summary(&file.index.summary, file.side); } diff --git a/tests/fixtures/late.cfg b/tests/fixtures/late.cfg new file mode 100644 index 0000000..48b3e92 --- /dev/null +++ b/tests/fixtures/late.cfg @@ -0,0 +1 @@ +ensure late diff --git a/tests/fixtures/resources/late/fxmanifest.lua b/tests/fixtures/resources/late/fxmanifest.lua new file mode 100644 index 0000000..1790fb7 --- /dev/null +++ b/tests/fixtures/resources/late/fxmanifest.lua @@ -0,0 +1,5 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +server_script 'server.lua' diff --git a/tests/fixtures/resources/late/server.lua b/tests/fixtures/resources/late/server.lua new file mode 100644 index 0000000..82a4b7d --- /dev/null +++ b/tests/fixtures/resources/late/server.lua @@ -0,0 +1 @@ +print(exports.mylib:Ping('hello')) diff --git a/tests/fixtures/server.cfg b/tests/fixtures/server.cfg new file mode 100644 index 0000000..6b8a04b --- /dev/null +++ b/tests/fixtures/server.cfg @@ -0,0 +1,5 @@ +# fixture start order +ensure shop +ensure [core] +exec late.cfg +ensure myresource diff --git a/tests/lsp.rs b/tests/lsp.rs index 47b32b4..b7f755c 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -166,7 +166,7 @@ const SERVER: &str = "myresource/server/main.lua"; 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"], 3); + assert_eq!(status["resources"], 4); assert!(status["files"].as_u64().unwrap() >= 10, "{status}"); } @@ -256,7 +256,9 @@ fn completes_members_globals_natives_and_events() { assert!(client.completion_labels(CLIENT, l, c).contains(&"format".to_string())); let (l, c) = with_line(&mut client, "exports.", 6); - assert_eq!(client.completion_labels(CLIENT, l, c), ["mylib", "myresource", "shop"]); + let mut resources = client.completion_labels(CLIENT, l, c); + resources.sort(); + assert_eq!(resources, ["late", "mylib", "myresource", "shop"]); let (l, c) = with_line(&mut client, "exports.mylib:", 7); let labels = client.completion_labels(CLIENT, l, c); @@ -565,6 +567,25 @@ fn formats_documents() { assert_eq!(client.request("textDocument/formatting", params), json!([])); } +#[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 table_hover_lists_only_the_fields_in_scope() { let mut client = Client::start(fixture_root()); From 5922ce981c880446c23c67cc1b427441b29fb953 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Mon, 21 Sep 2026 03:08:50 +0200 Subject: [PATCH 12/57] feat(diagnostics): pass installed resources to the linter and test exports completion --- src/features/diagnostics.rs | 7 +++-- src/server.rs | 2 ++ tests/fixtures/resources/late/bridge.lua | 11 ++++++++ tests/fixtures/resources/late/fxmanifest.lua | 2 +- tests/fixtures/resources/late/guarded.lua | 3 +++ tests/lsp.rs | 28 ++++++++++++++++++++ 6 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/resources/late/bridge.lua create mode 100644 tests/fixtures/resources/late/guarded.lua diff --git a/src/features/diagnostics.rs b/src/features/diagnostics.rs index 0016949..733bf4b 100644 --- a/src/features/diagnostics.rs +++ b/src/features/diagnostics.rs @@ -57,16 +57,15 @@ pub fn diagnostics( config.set(qbx_lua_analysis::rules::UNDEFINED_GLOBAL, Level::Off); } let env = resource_id.map(|id| ws.resource_env(id)); - let started_before = resource.and_then(|r| { - let order = qbx_lua_analysis::startup::StartOrder::discover(&r.root)?; - Some(order.started_before(&r.name)) - }); + let start_order = resource.and_then(|r| qbx_lua_analysis::startup::StartOrder::discover(&r.root)); + let started_before = resource.zip(start_order.as_ref()).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.as_ref().map(|order| &order.installed), }), _ => None, }; diff --git a/src/server.rs b/src/server.rs index a9a10cb..7a87377 100644 --- a/src/server.rs +++ b/src/server.rs @@ -494,6 +494,8 @@ impl Server { self.ws.lint_config = qbx_lua_analysis::Config::discover(root).ok().flatten().unwrap_or_default(); } } 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); manifests_changed = true; } else if change.typ == FileChangeType::DELETED { diff --git a/tests/fixtures/resources/late/bridge.lua b/tests/fixtures/resources/late/bridge.lua new file mode 100644 index 0000000..c1ecfd2 --- /dev/null +++ b/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/tests/fixtures/resources/late/fxmanifest.lua b/tests/fixtures/resources/late/fxmanifest.lua index 1790fb7..734cbf3 100644 --- a/tests/fixtures/resources/late/fxmanifest.lua +++ b/tests/fixtures/resources/late/fxmanifest.lua @@ -2,4 +2,4 @@ fx_version 'cerulean' game 'gta5' lua54 'yes' -server_script 'server.lua' +server_scripts { 'server.lua', 'bridge.lua', 'guarded.lua' } diff --git a/tests/fixtures/resources/late/guarded.lua b/tests/fixtures/resources/late/guarded.lua new file mode 100644 index 0000000..b074316 --- /dev/null +++ b/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/tests/lsp.rs b/tests/lsp.rs index b7f755c..717f914 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -586,6 +586,34 @@ fn server_cfg_start_order_settles_dependencies() { 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 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"]); + + 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()); From d45b692acebb3ffd1ef6f2d7ee02336339fe9f8d Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Mon, 21 Sep 2026 03:31:49 +0200 Subject: [PATCH 13/57] fix(diagnostics): treat escrowed, JS and dynamic exporters as opaque Calling an export of an escrowed resource was reported as unknown because only the readable Lua was considered. Resources are now opaque when they ship a .fxap marker, contain an encrypted file, run non-Lua scripts or register exports under computed names, and the cross-file rules stay silent about them. The escrow state is kept on the resource entry, which also makes a resource with an encrypted file but no marker count, as the linter already did. The encrypted fixture moves to its own resource so the shop fixture keeps exercising the cross-file rules. --- README.md | 1 + src/features/diagnostics.rs | 3 ++ src/index.rs | 4 +++ src/indexer.rs | 5 +++- src/server.rs | 2 +- src/workspace.rs | 27 ++++++++++++++++-- tests/fixtures/resources/late/fxmanifest.lua | 2 +- tests/fixtures/resources/late/hidden.lua | 2 ++ .../resources/{shop => vault}/escrowed.lua | Bin tests/fixtures/resources/vault/fxmanifest.lua | 5 ++++ tests/fixtures/resources/vault/open.lua | 1 + tests/fixtures/server.cfg | 1 + tests/lsp.rs | 22 ++++++++++---- 13 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 tests/fixtures/resources/late/hidden.lua rename tests/fixtures/resources/{shop => vault}/escrowed.lua (100%) create mode 100644 tests/fixtures/resources/vault/fxmanifest.lua create mode 100644 tests/fixtures/resources/vault/open.lua diff --git a/README.md b/README.md index 44b077a..dfae6ae 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ node scripts/bench-luals.mjs [library- | Project knowledge | `locale('…')` keys with their text from `locales/en.json`, convar names from `GetConvar*` calls and `set`/`setr`/`sets` lines in `.cfg` files, state bag keys after `.state.` and in `AddStateBagChangeHandler` | | Diagnostics | every `qbx-lint` rule with manifest context, for the whole workspace (closed files are linted one at a time and dropped again; a save only re-lints the affected resource), quick fixes, "disable for this line" actions | | Side awareness | natives and globals filtered by client/server, wrong-side errors, event name completion that follows the call direction (`TriggerServerEvent` only offers events handled on the server), `qbx/fileInfo` for editors | +| Escrow | encrypted (`FXAP`) files are never parsed; a resource with a `.fxap` marker or an encrypted file is opaque, so nothing is claimed about its globals, locale keys, exports or event handlers. Resources with JavaScript/C# scripts or computed export names are treated the same for exports | | ox_lib | `onCache` snippet and `lib.onCache('…')` completion whose key list is read from the indexed ox_lib source | Type sources: `---@class/@field/@alias/@enum/@type/@param/@return/@generic/@overload`, table diff --git a/src/features/diagnostics.rs b/src/features/diagnostics.rs index 733bf4b..d21f4da 100644 --- a/src/features/diagnostics.rs +++ b/src/features/diagnostics.rs @@ -69,7 +69,10 @@ pub fn diagnostics( }), _ => None, }; + let relative_path = + resource.map(|r| qbx_lua_analysis::project::relative_slash_path(&r.root, &doc.path)).unwrap_or_default(); check_file(&FileInput { + relative_path: &relative_path, source: &doc.text, chunk: &doc.chunk, resolution: &doc.resolution, diff --git a/src/index.rs b/src/index.rs index 3156290..5225b41 100644 --- a/src/index.rs +++ b/src/index.rs @@ -89,6 +89,8 @@ pub struct FileIndex { 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, } @@ -118,6 +120,8 @@ pub struct ResourceEntry { 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); diff --git a/src/indexer.rs b/src/indexer.rs index 2800ff4..b6c919e 100644 --- a/src/indexer.rs +++ b/src/indexer.rs @@ -488,7 +488,10 @@ impl<'a> Indexer<'a> { return; } let (Some(name_arg), Some(value)) = (args.first(), args.get(1)) else { return }; - let Some(name) = name_arg.as_string() 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))), diff --git a/src/server.rs b/src/server.rs index 7a87377..dc9ee75 100644 --- a/src/server.rs +++ b/src/server.rs @@ -354,7 +354,7 @@ impl Server { 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 qbx_lua_analysis::project::is_escrowed_resource(&entry.root) { + if entry.escrowed { continue; } let Some(locale) = qbx_lua_analysis::locale::LocaleFile::load(&entry.root) else { continue }; diff --git a/src/workspace.rs b/src/workspace.rs index ea14bd7..ced8615 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -161,6 +161,7 @@ impl Workspace { manifest, files: Vec::new(), imports: Vec::new(), + escrowed: qbx_lua_analysis::project::is_escrowed_resource(root), }); Some(self.index.resources.len() as ResourceId - 1) } @@ -204,7 +205,10 @@ impl Workspace { owned = text; &owned } - Err(_) => return false, + Err(_) => { + self.mark_escrowed(path); + return false; + } } } }; @@ -214,6 +218,16 @@ impl Workspace { 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, @@ -250,7 +264,7 @@ impl Workspace { 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 = qbx_lua_analysis::project::is_escrowed_resource(&entry.root); + 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); } @@ -287,8 +301,17 @@ impl Workspace { } 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()); } diff --git a/tests/fixtures/resources/late/fxmanifest.lua b/tests/fixtures/resources/late/fxmanifest.lua index 734cbf3..a039e26 100644 --- a/tests/fixtures/resources/late/fxmanifest.lua +++ b/tests/fixtures/resources/late/fxmanifest.lua @@ -2,4 +2,4 @@ fx_version 'cerulean' game 'gta5' lua54 'yes' -server_scripts { 'server.lua', 'bridge.lua', 'guarded.lua' } +server_scripts { 'server.lua', 'bridge.lua', 'guarded.lua', 'hidden.lua' } diff --git a/tests/fixtures/resources/late/hidden.lua b/tests/fixtures/resources/late/hidden.lua new file mode 100644 index 0000000..22f7966 --- /dev/null +++ b/tests/fixtures/resources/late/hidden.lua @@ -0,0 +1,2 @@ +exports.vault:CreateFingerPrint() +exports.mylib:Missing() diff --git a/tests/fixtures/resources/shop/escrowed.lua b/tests/fixtures/resources/vault/escrowed.lua similarity index 100% rename from tests/fixtures/resources/shop/escrowed.lua rename to tests/fixtures/resources/vault/escrowed.lua diff --git a/tests/fixtures/resources/vault/fxmanifest.lua b/tests/fixtures/resources/vault/fxmanifest.lua new file mode 100644 index 0000000..2ac8dce --- /dev/null +++ b/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/tests/fixtures/resources/vault/open.lua b/tests/fixtures/resources/vault/open.lua new file mode 100644 index 0000000..2dbecbf --- /dev/null +++ b/tests/fixtures/resources/vault/open.lua @@ -0,0 +1 @@ +exports('Open', function() end) diff --git a/tests/fixtures/server.cfg b/tests/fixtures/server.cfg index 6b8a04b..b7e57ae 100644 --- a/tests/fixtures/server.cfg +++ b/tests/fixtures/server.cfg @@ -1,3 +1,4 @@ +ensure vault # fixture start order ensure shop ensure [core] diff --git a/tests/lsp.rs b/tests/lsp.rs index 717f914..ce4246b 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -166,7 +166,7 @@ const SERVER: &str = "myresource/server/main.lua"; 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"], 4); + assert_eq!(status["resources"], 5); assert!(status["files"].as_u64().unwrap() >= 10, "{status}"); } @@ -258,7 +258,7 @@ fn completes_members_globals_natives_and_events() { 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"]); + 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); @@ -594,6 +594,16 @@ fn bridge_code_is_not_a_dependency_but_missing_resources_are_reported() { assert_eq!(client.diagnostics_for("late/guarded.lua"), [], "everything after the selector guard is optional"); } +#[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()); @@ -603,7 +613,7 @@ fn completes_resources_and_exports_in_both_spellings() { 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"]); + assert_eq!(resources, ["late", "mylib", "myresource", "shop", "vault"]); client.change(CLIENT, 3, &format!("{text}exports['mylib']:")); let labels = client.completion_labels(CLIENT, line, 17); @@ -641,10 +651,10 @@ fn table_hover_lists_only_the_fields_in_scope() { #[test] fn escrow_encrypted_files_are_ignored() { let mut client = Client::start(fixture_root()); - assert_eq!(client.diagnostics_for("shop/escrowed.lua"), [], "closed escrowed files are not linted"); + assert_eq!(client.diagnostics_for("vault/escrowed.lua"), [], "closed escrowed files are not linted"); - client.open_with("shop/escrowed.lua", "FXAP\u{1}\u{fffd}\u{fffd}garbage((("); - assert_eq!(client.diagnostics_for("shop/escrowed.lua"), [], "nor are they when opened in the editor"); + 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); From ac4c5b8db7e1f213df42988f7d9ba38b00155d05 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:29:36 +0200 Subject: [PATCH 14/57] feat(completion): offer natives for the side a guarded branch runs on Inside `if IsDuplicityVersion() then` of a client or shared file the server natives were hidden, because only the manifest side was used. Diagnostics pick the same narrowing up through the linter. The probe script now only records diagnostics of the probed document, prints the resolved side, and can print messages with PROBE_VERBOSE. --- scripts/probe.mjs | 9 +++++---- src/features/completion.rs | 5 ++++- tests/lsp.rs | 9 +++++++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/scripts/probe.mjs b/scripts/probe.mjs index 0c017ee..1ebecc0 100644 --- a/scripts/probe.mjs +++ b/scripts/probe.mjs @@ -22,17 +22,17 @@ server.stdout.on('data', (chunk) => { 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') diagnostics = message.params.diagnostics; + else if (message.method === 'textDocument/publishDiagnostics' && message.params.uri === probedUri) diagnostics = message.params.diagnostics; } }); -let 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 = pathToFileURL(resolve(virtualFile)).href; +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') } } }); @@ -48,8 +48,9 @@ for (const [line, raw] of lines.entries()) { 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}`).join(', ') || 'none'); +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/src/features/completion.rs b/src/features/completion.rs index ab8fd57..f49130c 100644 --- a/src/features/completion.rs +++ b/src/features/completion.rs @@ -607,7 +607,10 @@ fn scope_items( let mut incomplete = false; if prefix.len() >= 3 { - let side = ws.index.file(doc.file).and_then(|f| f.side).unwrap_or(Side::Shared); + 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()) { diff --git a/tests/lsp.rs b/tests/lsp.rs index ce4246b..27fd693 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -300,6 +300,15 @@ fn server_side_completion_hides_client_natives() { 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] From 515745c22c55448bec30c553f630c1e6c060e220 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:44:41 +0200 Subject: [PATCH 15/57] feat(completion): rank snippets first and make onCache always available A snippet shared its label with the plain function and sorted after it, so accepting the first `CreateThread` only inserted the word. Snippets now sort ahead, are marked as such in the list and preview their body. CreateThread expands to the loop with Wait(0), which is what people want nearly every time; `CreateThread once` keeps the bare form and SetTimeout joins the list. onCache no longer needs ox_lib in the workspace (it falls back to the usual cache keys) and is also offered after `lib.`. --- src/features/completion.rs | 62 +++++++++++++++++++++++--------------- tests/lsp.rs | 29 ++++++++++++++++++ 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/src/features/completion.rs b/src/features/completion.rs index f49130c..00bc11e 100644 --- a/src/features/completion.rs +++ b/src/features/completion.rs @@ -1,6 +1,6 @@ use lsp_types::{ - CompletionItem, CompletionItemKind, CompletionItemTag, CompletionList, CompletionResponse, Documentation, - InsertTextFormat, Position, + CompletionItem, CompletionItemKind, CompletionItemLabelDetails, CompletionItemTag, CompletionList, + CompletionResponse, Documentation, InsertTextFormat, Position, }; use qbx_fivem_data::{native, native_docs, natives, Side}; use qbx_lua_analysis::manifest::KNOWN_DIRECTIVES; @@ -27,13 +27,13 @@ const KEYWORDS: &[&str] = &[ "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", "CreateThread(function()\n\t$0\nend)", "Start a new thread"), - ( - "thread loop", - "CreateThread(function()\n\twhile true do\n\t\t$0\n\t\tWait(${1:0})\n\tend\nend)", - "Thread with a loop that yields every iteration", - ), + ("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)", @@ -146,6 +146,21 @@ fn item(label: &str, kind: CompletionItemKind, sort_group: u8) -> CompletionItem } } +/// 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 mut out = item(label, CompletionItemKind::SNIPPET, 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()) }); + let preview = body.replace("$0", "").replace('\t', " "); + out.documentation = Some(Documentation::MarkupContent(markdown(lua_block(&preview)))); + 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) { @@ -404,17 +419,20 @@ fn cache_key_items(ws: &Workspace, doc: &Document) -> Vec { }) } -fn on_cache_snippet(infer: &Infer) -> Option { - let keys: Vec = cache_keys(infer).iter().map(|k| k.name.to_string()).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 { + let mut keys: Vec = cache_keys(infer).iter().map(|k| k.name.to_string()).collect(); if keys.is_empty() { - return None; + keys = DEFAULT_CACHE_KEYS.iter().map(|k| k.to_string()).collect(); } - let mut out = item("onCache", CompletionItemKind::SNIPPET, 4); - out.insert_text = - Some(format!("lib.onCache('${{1|{}|}}', function(${{2:value}}, ${{3:oldValue}})\n\t$0\nend)", keys.join(","))); - out.insert_text_format = Some(InsertTextFormat::SNIPPET); - out.detail = Some(format!("React to an ox_lib cache change ({})", keys.join(", "))); - Some(out) + let body = + format!("{prefix}onCache('${{1|{}|}}', function(${{2:value}}, ${{3:oldValue}})\n\t$0\nend)", keys.join(",")); + let mut out = snippet_item("onCache", &body, &format!("React to an ox_lib cache change ({})", keys.join(", "))); + let preview = "lib.onCache('ped', function(value, oldValue)\n \nend)"; + out.documentation = Some(Documentation::MarkupContent(markdown(lua_block(preview)))); + out } fn resource_items(ws: &Workspace) -> Vec { @@ -495,6 +513,7 @@ fn member_items(infer: &Infer, doc: &Document, offset: u32, head: &str, via_colo } out }) + .chain((head == "lib.").then(|| on_cache_snippet(infer, ""))) .collect() } @@ -594,15 +613,10 @@ fn scope_items( items.push(item(keyword, CompletionItemKind::KEYWORD, 3)); } for (label, body, description) in SNIPPETS.iter().filter(|(label, ..)| matches(label)) { - let mut out = item(label, CompletionItemKind::SNIPPET, 4); - out.insert_text = Some(body.to_string()); - out.insert_text_format = Some(InsertTextFormat::SNIPPET); - out.detail = Some(description.to_string()); - out.filter_text = Some(label.to_string()); - items.push(out); + items.push(snippet_item(label, body, description)); } if matches("onCache") { - items.extend(on_cache_snippet(infer)); + items.push(on_cache_snippet(infer, "lib.")); } let mut incomplete = false; diff --git a/tests/lsp.rs b/tests/lsp.rs index 27fd693..f2d86c1 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -603,6 +603,35 @@ fn bridge_code_is_not_a_dependency_but_missing_resources_are_reported() { 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 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 exports_of_escrowed_resources_are_not_second_guessed() { let mut client = Client::start(fixture_root()); From 999da8fec747093d81ad93c05fb711741c6feccc Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:09:34 +0200 Subject: [PATCH 16/57] feat: show handler parameters for event calls and type onCache callbacks Signature help and inlay hints for TriggerServerEvent and friends only knew the native's `...: any`, although the index already holds the handler registered under the event name. They now show the native's leading parameters followed by the handler's, and say where the handler lives. lib.callback calls get the same, minus the `source` parameter of server callbacks. The callback of lib.onCache('key', ...) takes its parameter types from cache.key. Native handle types (Vehicle, Ped, Hash, ...) resolve to integer instead of whatever class a resource declared under that name. Snippets use the event and keyword icons, preview their body without placeholder syntax, and are listed by the new qbx/snippets request. --- README.md | 3 +- src/features/completion.rs | 69 ++++++++++++++++++++++++++++++----- src/features/event_call.rs | 73 ++++++++++++++++++++++++++++++++++++++ src/features/inlay.rs | 6 +++- src/features/mod.rs | 1 + src/features/signature.rs | 14 ++++++++ src/infer.rs | 24 +++++++++++-- src/server.rs | 12 +++++++ tests/lsp.rs | 22 ++++++++++++ 9 files changed, 211 insertions(+), 13 deletions(-) create mode 100644 src/features/event_call.rs diff --git a/README.md b/README.md index dfae6ae..d70c0f3 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,8 @@ node scripts/bench-luals.mjs [library- | Diagnostics | every `qbx-lint` rule with manifest context, for the whole workspace (closed files are linted one at a time and dropped again; a save only re-lints the affected resource), quick fixes, "disable for this line" actions | | Side awareness | natives and globals filtered by client/server, wrong-side errors, event name completion that follows the call direction (`TriggerServerEvent` only offers events handled on the server), `qbx/fileInfo` for editors | | Escrow | encrypted (`FXAP`) files are never parsed; a resource with a `.fxap` marker or an encrypted file is opaque, so nothing is claimed about its globals, locale keys, exports or event handlers. Resources with JavaScript/C# scripts or computed export names are treated the same for exports | -| ox_lib | `onCache` snippet and `lib.onCache('…')` completion whose key list is read from the indexed ox_lib source | +| Events | signature help and inlay hints of `TriggerServerEvent('name', …)`, `TriggerClientEvent`, `lib.callback` and `lib.callback.await` show the parameters of the handler registered for that name instead of the native's `...: any`, with the handler's location | +| ox_lib | `lib.onCache('vehicle', function(value, oldValue)` types both parameters as `cache.vehicle`; `onCache` snippet and `lib.onCache('…')` completion whose key list is read from the indexed ox_lib source | Type sources: `---@class/@field/@alias/@enum/@type/@param/@return/@generic/@overload`, table constructors (also behind `setmetatable`), `function Table.name()` / `Table.name = ...` anywhere diff --git a/src/features/completion.rs b/src/features/completion.rs index 00bc11e..bdf4417 100644 --- a/src/features/completion.rs +++ b/src/features/completion.rs @@ -149,15 +149,68 @@ fn item(label: &str, kind: CompletionItemKind, sort_group: u8) -> CompletionItem /// 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 mut out = item(label, CompletionItemKind::SNIPPET, 0); + 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()) }); - let preview = body.replace("$0", "").replace('\t', " "); - out.documentation = Some(Documentation::MarkupContent(markdown(lua_block(&preview)))); + 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 } @@ -423,16 +476,16 @@ fn cache_key_items(ws: &Workspace, doc: &Document) -> Vec { const DEFAULT_CACHE_KEYS: &[&str] = &["ped", "vehicle", "seat", "weapon", "playerId", "serverId", "coords"]; fn on_cache_snippet(infer: &Infer, prefix: &str) -> CompletionItem { - let mut keys: Vec = cache_keys(infer).iter().map(|k| k.name.to_string()).collect(); + 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(",")); - let mut out = snippet_item("onCache", &body, &format!("React to an ox_lib cache change ({})", keys.join(", "))); - let preview = "lib.onCache('ped', function(value, oldValue)\n \nend)"; - out.documentation = Some(Documentation::MarkupContent(markdown(lua_block(preview)))); - out + snippet_item("onCache", &body, &format!("React to an ox_lib cache change ({})", keys.join(", "))) } fn resource_items(ws: &Workspace) -> Vec { diff --git a/src/features/event_call.rs b/src/features/event_call.rs new file mode 100644 index 0000000..2255083 --- /dev/null +++ b/src/features/event_call.rs @@ -0,0 +1,73 @@ +use qbx_fivem_data::Side; +use qbx_lua_analysis::crossref::trigger_target; +use qbx_lua_syntax::ast::Expr; + +use crate::document::Document; +use crate::index::EventKind; +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, callee: &Expr, args: &[Expr], native: &FunType) -> Option { + let call = callee.dotted_path()?; + let name = args.first()?.as_string()?; + 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) = match trigger_target(&call, own_side) { + Some((target, skip)) => (target, skip, [EventKind::NetEvent, EventKind::Handler]), + None => { + let target = own_side.map(|side| if side == Side::Server { Side::Client } else { Side::Server }); + (target, callback_skip(&call)?, [EventKind::Callback, EventKind::Callback]) + } + }; + + let candidates = + ws.index.events().filter(|(_, e)| e.name == *name && wanted.contains(&e.kind) && e.handler.is_some()); + let (file, event) = candidates.max_by_key(|(file, _)| { + let side = ws.index.file(*file).and_then(|f| f.side); + matches!((target, 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 drops_source = is_callback && entry.side != Some(Side::Client) && !handler.params.is_empty(); + + let mut params: Vec = native.params.iter().take(skip).cloned().collect(); + while params.len() < skip { + params.push(Param { name: format!("arg{}", params.len() + 1).into(), ty: Type::Unknown, optional: false }); + } + params.extend(handler.params.iter().skip(usize::from(drops_source)).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 is_callback { handler.returns.clone() } else { Vec::new() }, + is_method: false, + }, + event: name.to_string(), + handler_location: format!("{}{file_name}:{}", resource.unwrap_or_default(), event.range.start.line + 1), + }) +} diff --git a/src/features/inlay.rs b/src/features/inlay.rs index e38ca02..4434304 100644 --- a/src/features/inlay.rs +++ b/src/features/inlay.rs @@ -3,6 +3,7 @@ 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; @@ -13,6 +14,7 @@ const MAX_HINTS: usize = 400; struct Hints<'a, 'b> { doc: &'a Document, infer: &'a Infer<'b>, + ws: &'a Workspace, range: Span, out: Vec, } @@ -32,6 +34,8 @@ impl Hints<'_, '_> { return; } let Some((fun, _)) = self.infer.callee_fun(base, method) else { return }; + let event = method.is_none().then(|| event_call(self.ws, self.doc, base, args, &fun)).flatten(); + let fun = event.map_or(fun, |e| e.fun.into()); 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) @@ -74,7 +78,7 @@ impl<'ast> Visitor<'ast> for Hints<'_, '_> { 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, range: span, out: Vec::new() }; + let mut hints = Hints { doc, infer, ws, range: span, out: Vec::new() }; hints.visit_block(&doc.chunk.block); hints.out }) diff --git a/src/features/mod.rs b/src/features/mod.rs index 397a712..a2f8df6 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -2,6 +2,7 @@ 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; diff --git a/src/features/signature.rs b/src/features/signature.rs index 8e8feef..c6a5d93 100644 --- a/src/features/signature.rs +++ b/src/features/signature.rs @@ -2,6 +2,7 @@ use lsp_types::{Documentation, ParameterInformation, ParameterLabel, Position, S 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; @@ -13,6 +14,8 @@ pub fn signature_help(ws: &Workspace, doc: &Document, position: Position) -> Opt let site = located.call?; with_infer(ws, doc, |infer| { let (fun, member) = infer.callee_fun(site.base, site.method)?; + let event = site.method.is_none().then(|| event_call(ws, doc, site.base, site.args, &fun)).flatten(); + let fun = event.as_ref().map_or(fun, |e| e.fun.clone().into()); let (skip_params, skip_args) = fun.call_offsets(site.method.is_some()); let params: Vec<_> = fun.params.iter().skip(skip_params).collect(); @@ -39,6 +42,17 @@ pub fn signature_help(ws: &Workspace, doc: &Document, position: Position) -> Opt } } + 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 { diff --git a/src/infer.rs b/src/infer.rs index 4cd94d8..2020997 100644 --- a/src/infer.rs +++ b/src/infer.rs @@ -221,11 +221,16 @@ pub struct Infer<'a> { depth: Cell, } +/// Natives call their handles `Vehicle`, `Ped` and so on. They are plain integers, and resources +/// (ox_lib, qbx_core) declare unrelated classes under the same names. +const NATIVE_HANDLE_TYPES: &[&str] = + &["Vehicle", "Ped", "Entity", "Object", "Player", "Hash", "Cam", "Blip", "Pickup", "ScrHandle", "FireId"]; + fn native_type(name: &str) -> Type { - match name { - "vector3" => Type::named("vector3"), - other => Type::named(other), + if NATIVE_HANDLE_TYPES.contains(&name) { + return Type::named("integer"); } + Type::named(name) } pub fn native_fun_type(native: &qbx_fivem_data::Native) -> FunType { @@ -417,7 +422,20 @@ impl<'a> Infer<'a> { } } + /// `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 = match &expected.call.kind { ExprKind::Call { callee, .. } => self.expr(callee).as_fun().cloned(), ExprKind::MethodCall { base, method, .. } => { diff --git a/src/server.rs b/src/server.rs index dc9ee75..20ffb0d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -658,6 +658,18 @@ impl Server { }; 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}")), } } diff --git a/tests/lsp.rs b/tests/lsp.rs index f2d86c1..1a35068 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -348,6 +348,26 @@ fn signature_help_tracks_the_active_parameter() { 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()); @@ -621,6 +641,8 @@ fn snippets_outrank_the_plain_name() { 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}"); From d913cf8628b716b5541e0be9638523aa3e03729f Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:09:18 +0200 Subject: [PATCH 17/57] feat(types): keep native handle names and resolve require 'glm' Mapping Vehicle, Ped, Hash and the other handle types to integer fixed the clash with classes of the same name in ox_lib and qbx_core, but it also turned every native signature into `vehicle: integer`. A handle is now its own type: it prints under its name, has no members and never looks a class up. `require 'glm'` returns the built-in library instead of a module of the resource, which is how ox_lib obtains it. --- README.md | 1 + src/infer.rs | 10 +++++++--- src/types.rs | 3 +++ tests/lsp.rs | 28 ++++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d70c0f3..bd72328 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ node scripts/bench-luals.mjs [library- | Side awareness | natives and globals filtered by client/server, wrong-side errors, event name completion that follows the call direction (`TriggerServerEvent` only offers events handled on the server), `qbx/fileInfo` for editors | | Escrow | encrypted (`FXAP`) files are never parsed; a resource with a `.fxap` marker or an encrypted file is opaque, so nothing is claimed about its globals, locale keys, exports or event handlers. Resources with JavaScript/C# scripts or computed export names are treated the same for exports | | Events | signature help and inlay hints of `TriggerServerEvent('name', …)`, `TriggerClientEvent`, `lib.callback` and `lib.callback.await` show the parameters of the handler registered for that name instead of the native's `...: any`, with the handler's location | +| Runtime types | native handles keep their name (`Vehicle`, `Ped`, `Hash`, …) in signatures and hovers but are integers without members, so they never resolve to a resource's class of the same name; the built-in `glm` library (also through `require 'glm'`) including `glm.polygon` and the other geometry helpers | | ox_lib | `lib.onCache('vehicle', function(value, oldValue)` types both parameters as `cache.vehicle`; `onCache` snippet and `lib.onCache('…')` completion whose key list is read from the indexed ox_lib source | Type sources: `---@class/@field/@alias/@enum/@type/@param/@return/@generic/@overload`, table diff --git a/src/infer.rs b/src/infer.rs index 2020997..80d5af0 100644 --- a/src/infer.rs +++ b/src/infer.rs @@ -221,14 +221,14 @@ pub struct Infer<'a> { depth: Cell, } -/// Natives call their handles `Vehicle`, `Ped` and so on. They are plain integers, and resources -/// (ox_lib, qbx_core) declare unrelated classes under the same names. +/// 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::named("integer"); + return Type::Handle(SmolStr::new(name)); } Type::named(name) } @@ -639,6 +639,10 @@ impl<'a> Infer<'a> { 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())]; } } diff --git a/src/types.rs b/src/types.rs index 26dd4db..31d925a 100644 --- a/src/types.rs +++ b/src/types.rs @@ -20,6 +20,8 @@ pub enum Type { 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), @@ -214,6 +216,7 @@ impl fmt::Display for Type { 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(); diff --git a/tests/lsp.rs b/tests/lsp.rs index 1a35068..a181d6b 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -654,6 +654,34 @@ fn snippets_outrank_the_plain_name() { assert!(member["insertText"].as_str().unwrap_or_default().starts_with("onCache('${1|"), "{member}"); } +#[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()); From 8076f326ce57c2b0ba3e2ab37363ee03f8bb5d0a Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:31:51 +0200 Subject: [PATCH 18/57] Prepare 1.0.0 release and repository documentation --- .editorconfig | 15 + .github/ISSUE_TEMPLATE/bug_report.yml | 30 ++ .github/ISSUE_TEMPLATE/feature_request.yml | 16 ++ .github/PULL_REQUEST_TEMPLATE.md | 7 + .github/workflows/ci.yml | 20 +- .github/workflows/discord-commit.yml | 12 + .github/workflows/discord-release.yml | 12 + .github/workflows/release.yml | 69 +++++ .gitignore | 6 + CHANGELOG.md | 19 ++ CONTRIBUTING.md | 81 ++++++ Cargo.lock | 10 +- Cargo.toml | 15 +- README.md | 170 +++++------- docs/protocol.md | 85 ++++++ src/features/completion.rs | 62 +++-- src/features/event_call.rs | 16 +- src/features/hover.rs | 2 +- src/features/member_refs.rs | 208 +++++++++++--- src/features/references.rs | 9 +- src/index.rs | 13 + src/indexer.rs | 105 ++++--- src/locate.rs | 41 ++- src/server.rs | 33 ++- src/workspace.rs | 20 +- tests/lsp.rs | 221 ++++++++++++++- tests/workspace_refresh.rs | 301 +++++++++++++++++++++ 27 files changed, 1345 insertions(+), 253 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/discord-commit.yml create mode 100644 .github/workflows/discord-release.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 docs/protocol.md create mode 100644 tests/workspace_refresh.rs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..03ae7fb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.{json,yml,yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..36d33b1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,30 @@ +name: Bug report +description: Report a problem with the tool. +body: + - type: textarea + id: environment + attributes: + label: Version and environment + description: Include the tool version, operating system, and editor if relevant. + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: How to reproduce + description: Include a small Lua example, relevant manifest entries, and the command or editor action. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected result + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual result + description: Include the error message or relevant output. Remove secrets and private server details. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..0c8ae2c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,16 @@ +name: Feature request +description: Suggest a change or a new feature. +body: + - type: textarea + id: problem + attributes: + label: What are you trying to do? + validations: + required: true + - type: textarea + id: suggestion + attributes: + label: What would help? + description: Describe the change, with an example if possible. + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..8b55dc6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +## Changes + +Describe what changed and why. Link the related issue, if there is one. + +## Checks + +List the tests or manual checks you ran, and any checks you could not run. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 781fb86..6f7e78c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,9 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: test: strategy: @@ -18,15 +21,18 @@ jobs: with: repository: Qbox-project/qbx-lint path: qbx-lint - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, rustfmt - - uses: Swatinem/rust-cache@v2 + - name: Install Rust + run: rustup toolchain install stable --profile minimal --component clippy --component rustfmt + - run: rustup default stable + - uses: actions/cache@v4 with: - workspaces: qbx-lua-ls + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - run: cargo fmt --check working-directory: qbx-lua-ls - - run: cargo clippy --all-targets -- -D warnings + - run: cargo clippy --all-targets --locked -- -D warnings working-directory: qbx-lua-ls - - run: cargo test + - run: cargo test --locked working-directory: qbx-lua-ls diff --git a/.github/workflows/discord-commit.yml b/.github/workflows/discord-commit.yml new file mode 100644 index 0000000..5ace926 --- /dev/null +++ b/.github/workflows/discord-commit.yml @@ -0,0 +1,12 @@ +name: Discord Commits +on: + push: + branches: [main] + +permissions: + contents: read + +jobs: + notify: + uses: Qbox-project/.github/.github/workflows/discord-commit.yml@main + secrets: inherit diff --git a/.github/workflows/discord-release.yml b/.github/workflows/discord-release.yml new file mode 100644 index 0000000..5fa6824 --- /dev/null +++ b/.github/workflows/discord-release.yml @@ -0,0 +1,12 @@ +name: Discord Releases +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + notify: + uses: Qbox-project/.github/.github/workflows/discord-release.yml@main + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f306e4c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +name: Release +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-latest, target: x86_64-unknown-linux-musl, archive: tar.gz } + - { os: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, archive: tar.gz } + - { os: macos-latest, target: aarch64-apple-darwin, archive: tar.gz } + - { os: macos-15-intel, target: x86_64-apple-darwin, archive: tar.gz } + - { os: windows-latest, target: x86_64-pc-windows-msvc, archive: zip } + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + path: qbx-lua-ls + - uses: actions/checkout@v4 + with: + repository: Qbox-project/qbx-lint + ref: ${{ github.ref_name }} + path: qbx-lint + - name: Install Rust + run: rustup toolchain install stable --profile minimal --target ${{ matrix.target }} + - 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 --target ${{ matrix.target }} + working-directory: qbx-lua-ls + - name: Check version and package + shell: bash + working-directory: qbx-lua-ls + run: | + set -euo pipefail + cd target/${{ matrix.target }}/release + binary="qbx-lua-ls" + if [ "${{ runner.os }}" = "Windows" ]; then binary="${binary}.exe"; fi + actual="$(./"${binary}" --version)" + expected="qbx-lua-ls ${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-lua-ls-${{ matrix.target }}" + if [ "${{ matrix.archive }}" = "zip" ]; then + 7z a "../../../${name}.zip" "${binary}" LICENSE + else + tar -czf "../../../${name}.tar.gz" "${binary}" LICENSE + fi + - uses: actions/upload-artifact@v4 + with: + name: binary-${{ matrix.target }} + path: qbx-lua-ls/qbx-lua-ls-${{ matrix.target }}.${{ matrix.archive }} + if-no-files-found: error + + publish: + needs: build + uses: Qbox-project/.github/.github/workflows/release-artifacts.yml@main + with: + artifact-pattern: binary-* + secrets: inherit diff --git a/.gitignore b/.gitignore index ea8c4bf..b006b05 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ /target + +# Local files +.DS_Store +Thumbs.db +/qbx-lua-ls-*.zip +/qbx-lua-ls-*.tar.gz diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..af7ce06 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +## 1.0.0 + +- Add FiveM Lua language support over stdio LSP: completion, hover, definitions, references, + rename, diagnostics, quick fixes, formatting, signature help, inlay hints, semantic tokens, + folding and symbols. +- Resolve resource imports and client/server scripts from `fxmanifest.lua`, with LuaCATS types, + native signatures and event/callback handler parameters available to editor features. +- Include static string keys and supported annotation fields in member rename. Refuse edits + when a known declaration or an unreadable file prevents a safe rename. +- Respect client/server guards when selecting event diagnostics, completions and signatures. +- Rebuild files, manifests and dependencies during manual reindex, preserving unsaved documents + and removing stale disk entries. +- Limit long expression chains and avoid offering a hash replacement that would create an + invalid Lua statement. +- Honor client capabilities for snippet completions and dynamic file-watch registration. +- Configure standalone release archives for Windows x64, Linux x64/ARM64 and macOS x64/ARM64. + Editor setup is documented in [qbx-editor](https://github.com/Qbox-project/qbx-editor). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5969d1d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,81 @@ +# Contributing + +## Set up + +Install stable Rust. Keep this checkout beside +[qbx-lint](https://github.com/Qbox-project/qbx-lint), which provides the parser, formatter, +analysis and FiveM data crates through local path dependencies: + +```text +work/ + qbx-lint/ + qbx-lua-ls/ +``` + +From `qbx-lua-ls`, build with `cargo build --locked`. If a change needs shared parser or lint +behavior, make the corresponding change in `qbx-lint` and mention both changes in the pull +request. Keep the path dependencies intact. + +## Check changes + +Run these commands from this repository before submitting a pull request: + +```sh +cargo fmt --check +cargo clippy --locked --all-targets -- -D warnings +cargo test --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 +node 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 and changelog when the user-facing +behavior changes. + +## Releases + +Keep the package version, lockfile and changelog in step. Release tags use `v` followed by +that version, such as `v1.0.0`; the workflow rejects a tag that does not match the package. + +Release the matching tag in `qbx-lint` first, then `qbx-lua-ls`, then `qbx-editor`. The server +and editor release workflows check out that same tag in their dependencies. Each workflow +builds all supported platforms before attaching the files and checksums to a GitHub Release. +The editor workflow does not publish to an extension marketplace. diff --git a/Cargo.lock b/Cargo.lock index aec924e..b66b815 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -324,11 +324,11 @@ dependencies = [ [[package]] name = "qbx_fivem_data" -version = "0.1.0" +version = "1.0.0" [[package]] name = "qbx_lua_analysis" -version = "0.1.0" +version = "1.0.0" dependencies = [ "globset", "qbx_fivem_data", @@ -344,7 +344,7 @@ dependencies = [ [[package]] name = "qbx_lua_fmt" -version = "0.1.0" +version = "1.0.0" dependencies = [ "qbx_lua_syntax", "serde", @@ -352,7 +352,7 @@ dependencies = [ [[package]] name = "qbx_lua_ls" -version = "0.1.0" +version = "1.0.0" dependencies = [ "crossbeam-channel", "lsp-server", @@ -370,7 +370,7 @@ dependencies = [ [[package]] name = "qbx_lua_syntax" -version = "0.1.0" +version = "1.0.0" dependencies = [ "smol_str", ] diff --git a/Cargo.toml b/Cargo.toml index 0f2b2c7..17dce78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,22 +1,21 @@ [package] name = "qbx_lua_ls" -description = "A low-memory language server for FiveM Lua" -version = "0.1.0" +description = "Language server for FiveM Lua" +version = "1.0.0" edition = "2021" license = "GPL-3.0-or-later" repository = "https://github.com/Qbox-project/qbx-lua-ls" -rust-version = "1.85" [[bin]] name = "qbx-lua-ls" path = "src/main.rs" [dependencies] -# Switch these to git dependencies on Qbox-project/qbx-lint once that repository is published. -qbx_lua_syntax = { path = "../qbx-lint/crates/qbx_lua_syntax" } -qbx_lua_fmt = { path = "../qbx-lint/crates/qbx_lua_fmt" } -qbx_lua_analysis = { path = "../qbx-lint/crates/qbx_lua_analysis", features = ["docs"] } -qbx_fivem_data = { path = "../qbx-lint/crates/qbx_fivem_data", features = ["docs"] } +# Keep qbx-lint beside this checkout. Release builds use the same tag in both repositories. +qbx_lua_syntax = { version = "=1.0.0", path = "../qbx-lint/crates/qbx_lua_syntax" } +qbx_lua_fmt = { version = "=1.0.0", path = "../qbx-lint/crates/qbx_lua_fmt" } +qbx_lua_analysis = { version = "=1.0.0", path = "../qbx-lint/crates/qbx_lua_analysis", features = ["docs"] } +qbx_fivem_data = { version = "=1.0.0", path = "../qbx-lint/crates/qbx_fivem_data", features = ["docs"] } lsp-server = "0.7" lsp-types = "0.95" serde = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index bd72328..f715887 100644 --- a/README.md +++ b/README.md @@ -1,138 +1,92 @@ # qbx-lua-ls -A language server for FiveM Lua that stays small. It understands CfxLua 5.4 syntax, reads -`fxmanifest.lua` to know which globals and natives exist on which side, takes its types from -LuaCATS annotations, and shares its diagnostics with [`qbx-lint`](../qbx-lint). +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). -> Status: proof of concept. It is an alternative to running `lua-language-server` with the cfxlua -> add-on, not a drop-in clone of it: the type checker is deliberately much simpler. +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. -## Numbers +## Features -Same machine (Windows 11), same workspace: qbx_core, qbx_police, qbx_vehicleshop, ox_lib and -ox_inventory, 224 Lua files. +- Completion and hover for Lua symbols, FiveM natives, exports, events and callbacks. +- Definitions, references and rename for locals, globals and fields, including static string + keys such as `Config['name']` and supported `---@field` declarations. +- Diagnostics and quick fixes with resource and client/server context. +- Signature help, parameter hints, semantic tokens, folding and document/workspace symbols. +- Whole-document formatting, configured through `qbxlint.toml`. +- Completion for manifest paths, locale keys, convars, state bag keys and LuaCATS annotations. -| | Workspace loaded | Resident memory | -| --- | --- | --- | -| lua-language-server 3.19.1 | 4.7 s | 309 MB | -| lua-language-server 3.19.1 + cfxlua natives library | 6.5 s | 458 MB | -| qbx-lua-ls | 0.21 s | 12 MB | +Opening a resource also indexes dependencies and imported scripts found in sibling resource +folders. Add other locations through the `library` setting. -Requests against a 2,600 line file (`ox_inventory/modules/inventory/server.lua`): hover, -completion and definition answer in well under a millisecond, semantic tokens in ~2 ms, document -symbols in ~7 ms. +## Build and run -Reproduce with: +The release workflow produces archives for Windows x64, Linux x64/ARM64 (musl), and macOS +x64/ARM64. Download a matching archive from [Releases](https://github.com/Qbox-project/qbx-lua-ls/releases) +when available, or build from source using the steps below. -```bash -cargo build --release -node scripts/bench.mjs [file-to-open] -node scripts/bench-luals.mjs [library-dir ...] -``` +Install stable Rust and keep these repositories next to each other. The server currently uses +local path dependencies from `qbx-lint`. -### Why it is small - -- **Closed files keep no syntax tree.** Each file is parsed once and reduced to a summary: global - symbols, table members, classes and aliases, exports, events and the module return type. Only - open documents keep their AST, and references in closed files are found by re-parsing on demand - (the parser handles ~30 MB/s, so that is cheap). -- **Natives are not Lua files.** The ~9,900 native signatures and their documentation are a - sorted table embedded in the binary and binary-searched in place; nothing is parsed or copied - at startup. -- **Types are resolved lazily.** Symbols store references such as "class `Player`", "table - `lib.callback`" or "module `config.shared`", which are looked up when a request needs them. -- **Dependency driven indexing.** Opening one resource indexes only that resource plus the - resources its manifest refers to (`@ox_lib/init.lua`, `dependencies { ... }`), found by walking - up to the surrounding `resources` folder. +```sh +git clone https://github.com/Qbox-project/qbx-lint.git +git clone https://github.com/Qbox-project/qbx-lua-ls.git +cd qbx-lua-ls +cargo build --release --locked +``` -## Features +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. -| | | -| --- | --- | -| Completion | locals, side-aware globals and natives, members through classes/tables/modules, `exports.resource:Fn`, event and callback names, `require` paths, expected table fields, LuaCATS tags and types, manifest directives and paths, FiveM snippets | -| Hover | signatures, LuaCATS docs, native docs with examples and side, event handler locations, and for tables an overview of their fields with types and literal values (`Debug: boolean = true`) | -| Scoping | a global table only shows what the current file can really see: `Config` means the `Config` of this resource (plus files its manifest imports), never the merged `Config` of every resource in the workspace; library tables such as `lib` are completed from the imported library | -| Navigation | definition (incl. `require` targets, event registrations, exports, locale keys), references and rename for locals, globals **and fields/methods** (each candidate's owner type is resolved, so `a.name` and `b.name` are not confused), document highlight, document and workspace symbols | -| Editing help | signature help, inlay parameter hints, folding, semantic tokens, **document formatting** through `qbx_lua_fmt` (options from `[format]` in `qbxlint.toml`, otherwise the editor's indentation) | -| Project knowledge | `locale('…')` keys with their text from `locales/en.json`, convar names from `GetConvar*` calls and `set`/`setr`/`sets` lines in `.cfg` files, state bag keys after `.state.` and in `AddStateBagChangeHandler` | -| Diagnostics | every `qbx-lint` rule with manifest context, for the whole workspace (closed files are linted one at a time and dropped again; a save only re-lints the affected resource), quick fixes, "disable for this line" actions | -| Side awareness | natives and globals filtered by client/server, wrong-side errors, event name completion that follows the call direction (`TriggerServerEvent` only offers events handled on the server), `qbx/fileInfo` for editors | -| Escrow | encrypted (`FXAP`) files are never parsed; a resource with a `.fxap` marker or an encrypted file is opaque, so nothing is claimed about its globals, locale keys, exports or event handlers. Resources with JavaScript/C# scripts or computed export names are treated the same for exports | -| Events | signature help and inlay hints of `TriggerServerEvent('name', …)`, `TriggerClientEvent`, `lib.callback` and `lib.callback.await` show the parameters of the handler registered for that name instead of the native's `...: any`, with the handler's location | -| Runtime types | native handles keep their name (`Vehicle`, `Ped`, `Hash`, …) in signatures and hovers but are integers without members, so they never resolve to a resource's class of the same name; the built-in `glm` library (also through `require 'glm'`) including `glm.polygon` and the other geometry helpers | -| ox_lib | `lib.onCache('vehicle', function(value, oldValue)` types both parameters as `cache.vehicle`; `onCache` snippet and `lib.onCache('…')` completion whose key list is read from the indexed ox_lib source | - -Type sources: `---@class/@field/@alias/@enum/@type/@param/@return/@generic/@overload`, table -constructors (also behind `setmetatable`), `function Table.name()` / `Table.name = ...` anywhere -in the resource, `_ENV.name = value`, module returns through `require`/`lib.load`, exports -registered with `exports('Name', fn)`, native signatures, and callback parameter types taken from -the function being called. - -### Known limits - -- No type *checking* (no "cannot assign string to number"); types drive completion, hover and - navigation only. -- No control-flow narrowing, no full generics (only simple `T` substitution), no operator - metamethod lookup apart from vectors. -- Field references are as good as the inferred owner type: a field reached through a value whose - type is unknown is not found, and fields declared only by a `---@field` comment are found but - the comment itself is not rewritten on rename. -- Range formatting is not implemented, only whole documents. +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 -Sent as `initializationOptions` and through `workspace/didChangeConfiguration` under `qbxLua`: +Send this object directly as LSP `initializationOptions`: -```jsonc +```json { - "library": ["C:/server/resources"], // extra folders to index - "diagnostics": { "enable": true, "rules": { "unused-argument": "off" } }, + "library": [], + "diagnostics": { + "enable": true, + "workspace": true, + "rules": {} + }, "inlayHints": { "enable": true }, "semanticTokens": { "enable": true } } ``` -A `qbxlint.toml` in the workspace root is honoured for diagnostics, so the editor shows what CI -will report. - -`diagnostics.workspace` (default `true`) controls whether files that are not open are reported. - -Custom requests: `qbx/status` (index statistics), `qbx/reindex` and `qbx/fileInfo` -(`{ uri }` → `{ side: client|server|shared|module|manifest|standalone, resource }`). +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. -## Editors +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. -- VS Code: [`qbx-vscode`](../qbx-vscode). -- Anything that speaks LSP over stdio, for example Neovim: +See the [client configuration and request reference](docs/protocol.md) for the full settings +and custom requests. - ```lua - vim.lsp.start({ name = 'qbx-lua-ls', cmd = { 'qbx-lua-ls' }, root_dir = vim.fs.root(0, { 'fxmanifest.lua', '.git' }) }) - ``` +## Limits -## Layout - -| Module | Purpose | -| --- | --- | -| `types`, `luacats` | type model and LuaCATS annotation parser | -| `indexer`, `index` | per-file summaries and the workspace index with visibility rules | -| `infer` | on-demand expression typing for open documents and for the indexer | -| `workspace` | resource discovery, sides, imports, dependency indexing, lint environment | -| `features/*` | one module per LSP feature | -| `server` | stdio main loop; diagnostics are published when the message queue is idle | - -The parser, scope resolver, manifest model, natives data and lint rules come from the `qbx-lint` -workspace through path dependencies; switch them to git dependencies once both are published. - -## Development - -```bash -cargo test # unit tests plus end-to-end LSP tests over an in-memory connection -node scripts/probe.mjs target/release/qbx-lua-ls < snippet.lua -``` +- 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. -`probe.mjs` opens a virtual document and prints completions (`--^` at the end of a line) and -hovers (`--?`), which is handy for checking behaviour against real resources. +See [CONTRIBUTING.md](CONTRIBUTING.md) for development checks and [CHANGELOG.md](CHANGELOG.md) +for release notes. ## License -GPL-3.0-or-later +[GPL-3.0-or-later](LICENSE). diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..e3a6817 --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,85 @@ +# 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. + +## 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/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. diff --git a/src/features/completion.rs b/src/features/completion.rs index bdf4417..d8d6cae 100644 --- a/src/features/completion.rs +++ b/src/features/completion.rs @@ -241,14 +241,14 @@ fn identifier_prefix(before: &str) -> &str { &before[start..] } -pub fn completion(ws: &Workspace, doc: &Document, position: Position) -> Option { +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), false)); + return is_doc.then(|| respond(doc_comment_items(ws, before, snippets), false)); } let in_string = doc.chunk.tokens.iter().position(|t| { @@ -270,7 +270,7 @@ pub fn completion(ws: &Workspace, doc: &Document, position: Position) -> Option< 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)); + 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)); @@ -279,7 +279,7 @@ pub fn completion(ws: &Workspace, doc: &Document, position: Position) -> Option< } if doc.is_manifest() { - return Some(respond(manifest_items(before, prefix), false)); + return Some(respond(manifest_items(before, prefix, snippets), false)); } if head.ends_with('{') || head.ends_with(',') || head.is_empty() { @@ -291,7 +291,7 @@ pub fn completion(ws: &Workspace, doc: &Document, position: Position) -> Option< if prefix.is_empty() { return None; } - let (items, incomplete) = with_infer(ws, doc, |infer| scope_items(ws, infer, doc, offset, prefix)); + let (items, incomplete) = with_infer(ws, doc, |infer| scope_items(ws, infer, doc, offset, prefix, snippets)); Some(respond(items, incomplete)) } @@ -301,7 +301,7 @@ fn respond(mut items: Vec, incomplete: bool) -> CompletionRespon CompletionResponse::List(CompletionList { is_incomplete: incomplete || truncated, items }) } -fn doc_comment_items(ws: &Workspace, before: &str) -> Vec { +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)) { @@ -310,8 +310,11 @@ fn doc_comment_items(ws: &Workspace, before: &str) -> Vec { .filter(|(tag, _)| tag.starts_with(tag_prefix)) .map(|(tag, snippet)| { let mut out = item(tag, CompletionItemKind::KEYWORD, 0); - out.insert_text = Some(snippet.to_string()); - out.insert_text_format = Some(InsertTextFormat::SNIPPET); + 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(); @@ -355,6 +358,8 @@ fn string_items(ws: &Workspace, doc: &Document, offset: u32, token_index: usize) if arg_index == 0 && (EVENT_NAME_CALLS.contains(&path) || CALLBACK_NAME_CALLS.contains(&path)) { let wants_callbacks = CALLBACK_NAME_CALLS.contains(&path); 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(call.span.start, own_side); // Where the handler has to live for this call to reach it. let target_side = match path { "TriggerServerEvent" | "TriggerLatentServerEvent" => Some(Side::Server), @@ -367,22 +372,19 @@ fn string_items(ws: &Workspace, doc: &Document, offset: u32, token_index: usize) }, _ => None, }; - let handled_on_target = |file| { - let side = ws.index.file(file).and_then(|f| f.side); - !matches!((target_side, side), (Some(target), Some(side)) if !side.is_available_on(target)) - }; + let handled_on_target = |side: Option| !matches!((target_side, side), (Some(target), Some(side)) if !side.is_available_on(target)); let candidates = |strict: bool| { let mut seen = FxHashSet::default(); ws.index .events() .filter(|(_, e)| (e.kind == EventKind::Callback) == wants_callbacks || e.kind == EventKind::Trigger) - .filter(|(file, e)| !strict || (e.kind != EventKind::Trigger && handled_on_target(*file))) + .filter(|(_, e)| !strict || (e.kind != EventKind::Trigger && handled_on_target(e.side))) .filter(|(_, e)| seen.insert(e.name.clone())) .map(|(file, event)| { let mut out = item(&event.name, CompletionItemKind::EVENT, 0); let entry = ws.index.file(file); let origin = entry.and_then(|f| f.resource).and_then(|r| ws.index.resource(r)); - let side = entry.and_then(|f| f.side).map_or(String::new(), |s| format!(" ({})", s.label())); + let side = event.side.map_or(String::new(), |s| format!(" ({})", s.label())); out.detail = match (&event.handler, origin) { (Some(handler), Some(resource)) => { Some(format!("{}{side} · {}", resource.name, handler.signature(""))) @@ -395,8 +397,7 @@ fn string_items(ws: &Workspace, doc: &Document, offset: u32, token_index: usize) }) .collect::>() }; - let strict = if target_side.is_some() { candidates(true) } else { Vec::new() }; - return if strict.is_empty() { candidates(false) } else { strict }; + return candidates(target_side.is_some()); } if arg_index == 0 && matches!(path, "lib.onCache") { return cache_key_items(ws, doc); @@ -524,7 +525,7 @@ fn manifest_path_items(ws: &Workspace, doc: &Document) -> Vec { items } -fn manifest_items(before: &str, prefix: &str) -> Vec { +fn manifest_items(before: &str, prefix: &str, snippets: bool) -> Vec { if before.trim_start().len() != prefix.len() { return Vec::new(); } @@ -532,6 +533,9 @@ fn manifest_items(before: &str, prefix: &str) -> Vec { .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(), @@ -546,7 +550,14 @@ fn manifest_items(before: &str, prefix: &str) -> Vec { .collect() } -fn member_items(infer: &Infer, doc: &Document, offset: u32, head: &str, via_colon: bool) -> Vec { +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()), @@ -566,7 +577,7 @@ fn member_items(infer: &Infer, doc: &Document, offset: u32, head: &str, via_colo } out }) - .chain((head == "lib.").then(|| on_cache_snippet(infer, ""))) + .chain((snippets && head == "lib.").then(|| on_cache_snippet(infer, ""))) .collect() } @@ -626,6 +637,7 @@ fn scope_items( 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(); @@ -665,11 +677,13 @@ fn scope_items( for keyword in KEYWORDS.iter().filter(|k| matches(k)) { items.push(item(keyword, CompletionItemKind::KEYWORD, 3)); } - 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.")); + 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; diff --git a/src/features/event_call.rs b/src/features/event_call.rs index 2255083..a171075 100644 --- a/src/features/event_call.rs +++ b/src/features/event_call.rs @@ -40,18 +40,20 @@ pub fn event_call(ws: &Workspace, doc: &Document, callee: &Expr, args: &[Expr], } }; - let candidates = - ws.index.events().filter(|(_, e)| e.name == *name && wanted.contains(&e.kind) && e.handler.is_some()); - let (file, event) = candidates.max_by_key(|(file, _)| { - let side = ws.index.file(*file).and_then(|f| f.side); - matches!((target, side), (Some(target), Some(side)) if side.is_available_on(target)) - })?; + let candidates = ws + .index + .events() + .filter(|(_, e)| e.name == *name && wanted.contains(&e.kind) && e.handler.is_some()) + .filter(|(_, e)| !matches!((target, e.side), (Some(target), Some(side)) if !side.is_available_on(target))); + let (file, event) = candidates.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 drops_source = is_callback && entry.side != Some(Side::Client) && !handler.params.is_empty(); + let drops_source = is_callback && event.side != Some(Side::Client) && !handler.params.is_empty(); let mut params: Vec = native.params.iter().take(skip).cloned().collect(); while params.len() < skip { diff --git a/src/features/hover.rs b/src/features/hover.rs index c4d75d9..48343bb 100644 --- a/src/features/hover.rs +++ b/src/features/hover.rs @@ -37,7 +37,7 @@ pub fn target_at(infer: &Infer, doc: &Document, offset: u32) -> Option { let located = locate(&doc.chunk, offset); if let Some(access) = located.member { let owner = infer.expr(access.base()); - let name = access.name(); + let name = access.name(&doc.text)?; let info = infer.member(&owner, &name.text)?; return Some(Target::Member { info, owner, span: name.span }); } diff --git a/src/features/member_refs.rs b/src/features/member_refs.rs index 1b5a70c..7d0703c 100644 --- a/src/features/member_refs.rs +++ b/src/features/member_refs.rs @@ -1,32 +1,129 @@ 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; +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::Infer; +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, - pub range: Range, + 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 { - with_infer(ws, doc, |infer| match target_at(infer, doc, offset)? { + let located = with_infer(ws, doc, |infer| match target_at(infer, doc, offset)? { Target::Member { info, .. } => { let (file, range) = info.location?; - Some(MemberTarget { name: info.name, file, range }) + 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> { @@ -37,11 +134,35 @@ struct Finder<'a, 'b> { } impl Finder<'_, '_> { - fn check(&mut self, owner: &crate::types::Type, name: &Name) { - let same = self.infer.member(owner, &name.text).and_then(|m| m.location) - == Some((self.target.file, self.target.range)); + fn check(&mut self, owner: &Type, name: &str, span: Span) { + 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.doc.range(name.span)); + self.out.push(self.doc.range(span)); + } + } + + fn table(&mut self, owner: &Type, expr: &Expr) { + 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); + } } } } @@ -55,12 +176,27 @@ impl<'ast> Visitor<'ast> for Finder<'_, '_> { let mut owner = self.infer.func_name_owner_type(&base); for segment in segments { if segment.text == self.target.name { - self.check(&owner, segment); + 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); } @@ -70,7 +206,13 @@ impl<'ast> Visitor<'ast> for Finder<'_, '_> { if name.text == self.target.name => { let owner = self.infer.expr(base); - self.check(&owner, name); + 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); + } } _ => {} } @@ -78,60 +220,54 @@ impl<'ast> Visitor<'ast> for Finder<'_, '_> { } } -fn occurrences_in(ws: &Workspace, doc: &Document, target: &MemberTarget) -> Vec { +fn occurrences_in(ws: &Workspace, doc: &Document, target: &MemberTarget) -> Option> { with_infer(ws, doc, |infer| { let mut finder = Finder { doc, infer, target, out: Vec::new() }; finder.visit_block(&doc.chunk.block); - finder.out + for (_, range) in target.declarations.iter().filter(|(file, _)| *file == doc.file) { + 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) }) } /// Every use of the member across the files that can reach its definition. Closed files are parsed -/// on demand, and only when they mention the name at all. +/// 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, -) -> Vec<(Url, Range)> { +) -> Option> { let mut out: Vec<(Url, Range)> = Vec::new(); for (id, entry) in ws.index.files() { - let reachable = id == target.file || ws.index.is_related(doc.file, id) || ws.index.is_related(target.file, id); + 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), + Some(open) => occurrences_in(ws, open, target)?, None => { - let Ok(source) = read_source(&entry.path) else { continue }; - if !source.contains(target.name.as_str()) { - continue; - } + 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) + occurrences_in(ws, &closed, target)? } }; out.extend(ranges.into_iter().map(|range| (entry.uri.clone(), range))); } - if let Some(definition) = ws.index.file(target.file) { - let declared = (definition.uri.clone(), target.range); - if !out.contains(&declared) { - out.push(declared); - } - } - out + Some(out) } pub fn in_document(ws: &Workspace, doc: &Document, target: &MemberTarget) -> Vec { - let mut ranges = occurrences_in(ws, doc, target); - if target.file == doc.file && !ranges.contains(&target.range) { - ranges.push(target.range); - } - ranges + 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 { - ws.index.file(target.file).is_some_and(|f| f.origin == FileOrigin::Workspace) + target.declarations.iter().all(|(file, _)| ws.index.file(*file).is_some_and(|f| f.origin == FileOrigin::Workspace)) } diff --git a/src/features/references.rs b/src/features/references.rs index 21672d8..e3518c1 100644 --- a/src/features/references.rs +++ b/src/features/references.rs @@ -96,6 +96,7 @@ pub fn references( 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(); @@ -176,13 +177,7 @@ pub fn rename( } } else { let target = member_target(ws, doc, offset).filter(|t| is_renamable(ws, t))?; - // A member declared by a `---@field` line is located by the whole comment, which must not - // be replaced; only ranges that cover exactly the name are edited. - let covers_name = |range: &lsp_types::Range| { - range.start.line == range.end.line - && (range.end.character - range.start.character) as usize == target.name.len() - }; - for (uri, range) in member_occurrences(ws, docs, doc, &target).into_iter().filter(|(_, r)| covers_name(r)) { + for (uri, range) in member_occurrences(ws, docs, doc, &target)? { changes.entry(uri).or_default().push(TextEdit::new(range, new_name.to_string())); } } diff --git a/src/index.rs b/src/index.rs index 5225b41..df5172c 100644 --- a/src/index.rs +++ b/src/index.rs @@ -74,6 +74,8 @@ pub enum EventKind { pub struct EventDef { pub name: SmolStr, pub kind: EventKind, + /// The manifest side narrowed by the guard around this registration or trigger. + pub side: Option, pub handler: Option>, pub range: Range, } @@ -161,6 +163,17 @@ fn remove_file_slots<'a>( } 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() } diff --git a/src/indexer.rs b/src/indexer.rs index b6c919e..e0a5540 100644 --- a/src/indexer.rs +++ b/src/indexer.rs @@ -2,7 +2,9 @@ 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}; @@ -42,12 +44,28 @@ pub fn render_doc(doc: &DocGroup) -> Option> { (!out.is_empty()).then(|| Arc::from(out)) } -pub fn index_file(file: FileId, source: &str, chunk: &Chunk, resolution: &Resolution, index: &Index) -> FileIndex { +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, out: FileIndex::default(), depth: 0 }; + let mut indexer = Indexer { + file, + source, + lines: &lines, + ctx: &ctx, + infer: &infer, + side, + regions: SideRegions::of(source, chunk), + 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() { @@ -64,6 +82,8 @@ struct Indexer<'a> { lines: &'a LineIndex, ctx: &'a FileContext<'a>, infer: &'a Infer<'a>, + side: Option, + regions: SideRegions, out: FileIndex, depth: u32, } @@ -422,41 +442,48 @@ impl<'a> Indexer<'a> { self.out.globals.push(symbol); } ExprKind::Field { base, name, .. } if !name.is_missing() => { - 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; - } + 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); } - 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, - }, + } + _ => {} + } + } + + 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, }; - 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; + match own_class.map(|class| Type::Named(class, Vec::new())).unwrap_or_else(|| self.infer.expr(base)) { + Type::Named(class, _) => class, + _ => SmolStr::new(path), } - self.push_member(owner, symbol); } - _ => {} + _ => 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 { @@ -558,7 +585,7 @@ impl<'a> Indexer<'a> { self.push_member(owner, symbol); } - fn event(&mut self, path: &str, args: &[Expr]) { + fn event(&mut self, path: &str, args: &[Expr], offset: u32) { let kind = if NET_EVENT_CALLS.contains(&path) { EventKind::NetEvent } else if HANDLER_CALLS.contains(&path) { @@ -576,7 +603,13 @@ impl<'a> Indexer<'a> { ExprKind::Function(func) => Some(Arc::new(self.infer.fun_type(func, None, false))), _ => None, }); - self.out.events.push(EventDef { name: name.clone(), kind, handler, range: self.range(name_arg.span) }); + self.out.events.push(EventDef { + name: name.clone(), + kind, + side: self.regions.effective(offset, self.side), + handler, + range: self.range(name_arg.span), + }); } fn expr(&mut self, expr: &Expr) { @@ -584,7 +617,7 @@ impl<'a> Indexer<'a> { ExprKind::Function(func) => self.func_body(func), ExprKind::Call { callee, args, .. } => { if let Some(path) = callee.dotted_path() { - self.event(&path, args); + self.event(&path, args, expr.span.start); 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)) { diff --git a/src/locate.rs b/src/locate.rs index f9d6a9b..cea6c33 100644 --- a/src/locate.rs +++ b/src/locate.rs @@ -6,18 +6,24 @@ use qbx_lua_syntax::Span; 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, .. } => base, + MemberAccess::Field { base, .. } | MemberAccess::Method { base, .. } | MemberAccess::Index { base, .. } => { + base + } } } - pub fn name(&self) -> &'a Name { + pub fn name(&self, source: &str) -> Option { match self { - MemberAccess::Field { name, .. } | MemberAccess::Method { name, .. } => name, + 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)? }) + } } } @@ -26,6 +32,30 @@ impl<'a> MemberAccess<'a> { } } +/// 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, @@ -99,6 +129,11 @@ impl<'a> Visitor<'a> for Locator<'a> { 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 }); diff --git a/src/server.rs b/src/server.rs index 20ffb0d..f3cea53 100644 --- a/src/server.rs +++ b/src/server.rs @@ -75,6 +75,8 @@ pub struct Server { ws: Workspace, docs: Documents, settings: Settings, + snippet_support: bool, + watched_files_registration: bool, dirty: FxHashSet, /// Closed files that currently have diagnostics published, so they can be cleared again. workspace_reported: FxHashSet, @@ -166,11 +168,28 @@ impl Server { 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(), workspace_reported: FxHashSet::default(), workspace_stale: true, @@ -201,6 +220,9 @@ impl Server { } fn register_watchers(&mut self) { + if !self.watched_files_registration { + return; + } let watchers = ["**/*.lua", "**/qbxlint.toml", "**/.qbxlint.toml", "**/locales/*.json", "**/*.cfg"] .iter() .map(|glob| FileSystemWatcher { glob_pattern: GlobPattern::String(glob.to_string()), kind: None }) @@ -263,6 +285,8 @@ impl Server { fn flush_index(&mut self) { for uri in &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 { doc.file = @@ -536,7 +560,7 @@ impl Server { 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)) + 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 => { @@ -640,8 +664,15 @@ impl Server { "natives": qbx_fivem_data::native_count(), })), "qbx/reindex" => { + qbx_lua_analysis::startup::clear_cache(); let stats = self.ws.scan(); 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.flush_index(); + self.stale_resources.clear(); self.workspace_stale = true; Ok(json!({ "files": stats.files, "resources": stats.resources, "millis": stats.millis as u64 })) } diff --git a/src/workspace.rs b/src/workspace.rs index ced8615..9c95b0f 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -70,9 +70,9 @@ impl Workspace { let id = self.index.allocate(&path); let chunk = parse(stub.source); let resolution = resolve(&chunk); - let index = index_file(id, stub.source, &chunk, &resolution, &self.index); 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 }); } } @@ -80,9 +80,10 @@ impl Workspace { pub fn scan(&mut self) -> ScanStats { let started = std::time::Instant::now(); let mut stats = ScanStats::default(); - if let Some(root) = self.roots.first() { - self.lint_config = Config::discover(root).ok().flatten().unwrap_or_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() @@ -91,7 +92,11 @@ impl Workspace { .collect(); for (root, origin) in roots { for path in lua_files_under(&root, &self.lint_config) { - if !is_manifest_file(&path) && self.index_path(&path, origin, None) { + 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; } } @@ -137,6 +142,7 @@ impl Workspace { 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; @@ -239,7 +245,7 @@ impl Workspace { 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); + 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); @@ -317,7 +323,7 @@ impl Workspace { } for event in file.index.events.iter().filter(|e| matches!(e.kind, EventKind::NetEvent | EventKind::Handler)) { - refs.add_event(event.name.clone(), file.side, event.handler.as_deref().map(arity)); + refs.add_event(event.name.clone(), event.side, event.handler.as_deref().map(arity)); } if let Some(resource) = resource { for export in &file.index.exports { diff --git a/tests/lsp.rs b/tests/lsp.rs index a181d6b..209c8fb 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -12,6 +12,7 @@ struct Client { server: Option>, next_id: i32, diagnostics: HashMap, + registrations: Vec, root: PathBuf, } @@ -21,17 +22,33 @@ fn fixture_root() -> PathBuf { 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(), root }; + let mut client = Client { + connection: client_side, + server: Some(server), + next_id: 0, + diagnostics: HashMap::new(), + registrations: 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": {}, "workspaceFolders": [{ "uri": root_uri, "name": "fixture" }] }), + json!({ "processId": null, "rootUri": root_uri, "capabilities": capabilities, "workspaceFolders": [{ "uri": root_uri, "name": "fixture" }] }), ); assert!(result["capabilities"]["completionProvider"].is_object()); client.notify("initialized", json!({})); @@ -53,6 +70,9 @@ impl Client { self.diagnostics.insert(uri, n.params["diagnostics"].clone()); } 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(); } @@ -583,6 +603,138 @@ fn finds_and_renames_fields_across_files() { 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()); @@ -654,6 +806,69 @@ fn snippets_outrank_the_plain_name() { 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()); diff --git a/tests/workspace_refresh.rs b/tests/workspace_refresh.rs new file mode 100644 index 0000000..adf4bfc --- /dev/null +++ b/tests/workspace_refresh.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::{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 = event_call::event_call(ws, doc, site.base, site.args, &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 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 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}"); +} From cfd947e4fe5a776d805922e49f438b12841af9dc Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:36:49 +0200 Subject: [PATCH 19/57] Document published release downloads --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f715887..17c70c4 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ folders. Add other locations through the `library` setting. ## Build and run -The release workflow produces archives for Windows x64, Linux x64/ARM64 (musl), and macOS -x64/ARM64. Download a matching archive from [Releases](https://github.com/Qbox-project/qbx-lua-ls/releases) -when available, or build from source using the steps below. +Download the archive for your platform from [Releases](https://github.com/Qbox-project/qbx-lua-ls/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 keep these repositories next to each other. The server currently uses local path dependencies from `qbx-lint`. From 3a534383dd82debca68fcf3049fe4653ec80ade3 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:50:01 +0200 Subject: [PATCH 20/57] Release 1.0.1 with corrected linter action failure handling --- CHANGELOG.md | 4 ++++ Cargo.lock | 10 +++++----- Cargo.toml | 10 +++++----- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af7ce06..0fe3803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1.0.1 + +- Update the shared parser and analysis crates to version 1.0.1. + ## 1.0.0 - Add FiveM Lua language support over stdio LSP: completion, hover, definitions, references, diff --git a/Cargo.lock b/Cargo.lock index b66b815..834b595 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -324,11 +324,11 @@ dependencies = [ [[package]] name = "qbx_fivem_data" -version = "1.0.0" +version = "1.0.1" [[package]] name = "qbx_lua_analysis" -version = "1.0.0" +version = "1.0.1" dependencies = [ "globset", "qbx_fivem_data", @@ -344,7 +344,7 @@ dependencies = [ [[package]] name = "qbx_lua_fmt" -version = "1.0.0" +version = "1.0.1" dependencies = [ "qbx_lua_syntax", "serde", @@ -352,7 +352,7 @@ dependencies = [ [[package]] name = "qbx_lua_ls" -version = "1.0.0" +version = "1.0.1" dependencies = [ "crossbeam-channel", "lsp-server", @@ -370,7 +370,7 @@ dependencies = [ [[package]] name = "qbx_lua_syntax" -version = "1.0.0" +version = "1.0.1" dependencies = [ "smol_str", ] diff --git a/Cargo.toml b/Cargo.toml index 17dce78..1843bbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "qbx_lua_ls" description = "Language server for FiveM Lua" -version = "1.0.0" +version = "1.0.1" edition = "2021" license = "GPL-3.0-or-later" repository = "https://github.com/Qbox-project/qbx-lua-ls" @@ -12,10 +12,10 @@ path = "src/main.rs" [dependencies] # Keep qbx-lint beside this checkout. Release builds use the same tag in both repositories. -qbx_lua_syntax = { version = "=1.0.0", path = "../qbx-lint/crates/qbx_lua_syntax" } -qbx_lua_fmt = { version = "=1.0.0", path = "../qbx-lint/crates/qbx_lua_fmt" } -qbx_lua_analysis = { version = "=1.0.0", path = "../qbx-lint/crates/qbx_lua_analysis", features = ["docs"] } -qbx_fivem_data = { version = "=1.0.0", path = "../qbx-lint/crates/qbx_fivem_data", features = ["docs"] } +qbx_lua_syntax = { version = "=1.0.1", path = "../qbx-lint/crates/qbx_lua_syntax" } +qbx_lua_fmt = { version = "=1.0.1", path = "../qbx-lint/crates/qbx_lua_fmt" } +qbx_lua_analysis = { version = "=1.0.1", path = "../qbx-lint/crates/qbx_lua_analysis", features = ["docs"] } +qbx_fivem_data = { version = "=1.0.1", path = "../qbx-lint/crates/qbx_fivem_data", features = ["docs"] } lsp-server = "0.7" lsp-types = "0.95" serde = { version = "1", features = ["derive"] } From 1076a48a22bcff986a603f91188f4b9fc025f1b1 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:48:04 +0200 Subject: [PATCH 21/57] Fix event completion across colons for 1.0.2 --- CHANGELOG.md | 6 ++++ CONTRIBUTING.md | 10 ------ Cargo.lock | 10 +++--- Cargo.toml | 10 +++--- src/features/completion.rs | 15 +++++++-- tests/lsp.rs | 68 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 96 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fe3803..9542838 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.0.2 + +- Keep event and callback completions anchored to the whole name when typing `:`. +- Replace the existing event name when accepting a suggestion, preserving its quotes and arguments. +- Update the shared parser and analysis crates to version 1.0.2. + ## 1.0.1 - Update the shared parser and analysis crates to version 1.0.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5969d1d..fe4c83d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,13 +69,3 @@ 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 and changelog when the user-facing behavior changes. - -## Releases - -Keep the package version, lockfile and changelog in step. Release tags use `v` followed by -that version, such as `v1.0.0`; the workflow rejects a tag that does not match the package. - -Release the matching tag in `qbx-lint` first, then `qbx-lua-ls`, then `qbx-editor`. The server -and editor release workflows check out that same tag in their dependencies. Each workflow -builds all supported platforms before attaching the files and checksums to a GitHub Release. -The editor workflow does not publish to an extension marketplace. diff --git a/Cargo.lock b/Cargo.lock index 834b595..d837015 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -324,11 +324,11 @@ dependencies = [ [[package]] name = "qbx_fivem_data" -version = "1.0.1" +version = "1.0.2" [[package]] name = "qbx_lua_analysis" -version = "1.0.1" +version = "1.0.2" dependencies = [ "globset", "qbx_fivem_data", @@ -344,7 +344,7 @@ dependencies = [ [[package]] name = "qbx_lua_fmt" -version = "1.0.1" +version = "1.0.2" dependencies = [ "qbx_lua_syntax", "serde", @@ -352,7 +352,7 @@ dependencies = [ [[package]] name = "qbx_lua_ls" -version = "1.0.1" +version = "1.0.2" dependencies = [ "crossbeam-channel", "lsp-server", @@ -370,7 +370,7 @@ dependencies = [ [[package]] name = "qbx_lua_syntax" -version = "1.0.1" +version = "1.0.2" dependencies = [ "smol_str", ] diff --git a/Cargo.toml b/Cargo.toml index 1843bbc..97e30d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "qbx_lua_ls" description = "Language server for FiveM Lua" -version = "1.0.1" +version = "1.0.2" edition = "2021" license = "GPL-3.0-or-later" repository = "https://github.com/Qbox-project/qbx-lua-ls" @@ -12,10 +12,10 @@ path = "src/main.rs" [dependencies] # Keep qbx-lint beside this checkout. Release builds use the same tag in both repositories. -qbx_lua_syntax = { version = "=1.0.1", path = "../qbx-lint/crates/qbx_lua_syntax" } -qbx_lua_fmt = { version = "=1.0.1", path = "../qbx-lint/crates/qbx_lua_fmt" } -qbx_lua_analysis = { version = "=1.0.1", path = "../qbx-lint/crates/qbx_lua_analysis", features = ["docs"] } -qbx_fivem_data = { version = "=1.0.1", path = "../qbx-lint/crates/qbx_fivem_data", features = ["docs"] } +qbx_lua_syntax = { version = "=1.0.2", path = "../qbx-lint/crates/qbx_lua_syntax" } +qbx_lua_fmt = { version = "=1.0.2", path = "../qbx-lint/crates/qbx_lua_fmt" } +qbx_lua_analysis = { version = "=1.0.2", path = "../qbx-lint/crates/qbx_lua_analysis", features = ["docs"] } +qbx_fivem_data = { version = "=1.0.2", path = "../qbx-lint/crates/qbx_fivem_data", features = ["docs"] } lsp-server = "0.7" lsp-types = "0.95" serde = { version = "1", features = ["derive"] } diff --git a/src/features/completion.rs b/src/features/completion.rs index d8d6cae..daad783 100644 --- a/src/features/completion.rs +++ b/src/features/completion.rs @@ -1,13 +1,13 @@ use lsp_types::{ CompletionItem, CompletionItemKind, CompletionItemLabelDetails, CompletionItemTag, CompletionList, - CompletionResponse, Documentation, InsertTextFormat, Position, + 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, TokenKind}; +use qbx_lua_syntax::{CommentKind, Span, TokenKind}; use rustc_hash::FxHashSet; use serde_json::json; @@ -15,7 +15,7 @@ use super::{lua_block, markdown, with_infer}; use crate::document::Document; use crate::index::{EventKind, FileOrigin, SymbolKind}; use crate::infer::{Infer, MemberInfo}; -use crate::locate::locate; +use crate::locate::{locate, string_content_span}; use crate::types::Type; use crate::workspace::Workspace; @@ -356,6 +356,12 @@ fn string_items(ws: &Workspace, doc: &Document, offset: u32, token_index: usize) let path = path.as_str(); if arg_index == 0 && (EVENT_NAME_CALLS.contains(&path) || CALLBACK_NAME_CALLS.contains(&path)) { + // 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 = CALLBACK_NAME_CALLS.contains(&path); let own_side = ws.index.file(doc.file).and_then(|f| f.side); let own_side = @@ -382,6 +388,9 @@ fn string_items(ws: &Workspace, doc: &Document, offset: u32, token_index: usize) .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())); diff --git a/tests/lsp.rs b/tests/lsp.rs index 209c8fb..c8d290b 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -513,6 +513,74 @@ fn event_completion_follows_the_call_direction() { 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()); From cd0d01fec79f76ce4a30d65155195e9e19164942 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:54:45 +0200 Subject: [PATCH 22/57] docs: replace CHANGELOG.md with generated release notes The shared release workflow now builds release notes from the commits since the previous tag, so the hand-kept file is redundant. --- CHANGELOG.md | 29 ----------------------------- CONTRIBUTING.md | 4 ++-- README.md | 4 ++-- 3 files changed, 4 insertions(+), 33 deletions(-) delete mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 9542838..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,29 +0,0 @@ -# Changelog - -## 1.0.2 - -- Keep event and callback completions anchored to the whole name when typing `:`. -- Replace the existing event name when accepting a suggestion, preserving its quotes and arguments. -- Update the shared parser and analysis crates to version 1.0.2. - -## 1.0.1 - -- Update the shared parser and analysis crates to version 1.0.1. - -## 1.0.0 - -- Add FiveM Lua language support over stdio LSP: completion, hover, definitions, references, - rename, diagnostics, quick fixes, formatting, signature help, inlay hints, semantic tokens, - folding and symbols. -- Resolve resource imports and client/server scripts from `fxmanifest.lua`, with LuaCATS types, - native signatures and event/callback handler parameters available to editor features. -- Include static string keys and supported annotation fields in member rename. Refuse edits - when a known declaration or an unreadable file prevents a safe rename. -- Respect client/server guards when selecting event diagnostics, completions and signatures. -- Rebuild files, manifests and dependencies during manual reindex, preserving unsaved documents - and removing stale disk entries. -- Limit long expression chains and avoid offering a hash replacement that would create an - invalid Lua statement. -- Honor client capabilities for snippet completions and dynamic file-watch registration. -- Configure standalone release archives for Windows x64, Linux x64/ARM64 and macOS x64/ARM64. - Editor setup is documented in [qbx-editor](https://github.com/Qbox-project/qbx-editor). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe4c83d..e590930 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,5 +67,5 @@ settings, expected behavior and a small Lua/manifest example. For crashes, inclu 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 and changelog when the user-facing -behavior changes. +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/README.md b/README.md index 17c70c4..66eed98 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,8 @@ and custom requests. - 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 [CHANGELOG.md](CHANGELOG.md) -for release notes. +See [CONTRIBUTING.md](CONTRIBUTING.md) for development checks and the +[releases page](https://github.com/Qbox-project/qbx-lua-ls/releases) for release notes. ## License From 78cb6b28e59bef19ff060223a5c7f6e9d6dda88c Mon Sep 17 00:00:00 2001 From: Scorpion <144194359+Scorpion7162@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:36:05 +0100 Subject: [PATCH 23/57] perf: avoid reindexing clean documents Requests flush dirty documents before handling, then workspace publication flushed the same documents again. Remove each document from the dirty set immediately after its index update. --- src/server.rs | 7 ++++--- tests/workspace_refresh.rs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/server.rs b/src/server.rs index f3cea53..213affd 100644 --- a/src/server.rs +++ b/src/server.rs @@ -283,8 +283,9 @@ impl Server { /// Open documents are reparsed on every edit but only reindexed once something needs the index. fn flush_index(&mut self) { - for uri in &self.dirty { - if let Some(doc) = self.docs.get_mut(uri) { + let dirty: Vec = self.dirty.iter().cloned().collect(); + for uri in 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()); @@ -293,6 +294,7 @@ impl Server { self.ws.index_parsed(&doc.path, FileOrigin::Workspace, &doc.text, &doc.chunk, &doc.resolution); } } + self.dirty.remove(&uri); } } @@ -301,7 +303,6 @@ impl Server { return; } self.flush_index(); - self.dirty.clear(); let crossrefs = self.ws.crossrefs(); let uris: Vec = self.docs.keys().cloned().collect(); for uri in uris { diff --git a/tests/workspace_refresh.rs b/tests/workspace_refresh.rs index adf4bfc..882b6d6 100644 --- a/tests/workspace_refresh.rs +++ b/tests/workspace_refresh.rs @@ -299,3 +299,20 @@ fn manual_reindex_restores_unsaved_documents_and_their_new_file_ids() { let symbols = client.request("workspace/symbol", json!({"query": "ScratchOnly"})); assert!(symbols.as_array().unwrap().is_empty(), "{symbols}"); } + +#[test] +fn a_request_flushes_dirty_documents_once() { + let fixture = Fixture::new(); + fixture.write("demo/fxmanifest.lua", "client_script '*.lua'"); + fixture.write("demo/main.lua", "DiskOnly = 1"); + let mut client = Client::start(&fixture.0); + let uri = path_to_uri(&fixture.0.join("demo/main.lua")); + client.open(&uri, "UnsavedOnly = 42"); + + let symbols = client.request("workspace/symbol", json!({"query": "UnsavedOnly"})); + assert!(symbols.as_array().unwrap().iter().any(|s| s["name"] == "UnsavedOnly")); + + let _ = client.request("qbx/status", Value::Null); + let symbols = client.request("workspace/symbol", json!({"query": "UnsavedOnly"})); + assert!(symbols.as_array().unwrap().iter().any(|s| s["name"] == "UnsavedOnly")); +} From 989c5bb8cd023ed1737e76b5136dbb9670d1b95e Mon Sep 17 00:00:00 2001 From: Scorpion <144194359+Scorpion7162@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:37:36 +0100 Subject: [PATCH 24/57] perf: scope diagnostics to changed resources A dirty document triggered diagnostics for every open document. Publish changed documents and open documents in their affected resources, where shared diagnostics context can change. --- src/server.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/server.rs b/src/server.rs index 213affd..abf2ca1 100644 --- a/src/server.rs +++ b/src/server.rs @@ -282,7 +282,7 @@ impl Server { } /// Open documents are reparsed on every edit but only reindexed once something needs the index. - fn flush_index(&mut self) { + fn flush_index(&mut self) -> Vec { let dirty: Vec = self.dirty.iter().cloned().collect(); for uri in dirty { if let Some(doc) = self.docs.get_mut(&uri) { @@ -296,17 +296,27 @@ impl Server { } self.dirty.remove(&uri); } + dirty } fn publish_dirty(&mut self) { if self.dirty.is_empty() { return; } - self.flush_index(); + let uris = self.flush_index(); let crossrefs = self.ws.crossrefs(); - let uris: Vec = self.docs.keys().cloned().collect(); - for uri in uris { - self.publish(&uri, &crossrefs); + let resources: FxHashSet<_> = uris + .iter() + .filter_map(|uri| self.docs.get(uri)) + .filter_map(|doc| self.ws.index.file(doc.file).and_then(|file| file.resource)) + .collect(); + for uri in self.docs.keys().filter(|uri| { + uris.contains(uri) + || self.docs.get(*uri).is_some_and(|doc| { + self.ws.index.file(doc.file).and_then(|file| file.resource).is_some_and(|r| resources.contains(&r)) + }) + }) { + self.publish(uri, &crossrefs); } } From aa55f1e4abd6f3234017652e713f8469ed5ea9a0 Mon Sep 17 00:00:00 2001 From: Scorpion <144194359+Scorpion7162@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:47:44 +0100 Subject: [PATCH 25/57] fix: preserve diagnostics publication behavior Keep the dirty-document indexing optimization while retaining diagnostics publication for every open document. Cross-file diagnostics can depend on documents outside the changed resource. --- src/server.rs | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/src/server.rs b/src/server.rs index abf2ca1..207bef8 100644 --- a/src/server.rs +++ b/src/server.rs @@ -282,9 +282,9 @@ impl Server { } /// Open documents are reparsed on every edit but only reindexed once something needs the index. - fn flush_index(&mut self) -> Vec { + fn flush_index(&mut self) { let dirty: Vec = self.dirty.iter().cloned().collect(); - for uri in dirty { + for uri in &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); @@ -294,29 +294,19 @@ impl Server { self.ws.index_parsed(&doc.path, FileOrigin::Workspace, &doc.text, &doc.chunk, &doc.resolution); } } - self.dirty.remove(&uri); + self.dirty.remove(uri); } - dirty } fn publish_dirty(&mut self) { if self.dirty.is_empty() { return; } - let uris = self.flush_index(); + self.flush_index(); let crossrefs = self.ws.crossrefs(); - let resources: FxHashSet<_> = uris - .iter() - .filter_map(|uri| self.docs.get(uri)) - .filter_map(|doc| self.ws.index.file(doc.file).and_then(|file| file.resource)) - .collect(); - for uri in self.docs.keys().filter(|uri| { - uris.contains(uri) - || self.docs.get(*uri).is_some_and(|doc| { - self.ws.index.file(doc.file).and_then(|file| file.resource).is_some_and(|r| resources.contains(&r)) - }) - }) { - self.publish(uri, &crossrefs); + let uris: Vec = self.docs.keys().cloned().collect(); + for uri in uris { + self.publish(&uri, &crossrefs); } } From c69c6b5ed61c6ca3e2045789139f62e1c8d0654c Mon Sep 17 00:00:00 2001 From: Scorpion <144194359+Scorpion7162@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:49:18 +0100 Subject: [PATCH 26/57] fix: preserve pending diagnostic updates Request-time indexing must not consume dirty document state before idle diagnostics publication. Keep dirty markers until publish_dirty completes so diagnostics are sent after requests. --- src/server.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/server.rs b/src/server.rs index 207bef8..07043f4 100644 --- a/src/server.rs +++ b/src/server.rs @@ -283,8 +283,7 @@ impl Server { /// Open documents are reparsed on every edit but only reindexed once something needs the index. fn flush_index(&mut self) { - let dirty: Vec = self.dirty.iter().cloned().collect(); - for uri in &dirty { + for uri in &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); @@ -294,7 +293,6 @@ impl Server { self.ws.index_parsed(&doc.path, FileOrigin::Workspace, &doc.text, &doc.chunk, &doc.resolution); } } - self.dirty.remove(uri); } } @@ -303,6 +301,7 @@ impl Server { return; } self.flush_index(); + self.dirty.clear(); let crossrefs = self.ws.crossrefs(); let uris: Vec = self.docs.keys().cloned().collect(); for uri in uris { From d5500fd5461a1eac7d2080204310d0a54f11b607 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:11:15 +0200 Subject: [PATCH 27/57] perf(server): reindex each edited document once - Requests flushed the dirty set without draining it, so every request in a burst and the idle publish reindexed the same documents again. - flush_index now drains the set and marks diagnostics as pending; publish_dirty reads that flag instead of the set. - Replace the test that passed without the fix with one that fails on both the old flush and a drain that drops publication. --- src/server.rs | 38 ++++++++++++++++++++++++++++++++++---- tests/workspace_refresh.rs | 17 ----------------- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/server.rs b/src/server.rs index 07043f4..2760c8f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -78,6 +78,7 @@ pub struct Server { 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, @@ -191,6 +192,7 @@ impl Server { snippet_support, watched_files_registration, dirty: FxHashSet::default(), + diagnostics_pending: false, workspace_reported: FxHashSet::default(), workspace_stale: true, stale_resources: FxHashSet::default(), @@ -276,6 +278,7 @@ impl Server { 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() @@ -283,7 +286,8 @@ impl Server { /// Open documents are reparsed on every edit but only reindexed once something needs the index. fn flush_index(&mut self) { - for uri in &self.dirty { + self.diagnostics_pending |= !self.dirty.is_empty(); + 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); @@ -297,11 +301,10 @@ impl Server { } fn publish_dirty(&mut self) { - if self.dirty.is_empty() { + self.flush_index(); + if !std::mem::take(&mut self.diagnostics_pending) { return; } - self.flush_index(); - self.dirty.clear(); let crossrefs = self.ws.crossrefs(); let uris: Vec = self.docs.keys().cloned().collect(); for uri in uris { @@ -671,6 +674,7 @@ impl Server { // 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; @@ -705,3 +709,29 @@ impl Server { } } } + +#[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/tests/workspace_refresh.rs b/tests/workspace_refresh.rs index 882b6d6..adf4bfc 100644 --- a/tests/workspace_refresh.rs +++ b/tests/workspace_refresh.rs @@ -299,20 +299,3 @@ fn manual_reindex_restores_unsaved_documents_and_their_new_file_ids() { let symbols = client.request("workspace/symbol", json!({"query": "ScratchOnly"})); assert!(symbols.as_array().unwrap().is_empty(), "{symbols}"); } - -#[test] -fn a_request_flushes_dirty_documents_once() { - let fixture = Fixture::new(); - fixture.write("demo/fxmanifest.lua", "client_script '*.lua'"); - fixture.write("demo/main.lua", "DiskOnly = 1"); - let mut client = Client::start(&fixture.0); - let uri = path_to_uri(&fixture.0.join("demo/main.lua")); - client.open(&uri, "UnsavedOnly = 42"); - - let symbols = client.request("workspace/symbol", json!({"query": "UnsavedOnly"})); - assert!(symbols.as_array().unwrap().iter().any(|s| s["name"] == "UnsavedOnly")); - - let _ = client.request("qbx/status", Value::Null); - let symbols = client.request("workspace/symbol", json!({"query": "UnsavedOnly"})); - assert!(symbols.as_array().unwrap().iter().any(|s| s["name"] == "UnsavedOnly")); -} From ee2004ab1bcf9d39766262bcfd604c3d2401ec60 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:27:49 +0200 Subject: [PATCH 28/57] fix(types): stop type lists at tokens that cannot start a type --- src/luacats.rs | 8 ++++++++ src/types.rs | 42 ++++++++++++++++++++++++++++++------------ 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/luacats.rs b/src/luacats.rs index 6be4ab6..0b182b3 100644 --- a/src/luacats.rs +++ b/src/luacats.rs @@ -312,6 +312,14 @@ mod tests { assert_eq!(doc.aliases[1].ty.to_string(), "integer|string"); } + #[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"); diff --git a/src/types.rs b/src/types.rs index 31d925a..b937649 100644 --- a/src/types.rs +++ b/src/types.rs @@ -391,12 +391,7 @@ impl<'a> TypeParser<'a> { b'{' => self.shape(), b'[' => { self.pos += 1; - let mut items = Vec::new(); - while !self.eat(b']') && self.pos < self.src.len() { - items.push(self.parse()); - self.eat(b','); - } - Type::Tuple(items) + Type::Tuple(self.list_until(b']')) } quote @ (b'"' | b'\'') => { self.pos += 1; @@ -431,6 +426,21 @@ impl<'a> TypeParser<'a> { } } + 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'(') } @@ -446,11 +456,7 @@ impl<'a> TypeParser<'a> { return Type::named(name); } self.pos += 1; - let mut args = Vec::new(); - while !self.eat(b'>') && self.pos < self.src.len() { - args.push(self.parse()); - self.eat(b','); - } + let mut args = self.list_until(b'>'); match (name, args.len()) { ("table", 2) => { let value = args.pop().unwrap_or_default(); @@ -581,7 +587,19 @@ mod tests { #[test] fn malformed_types_do_not_hang() { - for text in ["fun(", "{ a: ", "table<", "[", "((((", "fun(a: fun(b: fun(", "|||", ""] { + for text in [ + "fun(", + "{ a: ", + "table<", + "[", + "((((", + "fun(a: fun(b: fun(", + "|||", + "", + "table<}>", + "[}]", + "table", + ] { let _ = parse_type(text); } } From 9ab81067bf8b35ee16a37b89cbff4f660d188e81 Mon Sep 17 00:00:00 2001 From: hakanesnn <106948628+hakanesnn@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:29:05 +0300 Subject: [PATCH 29/57] feat(server): reload lint settings when .luarc.json or .emmyrc.json changes qbx-lint can take its settings from a LuaLS or EmmyLua config when there is no qbxlint.toml, so watch those files as well and reload the config when they change, the same way qbxlint.toml is handled. Formatting used an empty config root to tell that no qbxlint.toml was found and the editor's indentation should apply. A config read from .luarc.json has a root, which would have switched formatting to qbx-lint's defaults, so check format_configured instead. --- Cargo.lock | 1 + src/server.rs | 18 ++++++++++++---- tests/lsp.rs | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d837015..ea95e24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -337,6 +337,7 @@ dependencies = [ "rayon", "rustc-hash", "serde", + "serde_json", "smol_str", "toml", "walkdir", diff --git a/src/server.rs b/src/server.rs index 2760c8f..0b1e32d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -162,6 +162,10 @@ fn workspace_roots(params: &InitializeParams) -> Vec { 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 = @@ -225,9 +229,15 @@ impl Server { if !self.watched_files_registration { return; } - let watchers = ["**/*.lua", "**/qbxlint.toml", "**/.qbxlint.toml", "**/locales/*.json", "**/*.cfg"] + 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| FileSystemWatcher { glob_pattern: GlobPattern::String(glob.to_string()), kind: None }) + .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(), @@ -516,7 +526,7 @@ impl Server { 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") { + } else if path.extension().is_some_and(|e| e == "toml") || is_lua_ls_config(&path) { if let Some(root) = self.ws.roots.first() { self.ws.lint_config = qbx_lua_analysis::Config::discover(root).ok().flatten().unwrap_or_default(); } @@ -641,7 +651,7 @@ impl Server { 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.root.as_os_str().is_empty() { + 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; } diff --git a/tests/lsp.rs b/tests/lsp.rs index c8d290b..4767a9d 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -816,6 +816,64 @@ fn formats_documents() { 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": ["lib"], "diagnostics.disable": ["lowercase-global"] }"#, + ) + .unwrap(); + let text = "helper = function() return lib 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 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 lib 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": ["lib"] }"#).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" + ); +} + #[test] fn server_cfg_start_order_settles_dependencies() { let mut client = Client::start(fixture_root()); From f9db4dec16599f9e96fe8ea2cb390e832aaf53f4 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:55:59 +0200 Subject: [PATCH 30/57] feat(server): log which luarc files the lint fallback used or skipped --- src/server.rs | 19 ++++++++++++++++--- tests/lsp.rs | 26 ++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/server.rs b/src/server.rs index 0b1e32d..75fa6ce 100644 --- a/src/server.rs +++ b/src/server.rs @@ -214,6 +214,7 @@ impl Server { stats.millis, qbx_fivem_data::native_count() )); + self.log_config_notes(); self.register_watchers(); } @@ -221,6 +222,12 @@ impl Server { 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))); } @@ -522,14 +529,13 @@ impl Server { fn watched_files_changed(&mut self, changes: Vec) { let mut manifests_changed = 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) { - if let Some(root) = self.ws.roots.first() { - self.ws.lint_config = qbx_lua_analysis::Config::discover(root).ok().flatten().unwrap_or_default(); - } + 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(); @@ -544,6 +550,12 @@ impl Server { if manifests_changed { self.ws.link_imports(); } + 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(); + } self.workspace_stale = true; self.dirty.extend(self.docs.keys().cloned()); } @@ -679,6 +691,7 @@ impl Server { "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. diff --git a/tests/lsp.rs b/tests/lsp.rs index 4767a9d..7e3b162 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -13,6 +13,7 @@ struct Client { next_id: i32, diagnostics: HashMap, registrations: Vec, + logs: Vec, root: PathBuf, } @@ -43,6 +44,7 @@ impl Client { next_id: 0, diagnostics: HashMap::new(), registrations: Vec::new(), + logs: Vec::new(), root, }; let root_uri = Url::from_file_path(&client.root).unwrap(); @@ -69,6 +71,9 @@ impl Client { 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()); @@ -838,20 +843,23 @@ fn lua_ls_config_supplies_lint_settings_but_not_formatting() { .unwrap(); std::fs::write( fixture.0.join(".luarc.json"), - r#"{ "diagnostics.globals": ["lib"], "diagnostics.disable": ["lowercase-global"] }"#, + r#"{ "diagnostics.globals": ["Config"], "diagnostics.disable": ["lowercase-global"] }"#, ) .unwrap(); - let text = "helper = function() return lib end\nif helper then\nprint( helper )\nend\n"; + 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 lib end\nif helper then\n print(helper)\nend\n", + 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" ); @@ -862,7 +870,7 @@ fn lua_ls_config_supplies_lint_settings_but_not_formatting() { .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": ["lib"] }"#).unwrap(); + 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 }] }), @@ -872,6 +880,16 @@ fn lua_ls_config_supplies_lint_settings_but_not_formatting() { [("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] From 0b4c0b0e6a44a1314bf944959eedd840f3431703 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:01:32 +0200 Subject: [PATCH 31/57] build(release): bump version to 1.0.3 --- Cargo.lock | 10 +++++----- Cargo.toml | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ea95e24..736e49c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -324,11 +324,11 @@ dependencies = [ [[package]] name = "qbx_fivem_data" -version = "1.0.2" +version = "1.0.3" [[package]] name = "qbx_lua_analysis" -version = "1.0.2" +version = "1.0.3" dependencies = [ "globset", "qbx_fivem_data", @@ -345,7 +345,7 @@ dependencies = [ [[package]] name = "qbx_lua_fmt" -version = "1.0.2" +version = "1.0.3" dependencies = [ "qbx_lua_syntax", "serde", @@ -353,7 +353,7 @@ dependencies = [ [[package]] name = "qbx_lua_ls" -version = "1.0.2" +version = "1.0.3" dependencies = [ "crossbeam-channel", "lsp-server", @@ -371,7 +371,7 @@ dependencies = [ [[package]] name = "qbx_lua_syntax" -version = "1.0.2" +version = "1.0.3" dependencies = [ "smol_str", ] diff --git a/Cargo.toml b/Cargo.toml index 97e30d3..470804d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "qbx_lua_ls" description = "Language server for FiveM Lua" -version = "1.0.2" +version = "1.0.3" edition = "2021" license = "GPL-3.0-or-later" repository = "https://github.com/Qbox-project/qbx-lua-ls" @@ -12,10 +12,10 @@ path = "src/main.rs" [dependencies] # Keep qbx-lint beside this checkout. Release builds use the same tag in both repositories. -qbx_lua_syntax = { version = "=1.0.2", path = "../qbx-lint/crates/qbx_lua_syntax" } -qbx_lua_fmt = { version = "=1.0.2", path = "../qbx-lint/crates/qbx_lua_fmt" } -qbx_lua_analysis = { version = "=1.0.2", path = "../qbx-lint/crates/qbx_lua_analysis", features = ["docs"] } -qbx_fivem_data = { version = "=1.0.2", path = "../qbx-lint/crates/qbx_fivem_data", features = ["docs"] } +qbx_lua_syntax = { version = "=1.0.3", path = "../qbx-lint/crates/qbx_lua_syntax" } +qbx_lua_fmt = { version = "=1.0.3", path = "../qbx-lint/crates/qbx_lua_fmt" } +qbx_lua_analysis = { version = "=1.0.3", path = "../qbx-lint/crates/qbx_lua_analysis", features = ["docs"] } +qbx_fivem_data = { version = "=1.0.3", path = "../qbx-lint/crates/qbx_fivem_data", features = ["docs"] } lsp-server = "0.7" lsp-types = "0.95" serde = { version = "1", features = ["derive"] } From c7dfa5014659c2b3b899cd5e1a579ea666a87ef2 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:56:29 +0200 Subject: [PATCH 32/57] feat: add hover and go to definition for types named in annotations --- README.md | 1 + src/features/definition.rs | 5 + src/features/hover.rs | 71 +++++++++++++- src/index.rs | 5 + src/luacats.rs | 185 +++++++++++++++++++++++++++++++++++-- src/types.rs | 17 +++- tests/lsp.rs | 147 +++++++++++++++++++++++++++++ 7 files changed, 421 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 66eed98..f8debb9 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Available features depend on the editor's LSP client. - Completion and hover for Lua symbols, FiveM natives, exports, events and callbacks. - 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. - Whole-document formatting, configured through `qbxlint.toml`. diff --git a/src/features/definition.rs b/src/features/definition.rs index d43bfad..bb90b64 100644 --- a/src/features/definition.rs +++ b/src/features/definition.rs @@ -69,6 +69,11 @@ pub fn definition(ws: &Workspace, doc: &Document, position: Position) -> Option< 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, doc, offset), }); (!locations.is_empty()).then_some(GotoDefinitionResponse::Array(locations)) diff --git a/src/features/hover.rs b/src/features/hover.rs index 48343bb..e36138d 100644 --- a/src/features/hover.rs +++ b/src/features/hover.rs @@ -2,14 +2,15 @@ use lsp_types::{Hover, HoverContents, Position}; use qbx_fivem_data::{native, native_docs}; use qbx_lua_analysis::scope::{LocalId, LocalKind, Resolved}; use qbx_lua_syntax::ast::ExprKind; -use qbx_lua_syntax::{SmolStr, Span}; +use qbx_lua_syntax::{CommentKind, SmolStr, Span}; use super::{lua_block, markdown, with_infer}; use crate::document::Document; -use crate::index::{FileOrigin, SymbolKind}; +use crate::index::{ClassDef, 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; @@ -17,16 +18,34 @@ 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 { @@ -56,7 +75,7 @@ pub fn target_at(infer: &Infer, doc: &Document, offset: u32) -> Option { let info = infer.member(&owner, &name.text)?; return Some(Target::Member { info, owner, span: name.span }); } - None + annotation_type_at(doc, offset).map(|(name, span)| Target::Type(name, span)) } const MAX_OVERVIEW_FIELDS: usize = 14; @@ -206,6 +225,51 @@ pub fn member_hover(infer: &Infer, info: &MemberInfo, owner: &Type) -> String { 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) +} + fn string_hover(ws: &Workspace, doc: &Document, offset: u32) -> Option<(String, Span)> { let located = locate(&doc.chunk, offset); let (string, call) = located.string?; @@ -245,6 +309,7 @@ pub fn hover(ws: &Workspace, doc: &Document, position: Position) -> Option 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())) })?; diff --git a/src/index.rs b/src/index.rs index df5172c..1d076dd 100644 --- a/src/index.rs +++ b/src/index.rs @@ -385,6 +385,11 @@ impl Index { 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()) } diff --git a/src/luacats.rs b/src/luacats.rs index 0b182b3..e1275ad 100644 --- a/src/luacats.rs +++ b/src/luacats.rs @@ -103,6 +103,16 @@ fn split_tag(line: &str) -> Option<(&str, &str)> { 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 +} + /// 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(); @@ -231,12 +241,7 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { fn parse_field(rest: &str, line: usize, group: &mut DocGroup) { let Some(class) = group.classes.last_mut() else { return }; - let mut rest = rest; - for scope in ["public ", "private ", "protected ", "package "] { - if let Some(stripped) = rest.strip_prefix(scope) { - rest = stripped.trim_start(); - } - } + let rest = strip_visibility(rest); if let Some(index) = rest.strip_prefix('[') { let mut parser = TypeParser::new(index); let key = parser.parse(); @@ -262,6 +267,107 @@ fn parse_field(rest: &str, line: usize, group: &mut DocGroup) { }); } +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 line.trim_start().strip_prefix('|') { + Some(member) => ("|", member.trim_start_matches(['>', '+', ' '])), + None => split_tag(line)?, + }; + let mut names = TypeNames { line, found: Vec::new() }; + match tag { + "class" => { + let rest = rest.strip_prefix("(exact)").map_or(rest, str::trim_start); + 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" | "enum" => { + if let Some(rest) = names.declared(rest) { + names.types(rest); + } + } + "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)) + }), + "generic" => names.list(rest, |names, rest| { + let rest = skip_name(rest)?.trim_start(); + Some(rest.strip_prefix(':').map_or(rest, |constraint| names.ty(constraint))) + }), + "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::*; @@ -326,4 +432,71 @@ mod tests { 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", + "@alias Gar^age string", + "@alias Value Gar^age", + "| > Gar^age # description", + "@enum Gar^age", + "@overload fun(): Gar^age", + "@generic T, U: table", + "@generic T: string, U: Gar^age", + "@cast value +string, -Gar^age", + "@vararg Gar^age", + "@as Gar^age", + ] { + 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", + "@cast Gar^age string", + "@see Gar^age", + ] { + let offset = line.find('^').unwrap(); + assert_eq!(type_name_at(&line.replace('^', ""), offset), None, "{line}"); + } + } } diff --git a/src/types.rs b/src/types.rs index b937649..c8cbe81 100644 --- a/src/types.rs +++ b/src/types.rs @@ -286,11 +286,21 @@ 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 } + 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 { @@ -452,6 +462,11 @@ impl<'a> TypeParser<'a> { 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); } diff --git a/tests/lsp.rs b/tests/lsp.rs index 7e3b162..1a72036 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -238,6 +238,64 @@ fn hover_shows_types_docs_and_natives() { assert!(client.hover_text(CLIENT, l, c).contains("label: string")); } +#[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 hover_resolves_exports_across_resources() { let mut client = Client::start(fixture_root()); @@ -359,6 +417,95 @@ fn goes_to_definitions_across_files() { 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), + ("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()); From 8e714b86c0ef458df3b58cba291a7d6077ce138b Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:03:50 +0200 Subject: [PATCH 33/57] fix(luacats): skip attributes before class and enum names --- src/luacats.rs | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/luacats.rs b/src/luacats.rs index e1275ad..af3c195 100644 --- a/src/luacats.rs +++ b/src/luacats.rs @@ -113,6 +113,14 @@ fn strip_visibility(mut rest: &str) -> &str { rest } +/// 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(); @@ -138,7 +146,7 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { open_alias = false; match tag { "class" => { - let rest = rest.strip_prefix("(exact)").map_or(rest, str::trim_start); + 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() { @@ -183,7 +191,7 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { }); open_alias = true; } - "enum" => group.enum_name = rest.split_whitespace().next().map(SmolStr::new), + "enum" => group.enum_name = split_attributes(rest).1.split_whitespace().next().map(SmolStr::new), "param" => { let mut parser = TypeParser::new(rest); let name = if parser.rest().starts_with("...") { @@ -320,7 +328,7 @@ pub fn type_name_at(line: &str, offset: usize) -> Option<(usize, &str)> { let mut names = TypeNames { line, found: Vec::new() }; match tag { "class" => { - let rest = rest.strip_prefix("(exact)").map_or(rest, str::trim_start); + 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); @@ -329,11 +337,14 @@ pub fn type_name_at(line: &str, offset: usize) -> Option<(usize, &str)> { names.types(parents); } } - "alias" | "enum" => { + "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('?')); @@ -418,6 +429,14 @@ mod tests { 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"]); + assert_eq!(parse("---@enum (key) Side").enum_name.as_deref(), Some("Side")); + } + #[test] fn bracketed_descriptions_inside_field_types() { let doc = parse( @@ -455,10 +474,13 @@ mod tests { "@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", "@generic T, U: table", "@generic T: string, U: Gar^age", @@ -492,6 +514,8 @@ mod tests { "@type str^ing", "@generic Gar^age: string", "@class Child: Parent", + "@class (Gar^age) Child", + "@enum (Gar^age) Mode", "@cast Gar^age string", "@see Gar^age", ] { From 15be6f5655ca06452c92361de7ac92bbd87b7bea Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:04:40 +0200 Subject: [PATCH 34/57] fix(indexer): type (key) enums as the union of their keys --- src/indexer.rs | 8 +++++--- src/luacats.rs | 12 ++++++++++-- tests/lsp.rs | 10 ++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/indexer.rs b/src/indexer.rs index e0a5540..352b383 100644 --- a/src/indexer.rs +++ b/src/indexer.rs @@ -252,7 +252,7 @@ impl<'a> Indexer<'a> { 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(), fields, name.span); + 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)) @@ -292,10 +292,12 @@ impl<'a> Indexer<'a> { (is_literal && text.len() <= 48 && !text.contains('\n')).then(|| SmolStr::new(text)) } - fn enum_class(&mut self, name: SmolStr, fields: &[TableField], span: Span) { + 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, }) @@ -351,7 +353,7 @@ impl<'a> Indexer<'a> { None => self.ctx.local_owner_key(name.name.span.start), }; if let Some(enum_name) = &doc.enum_name { - self.enum_class(enum_name.clone(), fields, name.name.span); + self.enum_class(enum_name.clone(), doc.enum_keys, fields, name.name.span); } self.table_members(owner, fields, 1); } diff --git a/src/luacats.rs b/src/luacats.rs index af3c195..33dd2be 100644 --- a/src/luacats.rs +++ b/src/luacats.rs @@ -57,6 +57,7 @@ pub struct DocGroup { pub returns: Vec, pub ty: Option, pub enum_name: Option, + pub enum_keys: bool, pub generics: Vec, pub overloads: Vec>, pub deprecated: Option, @@ -191,7 +192,11 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { }); open_alias = true; } - "enum" => group.enum_name = split_attributes(rest).1.split_whitespace().next().map(SmolStr::new), + "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("...") { @@ -434,7 +439,10 @@ mod tests { let doc = parse("---@class (partial) Player : Entity"); assert_eq!(doc.classes[0].name, "Player"); assert_eq!(doc.classes[0].parents, ["Entity"]); - assert_eq!(parse("---@enum (key) Side").enum_name.as_deref(), Some("Side")); + 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] diff --git a/tests/lsp.rs b/tests/lsp.rs index 1a72036..e7ff58a 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -296,6 +296,16 @@ local modes = { active = 'active', closed = 'closed' } 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()); From 66406a14d7ae5d765777352c7a6645993b329331 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:05:14 +0200 Subject: [PATCH 35/57] feat(luacats): find type names in @operator annotations --- src/luacats.rs | 16 ++++++++++++++++ tests/lsp.rs | 1 + 2 files changed, 17 insertions(+) diff --git a/src/luacats.rs b/src/luacats.rs index 33dd2be..a6785dd 100644 --- a/src/luacats.rs +++ b/src/luacats.rs @@ -374,6 +374,17 @@ pub fn type_name_at(line: &str, offset: usize) -> Option<(usize, &str)> { 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))) @@ -490,6 +501,10 @@ mod tests { "@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", @@ -525,6 +540,7 @@ mod tests { "@class (Gar^age) Child", "@enum (Gar^age) Mode", "@cast Gar^age string", + "@operator Gar^age(number): string", "@see Gar^age", ] { let offset = line.find('^').unwrap(); diff --git a/tests/lsp.rs b/tests/lsp.rs index e7ff58a..7a6088d 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -439,6 +439,7 @@ fn annotation_definitions_and_hovers_use_closed_files() { ("---@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), ("local garage = {} --[[@as Garage]]", "Garage", 36), ]; let text = annotations.iter().map(|(line, ..)| *line).collect::>().join("\n"); From 805ee4213ed2ed3b2c698d2459b476521d43a5ed Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:05:40 +0200 Subject: [PATCH 36/57] feat(luacats): find type names in @see annotations --- src/luacats.rs | 7 ++++++- tests/lsp.rs | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/luacats.rs b/src/luacats.rs index a6785dd..3fab014 100644 --- a/src/luacats.rs +++ b/src/luacats.rs @@ -389,6 +389,9 @@ pub fn type_name_at(line: &str, offset: usize) -> Option<(usize, &str)> { 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), _ => {} } @@ -510,6 +513,8 @@ mod tests { "@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('^', ""); @@ -541,7 +546,7 @@ mod tests { "@enum (Gar^age) Mode", "@cast Gar^age string", "@operator Gar^age(number): string", - "@see Gar^age", + "@see string, see Gar^age", ] { let offset = line.find('^').unwrap(); assert_eq!(type_name_at(&line.replace('^', ""), offset), None, "{line}"); diff --git a/tests/lsp.rs b/tests/lsp.rs index 7a6088d..3f92073 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -440,6 +440,7 @@ fn annotation_definitions_and_hovers_use_closed_files() { ("---@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"); From ab23ba0667059b32dc64ca342e9f3d743f6a1bfa Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:06:54 +0200 Subject: [PATCH 37/57] refactor(luacats): share alias member parsing between doc parsers --- src/luacats.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/luacats.rs b/src/luacats.rs index 3fab014..b634c6f 100644 --- a/src/luacats.rs +++ b/src/luacats.rs @@ -114,6 +114,11 @@ fn strip_visibility(mut rest: &str) -> &str { 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(')')) { @@ -130,10 +135,10 @@ pub fn parse_doc_lines(lines: &[&str]) -> DocGroup { for (index, raw) in lines.iter().enumerate() { let line = raw.strip_prefix(' ').unwrap_or(raw); - if let Some(member) = line.trim_start().strip_prefix('|') { + if let Some(member) = alias_member(line) { if open_alias { if let Some(alias) = group.aliases.last_mut() { - let mut parser = TypeParser::new(member.trim_start_matches(['>', '+', ' '])); + let mut parser = TypeParser::new(member); let ty = parser.parse(); alias.ty = Type::union([std::mem::take(&mut alias.ty), ty]); } @@ -326,8 +331,8 @@ impl<'a> TypeNames<'a> { /// 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 line.trim_start().strip_prefix('|') { - Some(member) => ("|", member.trim_start_matches(['>', '+', ' '])), + let (tag, rest) = match alias_member(line) { + Some(member) => ("|", member), None => split_tag(line)?, }; let mut names = TypeNames { line, found: Vec::new() }; From 473cd9ed5055b98a4ebf7c6acaf788ed9d214fb9 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:26:00 +0200 Subject: [PATCH 38/57] fix(workspace): keep excluded files out of the index and diagnostics --- docs/protocol.md | 4 +++ src/features/diagnostics.rs | 8 +++++- src/server.rs | 5 +++- src/workspace.rs | 3 +++ tests/lsp.rs | 54 +++++++++++++++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 2 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index e3a6817..f340620 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -52,6 +52,10 @@ The server discovers `qbxlint.toml` from the first workspace root and its ancest 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 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 diff --git a/src/features/diagnostics.rs b/src/features/diagnostics.rs index d21f4da..9f3db2c 100644 --- a/src/features/diagnostics.rs +++ b/src/features/diagnostics.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use lsp_types::{Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString}; use qbx_lua_analysis::lint::all_files; use qbx_lua_analysis::summary::summarize; @@ -16,6 +18,10 @@ pub struct FixData { pub edits: Vec<(lsp_types::Range, String)>, } +pub fn is_silenced(ws: &Workspace, path: &Path) -> bool { + ws.lint_config.is_excluded(path) +} + pub fn diagnostics( ws: &Workspace, doc: &Document, @@ -23,7 +29,7 @@ pub fn diagnostics( crossrefs: &qbx_lua_analysis::crossref::CrossRefs, ) -> 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()) { + 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); diff --git a/src/server.rs b/src/server.rs index 75fa6ce..c72cbe3 100644 --- a/src/server.rs +++ b/src/server.rs @@ -309,7 +309,7 @@ impl Server { // 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 { + if !doc.is_manifest() && is_source && !self.ws.lint_config.is_excluded(&doc.path) { doc.file = self.ws.index_parsed(&doc.path, FileOrigin::Workspace, &doc.text, &doc.chunk, &doc.resolution); } @@ -402,6 +402,9 @@ impl Server { 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 }; diff --git a/src/workspace.rs b/src/workspace.rs index 9c95b0f..86c93d6 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -198,6 +198,9 @@ impl Workspace { /// 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, diff --git a/tests/lsp.rs b/tests/lsp.rs index 3f92073..0134497 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -1051,6 +1051,60 @@ fn lua_ls_config_supplies_lint_settings_but_not_formatting() { assert!(client.logs.iter().any(|l| l.starts_with("skipped") && l.contains(".luarc.json")), "{:?}", client.logs); } +#[test] +fn excluded_files_stay_out_of_the_index_and_diagnostics() { + 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/**']\n"); + write("fxmanifest.lua", "fx_version 'cerulean'\ngame 'gta5'\nclient_scripts { 'skip/*.lua', 'main.lua' }\n"); + write("skip/old.lua", "SkippedApi = {}\nCitizen.Wait(0)\n"); + write("main.lua", "print(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("skip/old.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()); From c2532fd7907eeec6f3088b6f1739c0b4c43df995 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:26:43 +0200 Subject: [PATCH 39/57] feat(diagnostics): hide findings for ignore_diagnostics paths --- Cargo.lock | 17 +++++++++++++++++ docs/protocol.md | 6 ++++-- src/features/diagnostics.rs | 2 +- tests/lsp.rs | 17 +++++++++++++---- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 736e49c..a3d394e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -238,6 +238,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -331,6 +347,7 @@ name = "qbx_lua_analysis" version = "1.0.3" dependencies = [ "globset", + "ignore", "qbx_fivem_data", "qbx_lua_fmt", "qbx_lua_syntax", diff --git a/docs/protocol.md b/docs/protocol.md index f340620..d075848 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -53,8 +53,10 @@ request supplies indentation width and tabs/spaces. Diagnostic rule overrides fr take precedence over the config file. Files matching the config's `exclude` patterns are never indexed or diagnosed, even while they are -open. Files added to or removed from `exclude` leave or enter the index on the next `qbx/reindex` -or restart. +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` changes apply once the client reports the saved config file. Files added to +or removed from `exclude` leave or enter the index on the next `qbx/reindex` or restart. ## Client capabilities and file changes diff --git a/src/features/diagnostics.rs b/src/features/diagnostics.rs index 9f3db2c..b7d95e4 100644 --- a/src/features/diagnostics.rs +++ b/src/features/diagnostics.rs @@ -19,7 +19,7 @@ pub struct FixData { } pub fn is_silenced(ws: &Workspace, path: &Path) -> bool { - ws.lint_config.is_excluded(path) + ws.lint_config.is_excluded(path) || ws.lint_config.ignores_diagnostics(path) } pub fn diagnostics( diff --git a/tests/lsp.rs b/tests/lsp.rs index 0134497..65ed1cd 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -1052,7 +1052,7 @@ fn lua_ls_config_supplies_lint_settings_but_not_formatting() { } #[test] -fn excluded_files_stay_out_of_the_index_and_diagnostics() { +fn excluded_files_stay_out_and_ignored_files_stay_quiet() { struct Fixture(PathBuf); impl Drop for Fixture { fn drop(&mut self) { @@ -1073,10 +1073,14 @@ fn excluded_files_stay_out_of_the_index_and_diagnostics() { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, text).unwrap(); }; - write("qbxlint.toml", "exclude = ['skip/**']\n"); - write("fxmanifest.lua", "fx_version 'cerulean'\ngame 'gta5'\nclient_scripts { 'skip/*.lua', 'main.lua' }\n"); + 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(SkippedApi)\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"); @@ -1086,8 +1090,13 @@ fn excluded_files_stay_out_of_the_index_and_diagnostics() { 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"); From d363ebe8ff3f3f9b660aff17ac201480d649c023 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:41:09 +0200 Subject: [PATCH 40/57] feat(types): infer loop, index and generic types more precisely Loop variables, indexed fields and generic calls now get the types their tables and annotations describe instead of `unknown`. - pairs/ipairs/next: keep literal types for inline tables, read the array part of top-level local tables, and handle tuples, class fields, unions and `for k, v in next, t`. ipairs yields only the array part. - Indexing: a key typed as string literals looks up each named field, and `list[i]` works on top-level local arrays. Table constructors keep their array part and every `[key]` next to named fields. - Generics: record `@generic` names on function types and bind them from `table`, `V[]` and `T?` parameters and callback returns. Callback parameters use the bound types, and unbound generics are `unknown` rather than their names. - Hover: string literal unions and aliases no longer list the string library, aliases of non-table types show their definition, and a top-level local array shows as `string[]`. --- src/features/event_call.rs | 1 + src/features/hover.rs | 75 +++++++-- src/infer.rs | 318 +++++++++++++++++++++++++++++-------- src/luacats.rs | 7 +- src/types.rs | 4 +- tests/lsp.rs | 213 +++++++++++++++++++++++++ 6 files changed, 537 insertions(+), 81 deletions(-) diff --git a/src/features/event_call.rs b/src/features/event_call.rs index a171075..5180855 100644 --- a/src/features/event_call.rs +++ b/src/features/event_call.rs @@ -68,6 +68,7 @@ pub fn event_call(ws: &Workspace, doc: &Document, callee: &Expr, args: &[Expr], params, returns: if is_callback { handler.returns.clone() } else { Vec::new() }, is_method: false, + generics: Vec::new(), }, event: name.to_string(), handler_location: format!("{}{file_name}:{}", resource.unwrap_or_default(), event.range.start.line + 1), diff --git a/src/features/hover.rs b/src/features/hover.rs index e36138d..328fd6d 100644 --- a/src/features/hover.rs +++ b/src/features/hover.rs @@ -81,25 +81,24 @@ pub fn target_at(infer: &Infer, doc: &Document, offset: u32) -> Option { 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. +/// 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 is_table = matches!( - bare, - Type::GlobalTable(_) - | Type::Named(..) - | Type::Shape(_) - | Type::Require(_) - | Type::Union(_) - | Type::Exports(Some(_)) - ); - let members = if is_table { infer.members(&bare) } else { Vec::new() }; + 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}: {ty}{value}"); + return format!("{prefix}{name}: {}{value}", shown_type(infer, ty)); } let label = match &bare { Type::Named(class, _) => format!("{class} "), @@ -122,6 +121,58 @@ fn describe_value(infer: &Infer, prefix: &str, name: &str, ty: &Type, literal: O out } +/// A top-level `local list = { 'a', 'b' }` is shown as `string[]`, as it would be inside a function, +/// rather than as the `table` its index entry says. +fn shown_type(infer: &Infer, ty: &Type) -> Type { + match ty { + Type::GlobalTable(owner) if owner.starts_with('%') => match infer.key_value_types(ty, true) { + (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); diff --git a/src/infer.rs b/src/infer.rs index 80d5af0..4d061b6 100644 --- a/src/infer.rs +++ b/src/infer.rs @@ -66,6 +66,12 @@ impl<'a> FileContext<'a> { 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> { @@ -241,6 +247,7 @@ pub fn native_fun_type(native: &qbx_fivem_data::Native) -> FunType { .collect(), returns: native.returns().map(native_type).collect(), is_method: false, + generics: Vec::new(), } } @@ -436,17 +443,20 @@ impl<'a> Infer<'a> { if let Some(ty) = self.on_cache_param(expected).filter(|_| index < 2) { return Some(ty); } - let fun = match &expected.call.kind { - ExprKind::Call { callee, .. } => self.expr(callee).as_fun().cloned(), - ExprKind::MethodCall { base, method, .. } => { - self.member(&self.expr(base), &method.text).and_then(|m| m.ty.as_fun().cloned()) + 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) } - _ => None, - }?; - let (skip_params, skip_args) = fun.call_offsets(matches!(expected.call.kind, ExprKind::MethodCall { .. })); + _ => return None, + }; + let fun = fun?; + 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(); - callback.params.get(index).map(|p| p.ty.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`. @@ -461,44 +471,121 @@ impl<'a> Infer<'a> { 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 }; - if let ExprKind::Call { callee, args, .. } = &first.kind { - let iterator = callee.dotted_path(); - if let (Some("pairs" | "ipairs" | "next"), Some(arg)) = (iterator.as_deref(), args.first()) { - let (key, value) = self.key_value_types(&self.expr(arg)); - let key = if iterator.as_deref() == Some("ipairs") { Type::Integer } else { key }; - return if index == 0 { - key - } else if index == 1 { - value - } else { - 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() } - fn key_value_types(&self, ty: &Type) -> (Type, Type) { - match self.resolve_alias(ty) { - Type::Array(inner) => (Type::Integer, *inner), - Type::Map(k, v) => (*k, *v), - Type::Shape(shape) => match &shape.index { - Some((k, v)) => (k.clone(), v.clone()), - None => (Type::String, Type::union(shape.fields.iter().map(|f| f.ty.clone()))), - }, - Type::Named(name, _) => match self.index.class(&name).and_then(|(_, c)| c.index.clone()) { - Some((k, v)) => (k, v), - None => (Type::String, Type::Unknown), - }, - Type::GlobalTable(_) => (Type::String, Type::Unknown), + /// `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], positional_only: bool) -> (Type, Type) { + let mut keys = Vec::new(); + let mut values = Vec::new(); + for field in fields.iter().take(MAX_SHAPE_FIELDS) { + let (key, value) = match field { + TableField::Positional(value) => (Type::Integer, self.expr(value)), + _ if positional_only => continue, + TableField::Named { name, value } => (Type::StringLit(name.text.clone()), self.expr(value)), + TableField::Keyed { key, value } => (self.expr(key), self.expr(value)), + TableField::SetMember(name) => (Type::StringLit(name.text.clone()), Type::BooleanLit(true)), + }; + keys.push(key); + values.push(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())); + fields.chain(shape.index.clone()).unzip() + } + Type::Named(ref name, _) => { + let index = self.index.class(name).and_then(|(_, c)| c.index.clone()); + // 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.key_value_types(t)) - .next() - .unwrap_or_default(), - _ => (Type::Unknown, Type::Unknown), + .map(|t| self.guarded(|| self.key_value_types(t, array_only))) + .unzip(), + _ => return (Type::Unknown, Type::Unknown), + }; + (Type::union(keys), Type::union(values)) + } + + /// The index holds the named fields of a table. Its array part and other keys are only in the + /// constructor, which is still at hand for a top-level local of this file. + 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); + } } + for field in self.local_table_fields(owner).unwrap_or_default().iter().take(MAX_SHAPE_FIELDS) { + match field { + TableField::Positional(value) => { + keys.push(Type::Integer); + values.push(self.expr(value).widen()); + } + TableField::Keyed { key, value } if !array_only && !matches!(key.kind, ExprKind::String(_)) => { + keys.push(self.expr(key).widen()); + values.push(self.expr(value).widen()); + } + _ => {} + } + } + 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 { @@ -526,9 +613,12 @@ impl<'a> Infer<'a> { fn index_expr(&self, base: &Expr, index: &Expr) -> Type { let base_ty = self.expr(base); - if let ExprKind::String(key) = &index.kind { - if let Some(member) = self.member(&base_ty, key) { - return member.ty; + 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); } } match self.resolve_alias(&base_ty.without_nil()) { @@ -544,14 +634,37 @@ impl<'a> Infer<'a> { 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 top-level `local list = { ... }`, whose array part is not in the index. + Type::GlobalTable(owner) if matches!(key_ty.widen(), Type::Integer | Type::Number) => { + 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 { let mut shape = Shape::default(); let mut positional = Vec::new(); + let (mut keys, mut values) = (Vec::new(), Vec::new()); for field in fields.iter().take(MAX_SHAPE_FIELDS) { match field { TableField::Named { name, value } => shape.fields.push(ShapeField { @@ -565,7 +678,10 @@ impl<'a> Infer<'a> { ty: self.expr(value).widen(), optional: false, }), - _ => shape.index = Some((self.expr(key).widen(), self.expr(value).widen())), + _ => { + keys.push(self.expr(key).widen()); + values.push(self.expr(value).widen()); + } }, TableField::SetMember(name) => { shape.fields.push(ShapeField { name: name.text.clone(), ty: Type::Boolean, optional: false }) @@ -573,12 +689,20 @@ impl<'a> Infer<'a> { TableField::Positional(value) => positional.push(self.expr(value).widen()), } } - if shape.fields.is_empty() && shape.index.is_none() && !positional.is_empty() { + if shape.fields.is_empty() && keys.is_empty() && !positional.is_empty() { return Type::Array(Box::new(Type::union(positional))); } if fields.is_empty() { return Type::Table; } + // A mixed table keeps its array part and every `[key]` next to the named fields. + if !positional.is_empty() { + keys.push(Type::Integer); + values.extend(positional); + } + if !keys.is_empty() { + shape.index = Some((Type::union(keys), Type::union(values))); + } Type::Shape(Arc::new(shape)) } @@ -653,39 +777,82 @@ impl<'a> Infer<'a> { } } let Some((fun, _)) = self.callee_fun(base, method) else { return Vec::new() }; - let generics = self.bind_generics(&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() } - fn bind_generics(&self, fun: &FunType, args: &[Expr], via_method: bool) -> Vec<(SmolStr, Type)> { + /// 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: &[Expr], + via_method: bool, + with_callbacks: bool, + ) -> Vec<(SmolStr, Type)> { let mut bound = Vec::new(); let (skip_params, skip_args) = fun.call_offsets(via_method); - for (param, arg) in fun.params.iter().skip(skip_params).zip(args.iter().skip(skip_args)) { - let target = match ¶m.ty { - Type::Named(name, args) if args.is_empty() => Some((name, false)), - Type::Array(inner) => match &**inner { - Type::Named(name, args) if args.is_empty() => Some((name, true)), - _ => None, - }, - _ => None, - }; - let Some((name, in_array)) = target else { continue }; - if self.index.class(name).is_some() || self.index.alias(name).is_some() || name.len() > 2 { - continue; - } - let arg_ty = self.expr(arg).widen(); - let arg_ty = match (in_array, arg_ty) { - (true, Type::Array(inner)) => *inner, - (true, _) => continue, - (false, ty) => ty, - }; - if !arg_ty.is_unknown() && !bound.iter().any(|(n, _)| n == name) { - bound.push((name.clone(), arg_ty)); + let pairs: Vec<(&Param, &Expr)> = + fun.params.iter().skip(skip_params).zip(args.iter().skip(skip_args)).collect(); + let is_callback = |arg: &Expr| matches!(arg.unparen().kind, ExprKind::Function(_)); + for (param, arg) in pairs.iter().filter(|(_, arg)| !is_callback(arg)) { + self.unify(fun, ¶m.ty, &self.expr(arg).widen(), &mut bound, 0); + } + if with_callbacks { + for (param, arg) in pairs.iter().filter(|(_, arg)| is_callback(arg)) { + self.unify(fun, ¶m.ty, &self.expr(arg), &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); + } + } + } + _ => {} + } + } + + /// 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(); @@ -873,9 +1040,26 @@ fn substitute(ty: &Type, generics: &[(SmolStr, Type)]) -> Type { 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::Union(types) => Type::union(types.iter().map(|t| substitute(t, 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`. + Type::Union(types) => { + let parts: Vec = types.iter().map(|t| substitute(t, generics)).collect(); + if parts.iter().any(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(), + })), other => other.clone(), } } diff --git a/src/luacats.rs b/src/luacats.rs index b634c6f..c67899f 100644 --- a/src/luacats.rs +++ b/src/luacats.rs @@ -84,7 +84,12 @@ impl DocGroup { 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 }); } - FunType { params, returns: self.returns.iter().map(|r| r.ty.clone()).collect(), is_method } + FunType { + params, + returns: self.returns.iter().map(|r| r.ty.clone()).collect(), + is_method, + generics: self.generics.clone(), + } } pub fn param_description(&self, name: &str) -> Option<&str> { diff --git a/src/types.rs b/src/types.rs index c8cbe81..422d08d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -50,6 +50,8 @@ 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, } #[derive(Clone, Debug, PartialEq, Default)] @@ -505,7 +507,7 @@ impl<'a> TypeParser<'a> { 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 })) + Type::Fun(Arc::new(FunType { params, returns, is_method: false, generics: Vec::new() })) } fn parse_return_list(&mut self) -> Vec { diff --git a/tests/lsp.rs b/tests/lsp.rs index 65ed1cd..eb38b30 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -238,6 +238,219 @@ fn hover_shows_types_docs_and_natives() { assert!(client.hover_text(CLIENT, l, c).contains("label: string")); } +#[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 +"; + 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"), + ("element", "element: string"), + ("name,", "name: string"), + ("garage in", "label: string"), + ]; + 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] +end +"; + client.open_with(CLIENT, text); + let cases: &[(&str, &[&str])] = &[ + ("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_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 + +---@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) +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"), + // 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()); From bb29458dbc762a485eaca226015e0bff82c54332 Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:47:17 +0200 Subject: [PATCH 41/57] feat(types): read global array parts, pick overloads, index unions - Index the array part and `[key]` entries of every indexed table, so `Config.Items = { 'a', 'b' }` loops and indexes as `string[]`. - Keep `@overload` signatures on function types. A call uses the declared signature unless its argument count or value kinds rule it out, then the first overload that fits, for both the result and callback parameter types. - Index each part of a union: `(string[]|integer[])[1]` is `string|integer`. --- src/features/event_call.rs | 1 + src/features/hover.rs | 6 +- src/index.rs | 43 +++++++++-- src/indexer.rs | 29 +++++++- src/infer.rs | 149 +++++++++++++++++++++++++++++++++---- src/luacats.rs | 7 ++ src/types.rs | 4 +- tests/lsp.rs | 72 ++++++++++++++++++ 8 files changed, 284 insertions(+), 27 deletions(-) diff --git a/src/features/event_call.rs b/src/features/event_call.rs index 5180855..399913c 100644 --- a/src/features/event_call.rs +++ b/src/features/event_call.rs @@ -69,6 +69,7 @@ pub fn event_call(ws: &Workspace, doc: &Document, callee: &Expr, args: &[Expr], returns: 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/src/features/hover.rs b/src/features/hover.rs index 328fd6d..0ba770e 100644 --- a/src/features/hover.rs +++ b/src/features/hover.rs @@ -121,11 +121,11 @@ fn value_overview(infer: &Infer, prefix: &str, name: &str, ty: &Type, literal: O out } -/// A top-level `local list = { 'a', 'b' }` is shown as `string[]`, as it would be inside a function, -/// rather than as the `table` its index entry says. +/// A table the index holds with only an array part, 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(owner) if owner.starts_with('%') => match infer.key_value_types(ty, true) { + Type::GlobalTable(_) => match infer.key_value_types(ty, true) { (Type::Integer, value) if !value.is_unknown() => Type::Array(Box::new(value)), _ => ty.clone(), }, diff --git a/src/index.rs b/src/index.rs index 1d076dd..6da5e4b 100644 --- a/src/index.rs +++ b/src/index.rs @@ -43,6 +43,15 @@ pub struct Member { pub symbol: Symbol, } +/// The entries of a table constructor that have no name: its array part (`key` is `integer`), or +/// its `[key] = value` pairs. +#[derive(Clone, Debug)] +pub struct Element { + pub owner: SmolStr, + pub key: Type, + pub value: Type, +} + #[derive(Clone, Debug)] pub struct ClassDef { pub name: SmolStr, @@ -84,6 +93,7 @@ pub struct EventDef { pub struct FileIndex { pub globals: Vec, pub members: Vec, + pub elements: Vec, pub classes: Vec, pub aliases: Vec, pub exports: Vec, @@ -135,6 +145,7 @@ pub struct Index { pub resources: Vec, globals: FxHashMap>, members: FxHashMap>, + elements: FxHashMap>, classes: FxHashMap>, aliases: FxHashMap>, } @@ -226,6 +237,9 @@ impl Index { 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)); } @@ -244,6 +258,7 @@ impl Index { 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); } @@ -321,8 +336,22 @@ impl Index { /// 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)> { - let Some(slots) = self.members.get(owner) else { return Vec::new() }; - let resolve = |(file, i): &Slot| Some((*file, &self.file(*file)?.index.members.get(*i as usize)?.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), @@ -336,7 +365,7 @@ impl Index { // `%`-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.iter().filter_map(resolve).collect(); + 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. @@ -344,17 +373,17 @@ impl Index { 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<&Slot> = slots.iter().filter(|(f, _)| self.is_visible(from, *f)).collect(); + 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.into_iter().filter_map(resolve).collect(); + return in_scope; } - let visible: Vec<_> = slots.iter().filter(|(f, _)| reachable(*f)).filter_map(resolve).collect(); + 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.iter().filter_map(resolve).collect() + slots.clone() } fn imports_resource_of(&self, from: FileId, target: FileId) -> bool { diff --git a/src/indexer.rs b/src/indexer.rs index 352b383..728e91a 100644 --- a/src/indexer.rs +++ b/src/indexer.rs @@ -9,7 +9,9 @@ use qbx_lua_analysis::summary::summarize; use qbx_lua_syntax::ast::*; use qbx_lua_syntax::{Comment, LineIndex, SmolStr, Span}; -use crate::index::{AliasDef, ClassDef, EventDef, EventKind, FileId, FileIndex, Index, Member, Symbol, SymbolKind}; +use crate::index::{ + AliasDef, ClassDef, Element, EventDef, EventKind, FileId, FileIndex, Index, Member, Symbol, SymbolKind, +}; use crate::infer::{table_fields, FileContext, Infer}; use crate::luacats::{parse_doc_lines, DocGroup}; use crate::types::Type; @@ -306,14 +308,30 @@ impl<'a> Indexer<'a> { self.out.aliases.push(AliasDef { name, ty: Type::union(values), doc: None, range }); } + fn push_element(&mut self, owner: SmolStr, key: Type, 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 (mut array, mut keys, mut values) = (Vec::new(), Vec::new(), Vec::new()); for field in fields.iter().take(MAX_TABLE_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) } - _ => continue, + TableField::Positional(value) => { + array.push(self.infer.expr(value).widen()); + continue; + } + TableField::Keyed { key, value } => { + keys.push(self.infer.expr(key).widen()); + values.push(self.infer.expr(value).widen()); + continue; + } + TableField::SetMember(_) => continue, }; let nested = format!("{owner}.{}", name.text); let mut symbol = self.value_symbol(&name, Some(value), name.span.start, &nested, depth); @@ -322,6 +340,13 @@ impl<'a> Indexer<'a> { } self.push_member(owner.clone(), symbol); } + // Kept apart from the `[key]` pairs, since `ipairs` visits only the array part. + if !array.is_empty() { + self.push_element(owner.clone(), Type::Integer, Type::union(array)); + } + if !keys.is_empty() { + self.push_element(owner, Type::union(keys), Type::union(values)); + } } fn block(&mut self, block: &Block) { diff --git a/src/infer.rs b/src/infer.rs index 4d061b6..a4e49d7 100644 --- a/src/infer.rs +++ b/src/infer.rs @@ -17,6 +17,17 @@ 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; +} + pub enum Decl<'a> { Local { stmt: &'a Stmt, index: usize }, LocalFunction { stmt: &'a Stmt, func: &'a FuncBody }, @@ -248,6 +259,7 @@ pub fn native_fun_type(native: &qbx_fivem_data::Native) -> FunType { returns: native.returns().map(native_type).collect(), is_method: false, generics: Vec::new(), + overloads: Vec::new(), } } @@ -451,7 +463,7 @@ impl<'a> Infer<'a> { } _ => return None, }; - let fun = fun?; + 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(); @@ -552,8 +564,9 @@ impl<'a> Infer<'a> { (Type::union(keys), Type::union(values)) } - /// The index holds the named fields of a table. Its array part and other keys are only in the - /// constructor, which is still at hand for a top-level local of this file. + /// 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(); @@ -563,17 +576,29 @@ impl<'a> Infer<'a> { values.push(member.ty); } } - for field in self.local_table_fields(owner).unwrap_or_default().iter().take(MAX_SHAPE_FIELDS) { - match field { - TableField::Positional(value) => { - keys.push(Type::Integer); - values.push(self.expr(value).widen()); + match self.local_table_fields(owner) { + Some(fields) => { + for field in fields.iter().take(MAX_SHAPE_FIELDS) { + match field { + TableField::Positional(value) => { + keys.push(Type::Integer); + values.push(self.expr(value).widen()); + } + TableField::Keyed { key, value } if !array_only && !matches!(key.kind, ExprKind::String(_)) => { + keys.push(self.expr(key).widen()); + values.push(self.expr(value).widen()); + } + _ => {} + } } - TableField::Keyed { key, value } if !array_only && !matches!(key.kind, ExprKind::String(_)) => { - keys.push(self.expr(key).widen()); - values.push(self.expr(value).widen()); + } + None => { + for element in self.index.elements_of(owner, self.ctx.file) { + if !array_only || element.key == Type::Integer { + keys.push(element.key.clone()); + values.push(element.value.clone()); + } } - _ => {} } } if keys.is_empty() { @@ -621,7 +646,18 @@ impl<'a> Infer<'a> { return Type::union(fields); } } - match self.resolve_alias(&base_ty.without_nil()) { + 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 { @@ -634,7 +670,7 @@ impl<'a> Infer<'a> { 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 top-level `local list = { ... }`, whose array part is not in the index. + // `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.widen(), Type::Integer | Type::Number) => { self.global_table_key_values(&owner, true).1 } @@ -777,6 +813,7 @@ impl<'a> Infer<'a> { } } let Some((fun, _)) = self.callee_fun(base, method) else { return Vec::new() }; + 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() } @@ -846,6 +883,89 @@ impl<'a> Infer<'a> { } } + /// The signature a call uses: the declared one, or else the first `@overload` whose arity and + /// argument kinds fit. + fn signature_for(&self, fun: &Arc, args: &[Expr], via_method: bool) -> Arc { + if fun.overloads.is_empty() || self.fits(fun, args, via_method) { + return fun.clone(); + } + fun.overloads.iter().find(|overload| self.fits(overload, args, via_method)).unwrap_or(fun).clone() + } + + fn fits(&self, fun: &FunType, args: &[Expr], via_method: bool) -> bool { + let (skip_params, skip_args) = fun.call_offsets(via_method); + let params = fun.params.get(skip_params..).unwrap_or_default(); + let args = args.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); + if args.len() - usize::from(open_ended) > fixed.len() && !variadic { + return false; + } + let missing_required = fixed + .iter() + .skip(args.len()) + .any(|p| !p.optional && self.value_kinds(fun, &p.ty, 0).is_some_and(|kinds| kinds & kind::NIL == 0)); + if missing_required && !open_ended { + return false; + } + fixed.iter().zip(args).all(|(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, &self.expr(arg), 0), + }; + match (self.value_kinds(fun, ¶m.ty, 0), given) { + (Some(wanted), Some(given)) => wanted & given != 0, + _ => true, + } + }) + } + + /// 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 { @@ -1059,6 +1179,7 @@ fn substitute(ty: &Type, generics: &[(SmolStr, Type)]) -> Type { returns: fun.returns.iter().map(|t| substitute(t, generics)).collect(), is_method: fun.is_method, generics: Vec::new(), + overloads: Vec::new(), })), other => other.clone(), } diff --git a/src/luacats.rs b/src/luacats.rs index c67899f..c1cd08e 100644 --- a/src/luacats.rs +++ b/src/luacats.rs @@ -84,11 +84,18 @@ impl DocGroup { 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. + let overloads = self + .overloads + .iter() + .map(|overload| 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, } } diff --git a/src/types.rs b/src/types.rs index 422d08d..f11cab1 100644 --- a/src/types.rs +++ b/src/types.rs @@ -52,6 +52,8 @@ pub struct FunType { 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)] @@ -507,7 +509,7 @@ impl<'a> TypeParser<'a> { 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() })) + Type::Fun(Arc::new(FunType { params, returns, is_method: false, generics: Vec::new(), overloads: Vec::new() })) } fn parse_return_list(&mut self) -> Vec { diff --git a/tests/lsp.rs b/tests/lsp.rs index eb38b30..1448595 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -366,6 +366,78 @@ end } } +#[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 +"; + 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"), + ]; + 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 + +local byId = find(1) +local byName = find('x') +local none = arity() +lookup('x', function(named) end) +lookup(1, function(numbered) end) +"; + client.open_with(CLIENT, text); + let cases = [ + ("byId", "byId: integer"), + ("byName", "byName: string"), + ("none", "none: boolean"), + ("named", "named: string"), + ("numbered", "numbered: integer"), + ]; + 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()); From 0e3719b077841760f5cf88c0419861a2d10ccaea Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:41:09 +0200 Subject: [PATCH 42/57] fix(infer): let nil fill optional parameters when matching overloads --- src/infer.rs | 13 ++++++++----- tests/lsp.rs | 8 ++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/infer.rs b/src/infer.rs index a4e49d7..d234abb 100644 --- a/src/infer.rs +++ b/src/infer.rs @@ -905,10 +905,8 @@ impl<'a> Infer<'a> { if args.len() - usize::from(open_ended) > fixed.len() && !variadic { return false; } - let missing_required = fixed - .iter() - .skip(args.len()) - .any(|p| !p.optional && self.value_kinds(fun, &p.ty, 0).is_some_and(|kinds| kinds & kind::NIL == 0)); + 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 false; } @@ -918,13 +916,18 @@ impl<'a> Infer<'a> { ExprKind::Function(_) => Some(kind::FUNCTION), _ => self.value_kinds(fun, &self.expr(arg), 0), }; - match (self.value_kinds(fun, ¶m.ty, 0), given) { + match (self.param_kinds(fun, param), given) { (Some(wanted), Some(given)) => wanted & given != 0, _ => true, } }) } + /// 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 { diff --git a/tests/lsp.rs b/tests/lsp.rs index 1448595..b13c93d 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -417,11 +417,17 @@ local function arity(x) end ---@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 + 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) "; client.open_with(CLIENT, text); let cases = [ @@ -430,6 +436,8 @@ lookup(1, function(numbered) end) ("none", "none: boolean"), ("named", "named: string"), ("numbered", "numbered: integer"), + // `nil` fills the optional `id`, so the declared signature still fits. + ("byNil", "byNil: integer"), ]; for (needle, expected) in cases { let (l, c) = pos(text, needle, 0); From c8c34f4397c9223e5273bc622a16ec87ef8037a6 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:41:33 +0200 Subject: [PATCH 43/57] fix(luacats): match overloads that list self like fun(self) fields --- src/luacats.rs | 9 +++++++-- tests/lsp.rs | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/luacats.rs b/src/luacats.rs index c1cd08e..45d2ca3 100644 --- a/src/luacats.rs +++ b/src/luacats.rs @@ -84,11 +84,16 @@ impl DocGroup { 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. + // 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| Arc::new(FunType { is_method, generics: self.generics.clone(), ..(**overload).clone() })) + .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, diff --git a/tests/lsp.rs b/tests/lsp.rs index b13c93d..95c6e7e 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -422,12 +422,30 @@ local function lookup(id, cb) end ---@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') "; client.open_with(CLIENT, text); let cases = [ @@ -438,6 +456,11 @@ search(nil, function(byNil) end) ("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"), ]; for (needle, expected) in cases { let (l, c) = pos(text, needle, 0); From abe89af8f6b5c8d8b6447d840b75231f68f2e40e Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:42:09 +0200 Subject: [PATCH 44/57] fix(infer): prefer overloads that fit without the declared vararg --- src/infer.rs | 36 ++++++++++++++++++++++++++---------- tests/lsp.rs | 5 +++++ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/infer.rs b/src/infer.rs index d234abb..fcec6b0 100644 --- a/src/infer.rs +++ b/src/infer.rs @@ -28,6 +28,15 @@ mod kind { 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, +} + pub enum Decl<'a> { Local { stmt: &'a Stmt, index: usize }, LocalFunction { stmt: &'a Stmt, func: &'a FuncBody }, @@ -883,16 +892,17 @@ impl<'a> Infer<'a> { } } - /// The signature a call uses: the declared one, or else the first `@overload` whose arity and - /// argument kinds fit. + /// 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: &[Expr], via_method: bool) -> Arc { - if fun.overloads.is_empty() || self.fits(fun, args, via_method) { + if fun.overloads.is_empty() { return fun.clone(); } - fun.overloads.iter().find(|overload| self.fits(overload, args, via_method)).unwrap_or(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 fits(&self, fun: &FunType, args: &[Expr], via_method: bool) -> bool { + fn fit(&self, fun: &FunType, args: &[Expr], 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 = args.get(skip_args..).unwrap_or_default(); @@ -902,15 +912,16 @@ impl<'a> Infer<'a> { }; // 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); - if args.len() - usize::from(open_ended) > fixed.len() && !variadic { - return false; + 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 false; + return Fit::No; } - fixed.iter().zip(args).all(|(param, arg)| { + let kinds_fit = fixed.iter().zip(args).all(|(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), @@ -920,7 +931,12 @@ impl<'a> Infer<'a> { (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`. diff --git a/tests/lsp.rs b/tests/lsp.rs index 95c6e7e..e9d4d25 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -446,6 +446,8 @@ 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 = [ @@ -461,6 +463,9 @@ local priceViaDot = OverloadedShop.price(OverloadedShop, 'bread') ("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); From c0d03528da08a423a4a2fed9977f3231160a58ad Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:43:13 +0200 Subject: [PATCH 45/57] perf(infer): infer call arguments once and only where generics bind --- src/infer.rs | 72 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/src/infer.rs b/src/infer.rs index fcec6b0..3ae6410 100644 --- a/src/infer.rs +++ b/src/infer.rs @@ -1,4 +1,4 @@ -use std::cell::{Cell, RefCell}; +use std::cell::{Cell, OnceCell, RefCell}; use std::rc::Rc; use std::sync::Arc; @@ -37,6 +37,23 @@ enum Fit { 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 }, @@ -472,12 +489,13 @@ impl<'a> Infer<'a> { } _ => return None, }; - let fun = self.signature_for(&fun?, args, via_method); + 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))) + 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`. @@ -822,8 +840,9 @@ impl<'a> Infer<'a> { } } let Some((fun, _)) = self.callee_fun(base, method) else { return Vec::new() }; - let fun = self.signature_for(&fun, args, method.is_some()); - let generics = self.bind_generics(&fun, args, method.is_some(), true); + 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() } @@ -833,21 +852,26 @@ impl<'a> Infer<'a> { fn bind_generics( &self, fun: &FunType, - args: &[Expr], + 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 pairs: Vec<(&Param, &Expr)> = - fun.params.iter().skip(skip_params).zip(args.iter().skip(skip_args)).collect(); - let is_callback = |arg: &Expr| matches!(arg.unparen().kind, ExprKind::Function(_)); - for (param, arg) in pairs.iter().filter(|(_, arg)| !is_callback(arg)) { - self.unify(fun, ¶m.ty, &self.expr(arg).widen(), &mut bound, 0); + 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, arg) in pairs.iter().filter(|(_, arg)| is_callback(arg)) { - self.unify(fun, ¶m.ty, &self.expr(arg), &mut bound, 0); + 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`. @@ -892,9 +916,21 @@ impl<'a> Infer<'a> { } } + /// 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: &[Expr], via_method: bool) -> Arc { + fn signature_for(&self, fun: &Arc, args: &CallArgs, via_method: bool) -> Arc { if fun.overloads.is_empty() { return fun.clone(); } @@ -902,10 +938,10 @@ impl<'a> Infer<'a> { fun.overloads.iter().find(|overload| self.fit(overload, args, via_method) > declared).unwrap_or(fun).clone() } - fn fit(&self, fun: &FunType, args: &[Expr], via_method: bool) -> Fit { + 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 = args.get(skip_args..).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), @@ -921,11 +957,11 @@ impl<'a> Infer<'a> { if missing_required && !open_ended { return Fit::No; } - let kinds_fit = fixed.iter().zip(args).all(|(param, arg)| { + 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, &self.expr(arg), 0), + _ => 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, From c05b6b29c5d9f5b1fb33f8cb652ff8d4b93b123d Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:45:45 +0200 Subject: [PATCH 46/57] fix(infer): keep table array parts apart from other keys --- src/index.rs | 6 +- src/indexer.rs | 35 +++++------ src/infer.rs | 159 +++++++++++++++++++++++++++++++------------------ src/types.rs | 7 ++- tests/lsp.rs | 15 ++++- 5 files changed, 140 insertions(+), 82 deletions(-) diff --git a/src/index.rs b/src/index.rs index 6da5e4b..2f23a93 100644 --- a/src/index.rs +++ b/src/index.rs @@ -43,12 +43,12 @@ pub struct Member { pub symbol: Symbol, } -/// The entries of a table constructor that have no name: its array part (`key` is `integer`), or -/// its `[key] = value` pairs. +/// 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: Type, + pub key: Option, pub value: Type, } diff --git a/src/indexer.rs b/src/indexer.rs index 728e91a..75aebe7 100644 --- a/src/indexer.rs +++ b/src/indexer.rs @@ -12,7 +12,7 @@ use qbx_lua_syntax::{Comment, LineIndex, SmolStr, Span}; use crate::index::{ AliasDef, ClassDef, Element, EventDef, EventKind, FileId, FileIndex, Index, Member, Symbol, SymbolKind, }; -use crate::infer::{table_fields, FileContext, Infer}; +use crate::infer::{table_elements, table_fields, FileContext, Infer}; use crate::luacats::{parse_doc_lines, DocGroup}; use crate::types::Type; @@ -308,30 +308,21 @@ impl<'a> Indexer<'a> { self.out.aliases.push(AliasDef { name, ty: Type::union(values), doc: None, range }); } - fn push_element(&mut self, owner: SmolStr, key: Type, value: Type) { + 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 (mut array, mut keys, mut values) = (Vec::new(), Vec::new(), Vec::new()); - for field in fields.iter().take(MAX_TABLE_FIELDS) { + 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::Positional(value) => { - array.push(self.infer.expr(value).widen()); - continue; - } - TableField::Keyed { key, value } => { - keys.push(self.infer.expr(key).widen()); - values.push(self.infer.expr(value).widen()); - continue; - } - TableField::SetMember(_) => continue, + 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); @@ -340,12 +331,18 @@ impl<'a> Indexer<'a> { } self.push_member(owner.clone(), symbol); } - // Kept apart from the `[key]` pairs, since `ipairs` visits only the array part. - if !array.is_empty() { - self.push_element(owner.clone(), Type::Integer, Type::union(array)); + 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 !keys.is_empty() { - self.push_element(owner, Type::union(keys), Type::union(values)); + 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)); } } diff --git a/src/infer.rs b/src/infer.rs index 3ae6410..5b9504e 100644 --- a/src/infer.rs +++ b/src/infer.rs @@ -538,20 +538,31 @@ impl<'a> Infer<'a> { /// `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], positional_only: bool) -> (Type, Type) { + 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().take(MAX_SHAPE_FIELDS) { + for field in fields.iter().filter(|_| !array_only) { let (key, value) = match field { - TableField::Positional(value) => (Type::Integer, self.expr(value)), - _ if positional_only => continue, TableField::Named { name, value } => (Type::StringLit(name.text.clone()), self.expr(value)), - TableField::Keyed { key, value } => (self.expr(key), 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)) } @@ -564,10 +575,16 @@ impl<'a> Infer<'a> { 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())); - fields.chain(shape.index.clone()).unzip() + 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()); + 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) @@ -605,26 +622,25 @@ impl<'a> Infer<'a> { } match self.local_table_fields(owner) { Some(fields) => { - for field in fields.iter().take(MAX_SHAPE_FIELDS) { - match field { - TableField::Positional(value) => { - keys.push(Type::Integer); - values.push(self.expr(value).widen()); - } - TableField::Keyed { key, value } if !array_only && !matches!(key.kind, ExprKind::String(_)) => { - keys.push(self.expr(key).widen()); - values.push(self.expr(value).widen()); - } - _ => {} - } + 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) { - if !array_only || element.key == Type::Integer { - keys.push(element.key.clone()); - values.push(element.value.clone()); - } + 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()); } } } @@ -693,7 +709,7 @@ impl<'a> Infer<'a> { } _ => Type::union(items), }, - Type::Shape(shape) => shape.index.as_ref().map(|(_, v)| v.clone()).unwrap_or_default(), + 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() } @@ -725,45 +741,33 @@ impl<'a> Infer<'a> { } 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(); - let mut positional = Vec::new(); - let (mut keys, mut values) = (Vec::new(), Vec::new()); - for field in fields.iter().take(MAX_SHAPE_FIELDS) { - match field { - TableField::Named { name, value } => shape.fields.push(ShapeField { - name: name.text.clone(), - ty: self.expr(value).widen(), - optional: false, - }), - TableField::Keyed { key, value } => match &key.kind { - ExprKind::String(name) => shape.fields.push(ShapeField { - name: name.clone(), - ty: self.expr(value).widen(), - optional: false, - }), - _ => { - keys.push(self.expr(key).widen()); - values.push(self.expr(value).widen()); - } - }, - TableField::SetMember(name) => { - shape.fields.push(ShapeField { name: name.text.clone(), ty: Type::Boolean, optional: false }) + 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::Positional(value) => positional.push(self.expr(value).widen()), - } - } - if shape.fields.is_empty() && keys.is_empty() && !positional.is_empty() { - return Type::Array(Box::new(Type::union(positional))); + TableField::SetMember(name) => (name.text.clone(), Type::Boolean), + TableField::Keyed { .. } | TableField::Positional(_) => continue, + }; + shape.fields.push(ShapeField { name, ty, optional: false }); } - if fields.is_empty() { - return Type::Table; + 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)); } - // A mixed table keeps its array part and every `[key]` next to the named fields. - if !positional.is_empty() { - keys.push(Type::Integer); - values.extend(positional); + if !elements.array.is_empty() { + shape.array = Some(array); } - if !keys.is_empty() { + 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)) @@ -1251,6 +1255,45 @@ pub fn table_fields(expr: &Expr) -> Option<&[TableField]> { } } +/// 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 { diff --git a/src/types.rs b/src/types.rs index f11cab1..fc3e151 100644 --- a/src/types.rs +++ b/src/types.rs @@ -66,6 +66,8 @@ pub struct ShapeField { #[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)>, } @@ -259,7 +261,7 @@ impl fmt::Display for Type { Ok(()) } Type::Shape(shape) => { - if shape.fields.is_empty() && shape.index.is_none() { + if shape.fields.is_empty() && shape.array.is_none() && shape.index.is_none() { return f.write_str("table"); } let mut parts: Vec = shape @@ -268,6 +270,9 @@ impl fmt::Display for Type { .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}")); } diff --git a/tests/lsp.rs b/tests/lsp.rs index e9d4d25..527ddf3 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -309,6 +309,9 @@ 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 "; client.open_with(CLIENT, text); let cases = [ @@ -317,9 +320,13 @@ for name, garage in pairs(garages) do end ("item", "item: string"), ("k, v", "k: string|integer"), ("v in", "v: integer|string|boolean"), - ("element", "element: string"), + // `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"), ]; for (needle, expected) in cases { let (l, c) = pos(text, needle, 0); @@ -343,10 +350,16 @@ local function f(payload, both, t) 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"]), From bede0ab1ac1296ade8bd1e8b973b8b8496687f6e Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:46:05 +0200 Subject: [PATCH 47/57] fix(hover): show only integer-keyed tables as arrays --- src/features/hover.rs | 4 ++-- tests/lsp.rs | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/features/hover.rs b/src/features/hover.rs index 0ba770e..d0074d6 100644 --- a/src/features/hover.rs +++ b/src/features/hover.rs @@ -121,11 +121,11 @@ fn value_overview(infer: &Infer, prefix: &str, name: &str, ty: &Type, literal: O out } -/// A table the index holds with only an array part, such as `local list = { 'a', 'b' }` or +/// 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, true) { + Type::GlobalTable(_) => match infer.key_value_types(ty, false) { (Type::Integer, value) if !value.is_unknown() => Type::Array(Box::new(value)), _ => ty.clone(), }, diff --git a/tests/lsp.rs b/tests/lsp.rs index 527ddf3..bad019e 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -312,6 +312,8 @@ 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 = [ @@ -327,6 +329,8 @@ for _, looked in ipairs(lookup) do end ("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); From 09fbfde949602f4b1195e9aa952fa9b5e61989cd Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:46:23 +0200 Subject: [PATCH 48/57] fix(infer): keep known union parts when a generic is unbound --- src/infer.rs | 4 ++-- tests/lsp.rs | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/infer.rs b/src/infer.rs index 5b9504e..fcab1cd 100644 --- a/src/infer.rs +++ b/src/infer.rs @@ -1223,10 +1223,10 @@ fn substitute(ty: &Type, generics: &[(SmolStr, Type)]) -> Type { 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`. + // `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().any(Type::is_unknown) { + if parts.iter().filter(|t| !matches!(t, Type::Nil)).all(Type::is_unknown) { Type::Unknown } else { Type::union(parts) diff --git a/tests/lsp.rs b/tests/lsp.rs index bad019e..9a79b63 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -512,6 +512,11 @@ local function normalize(step) return step / 10 end +---@generic T +---@param value? T +---@return T|string +local function orName(value) end + ---@param steps { [number]: number } local function send(steps) local mapped = table.mapEntries(steps, function(step, featureId) @@ -521,6 +526,7 @@ local function send(steps) return position, letter end) local unbound = table.mapEntries(steps, function() end) + local named = orName() end "; client.open_with(CLIENT, text); @@ -532,6 +538,7 @@ end ("letter,", "letter: string"), ("position)", "position: integer"), ("unbound", "unbound: table"), + ("named =", "named: string"), // Inside the generic function its parameters stay generic. ("key, value", "key: K"), ]; From 1480087ed9557bdfb63cc707f45ef7631242a5de Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:46:46 +0200 Subject: [PATCH 49/57] fix(infer): substitute generics inside table shapes --- src/infer.rs | 5 +++++ tests/lsp.rs | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/src/infer.rs b/src/infer.rs index fcab1cd..8fa033c 100644 --- a/src/infer.rs +++ b/src/infer.rs @@ -1240,6 +1240,11 @@ fn substitute(ty: &Type, generics: &[(SmolStr, Type)]) -> Type { 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(), } } diff --git a/tests/lsp.rs b/tests/lsp.rs index 9a79b63..8bd326d 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -517,6 +517,12 @@ end ---@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) @@ -527,6 +533,7 @@ local function send(steps) end) local unbound = table.mapEntries(steps, function() end) local named = orName() + local boxed = box(1, function(opened) end) end "; client.open_with(CLIENT, text); @@ -539,6 +546,8 @@ end ("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"), ]; From 70f78e2a75ff7e27d5592c07bcc29bd4d32868d3 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:47:15 +0200 Subject: [PATCH 50/57] fix(infer): index top-level arrays with optional or untyped keys --- src/infer.rs | 4 +++- tests/lsp.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/infer.rs b/src/infer.rs index 8fa033c..b5b2c5f 100644 --- a/src/infer.rs +++ b/src/infer.rs @@ -714,7 +714,9 @@ impl<'a> Infer<'a> { 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.widen(), Type::Integer | Type::Number) => { + 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, diff --git a/tests/lsp.rs b/tests/lsp.rs index 8bd326d..36a0942 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -398,6 +398,15 @@ for k, v in pairs(TestShop.Lookup) do end 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 = [ @@ -407,6 +416,9 @@ end ("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); From ec5b7dcbc3335ba11c97a456ecce833bba1901f7 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:34:42 +0200 Subject: [PATCH 51/57] feat(lua-ls): expand FiveM resource and assistant tooling --- Cargo.lock | 10 +- Cargo.toml | 10 +- README.md | 36 + docs/framework-callbacks.md | 54 ++ docs/protocol.md | 397 +++++++++ src/features/assistant.rs | 674 +++++++++++++++ src/features/completion.rs | 75 +- src/features/definition.rs | 14 +- src/features/diagnostics.rs | 53 +- src/features/event_call.rs | 89 +- src/features/hover.rs | 103 ++- src/features/inlay.rs | 9 +- src/features/member_refs.rs | 79 +- src/features/mod.rs | 7 + src/features/native_argument.rs | 137 ++++ src/features/nui_resource.rs | 119 +++ src/features/reference.rs | 368 +++++++++ src/features/references.rs | 86 +- src/features/resource_assets.rs | 402 +++++++++ src/features/resource_details.rs | 393 +++++++++ src/features/signature.rs | 11 +- src/features/workspace_health.rs | 175 ++++ src/framework_callbacks.rs | 374 +++++++++ src/index.rs | 19 + src/indexer.rs | 90 +- src/lib.rs | 2 + src/nui_callbacks.rs | 36 + src/server.rs | 65 +- .../framework_callbacks/adapters/client.lua | 9 + .../adapters/fxmanifest.lua | 6 + .../adapters/server/esx.lua | 17 + .../adapters/server/other.lua | 12 + .../adapters/server/qb.lua | 17 + .../framework_callbacks/adapters/shared.lua | 8 + .../imported_esx/client.lua | 1 + .../imported_esx/fxmanifest.lua | 5 + .../imported_esx/server.lua | 3 + .../framework_callbacks/ox_lib/fxmanifest.lua | 3 + .../framework_callbacks/ox_lib/init.lua | 8 + tests/lsp.rs | 767 ++++++++++++++++++ tests/nui_resource.rs | 314 +++++++ tests/resource_details.rs | 392 +++++++++ tests/structured_tools.rs | 374 +++++++++ tests/workspace_health.rs | 301 +++++++ tests/workspace_refresh.rs | 4 +- 45 files changed, 6013 insertions(+), 115 deletions(-) create mode 100644 docs/framework-callbacks.md create mode 100644 src/features/assistant.rs create mode 100644 src/features/native_argument.rs create mode 100644 src/features/nui_resource.rs create mode 100644 src/features/reference.rs create mode 100644 src/features/resource_assets.rs create mode 100644 src/features/resource_details.rs create mode 100644 src/features/workspace_health.rs create mode 100644 src/framework_callbacks.rs create mode 100644 src/nui_callbacks.rs create mode 100644 tests/fixtures/framework_callbacks/adapters/client.lua create mode 100644 tests/fixtures/framework_callbacks/adapters/fxmanifest.lua create mode 100644 tests/fixtures/framework_callbacks/adapters/server/esx.lua create mode 100644 tests/fixtures/framework_callbacks/adapters/server/other.lua create mode 100644 tests/fixtures/framework_callbacks/adapters/server/qb.lua create mode 100644 tests/fixtures/framework_callbacks/adapters/shared.lua create mode 100644 tests/fixtures/framework_callbacks/imported_esx/client.lua create mode 100644 tests/fixtures/framework_callbacks/imported_esx/fxmanifest.lua create mode 100644 tests/fixtures/framework_callbacks/imported_esx/server.lua create mode 100644 tests/fixtures/framework_callbacks/ox_lib/fxmanifest.lua create mode 100644 tests/fixtures/framework_callbacks/ox_lib/init.lua create mode 100644 tests/nui_resource.rs create mode 100644 tests/resource_details.rs create mode 100644 tests/structured_tools.rs create mode 100644 tests/workspace_health.rs diff --git a/Cargo.lock b/Cargo.lock index a3d394e..e42cdc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -340,11 +340,11 @@ dependencies = [ [[package]] name = "qbx_fivem_data" -version = "1.0.3" +version = "1.0.4" [[package]] name = "qbx_lua_analysis" -version = "1.0.3" +version = "1.0.4" dependencies = [ "globset", "ignore", @@ -362,7 +362,7 @@ dependencies = [ [[package]] name = "qbx_lua_fmt" -version = "1.0.3" +version = "1.0.4" dependencies = [ "qbx_lua_syntax", "serde", @@ -370,7 +370,7 @@ dependencies = [ [[package]] name = "qbx_lua_ls" -version = "1.0.3" +version = "1.0.4" dependencies = [ "crossbeam-channel", "lsp-server", @@ -388,7 +388,7 @@ dependencies = [ [[package]] name = "qbx_lua_syntax" -version = "1.0.3" +version = "1.0.4" dependencies = [ "smol_str", ] diff --git a/Cargo.toml b/Cargo.toml index 470804d..c23af6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "qbx_lua_ls" description = "Language server for FiveM Lua" -version = "1.0.3" +version = "1.0.4" edition = "2021" license = "GPL-3.0-or-later" repository = "https://github.com/Qbox-project/qbx-lua-ls" @@ -12,10 +12,10 @@ path = "src/main.rs" [dependencies] # Keep qbx-lint beside this checkout. Release builds use the same tag in both repositories. -qbx_lua_syntax = { version = "=1.0.3", path = "../qbx-lint/crates/qbx_lua_syntax" } -qbx_lua_fmt = { version = "=1.0.3", path = "../qbx-lint/crates/qbx_lua_fmt" } -qbx_lua_analysis = { version = "=1.0.3", path = "../qbx-lint/crates/qbx_lua_analysis", features = ["docs"] } -qbx_fivem_data = { version = "=1.0.3", path = "../qbx-lint/crates/qbx_fivem_data", features = ["docs"] } +qbx_lua_syntax = { version = "=1.0.4", path = "../qbx-lint/crates/qbx_lua_syntax" } +qbx_lua_fmt = { version = "=1.0.4", path = "../qbx-lint/crates/qbx_lua_fmt" } +qbx_lua_analysis = { version = "=1.0.4", path = "../qbx-lint/crates/qbx_lua_analysis", features = ["docs"] } +qbx_fivem_data = { version = "=1.0.4", path = "../qbx-lint/crates/qbx_fivem_data", features = ["docs"] } lsp-server = "0.7" lsp-types = "0.95" serde = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index f8debb9..c5e5511 100644 --- a/README.md +++ b/README.md @@ -12,17 +12,53 @@ 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-lua-ls/releases). diff --git a/docs/framework-callbacks.md b/docs/framework-callbacks.md new file mode 100644 index 0000000..90eb0ff --- /dev/null +++ b/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/docs/protocol.md b/docs/protocol.md index d075848..b75adf9 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -84,8 +84,405 @@ standard LSP requests. | `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/src/features/assistant.rs b/src/features/assistant.rs new file mode 100644 index 0000000..fedcbb1 --- /dev/null +++ b/src/features/assistant.rs @@ -0,0 +1,674 @@ +//! 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()); + } + pub fn omitted(&self) -> usize { + self.omitted.len() + } + 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 file was skipped, do not infer unused keys from incomplete usage information. + if requested.is_none() && skipped == 0 && budget.omitted() == 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/src/features/completion.rs b/src/features/completion.rs index daad783..24634c2 100644 --- a/src/features/completion.rs +++ b/src/features/completion.rs @@ -8,12 +8,13 @@ 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::FxHashSet; +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::{EventKind, FileOrigin, SymbolKind}; +use crate::index::{EventFamily, EventKind, FileOrigin, SymbolKind}; use crate::infer::{Infer, MemberInfo}; use crate::locate::{locate, string_content_span}; use crate::types::Type; @@ -94,17 +95,6 @@ const PRIMITIVE_TYPES: &[&str] = &[ "table", ]; -const EVENT_NAME_CALLS: &[&str] = &[ - "TriggerEvent", - "TriggerServerEvent", - "TriggerClientEvent", - "TriggerLatentServerEvent", - "TriggerLatentClientEvent", - "AddEventHandler", - "RegisterNetEvent", - "RegisterServerEvent", -]; -const CALLBACK_NAME_CALLS: &[&str] = &["lib.callback", "lib.callback.await", "lib.callback.register"]; const REQUIRE_CALLS: &[&str] = &["require", "lib.require", "lib.load"]; const RESOURCE_NAME_CALLS: &[&str] = &[ "GetResourceState", @@ -352,39 +342,42 @@ fn string_items(ws: &Workspace, doc: &Document, offset: u32, token_index: usize) ExprKind::Call { callee, .. } => callee.dotted_path(), _ => None, }; - let Some(path) = path else { return Vec::new() }; - let path = path.as_str(); - - if arg_index == 0 && (EVENT_NAME_CALLS.contains(&path) || CALLBACK_NAME_CALLS.contains(&path)) { + 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 = CALLBACK_NAME_CALLS.contains(&path); - 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(call.span.start, own_side); - // Where the handler has to live for this call to reach it. - let target_side = match path { - "TriggerServerEvent" | "TriggerLatentServerEvent" => Some(Side::Server), - "TriggerClientEvent" | "TriggerLatentClientEvent" => Some(Side::Client), - "TriggerEvent" => own_side, - "lib.callback" | "lib.callback.await" => match own_side { - Some(Side::Client) => Some(Side::Server), - Some(Side::Server) => Some(Side::Client), - _ => None, - }, - _ => None, - }; + 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); @@ -394,13 +387,15 @@ fn string_items(ws: &Workspace, doc: &Document, offset: u32, token_index: usize) 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 = match (&event.handler, origin) { - (Some(handler), Some(resource)) => { - Some(format!("{}{side} · {}", resource.name, handler.signature(""))) + 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, } - (None, Some(resource)) => Some(format!("{}{side}", resource.name)), - (Some(handler), None) => Some(handler.signature("")), - (None, None) => None, }; out }) @@ -408,6 +403,8 @@ fn string_items(ws: &Workspace, doc: &Document, offset: u32, token_index: usize) }; 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); } diff --git a/src/features/definition.rs b/src/features/definition.rs index bb90b64..e9942b9 100644 --- a/src/features/definition.rs +++ b/src/features/definition.rs @@ -1,10 +1,11 @@ use lsp_types::{GotoDefinitionResponse, Location, Position, Range}; use qbx_lua_syntax::ast::ExprKind; -use super::hover::{target_at, Target}; +use super::hover::{event_string_context, target_at, Target}; use super::with_infer; use crate::document::Document; -use crate::index::{EventKind, FileId, FileOrigin}; +use crate::index::{EventFamily, EventKind, FileId, FileOrigin}; +use crate::infer::Infer; use crate::locate::locate; use crate::workspace::Workspace; @@ -15,7 +16,7 @@ fn location(ws: &Workspace, file: FileId, range: Range) -> Option { Some(Location::new(entry.uri.clone(), range)) } -fn string_definition(ws: &Workspace, doc: &Document, offset: u32) -> Vec { +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() }; @@ -46,9 +47,14 @@ fn string_definition(ws: &Workspace, doc: &Document, offset: u32) -> Vec context.accepts_registration(event), + None => matches!(event.family, EventFamily::Native | EventFamily::OxLib), + }) .filter_map(|(file, event)| location(ws, file, event.range)) .collect() } @@ -74,7 +80,7 @@ pub fn definition(ws: &Workspace, doc: &Document, position: Position) -> Option< 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, doc, offset), + None => string_definition(ws, infer, doc, offset), }); (!locations.is_empty()).then_some(GotoDefinitionResponse::Array(locations)) } diff --git a/src/features/diagnostics.rs b/src/features/diagnostics.rs index b7d95e4..c03f300 100644 --- a/src/features/diagnostics.rs +++ b/src/features/diagnostics.rs @@ -27,6 +27,24 @@ pub fn diagnostics( 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) { @@ -36,6 +54,13 @@ pub fn diagnostics( 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)); @@ -46,12 +71,13 @@ pub fn diagnostics( 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: &all_files(&resource.root), + resource_files: support.map_or(files.as_slice(), |support| support.resource_files), has_lua_scripts: !resource.files.is_empty(), }) } else { @@ -63,20 +89,30 @@ pub fn diagnostics( config.set(qbx_lua_analysis::rules::UNDEFINED_GLOBAL, Level::Off); } let env = resource_id.map(|id| ws.resource_env(id)); - let start_order = resource.and_then(|r| qbx_lua_analysis::startup::StartOrder::discover(&r.root)); - let started_before = resource.zip(start_order.as_ref()).map(|(r, order)| order.started_before(&r.name)); + 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.as_ref().map(|order| &order.installed), + 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, @@ -87,19 +123,22 @@ pub fn diagnostics( side: entry.and_then(|f| f.side), resource: resource_input, crossrefs: Some(crossrefs), - locale: resource.and_then(|r| qbx_lua_analysis::locale::LocaleFile::load(&r.root)).as_ref(), + 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| (doc.range(e.span), e.new_text.clone())).collect(), + edits: fix.edits.iter().map(|e| (range(e.span), e.new_text.clone())).collect(), }); Diagnostic { - range: doc.range(d.span), + range: range(d.span), severity: Some(match d.severity { Severity::Error => DiagnosticSeverity::ERROR, Severity::Warning => DiagnosticSeverity::WARNING, diff --git a/src/features/event_call.rs b/src/features/event_call.rs index 399913c..75ec5bb 100644 --- a/src/features/event_call.rs +++ b/src/features/event_call.rs @@ -3,7 +3,9 @@ use qbx_lua_analysis::crossref::trigger_target; use qbx_lua_syntax::ast::Expr; use crate::document::Document; -use crate::index::EventKind; +use crate::framework_callbacks; +use crate::index::{EventFamily, EventKind}; +use crate::infer::Infer; use crate::types::{FunType, Param, Type}; use crate::workspace::Workspace; @@ -25,27 +27,56 @@ fn callback_skip(call: &str) -> Option { /// 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, callee: &Expr, args: &[Expr], native: &FunType) -> Option { +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) = match trigger_target(&call, own_side) { - Some((target, skip)) => (target, skip, [EventKind::NetEvent, EventKind::Handler]), - None => { - let target = own_side.map(|side| if side == Side::Server { Side::Client } else { Side::Server }); - (target, callback_skip(&call)?, [EventKind::Callback, EventKind::Callback]) + 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 && wanted.contains(&e.kind) && e.handler.is_some()) + .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 (file, event) = candidates.max_by_key( + 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()?; @@ -53,20 +84,50 @@ pub fn event_call(ws: &Workspace, doc: &Document, callee: &Expr, args: &[Expr], // Server callbacks receive the calling player as their first parameter. let is_callback = event.kind == EventKind::Callback; - let drops_source = is_callback && event.side != Some(Side::Client) && !handler.params.is_empty(); + 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.params.iter().take(skip).cloned().collect(); + let mut params: Vec = native.into_iter().flat_map(|fun| fun.params.iter().take(skip).cloned()).collect(); while params.len() < skip { - params.push(Param { name: format!("arg{}", params.len() + 1).into(), ty: Type::Unknown, optional: false }); + 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(usize::from(drops_source)).cloned()); + 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 is_callback { handler.returns.clone() } else { Vec::new() }, + 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(), diff --git a/src/features/hover.rs b/src/features/hover.rs index d0074d6..1455e7a 100644 --- a/src/features/hover.rs +++ b/src/features/hover.rs @@ -1,12 +1,12 @@ use lsp_types::{Hover, HoverContents, Position}; -use qbx_fivem_data::{native, native_docs}; +use qbx_fivem_data::{native, native_docs, Side}; use qbx_lua_analysis::scope::{LocalId, LocalKind, Resolved}; -use qbx_lua_syntax::ast::ExprKind; +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, FileId, FileOrigin, SymbolKind}; +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; @@ -321,7 +321,74 @@ fn type_hover(infer: &Infer, name: &str) -> Option { Some(out) } -fn string_hover(ws: &Workspace, doc: &Document, offset: u32) -> Option<(String, Span)> { +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 }; @@ -336,26 +403,44 @@ fn string_hover(ws: &Workspace, doc: &Document, offset: u32) -> Option<(String, let text = locale.text_of(value)?; return Some((format!("`{value}` · locales/{file}\n\n{text}"), string.span)); } - let registrations: Vec<_> = - ws.index.events().filter(|(_, e)| e.name == *value && e.kind != crate::index::EventKind::Trigger).collect(); + 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 mut out = format!("event `{value}`"); + 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.as_ref().map(|h| format!(" `{}`", h.signature(""))).unwrap_or_default(); + 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 string_hover(ws, doc, offset) }; + 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), diff --git a/src/features/inlay.rs b/src/features/inlay.rs index 4434304..a5e731f 100644 --- a/src/features/inlay.rs +++ b/src/features/inlay.rs @@ -33,9 +33,12 @@ impl Hints<'_, '_> { if self.out.len() >= MAX_HINTS || !args.iter().any(is_literal) { return; } - let Some((fun, _)) = self.infer.callee_fun(base, method) else { return }; - let event = method.is_none().then(|| event_call(self.ws, self.doc, base, args, &fun)).flatten(); - let fun = event.map_or(fun, |e| e.fun.into()); + 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) diff --git a/src/features/member_refs.rs b/src/features/member_refs.rs index 7d0703c..c2ab12c 100644 --- a/src/features/member_refs.rs +++ b/src/features/member_refs.rs @@ -131,21 +131,30 @@ struct Finder<'a, 'b> { 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.doc.range(span)); + 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 { @@ -169,6 +178,9 @@ impl Finder<'_, '_> { 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) { @@ -201,6 +213,9 @@ impl<'ast> Visitor<'ast> for Finder<'_, '_> { } 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 => @@ -221,10 +236,23 @@ impl<'ast> Visitor<'ast> for Finder<'_, '_> { } 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() }; + 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)); @@ -233,6 +261,53 @@ fn occurrences_in(ws: &Workspace, doc: &Document, target: &MemberTarget) -> Opti }) } +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( diff --git a/src/features/mod.rs b/src/features/mod.rs index a2f8df6..b5aa5b6 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -1,3 +1,4 @@ +pub mod assistant; pub mod code_action; pub mod completion; pub mod definition; @@ -7,10 +8,16 @@ 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}; diff --git a/src/features/native_argument.rs b/src/features/native_argument.rs new file mode 100644 index 0000000..eb6a1f5 --- /dev/null +++ b/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/src/features/nui_resource.rs b/src/features/nui_resource.rs new file mode 100644 index 0000000..cb44832 --- /dev/null +++ b/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/src/features/reference.rs b/src/features/reference.rs new file mode 100644 index 0000000..d56a148 --- /dev/null +++ b/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/src/features/references.rs b/src/features/references.rs index e3518c1..45ab9bc 100644 --- a/src/features/references.rs +++ b/src/features/references.rs @@ -7,6 +7,7 @@ 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; @@ -44,6 +45,16 @@ fn global_occurrences( 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() { @@ -51,22 +62,49 @@ fn global_occurrences( continue; } let mentions = |text: &str| text.contains(name); - if let Some(open) = docs.get(&entry.uri) { + 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) { - out.push((entry.uri.clone(), open.range(global.span), global.kind != GlobalRefKind::Read)); + 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 Ok(source) = read_source(&entry.path) else { 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(), - span_to_range(&source, &lines, global.span), + positions.as_ref().map_or_else( + || span_to_range(&source, &lines, global.span), + |positions| positions.range(global.span), + ), global.kind != GlobalRefKind::Read, )); } @@ -74,6 +112,46 @@ fn global_occurrences( 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, diff --git a/src/features/resource_assets.rs b/src/features/resource_assets.rs new file mode 100644 index 0000000..53a3788 --- /dev/null +++ b/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/src/features/resource_details.rs b/src/features/resource_details.rs new file mode 100644 index 0000000..5bfbfd1 --- /dev/null +++ b/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/src/features/signature.rs b/src/features/signature.rs index c6a5d93..5dfde99 100644 --- a/src/features/signature.rs +++ b/src/features/signature.rs @@ -13,9 +13,14 @@ pub fn signature_help(ws: &Workspace, doc: &Document, position: Position) -> Opt let located = locate(&doc.chunk, offset); let site = located.call?; with_infer(ws, doc, |infer| { - let (fun, member) = infer.callee_fun(site.base, site.method)?; - let event = site.method.is_none().then(|| event_call(ws, doc, site.base, site.args, &fun)).flatten(); - let fun = event.as_ref().map_or(fun, |e| e.fun.clone().into()); + 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(); diff --git a/src/features/workspace_health.rs b/src/features/workspace_health.rs new file mode 100644 index 0000000..de65d87 --- /dev/null +++ b/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/src/framework_callbacks.rs b/src/framework_callbacks.rs new file mode 100644 index 0000000..a4ba8d2 --- /dev/null +++ b/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/src/index.rs b/src/index.rs index 2f23a93..59500ab 100644 --- a/src/index.rs +++ b/src/index.rs @@ -79,16 +79,33 @@ pub enum EventKind { 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, @@ -98,6 +115,8 @@ pub struct FileIndex { 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, diff --git a/src/indexer.rs b/src/indexer.rs index 75aebe7..6c8a858 100644 --- a/src/indexer.rs +++ b/src/indexer.rs @@ -10,7 +10,8 @@ use qbx_lua_syntax::ast::*; use qbx_lua_syntax::{Comment, LineIndex, SmolStr, Span}; use crate::index::{ - AliasDef, ClassDef, Element, EventDef, EventKind, FileId, FileIndex, Index, Member, Symbol, SymbolKind, + 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}; @@ -65,6 +66,7 @@ pub fn index_file( infer: &infer, side, regions: SideRegions::of(source, chunk), + nui_globals: crate::nui_callbacks::NuiGlobals::of(&ctx), out: FileIndex::default(), depth: 0, }; @@ -86,6 +88,7 @@ struct Indexer<'a> { infer: &'a Infer<'a>, side: Option, regions: SideRegions, + nui_globals: crate::nui_callbacks::NuiGlobals, out: FileIndex, depth: u32, } @@ -609,39 +612,92 @@ impl<'a> Indexer<'a> { self.push_member(owner, symbol); } - fn event(&mut self, path: &str, args: &[Expr], offset: u32) { - let kind = if NET_EVENT_CALLS.contains(&path) { - EventKind::NetEvent - } else if HANDLER_CALLS.contains(&path) { - EventKind::Handler - } else if CALLBACK_CALLS.contains(&path) { - EventKind::Callback - } else if TRIGGER_CALLS.contains(&path) { - EventKind::Trigger + 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 { - return; + 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 = args.iter().skip(1).find_map(|arg| match &arg.kind { - ExprKind::Function(func) => Some(Arc::new(self.infer.fun_type(func, None, false))), - _ => None, - }); + 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, - side: self.regions.effective(offset, self.side), + 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() { - self.event(&path, args, expr.span.start); 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)) { diff --git a/src/lib.rs b/src/lib.rs index c6eb38c..d2c8201 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +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/src/nui_callbacks.rs b/src/nui_callbacks.rs new file mode 100644 index 0000000..d87adc1 --- /dev/null +++ b/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/src/server.rs b/src/server.rs index c72cbe3..f86d8dc 100644 --- a/src/server.rs +++ b/src/server.rs @@ -13,8 +13,8 @@ use serde_json::{json, Value}; use crate::document::Document; use crate::features::{ - code_action, completion, definition, diagnostics, folding, hover, inlay, references, semantic_tokens, signature, - symbols, + 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}; @@ -691,6 +691,67 @@ impl Server { "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(); diff --git a/tests/fixtures/framework_callbacks/adapters/client.lua b/tests/fixtures/framework_callbacks/adapters/client.lua new file mode 100644 index 0000000..0aa0335 --- /dev/null +++ b/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/tests/fixtures/framework_callbacks/adapters/fxmanifest.lua b/tests/fixtures/framework_callbacks/adapters/fxmanifest.lua new file mode 100644 index 0000000..fccd618 --- /dev/null +++ b/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/tests/fixtures/framework_callbacks/adapters/server/esx.lua b/tests/fixtures/framework_callbacks/adapters/server/esx.lua new file mode 100644 index 0000000..fe1aad8 --- /dev/null +++ b/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/tests/fixtures/framework_callbacks/adapters/server/other.lua b/tests/fixtures/framework_callbacks/adapters/server/other.lua new file mode 100644 index 0000000..1d00bde --- /dev/null +++ b/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/tests/fixtures/framework_callbacks/adapters/server/qb.lua b/tests/fixtures/framework_callbacks/adapters/server/qb.lua new file mode 100644 index 0000000..5697308 --- /dev/null +++ b/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/tests/fixtures/framework_callbacks/adapters/shared.lua b/tests/fixtures/framework_callbacks/adapters/shared.lua new file mode 100644 index 0000000..0253644 --- /dev/null +++ b/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/tests/fixtures/framework_callbacks/imported_esx/client.lua b/tests/fixtures/framework_callbacks/imported_esx/client.lua new file mode 100644 index 0000000..b0873d8 --- /dev/null +++ b/tests/fixtures/framework_callbacks/imported_esx/client.lua @@ -0,0 +1 @@ +ESX.TriggerServerCallback('esx:imported', function(result) print(result) end, 'imported') diff --git a/tests/fixtures/framework_callbacks/imported_esx/fxmanifest.lua b/tests/fixtures/framework_callbacks/imported_esx/fxmanifest.lua new file mode 100644 index 0000000..8561934 --- /dev/null +++ b/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/tests/fixtures/framework_callbacks/imported_esx/server.lua b/tests/fixtures/framework_callbacks/imported_esx/server.lua new file mode 100644 index 0000000..f23e785 --- /dev/null +++ b/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/tests/fixtures/framework_callbacks/ox_lib/fxmanifest.lua b/tests/fixtures/framework_callbacks/ox_lib/fxmanifest.lua new file mode 100644 index 0000000..44fa629 --- /dev/null +++ b/tests/fixtures/framework_callbacks/ox_lib/fxmanifest.lua @@ -0,0 +1,3 @@ +fx_version 'cerulean' +game 'gta5' +shared_script 'init.lua' diff --git a/tests/fixtures/framework_callbacks/ox_lib/init.lua b/tests/fixtures/framework_callbacks/ox_lib/init.lua new file mode 100644 index 0000000..df1f5ab --- /dev/null +++ b/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/tests/lsp.rs b/tests/lsp.rs index 36a0942..f8dc5fe 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -104,6 +104,25 @@ impl Client { } } + 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() } @@ -187,6 +206,207 @@ fn pos(text: &str, needle: &str, delta: u32) -> (u32, u32) { 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()); @@ -238,6 +458,235 @@ fn hover_shows_types_docs_and_natives() { 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()); @@ -1695,3 +2144,321 @@ fn escrow_encrypted_files_are_ignored() { 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/tests/nui_resource.rs b/tests/nui_resource.rs new file mode 100644 index 0000000..3ce7947 --- /dev/null +++ b/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/tests/resource_details.rs b/tests/resource_details.rs new file mode 100644 index 0000000..8ff2df1 --- /dev/null +++ b/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/tests/structured_tools.rs b/tests/structured_tools.rs new file mode 100644 index 0000000..7c61486 --- /dev/null +++ b/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/tests/workspace_health.rs b/tests/workspace_health.rs new file mode 100644 index 0000000..8a8a966 --- /dev/null +++ b/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/tests/workspace_refresh.rs b/tests/workspace_refresh.rs index adf4bfc..604abaa 100644 --- a/tests/workspace_refresh.rs +++ b/tests/workspace_refresh.rs @@ -111,7 +111,9 @@ fn labels(ws: &Workspace, doc: &Document) -> Vec { 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 = event_call::event_call(ws, doc, site.base, site.args, &FunType::default())?; + 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()) } From 450c6f36f4bf67fea0eb0f226fd08f1f2ea940de Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:59:06 +0000 Subject: [PATCH 52/57] fix(workspace): record the resource that registers each event handler --- src/workspace.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workspace.rs b/src/workspace.rs index 86c93d6..9acb93d 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -326,7 +326,7 @@ impl Workspace { } 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)); + 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 { From 123a621856049dd9e40d11dfb5cfddc999dc7be9 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:59:06 +0000 Subject: [PATCH 53/57] fix(assistant): keep locale checks when only supporting files were skipped --- src/features/assistant.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/features/assistant.rs b/src/features/assistant.rs index fedcbb1..2ece727 100644 --- a/src/features/assistant.rs +++ b/src/features/assistant.rs @@ -45,9 +45,6 @@ impl InspectionBudget { pub fn skip(&mut self, path: &Path) { self.omitted.insert(path.to_path_buf()); } - pub fn omitted(&self) -> usize { - self.omitted.len() - } fn contains(&self, path: &Path) -> bool { std::fs::canonicalize(path).ok().is_some_and(|path| self.roots.iter().any(|root| path.starts_with(root))) } @@ -548,8 +545,10 @@ pub fn diagnostic_snapshot( } } // Whole-workspace queries include the same unused locale-key checks as the Problems panel. - // If any file was skipped, do not infer unused keys from incomplete usage information. - if requested.is_none() && skipped == 0 && budget.omitted() == 0 && items.len() < MAX_RESULTS { + // 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 { From 11d4e0511959820b1e2dba47de302ec44ba17142 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:59:06 +0000 Subject: [PATCH 54/57] feat(cli): print usage for --help --- src/main.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main.rs b/src/main.rs index 0337bd6..c923544 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,13 @@ fn main() { 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; From d9a6ae5e77f4bbfb8621498ea48f5fc5cda9d20c Mon Sep 17 00:00:00 2001 From: Loaf <60279520+loaf-scripts@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:31:38 +0200 Subject: [PATCH 55/57] feat(workspace): follow imports configured in qbxlint.toml --- docs/protocol.md | 4 ++- src/server.rs | 13 +++++---- src/workspace.rs | 57 ++++++++++++++++++++------------------ tests/workspace_refresh.rs | 35 +++++++++++++++++++++++ 4 files changed, 76 insertions(+), 33 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index b75adf9..c4b9d5d 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -55,7 +55,9 @@ 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` changes apply once the client reports the saved config file. Files added to +`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 diff --git a/src/server.rs b/src/server.rs index f86d8dc..88d1d88 100644 --- a/src/server.rs +++ b/src/server.rs @@ -531,7 +531,9 @@ impl Server { } fn watched_files_changed(&mut self, changes: Vec) { - let mut manifests_changed = false; + // 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 }; @@ -543,22 +545,23 @@ impl Server { // A manifest appearing or vanishing changes which resources count as installed. qbx_lua_analysis::startup::clear_cache(); self.ws.reload_manifest(&path); - manifests_changed = true; + relink = true; } else if change.typ == FileChangeType::DELETED { self.ws.index.remove_file(&path); } else if path.extension().is_some_and(|e| e == "lua") && !self.docs.contains_key(&change.uri) { self.ws.index_path(&path, FileOrigin::Workspace, None); + relink |= change.typ == FileChangeType::CREATED; } } - if manifests_changed { - self.ws.link_imports(); - } 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()); } diff --git a/src/workspace.rs b/src/workspace.rs index 9acb93d..ac35d16 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -1,18 +1,19 @@ use std::path::{Path, PathBuf}; use lsp_types::Url; -use qbx_fivem_data::{known_import, Side, STUBS}; +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, side_of, - split_import, ResourceEnv, ResourceLocator, UnresolvedImport, + 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::{FileEntry, FileId, FileOrigin, Index, ResourceEntry, ResourceId}; +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; @@ -129,7 +130,9 @@ impl Workspace { fn index_dependencies(&mut self) -> usize { let mut wanted: Vec<(PathBuf, SmolStr)> = Vec::new(); for resource in &self.index.resources { - let imports = resource.manifest.imports().filter_map(|s| split_import(&s.pattern)).map(|(name, _)| name); + 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('/')))); @@ -257,18 +260,29 @@ impl Workspace { pub fn link_imports(&mut self) { for id in 0..self.index.resources.len() { - let mut imports = Vec::new(); - for script in self.index.resources[id].manifest.imports() { - let Some((resource, file)) = split_import(&script.pattern) else { continue }; - let Some((_, target)) = self.index.resource_by_name(resource) else { continue }; - if let Some(file_id) = self.index.file_id(&target.root.join(file)) { - imports.push((file_id, script.side)); - } - } + 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(); @@ -277,20 +291,9 @@ impl Workspace { for file in entry.files.iter().filter_map(|id| self.index.file(*id)) { env.add_summary(&file.index.summary, file.side); } - for script in entry.manifest.imports().filter(|s| s.pattern.ends_with(".lua")) { - let resolved = split_import(&script.pattern) - .and_then(|(name, file)| Some(self.index.resource_by_name(name)?.1.root.join(file))) - .and_then(|path| self.index.file(self.index.file_id(&path)?)); - if let Some(file) = resolved { - env.add_summary(&file.index.summary, Some(script.side)); - } - match known_import(&script.pattern) { - Some(known) => known.globals.iter().for_each(|g| env.add_global(&SmolStr::new(g), Some(script.side))), - None if resolved.is_none() => { - env.unresolved_imports.push(UnresolvedImport { path: script.pattern.clone(), side: script.side }) - } - None => {} - } + 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 } diff --git a/tests/workspace_refresh.rs b/tests/workspace_refresh.rs index 604abaa..7883754 100644 --- a/tests/workspace_refresh.rs +++ b/tests/workspace_refresh.rs @@ -176,6 +176,41 @@ fn scan_reloads_manifests_and_removes_deleted_and_excluded_state() { 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(); From 39579725b52266a9de03bc38819d588bab2fc0e0 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:20:22 +0200 Subject: [PATCH 56/57] fix(workspace): relink imports when open files enter the index --- src/server.rs | 11 +++++++-- tests/workspace_refresh.rs | 50 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/server.rs b/src/server.rs index 88d1d88..159a5d0 100644 --- a/src/server.rs +++ b/src/server.rs @@ -304,17 +304,22 @@ impl Server { /// 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) { @@ -548,8 +553,10 @@ impl Server { relink = true; } else if change.typ == FileChangeType::DELETED { self.ws.index.remove_file(&path); - } else if path.extension().is_some_and(|e| e == "lua") && !self.docs.contains_key(&change.uri) { - self.ws.index_path(&path, FileOrigin::Workspace, None); + } 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; } } diff --git a/tests/workspace_refresh.rs b/tests/workspace_refresh.rs index 7883754..c69fa03 100644 --- a/tests/workspace_refresh.rs +++ b/tests/workspace_refresh.rs @@ -297,6 +297,56 @@ impl Drop for Client { } } +#[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(); From 11a207d6d1208bc8011d1be00c5d689ab840a1b7 Mon Sep 17 00:00:00 2001 From: ChatDisabled <44729807+ChatDisabled@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:00:54 +0200 Subject: [PATCH 57/57] chore(workspace): unify the linter and language server --- .gitattributes | 1 + .github/workflows/release.yml | 47 +- .gitignore | 2 + CONTRIBUTING.md | 17 +- Cargo.lock | 71 ++ Cargo.toml | 6 + README.md | 14 +- crates/qbx_lua_ls/.editorconfig | 15 - crates/qbx_lua_ls/.gitattributes | 3 - .../.github/ISSUE_TEMPLATE/bug_report.yml | 30 - .../ISSUE_TEMPLATE/feature_request.yml | 16 - .../.github/PULL_REQUEST_TEMPLATE.md | 7 - crates/qbx_lua_ls/.github/workflows/ci.yml | 38 - .../.github/workflows/discord-commit.yml | 12 - .../.github/workflows/discord-release.yml | 12 - .../qbx_lua_ls/.github/workflows/release.yml | 69 -- crates/qbx_lua_ls/.gitignore | 7 - crates/qbx_lua_ls/CONTRIBUTING.md | 32 +- crates/qbx_lua_ls/Cargo.lock | 770 ------------------ crates/qbx_lua_ls/Cargo.toml | 27 +- crates/qbx_lua_ls/README.md | 13 +- crates/qbx_lua_ls/rustfmt.toml | 2 - crates/qbx_lua_ls/scripts/bench.mjs | 5 +- crates/qbx_lua_ls/scripts/stress.mjs | 5 +- docs/repository-migration.md | 61 ++ 25 files changed, 232 insertions(+), 1050 deletions(-) delete mode 100644 crates/qbx_lua_ls/.editorconfig delete mode 100644 crates/qbx_lua_ls/.gitattributes delete mode 100644 crates/qbx_lua_ls/.github/ISSUE_TEMPLATE/bug_report.yml delete mode 100644 crates/qbx_lua_ls/.github/ISSUE_TEMPLATE/feature_request.yml delete mode 100644 crates/qbx_lua_ls/.github/PULL_REQUEST_TEMPLATE.md delete mode 100644 crates/qbx_lua_ls/.github/workflows/ci.yml delete mode 100644 crates/qbx_lua_ls/.github/workflows/discord-commit.yml delete mode 100644 crates/qbx_lua_ls/.github/workflows/discord-release.yml delete mode 100644 crates/qbx_lua_ls/.github/workflows/release.yml delete mode 100644 crates/qbx_lua_ls/.gitignore delete mode 100644 crates/qbx_lua_ls/Cargo.lock delete mode 100644 crates/qbx_lua_ls/rustfmt.toml create mode 100644 docs/repository-migration.md 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/.editorconfig b/crates/qbx_lua_ls/.editorconfig deleted file mode 100644 index 03ae7fb..0000000 --- a/crates/qbx_lua_ls/.editorconfig +++ /dev/null @@ -1,15 +0,0 @@ -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -indent_style = space -indent_size = 4 -trim_trailing_whitespace = true - -[*.{json,yml,yaml}] -indent_size = 2 - -[*.md] -trim_trailing_whitespace = false diff --git a/crates/qbx_lua_ls/.gitattributes b/crates/qbx_lua_ls/.gitattributes deleted file mode 100644 index e6a5221..0000000 --- a/crates/qbx_lua_ls/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ -* text=auto eol=lf -*.lua text eol=lf -**/escrowed.lua binary diff --git a/crates/qbx_lua_ls/.github/ISSUE_TEMPLATE/bug_report.yml b/crates/qbx_lua_ls/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index 36d33b1..0000000 --- a/crates/qbx_lua_ls/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Bug report -description: Report a problem with the tool. -body: - - type: textarea - id: environment - attributes: - label: Version and environment - description: Include the tool version, operating system, and editor if relevant. - validations: - required: true - - type: textarea - id: reproduce - attributes: - label: How to reproduce - description: Include a small Lua example, relevant manifest entries, and the command or editor action. - validations: - required: true - - type: textarea - id: expected - attributes: - label: Expected result - validations: - required: true - - type: textarea - id: actual - attributes: - label: Actual result - description: Include the error message or relevant output. Remove secrets and private server details. - validations: - required: true diff --git a/crates/qbx_lua_ls/.github/ISSUE_TEMPLATE/feature_request.yml b/crates/qbx_lua_ls/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 0c8ae2c..0000000 --- a/crates/qbx_lua_ls/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Feature request -description: Suggest a change or a new feature. -body: - - type: textarea - id: problem - attributes: - label: What are you trying to do? - validations: - required: true - - type: textarea - id: suggestion - attributes: - label: What would help? - description: Describe the change, with an example if possible. - validations: - required: true diff --git a/crates/qbx_lua_ls/.github/PULL_REQUEST_TEMPLATE.md b/crates/qbx_lua_ls/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 8b55dc6..0000000 --- a/crates/qbx_lua_ls/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,7 +0,0 @@ -## Changes - -Describe what changed and why. Link the related issue, if there is one. - -## Checks - -List the tests or manual checks you ran, and any checks you could not run. diff --git a/crates/qbx_lua_ls/.github/workflows/ci.yml b/crates/qbx_lua_ls/.github/workflows/ci.yml deleted file mode 100644 index 6f7e78c..0000000 --- a/crates/qbx_lua_ls/.github/workflows/ci.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: CI -on: - push: - branches: [main] - pull_request: - -permissions: - contents: read - -jobs: - test: - strategy: - matrix: - os: [ubuntu-latest, windows-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - with: - path: qbx-lua-ls - - uses: actions/checkout@v4 - with: - repository: Qbox-project/qbx-lint - path: qbx-lint - - name: Install Rust - run: rustup toolchain install stable --profile minimal --component clippy --component rustfmt - - run: rustup default stable - - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - run: cargo fmt --check - working-directory: qbx-lua-ls - - run: cargo clippy --all-targets --locked -- -D warnings - working-directory: qbx-lua-ls - - run: cargo test --locked - working-directory: qbx-lua-ls diff --git a/crates/qbx_lua_ls/.github/workflows/discord-commit.yml b/crates/qbx_lua_ls/.github/workflows/discord-commit.yml deleted file mode 100644 index 5ace926..0000000 --- a/crates/qbx_lua_ls/.github/workflows/discord-commit.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Discord Commits -on: - push: - branches: [main] - -permissions: - contents: read - -jobs: - notify: - uses: Qbox-project/.github/.github/workflows/discord-commit.yml@main - secrets: inherit diff --git a/crates/qbx_lua_ls/.github/workflows/discord-release.yml b/crates/qbx_lua_ls/.github/workflows/discord-release.yml deleted file mode 100644 index 5fa6824..0000000 --- a/crates/qbx_lua_ls/.github/workflows/discord-release.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Discord Releases -on: - release: - types: [published] - -permissions: - contents: read - -jobs: - notify: - uses: Qbox-project/.github/.github/workflows/discord-release.yml@main - secrets: inherit diff --git a/crates/qbx_lua_ls/.github/workflows/release.yml b/crates/qbx_lua_ls/.github/workflows/release.yml deleted file mode 100644 index f306e4c..0000000 --- a/crates/qbx_lua_ls/.github/workflows/release.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Release -on: - push: - tags: ["v*"] - -permissions: - contents: read - -jobs: - build: - strategy: - fail-fast: false - matrix: - include: - - { os: ubuntu-latest, target: x86_64-unknown-linux-musl, archive: tar.gz } - - { os: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, archive: tar.gz } - - { os: macos-latest, target: aarch64-apple-darwin, archive: tar.gz } - - { os: macos-15-intel, target: x86_64-apple-darwin, archive: tar.gz } - - { os: windows-latest, target: x86_64-pc-windows-msvc, archive: zip } - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - with: - path: qbx-lua-ls - - uses: actions/checkout@v4 - with: - repository: Qbox-project/qbx-lint - ref: ${{ github.ref_name }} - path: qbx-lint - - name: Install Rust - run: rustup toolchain install stable --profile minimal --target ${{ matrix.target }} - - 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 --target ${{ matrix.target }} - working-directory: qbx-lua-ls - - name: Check version and package - shell: bash - working-directory: qbx-lua-ls - run: | - set -euo pipefail - cd target/${{ matrix.target }}/release - binary="qbx-lua-ls" - if [ "${{ runner.os }}" = "Windows" ]; then binary="${binary}.exe"; fi - actual="$(./"${binary}" --version)" - expected="qbx-lua-ls ${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-lua-ls-${{ matrix.target }}" - if [ "${{ matrix.archive }}" = "zip" ]; then - 7z a "../../../${name}.zip" "${binary}" LICENSE - else - tar -czf "../../../${name}.tar.gz" "${binary}" LICENSE - fi - - uses: actions/upload-artifact@v4 - with: - name: binary-${{ matrix.target }} - path: qbx-lua-ls/qbx-lua-ls-${{ matrix.target }}.${{ matrix.archive }} - if-no-files-found: error - - publish: - needs: build - uses: Qbox-project/.github/.github/workflows/release-artifacts.yml@main - with: - artifact-pattern: binary-* - secrets: inherit diff --git a/crates/qbx_lua_ls/.gitignore b/crates/qbx_lua_ls/.gitignore deleted file mode 100644 index b006b05..0000000 --- a/crates/qbx_lua_ls/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -/target - -# Local files -.DS_Store -Thumbs.db -/qbx-lua-ls-*.zip -/qbx-lua-ls-*.tar.gz diff --git a/crates/qbx_lua_ls/CONTRIBUTING.md b/crates/qbx_lua_ls/CONTRIBUTING.md index e590930..6daf80c 100644 --- a/crates/qbx_lua_ls/CONTRIBUTING.md +++ b/crates/qbx_lua_ls/CONTRIBUTING.md @@ -2,28 +2,30 @@ ## Set up -Install stable Rust. Keep this checkout beside -[qbx-lint](https://github.com/Qbox-project/qbx-lint), which provides the parser, formatter, -analysis and FiveM data crates through local path dependencies: +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 -work/ - qbx-lint/ - qbx-lua-ls/ +qbx-lint/ + Cargo.toml + crates/ + qbx_lua_ls/ + qbx_lua_analysis/ + ... ``` -From `qbx-lua-ls`, build with `cargo build --locked`. If a change needs shared parser or lint -behavior, make the corresponding change in `qbx-lint` and mention both changes in the pull -request. Keep the path dependencies intact. +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 this repository before submitting a pull request: +Run these commands from the workspace root before submitting a pull request: ```sh -cargo fmt --check -cargo clippy --locked --all-targets -- -D warnings -cargo test --locked +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 @@ -38,8 +40,8 @@ protocol behavior. Editor integration code belongs in For manual inspection, build the executable and use the probe script: ```sh -cargo build --release --locked -node scripts/probe.mjs < snippet.lua +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 diff --git a/crates/qbx_lua_ls/Cargo.lock b/crates/qbx_lua_ls/Cargo.lock deleted file mode 100644 index e42cdc7..0000000 --- a/crates/qbx_lua_ls/Cargo.lock +++ /dev/null @@ -1,770 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" -dependencies = [ - "bytes", - "cfg_aliases", -] - -[[package]] -name = "bstr" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" -dependencies = [ - "memchr", - "serde_core", -] - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "either" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "globset" -version = "0.4.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "icu_collections" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" - -[[package]] -name = "icu_properties" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" - -[[package]] -name = "icu_provider" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "ignore" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] - -[[package]] -name = "indexmap" -version = "2.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "litemap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - -[[package]] -name = "log" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "zerovec", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "qbx_fivem_data" -version = "1.0.4" - -[[package]] -name = "qbx_lua_analysis" -version = "1.0.4" -dependencies = [ - "globset", - "ignore", - "qbx_fivem_data", - "qbx_lua_fmt", - "qbx_lua_syntax", - "rayon", - "rustc-hash", - "serde", - "serde_json", - "smol_str", - "toml", - "walkdir", -] - -[[package]] -name = "qbx_lua_fmt" -version = "1.0.4" -dependencies = [ - "qbx_lua_syntax", - "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" -dependencies = [ - "smol_str", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "smallvec" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" - -[[package]] -name = "smol_str" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" -dependencies = [ - "borsh", - "serde_core", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "syn" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tinystr" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - -[[package]] -name = "unicode-ident" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/qbx_lua_ls/Cargo.toml b/crates/qbx_lua_ls/Cargo.toml index c23af6d..a6c75a8 100644 --- a/crates/qbx_lua_ls/Cargo.toml +++ b/crates/qbx_lua_ls/Cargo.toml @@ -1,32 +1,25 @@ [package] name = "qbx_lua_ls" description = "Language server for FiveM Lua" -version = "1.0.4" -edition = "2021" -license = "GPL-3.0-or-later" -repository = "https://github.com/Qbox-project/qbx-lua-ls" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true [[bin]] name = "qbx-lua-ls" path = "src/main.rs" [dependencies] -# Keep qbx-lint beside this checkout. Release builds use the same tag in both repositories. -qbx_lua_syntax = { version = "=1.0.4", path = "../qbx-lint/crates/qbx_lua_syntax" } -qbx_lua_fmt = { version = "=1.0.4", path = "../qbx-lint/crates/qbx_lua_fmt" } -qbx_lua_analysis = { version = "=1.0.4", path = "../qbx-lint/crates/qbx_lua_analysis", features = ["docs"] } -qbx_fivem_data = { version = "=1.0.4", path = "../qbx-lint/crates/qbx_fivem_data", features = ["docs"] } +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 = "2" -smol_str = "0.3" +rustc-hash.workspace = true +smol_str.workspace = true walkdir = "2" - -[profile.release] -opt-level = 3 -lto = "fat" -codegen-units = 1 -strip = true diff --git a/crates/qbx_lua_ls/README.md b/crates/qbx_lua_ls/README.md index c5e5511..86ebdf6 100644 --- a/crates/qbx_lua_ls/README.md +++ b/crates/qbx_lua_ls/README.md @@ -61,18 +61,17 @@ See the [convention and maintenance notes](docs/framework-callbacks.md) for sour ## Build and run -Download the archive for your platform from [Releases](https://github.com/Qbox-project/qbx-lua-ls/releases). +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 keep these repositories next to each other. The server currently uses -local path dependencies from `qbx-lint`. +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 -git clone https://github.com/Qbox-project/qbx-lua-ls.git -cd qbx-lua-ls -cargo build --release --locked +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. @@ -122,7 +121,7 @@ and custom requests. 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-lua-ls/releases) for release notes. +[releases page](https://github.com/Qbox-project/qbx-lint/releases) for release notes. ## License diff --git a/crates/qbx_lua_ls/rustfmt.toml b/crates/qbx_lua_ls/rustfmt.toml deleted file mode 100644 index c775577..0000000 --- a/crates/qbx_lua_ls/rustfmt.toml +++ /dev/null @@ -1,2 +0,0 @@ -max_width = 120 -use_small_heuristics = "Max" diff --git a/crates/qbx_lua_ls/scripts/bench.mjs b/crates/qbx_lua_ls/scripts/bench.mjs index 213501f..d9c0edd 100644 --- a/crates/qbx_lua_ls/scripts/bench.mjs +++ b/crates/qbx_lua_ls/scripts/bench.mjs @@ -3,9 +3,10 @@ import { spawn, execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; -const [workspace, fileToOpen, binary = 'target/release/qbx-lua-ls'] = process.argv.slice(2); +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); diff --git a/crates/qbx_lua_ls/scripts/stress.mjs b/crates/qbx_lua_ls/scripts/stress.mjs index 74d2401..fe8bdc9 100644 --- a/crates/qbx_lua_ls/scripts/stress.mjs +++ b/crates/qbx_lua_ls/scripts/stress.mjs @@ -4,9 +4,10 @@ import { spawn } from 'node:child_process'; import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join, resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; -const [workspace, binary = 'target/release/qbx-lua-ls', perFile = '25'] = process.argv.slice(2); +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); 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.