From 505da2971ec8bc8a0d72f54e50ee55c92b111e68 Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Thu, 28 May 2026 22:14:50 +0100 Subject: [PATCH 1/2] feat: plugin system --- README.md | 2 +- .../glua_code_analysis/resources/schema.json | 16 +++- .../src/config/configs/gmod.rs | 12 ++- .../glua_ls/src/context/workspace_manager.rs | 95 ++++++++++++++++++- .../glua_ls/src/handlers/configuration/mod.rs | 9 +- .../handlers/initialized/client_config/mod.rs | 78 +++++++++++++++ .../glua_ls/src/handlers/initialized/mod.rs | 20 ++++ .../workspace/did_change_workspace_folders.rs | 7 +- .../configuration/annotations-management.mdx | 2 + .../configuration/framework-plugins.mdx | 89 +++++++++++++++++ docs/mintlify/docs.json | 3 +- .../getting-started/workspace-setup.mdx | 20 ++++ 12 files changed, 338 insertions(+), 15 deletions(-) create mode 100644 docs/mintlify/configuration/framework-plugins.mdx diff --git a/README.md b/README.md index 6fbf23d4..98348917 100644 --- a/README.md +++ b/README.md @@ -124,4 +124,4 @@ If you are working outside the standard `garrysmod/addons` or `garrysmod/gamemod This is a hard fork of [EmmyLua Analyzer Rust](https://github.com/CppCXY/emmylua-analyzer-rust), maintained specifically for Garry's Mod GLua. The original EmmyLua project does not support plugins, nor does it have any plan for them, making it difficult to fully adapt for Garry's Mod. This project contains significant changes from the original and only works for Garry's Mod GLua. -While LuaLS has plugin support, it was annoyingly slow to use. Many features here are based on my [LuaLS plugin](https://github.com/Pollux12/gmod-luals-addon). +While LuaLS has plugin support, it was annoyingly slow to use. Many features here are based on my earlier LuaLS addon work, now maintained in [annotations-gmod-glua-ls](https://github.com/Pollux12/annotations-gmod-glua-ls). diff --git a/crates/glua_code_analysis/resources/schema.json b/crates/glua_code_analysis/resources/schema.json index b89a5b03..3b391817 100644 --- a/crates/glua_code_analysis/resources/schema.json +++ b/crates/glua_code_analysis/resources/schema.json @@ -1452,14 +1452,14 @@ "EmmyrcGmod": { "properties": { "annotationsPath": { - "description": "Path to GMod annotations to load as core library.\nWhen set to empty string or not provided, uses VSCode extension's auto-downloaded annotations (if enabled).\nSet to explicit path to override, or use `autoLoadAnnotations: false` in .gluarc to disable entirely.", + "description": "Override path to GMod annotations directory. Set to empty to use VSCode downloaded annotations.\nWhen set to empty string or not provided, uses VSCode extension's auto-downloaded annotations (if enabled).\nSet to explicit path to override, or use `autoLoadAnnotations: false` in .gluarc to disable entirely.", "type": [ "string", "null" ] }, "autoDetectGamemodeBase": { - "description": "Automatically detect and add the base gamemode as a library when a gamemode\nderives from another (via the `\"base\"` field in the gamemode `.txt` file).\nSet to `false` to disable this detection.", + "description": "Automatically add base gamemodes as libraries when a gamemode\nderives from another (via the `\"base\"` field in the gamemode `.txt` file).\nSet to `false` to disable this detection.", "type": [ "boolean", "null" @@ -1564,6 +1564,15 @@ "verbosity": "normal" } }, + "plugins": { + "default": [], + "description": "Ordered plugin ids persisted by editor integrations.\nThe language server remains plugin-agnostic and consumes resolved config only.", + "items": { + "type": "string" + }, + "type": "array", + "x-gluals-editor": "pluginList" + }, "scriptedClassScopes": { "$ref": "#/$defs/EmmyrcGmodScriptedClassScopes", "default": { @@ -1698,7 +1707,7 @@ } }, "templatePath": { - "description": "Path to a folder containing custom GLua scaffolding templates (`.lua` files).\nBuilt-in templates are used as fallback when a custom one is not found.\nAccepts an absolute path or a path relative to the workspace root.", + "description": "Path to custom GLua scaffolding templates folder.\nBuilt-in templates are used as fallback when a custom one is not found.\nAccepts an absolute path or a path relative to the workspace root.", "type": [ "string", "null" @@ -2759,6 +2768,7 @@ "outline": { "verbosity": "normal" }, + "plugins": [], "scriptedClassScopes": { "include": [ { diff --git a/crates/glua_code_analysis/src/config/configs/gmod.rs b/crates/glua_code_analysis/src/config/configs/gmod.rs index 2a176922..7e9030c5 100644 --- a/crates/glua_code_analysis/src/config/configs/gmod.rs +++ b/crates/glua_code_analysis/src/config/configs/gmod.rs @@ -48,6 +48,11 @@ pub struct EmmyrcGmod { pub scripted_class_scopes: EmmyrcGmodScriptedClassScopes, #[serde(default)] pub hook_mappings: EmmyrcGmodHookMappings, + /// Ordered plugin ids persisted by editor integrations. + /// The language server remains plugin-agnostic and consumes resolved config only. + #[serde(default)] + #[schemars(extend("x-gluals-editor" = "pluginList"))] + pub plugins: Vec, #[serde(default)] pub network: EmmyrcGmodNetwork, #[serde(default)] @@ -73,7 +78,7 @@ pub struct EmmyrcGmod { pub infer_dynamic_fields: bool, #[serde(default = "dynamic_fields_global_default")] pub dynamic_fields_global: bool, - /// Path to GMod annotations to load as core library. + /// Override path to GMod annotations directory. Set to empty to use VSCode downloaded annotations. /// When set to empty string or not provided, uses VSCode extension's auto-downloaded annotations (if enabled). /// Set to explicit path to override, or use `autoLoadAnnotations: false` in .gluarc to disable entirely. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -82,12 +87,12 @@ pub struct EmmyrcGmod { /// This takes precedence over extension settings. #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_load_annotations: Option, - /// Path to a folder containing custom GLua scaffolding templates (`.lua` files). + /// Path to custom GLua scaffolding templates folder. /// Built-in templates are used as fallback when a custom one is not found. /// Accepts an absolute path or a path relative to the workspace root. #[serde(default, skip_serializing_if = "Option::is_none")] pub template_path: Option, - /// Automatically detect and add the base gamemode as a library when a gamemode + /// Automatically add base gamemodes as libraries when a gamemode /// derives from another (via the `"base"` field in the gamemode `.txt` file). /// Set to `false` to disable this detection. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -504,6 +509,7 @@ impl Default for EmmyrcGmod { default_realm: EmmyrcGmodRealm::default(), scripted_class_scopes: EmmyrcGmodScriptedClassScopes::default(), hook_mappings: EmmyrcGmodHookMappings::default(), + plugins: Vec::new(), network: EmmyrcGmodNetwork::default(), vgui: EmmyrcGmodVgui::default(), outline: EmmyrcGmodOutline::default(), diff --git a/crates/glua_ls/src/context/workspace_manager.rs b/crates/glua_ls/src/context/workspace_manager.rs index 61c7ee66..d95924f6 100644 --- a/crates/glua_ls/src/context/workspace_manager.rs +++ b/crates/glua_ls/src/context/workspace_manager.rs @@ -439,6 +439,9 @@ pub fn load_emmy_config(config_roots: Vec, client_config: ClientConfig) // Inject GMod annotations path if provided and not explicitly disabled inject_gmod_annotations(&client_config, &mut emmyrc); + // Inject plugin libraries selected by the VSCode extension. + inject_gmod_plugin_libraries(&client_config, &mut emmyrc); + // Inject gamemode base libraries if detected and not explicitly disabled. // For the global merged config we don't have a single workspace root, so // pass `None`; per-root detection happens in `pre_process_emmyrc_for_all_roots`. @@ -499,6 +502,7 @@ fn pre_process_emmyrc_for_all_roots( ); merge_client_config(client_config, &mut workspace_emmyrc); inject_gmod_annotations(client_config, &mut workspace_emmyrc); + inject_gmod_plugin_libraries(client_config, &mut workspace_emmyrc); inject_gamemode_base_libraries( client_config, &mut workspace_emmyrc, @@ -894,6 +898,43 @@ fn inject_gamemode_base_libraries( } } +/// Inject plugin annotation libraries selected by the VSCode extension. +fn inject_gmod_plugin_libraries(client_config: &ClientConfig, emmyrc: &mut Emmyrc) { + if matches!(emmyrc.gmod.auto_load_annotations, Some(false)) { + log::info!("GMod plugin annotations auto-load explicitly disabled in config"); + return; + } + + if client_config.gmod_plugin_library_paths.is_empty() { + return; + } + + use glua_code_analysis::EmmyLibraryItem; + for lib_path in &client_config.gmod_plugin_library_paths { + if emmyrc + .workspace + .library + .iter() + .any(|item| item.get_path() == lib_path) + { + log::info!( + "GMod plugin library already exists in workspace library: {}", + lib_path + ); + continue; + } + + emmyrc + .workspace + .library + .push(EmmyLibraryItem::Path(lib_path.clone())); + log::info!( + "GMod plugin library added to workspace library: {}", + lib_path + ); + } +} + #[derive(Debug)] pub struct ReindexToken { cancel_token: CancellationToken, @@ -1023,13 +1064,13 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - use glua_code_analysis::{DiagnosticCode, WorkspaceFolder, collect_workspace_files}; + use glua_code_analysis::{DiagnosticCode, Emmyrc, WorkspaceFolder, collect_workspace_files}; use crate::handlers::ClientConfig; use super::{ WorkspaceFileMatcher, collect_config_files_from_dir, collect_config_roots, dedup_paths, - load_emmy_config, push_configs_from_preferred_workspace_root, + inject_gmod_plugin_libraries, load_emmy_config, push_configs_from_preferred_workspace_root, }; fn create_temp_dir() -> PathBuf { @@ -1244,6 +1285,56 @@ mod tests { let _ = fs::remove_dir_all(workspace_b); } + #[test] + fn test_inject_gmod_plugin_libraries_respects_auto_load_annotations_disabled() { + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.auto_load_annotations = Some(false); + + let client_config = ClientConfig { + gmod_plugin_library_paths: vec!["/plugins/darkrp".to_string()], + ..Default::default() + }; + + inject_gmod_plugin_libraries(&client_config, &mut emmyrc); + assert!( + !emmyrc + .workspace + .library + .iter() + .any(|item| item.get_path() == "/plugins/darkrp") + ); + } + + #[test] + fn test_inject_gmod_plugin_libraries_appends_unique_paths() { + let mut emmyrc = Emmyrc::default(); + let client_config = ClientConfig { + gmod_plugin_library_paths: vec![ + "/plugins/darkrp".to_string(), + "/plugins/darkrp".to_string(), + "/plugins/helix".to_string(), + ], + ..Default::default() + }; + + inject_gmod_plugin_libraries(&client_config, &mut emmyrc); + + assert!( + emmyrc + .workspace + .library + .iter() + .any(|item| item.get_path() == "/plugins/darkrp") + ); + assert!( + emmyrc + .workspace + .library + .iter() + .any(|item| item.get_path() == "/plugins/helix") + ); + } + #[test] fn test_load_emmy_config_merges_runtime_extensions_for_each_workspace() { let workspace_a = create_temp_dir(); diff --git a/crates/glua_ls/src/handlers/configuration/mod.rs b/crates/glua_ls/src/handlers/configuration/mod.rs index cd6c9f6c..9f08fe8d 100644 --- a/crates/glua_ls/src/handlers/configuration/mod.rs +++ b/crates/glua_ls/src/handlers/configuration/mod.rs @@ -12,17 +12,20 @@ pub async fn on_did_change_configuration( log::info!("on_did_change_configuration: {}", pretty_json); // Check initialization status and get client config - let (client_id, supports_config_request) = { + let (client_id, supports_config_request, previous_client_config) = { let workspace_manager = context.workspace_manager().read().await; let client_id = workspace_manager.client_config.client_id; let supports_config_request = context.lsp_features().supports_config_request(); - (client_id, supports_config_request) + let previous_client_config = workspace_manager.client_config.clone(); + (client_id, supports_config_request, previous_client_config) }; log::info!("change config client_id: {:?}", client_id); // Get new config without holding any locks - let new_client_config = get_client_config(&context, client_id, supports_config_request).await; + let new_client_config = get_client_config(&context, client_id, supports_config_request) + .await + .preserve_initialization_options_from(&previous_client_config); // Update config and reload - acquire write lock only when necessary { diff --git a/crates/glua_ls/src/handlers/initialized/client_config/mod.rs b/crates/glua_ls/src/handlers/initialized/client_config/mod.rs index b7eae556..365a3e46 100644 --- a/crates/glua_ls/src/handlers/initialized/client_config/mod.rs +++ b/crates/glua_ls/src/handlers/initialized/client_config/mod.rs @@ -17,6 +17,23 @@ pub struct ClientConfig { pub partial_emmyrcs: Option>, pub gmod_annotations_path: Option, pub gamemode_base_libraries: Vec, + pub gmod_plugin_library_paths: Vec, +} + +impl ClientConfig { + pub fn preserve_initialization_options_from(mut self, previous: &Self) -> Self { + if self.gmod_annotations_path.is_none() { + self.gmod_annotations_path = previous.gmod_annotations_path.clone(); + } + if self.gamemode_base_libraries.is_empty() { + self.gamemode_base_libraries = previous.gamemode_base_libraries.clone(); + } + if self.gmod_plugin_library_paths.is_empty() { + self.gmod_plugin_library_paths = previous.gmod_plugin_library_paths.clone(); + } + + self + } } pub async fn get_client_config( @@ -32,6 +49,7 @@ pub async fn get_client_config( partial_emmyrcs: None, gmod_annotations_path: None, gamemode_base_libraries: Vec::new(), + gmod_plugin_library_paths: Vec::new(), }; match client_id { ClientId::VSCode => { @@ -48,3 +66,63 @@ pub async fn get_client_config( config } + +#[cfg(test)] +mod tests { + use super::ClientConfig; + + #[test] + fn preserve_initialization_options_from_keeps_init_only_paths() { + let previous = ClientConfig { + gmod_annotations_path: Some("/annotations".to_string()), + gamemode_base_libraries: vec!["/gamemodes/base".to_string()], + gmod_plugin_library_paths: vec!["/plugins/darkrp".to_string()], + ..Default::default() + }; + + let next = ClientConfig { + extensions: vec!["*.lua".to_string()], + ..Default::default() + } + .preserve_initialization_options_from(&previous); + + assert_eq!(next.gmod_annotations_path, previous.gmod_annotations_path); + assert_eq!( + next.gamemode_base_libraries, + previous.gamemode_base_libraries + ); + assert_eq!( + next.gmod_plugin_library_paths, + previous.gmod_plugin_library_paths + ); + assert_eq!(next.extensions, vec!["*.lua".to_string()]); + } + + #[test] + fn preserve_initialization_options_from_keeps_new_values_when_present() { + let previous = ClientConfig { + gmod_annotations_path: Some("/old-annotations".to_string()), + gamemode_base_libraries: vec!["/old-base".to_string()], + gmod_plugin_library_paths: vec!["/old-plugin".to_string()], + ..Default::default() + }; + + let next = ClientConfig { + gmod_annotations_path: Some("/new-annotations".to_string()), + gamemode_base_libraries: vec!["/new-base".to_string()], + gmod_plugin_library_paths: vec!["/new-plugin".to_string()], + ..Default::default() + } + .preserve_initialization_options_from(&previous); + + assert_eq!( + next.gmod_annotations_path, + Some("/new-annotations".to_string()) + ); + assert_eq!(next.gamemode_base_libraries, vec!["/new-base".to_string()]); + assert_eq!( + next.gmod_plugin_library_paths, + vec!["/new-plugin".to_string()] + ); + } +} diff --git a/crates/glua_ls/src/handlers/initialized/mod.rs b/crates/glua_ls/src/handlers/initialized/mod.rs index a6b68338..fdffbe0b 100644 --- a/crates/glua_ls/src/handlers/initialized/mod.rs +++ b/crates/glua_ls/src/handlers/initialized/mod.rs @@ -102,6 +102,26 @@ pub async fn initialized_handler( } } } + + if let Some(plugin_libraries) = init_options.get("gmodPluginLibraryPaths") { + if let Some(arr) = plugin_libraries.as_array() { + for lib in arr { + if let Some(lib_str) = lib.as_str() { + if !lib_str.is_empty() { + client_config + .gmod_plugin_library_paths + .push(lib_str.to_string()); + } + } + } + if !client_config.gmod_plugin_library_paths.is_empty() { + log::info!( + "Received plugin annotation libraries from VSCode: {:?}", + client_config.gmod_plugin_library_paths + ); + } + } + } } // Apply CLI-provided annotations path (highest precedence after .gluarc.json) diff --git a/crates/glua_ls/src/handlers/workspace/did_change_workspace_folders.rs b/crates/glua_ls/src/handlers/workspace/did_change_workspace_folders.rs index 36d6f280..a740388a 100644 --- a/crates/glua_ls/src/handlers/workspace/did_change_workspace_folders.rs +++ b/crates/glua_ls/src/handlers/workspace/did_change_workspace_folders.rs @@ -34,7 +34,7 @@ pub async fn on_did_change_workspace_folders( removed_roots.len() ); - let (client_id, supports_config_request) = { + let (client_id, supports_config_request, previous_client_config) = { let mut workspace_manager = context.workspace_manager().write().await; if !removed_roots.is_empty() { @@ -56,10 +56,13 @@ pub async fn on_did_change_workspace_folders( ( workspace_manager.client_config.client_id, context.lsp_features().supports_config_request(), + workspace_manager.client_config.clone(), ) }; - let client_config = get_client_config(&context, client_id, supports_config_request).await; + let client_config = get_client_config(&context, client_id, supports_config_request) + .await + .preserve_initialization_options_from(&previous_client_config); let mut workspace_manager = context.workspace_manager().write().await; workspace_manager.client_config = client_config; diff --git a/docs/mintlify/configuration/annotations-management.mdx b/docs/mintlify/configuration/annotations-management.mdx index 00e3d43a..ed371603 100644 --- a/docs/mintlify/configuration/annotations-management.mdx +++ b/docs/mintlify/configuration/annotations-management.mdx @@ -8,6 +8,8 @@ import ConfigTip from "/snippets/config-tip.mdx" GLuaLS ships without bundled GMod API definitions. The VS Code extension downloads the latest annotations generated from the Garry's Mod wiki, so your API definitions stay current without extension updates. +For framework plugin detection/presets (DarkRP, Helix, etc), see [Framework plugins](/configuration/framework-plugins). + --- ## How it works diff --git a/docs/mintlify/configuration/framework-plugins.mdx b/docs/mintlify/configuration/framework-plugins.mdx new file mode 100644 index 00000000..00a00ead --- /dev/null +++ b/docs/mintlify/configuration/framework-plugins.mdx @@ -0,0 +1,89 @@ +--- +title: Framework plugins +description: Use framework plugins, apply them safely, and build your own plugin packs. +--- + +## What this feature does + +Framework plugins help GLuaLS understand framework-specific code (for example DarkRP or Helix). + +When a plugin is applied, GLuaLS updates your workspace `.gluarc.json` with plugin settings. + +This gives you better completions/diagnostics for that framework without replacing your existing config. + +--- + +## Using framework plugins + +If GLuaLS detects a framework, you will see a prompt with: + +- **Apply** +- **Review Setup** +- **Dismiss** + +You can also run these commands from the Command Palette: + +- **GLuaLS: Re-run Framework/Plugin Detection** +- **GLuaLS: Framework/Plugin Setup Wizard** +- **GLuaLS: Apply Framework Plugin** (manual override) + +Recommended path: + +- Use **Framework/Plugin Setup Wizard** first. + +Secondary options: + +- Use **Re-run Framework/Plugin Detection** to run detection again. +- Use **Apply Framework Plugin** only when you want to manually force a specific plugin. + +--- + +## If you do not get a popup + +This is usually expected when: + +- your workspace already has a `.gluarc.json` (to avoid unwanted changes), or +- your project does not clearly match a supported framework. + +In that case, run **GLuaLS: Re-run Framework/Plugin Detection** first, then use the setup wizard if needed. + +--- + +## Using more than one plugin + +Plugins are listed in `.gluarc.json` under `gmod.plugins`. + +Order matters: later plugins can override earlier plugin settings. + +If two plugins conflict, move the preferred one later in the list. + +--- + +## Create your own plugin (local/private) + +If you want custom framework support for your own team/project: + +1. In `glua-api-snippets`, create `plugin//` with: + - `plugin.json` (plugin metadata + detection rules) + - `gluarc.json` (settings fragment to apply) + - optional `annotations/` folder +2. Run: + - `npm run generate-all` +3. In VS Code settings, point to your local generated outputs: + - `gluals.ls.annotationPath` → your `output` folder + - `gluals.ls.pluginBundlePath` → your `output-plugins` folder +4. Run **GLuaLS: Re-run Framework/Plugin Detection**. + +--- + +## Contribute a plugin upstream + +If you want everyone to use your plugin: + +1. Add your plugin folder under `glua-api-snippets/plugin//`. +2. Run `npm run generate-all` and tests. +3. Open a PR with: + - your plugin descriptor (`plugin.json`) + - `gluarc.json` + - optional annotations +4. After merge, CI generates/publishes the plugin index and bundle. diff --git a/docs/mintlify/docs.json b/docs/mintlify/docs.json index 23bfb4f2..4a97bcf1 100644 --- a/docs/mintlify/docs.json +++ b/docs/mintlify/docs.json @@ -146,7 +146,8 @@ "configuration/hints-and-hover", "configuration/workspace", "configuration/runtime", - "configuration/annotations-management" + "configuration/annotations-management", + "configuration/framework-plugins" ] } ] diff --git a/docs/mintlify/getting-started/workspace-setup.mdx b/docs/mintlify/getting-started/workspace-setup.mdx index d698e391..a1dd6b3d 100644 --- a/docs/mintlify/getting-started/workspace-setup.mdx +++ b/docs/mintlify/getting-started/workspace-setup.mdx @@ -123,6 +123,26 @@ See [Annotations management](/configuration/annotations-management) for details --- +## Framework plugin quick start + +If you are using a framework (for example DarkRP or Helix): + +1. Open your project folder in VS Code. +2. Wait for the plugin detection prompt. +3. Click **Apply** to apply plugin settings to `.gluarc.json`. + +If no prompt appears, open Command Palette and run: + +- **GLuaLS: Framework/Plugin Setup Wizard** +- **GLuaLS: Re-run Framework/Plugin Detection** +- **GLuaLS: Apply Framework Plugin** (manual override) + +Use the setup wizard first. Use the other two commands only if needed. + +For full plugin usage and custom plugin creation, see [Framework plugins](/configuration/framework-plugins). + +--- + ## Next steps From d56f907b4da7cc3f10c261f016dba231d0e6962e Mon Sep 17 00:00:00 2001 From: Pollux <39353174+Pollux12@users.noreply.github.com> Date: Fri, 29 May 2026 01:42:49 +0100 Subject: [PATCH 2/2] feat: port scripted class metadata support --- .../glua_code_analysis/resources/schema.json | 162 ++- .../src/compilation/analyzer/decl/mod.rs | 91 +- .../src/compilation/analyzer/gmod/mod.rs | 167 ++- .../src/compilation/analyzer/mod.rs | 30 + .../analyzer/unresolve/resolve_closure.rs | 22 +- .../test/gmod_scripted_class_test.rs | 81 +- .../src/config/configs/gmod.rs | 954 ++++++++++++++++-- .../src/db_index/gmod_infer/mod.rs | 6 + .../diagnostic/checker/undefined_global.rs | 34 +- .../diagnostic/test/undefined_global_test.rs | 100 ++ .../src/semantic/infer/infer_name.rs | 45 +- .../completion/providers/member_provider.rs | 84 +- .../build_gmod_scripted_classes.rs | 9 +- .../glua_ls/src/handlers/hover/build_hover.rs | 32 +- crates/glua_ls/src/handlers/hover/mod.rs | 23 +- .../src/handlers/test/completion_test.rs | 20 +- .../glua_ls/src/handlers/test/hover_test.rs | 50 + 17 files changed, 1764 insertions(+), 146 deletions(-) diff --git a/crates/glua_code_analysis/resources/schema.json b/crates/glua_code_analysis/resources/schema.json index 3b391817..6c338e46 100644 --- a/crates/glua_code_analysis/resources/schema.json +++ b/crates/glua_code_analysis/resources/schema.json @@ -1678,6 +1678,7 @@ }, { "classGlobal": "PLUGIN", + "hookOwner": true, "icon": "extensions", "id": "plugins", "include": [ @@ -1687,7 +1688,12 @@ "path": [ "plugins" ], - "rootDir": "plugins" + "rootDir": "plugins", + "superTypes": [ + "GM", + "GAMEMODE", + "SANDBOX" + ] }, { "classGlobal": "GM", @@ -1706,6 +1712,13 @@ ] } }, + "scriptedOwners": { + "$ref": "#/$defs/EmmyrcGmodScriptedOwners", + "default": { + "include": [] + }, + "description": "Configures additional hook-owner globals beyond the built-in\n`GM` / `GAMEMODE` / `SANDBOX` set." + }, "templatePath": { "description": "Path to custom GLua scaffolding templates folder.\nBuilt-in templates are used as fallback when a custom one is not found.\nAccepts an absolute path or a path relative to the workspace root.", "type": [ @@ -1822,6 +1835,16 @@ }, "EmmyrcGmodScriptedClassDefinition": { "properties": { + "aliases": { + "description": "Additional global names exposed for the same class global.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "classGlobal": { "type": [ "string", @@ -1850,6 +1873,27 @@ "null" ] }, + "fixedClassName": { + "description": "When set, every file matched by this scope resolves to this class name.", + "type": [ + "string", + "null" + ] + }, + "hideFromOutline": { + "description": "When true, editor outline/class explorer views should hide this scope.", + "type": [ + "boolean", + "null" + ] + }, + "hookOwner": { + "description": "Whether this class global should be treated as a hook owner.", + "type": [ + "boolean", + "null" + ] + }, "icon": { "type": [ "string", @@ -1868,6 +1912,13 @@ "null" ] }, + "isGlobalSingleton": { + "description": "When true, the scope's class global is a workspace-global singleton.", + "type": [ + "boolean", + "null" + ] + }, "label": { "type": [ "string", @@ -1904,6 +1955,23 @@ "type": "null" } ] + }, + "stripFilePrefix": { + "description": "When true, strips sh_/sv_/cl_ from single-file class names.", + "type": [ + "boolean", + "null" + ] + }, + "superTypes": { + "description": "Class globals this scope inherits from.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -2053,6 +2121,7 @@ }, { "classGlobal": "PLUGIN", + "hookOwner": true, "icon": "extensions", "id": "plugins", "include": [ @@ -2062,7 +2131,12 @@ "path": [ "plugins" ], - "rootDir": "plugins" + "rootDir": "plugins", + "superTypes": [ + "GM", + "GAMEMODE", + "SANDBOX" + ] }, { "classGlobal": "GM", @@ -2088,6 +2162,79 @@ }, "type": "object" }, + "EmmyrcGmodScriptedOwnerEntry": { + "properties": { + "aliases": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "disabled": { + "type": [ + "boolean", + "null" + ] + }, + "exclude": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "fallbackOwners": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "global": { + "type": "string" + }, + "hookOwner": { + "type": [ + "boolean", + "null" + ] + }, + "id": { + "type": "string" + }, + "include": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "id", + "global", + "include" + ], + "type": "object" + }, + "EmmyrcGmodScriptedOwners": { + "properties": { + "include": { + "default": [], + "items": { + "$ref": "#/$defs/EmmyrcGmodScriptedOwnerEntry" + }, + "type": "array" + } + }, + "type": "object" + }, "EmmyrcGmodVgui": { "properties": { "codeLensEnabled": { @@ -2872,6 +3019,7 @@ }, { "classGlobal": "PLUGIN", + "hookOwner": true, "icon": "extensions", "id": "plugins", "include": [ @@ -2881,7 +3029,12 @@ "path": [ "plugins" ], - "rootDir": "plugins" + "rootDir": "plugins", + "superTypes": [ + "GM", + "GAMEMODE", + "SANDBOX" + ] }, { "classGlobal": "GM", @@ -2899,6 +3052,9 @@ } ] }, + "scriptedOwners": { + "include": [] + }, "vgui": { "codeLensEnabled": true, "inlayHintEnabled": false diff --git a/crates/glua_code_analysis/src/compilation/analyzer/decl/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/decl/mod.rs index c9b85274..85254efe 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/decl/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/decl/mod.rs @@ -5,7 +5,7 @@ mod stats; use crate::{ compilation::analyzer::AnalysisPipeline, - db_index::{DbIndex, LegacyModuleEnv, LuaScopeKind}, + db_index::{DbIndex, GmodScopedClassInfo, LegacyModuleEnv, LuaScopeKind}, profile::Profile, }; @@ -38,13 +38,13 @@ impl AnalysisPipeline for DeclAnalysisPipeline { for in_filed_tree in tree_list.iter() { // Detect scoped class once here and cache in GmodInferIndex for gmod_pre reuse. - let scoped_class_global_name = if let Some(scripted_scope_infos) = + let scoped_class_info = if let Some(scripted_scope_infos) = scripted_scope_infos.as_ref() && let Some(info) = scripted_scope_infos.get(&in_filed_tree.file_id) { db.get_gmod_infer_index_mut() .set_scoped_class_info(in_filed_tree.file_id, info.clone()); - Some(info.global_name.clone()) + Some(info.clone()) } else { None }; @@ -57,7 +57,7 @@ impl AnalysisPipeline for DeclAnalysisPipeline { in_filed_tree.file_id, in_filed_tree.value.clone(), context, - scoped_class_global_name, + scoped_class_info, ); analyzer.analyze(); let decl_tree = analyzer.get_decl_tree(); @@ -188,7 +188,7 @@ pub struct DeclAnalyzer<'a> { db: &'a mut DbIndex, root: LuaChunk, decl: LuaDeclarationTree, - scoped_class_global_name: Option, + scoped_class_info: Option, seeded_scoped_class_decl: bool, legacy_module_envs: Vec, scopes: Vec, @@ -202,13 +202,13 @@ impl<'a> DeclAnalyzer<'a> { file_id: FileId, root: LuaChunk, context: &'a mut AnalyzeContext, - scoped_class_global_name: Option, + scoped_class_info: Option, ) -> DeclAnalyzer<'a> { DeclAnalyzer { db, root, decl: LuaDeclarationTree::new(file_id), - scoped_class_global_name, + scoped_class_info, seeded_scoped_class_decl: false, legacy_module_envs: Vec::new(), scopes: Vec::new(), @@ -255,8 +255,9 @@ impl<'a> DeclAnalyzer<'a> { } pub fn add_decl(&mut self, mut decl: LuaDecl) -> LuaDeclId { - if let Some(scoped_class_global_name) = self.scoped_class_global_name.as_ref() - && decl.get_name() == scoped_class_global_name + if let Some(scoped_class_info) = self.scoped_class_info.as_ref() + && decl.get_name() == scoped_class_info.global_name + && !scoped_class_info.is_global_singleton && let LuaDeclExtra::Global { kind } = decl.extra.clone() { decl.extra = LuaDeclExtra::Local { kind, attrib: None }; @@ -327,9 +328,14 @@ impl<'a> DeclAnalyzer<'a> { } pub fn is_scoped_class_global_name(&self, name: &str) -> bool { - self.scoped_class_global_name - .as_ref() - .is_some_and(|scoped_name| scoped_name == name) + self.scoped_class_info.as_ref().is_some_and(|info| { + info.global_name == name + || info.aliases.iter().any(|alias| alias == name) + || info + .extra_scope_matches + .iter() + .any(|(_, global_name, _, _, _)| global_name == name) + }) } pub fn set_legacy_module_env(&mut self, legacy_module_env: LegacyModuleEnv) { @@ -363,27 +369,68 @@ impl<'a> DeclAnalyzer<'a> { return; } - let Some(scoped_class_global_name) = self.scoped_class_global_name.as_ref() else { + let Some(scoped_class_info) = self.scoped_class_info.clone() else { return; }; - let file_id = self.get_file_id(); let synthetic_pos = chunk_range.start(); let synthetic_range = TextRange::new(synthetic_pos, synthetic_pos); - let mut decl = LuaDecl::new( - scoped_class_global_name, - file_id, + self.seed_scoped_class_decl_name( + &scoped_class_info.global_name, + scoped_class_info.is_global_singleton, synthetic_range, + ); + + if scoped_class_info.is_global_singleton { + for alias in &scoped_class_info.aliases { + self.seed_scoped_class_decl_name(alias, true, synthetic_range); + } + } + + for (_, extra_global_name, _, extra_is_singleton, _) in + &scoped_class_info.extra_scope_matches + { + self.seed_scoped_class_decl_name( + extra_global_name, + *extra_is_singleton, + synthetic_range, + ); + if *extra_is_singleton { + for alias in super::gmod::remap_scoped_alias( + extra_global_name, + &self.db.get_emmyrc().gmod.scripted_class_scopes, + ) { + self.seed_scoped_class_decl_name(&alias, true, synthetic_range); + } + } + } + + self.seeded_scoped_class_decl = true; + } + + fn seed_scoped_class_decl_name( + &mut self, + global_name: &str, + is_global_singleton: bool, + synthetic_range: TextRange, + ) { + let file_id = self.get_file_id(); + let extra = if is_global_singleton { + LuaDeclExtra::Global { + kind: LuaSyntaxKind::NameExpr.into(), + } + } else { LuaDeclExtra::Local { kind: LuaSyntaxKind::NameExpr.into(), attrib: None, - }, - None, - ); - decl.mark_seeded_class_local(); + } + }; + let mut decl = LuaDecl::new(global_name, file_id, synthetic_range, extra, None); + if !is_global_singleton { + decl.mark_seeded_class_local(); + } self.add_decl(decl); - self.seeded_scoped_class_decl = true; } fn project_legacy_module_chain_members(&mut self, legacy_module_env: &LegacyModuleEnv) { diff --git a/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs index b78b7012..bfc5e98d 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/gmod/mod.rs @@ -65,10 +65,12 @@ impl GmodKeywords { } } +const BUILTIN_METHOD_HOOK_PREFIXES: &[&str] = &["GM", "GAMEMODE", "SANDBOX"]; + fn scan_gmod_keywords(content: &str, formatted_hook_prefixes: &[String]) -> GmodKeywords { - let has_gm_func = content.contains("GM:") - || content.contains("GAMEMODE:") - || formatted_hook_prefixes.iter().any(|p| content.contains(p)); + let has_gm_func = formatted_hook_prefixes + .iter() + .any(|prefix| content.contains(prefix)); GmodKeywords { has_hook: content.contains("hook"), has_net: content.contains("net."), @@ -125,14 +127,27 @@ impl AnalysisPipeline for GmodPreAnalysisPipeline { let helper_registry = build_helper_registry(db); // Pre-format hook method prefixes once to avoid per-file `format!("{p}:")` allocations - let formatted_hook_prefixes: Vec = db - .get_emmyrc() - .gmod - .hook_mappings - .method_prefixes - .iter() - .map(|p| format!("{p}:")) - .collect(); + let formatted_hook_prefixes: Vec = { + let emmyrc = db.get_emmyrc(); + emmyrc + .gmod + .hook_mappings + .method_prefixes + .iter() + .cloned() + .chain( + BUILTIN_METHOD_HOOK_PREFIXES + .iter() + .map(|prefix| prefix.to_string()), + ) + .chain(emmyrc.gmod.scripted_owners.hook_owner_names()) + .chain(emmyrc.gmod.scripted_class_scopes.hook_owner_globals()) + .map(|mut prefix| { + prefix.push(':'); + prefix + }) + .collect() + }; for in_filed_tree in &tree_list { let is_in_scope = scripted_scope_files.contains(&in_filed_tree.file_id); @@ -203,15 +218,27 @@ impl AnalysisPipeline for GmodPreAnalysisPipeline { class_name: info.class_name.clone(), global_name: info.global_name.clone(), class_name_prefix: info.class_name_prefix.clone(), + is_global_singleton: info.is_global_singleton, + hook_owner: info.hook_owner, }) .or_else(|| { let m = detect_scoped_class_from_path(db, in_filed_tree.file_id)?; + let aliases = remap_scoped_alias( + &m.global_name, + &db.get_emmyrc().gmod.scripted_class_scopes, + ); + let extra_scope_matches = + compute_extra_scope_matches(db, in_filed_tree.file_id, &m.global_name); db.get_gmod_infer_index_mut().set_scoped_class_info( in_filed_tree.file_id, GmodScopedClassInfo { class_name: m.class_name.clone(), global_name: m.global_name.clone(), class_name_prefix: m.class_name_prefix.clone(), + aliases, + is_global_singleton: m.is_global_singleton, + hook_owner: m.hook_owner, + extra_scope_matches, }, ); Some(m) @@ -233,6 +260,39 @@ impl AnalysisPipeline for GmodPreAnalysisPipeline { in_filed_tree.file_id, &scope_match, ); + let extra_matches = db + .get_gmod_infer_index() + .get_scoped_class_info(&in_filed_tree.file_id) + .map(|info| info.extra_scope_matches.clone()) + .unwrap_or_default(); + for ( + extra_class_name, + extra_global_name, + extra_class_name_prefix, + extra_is_singleton, + extra_hook_owner, + ) in extra_matches + { + let extra_scope_match = GmodScopedClassMatch { + class_name: extra_class_name, + global_name: extra_global_name, + class_name_prefix: extra_class_name_prefix, + is_global_singleton: extra_is_singleton, + hook_owner: extra_hook_owner, + }; + ensure_scoped_class_type_decl( + db, + in_filed_tree.file_id, + &extra_scope_match.class_name, + &extra_scope_match.global_name, + in_filed_tree.value.syntax().text_range(), + ); + collect_scripted_scope_type_bindings_with( + db, + in_filed_tree.file_id, + &extra_scope_match, + ); + } synthesize_scoped_base_assignments_with( db, in_filed_tree.file_id, @@ -1597,6 +1657,8 @@ pub(crate) struct GmodScopedClassMatch { /// short name for parent-alias synthesis (e.g. `gamemode_sandbox` → /// `sandbox` → `Sandbox`). pub class_name_prefix: Option, + pub is_global_singleton: bool, + pub hook_owner: bool, } const GMOD_ENT_BASE_TO_ENT: &[&str] = &[ @@ -1626,7 +1688,8 @@ fn collect_scripted_scope_type_bindings_with( } let is_scoped_local = decl.is_local() - && (decl.is_seeded_class_local() || scope_match.global_name == "PLUGIN"); + && (decl.is_seeded_class_local() + || (!scope_match.is_global_singleton && scope_match.hook_owner)); if is_scoped_local || decl.is_global() { decls.push((decl.get_id(), decl.get_range())); } @@ -1717,7 +1780,8 @@ fn ensure_scoped_class_type_decl( ); } - for super_type in scoped_class_super_types(global_name) { + let scoped_class_scopes = &db.get_emmyrc().gmod.scripted_class_scopes; + for super_type in scoped_class_super_types(global_name, scoped_class_scopes) { db.get_type_index_mut().add_super_type_if_missing( class_decl_id.clone(), file_id, @@ -1742,16 +1806,25 @@ fn scoped_class_uses_global_namespace(global_name: &str) -> bool { matches!(global_name, "TOOL" | "EFFECT") } -fn scoped_class_super_types(global_name: &str) -> Vec { +fn scoped_class_super_types( + global_name: &str, + scoped_class_scopes: &crate::config::EmmyrcGmodScriptedClassScopes, +) -> Vec { let mut super_types = vec![LuaType::Ref(LuaTypeDeclId::global(global_name))]; match global_name { "TOOL" => super_types.push(LuaType::Ref(LuaTypeDeclId::global("Tool"))), "SWEP" => super_types.push(LuaType::Ref(LuaTypeDeclId::global("Weapon"))), "ENT" => super_types.push(LuaType::Ref(LuaTypeDeclId::global("Entity"))), - "PLUGIN" => super_types.push(LuaType::Ref(LuaTypeDeclId::global("GM"))), _ => {} } + for super_type_name in scoped_class_scopes.super_types_for_global(global_name) { + let super_type = LuaType::Ref(LuaTypeDeclId::global(&super_type_name)); + if !super_types.iter().any(|existing| existing == &super_type) { + super_types.push(super_type); + } + } + super_types } @@ -3568,7 +3641,47 @@ fn detect_scoped_class_from_path(db: &DbIndex, file_id: FileId) -> Option Vec { + scopes.aliases_for_global(global_name) +} + +pub(crate) fn compute_extra_scope_matches( + db: &DbIndex, + file_id: FileId, + primary_global_name: &str, +) -> Vec<(String, String, Option, bool, bool)> { + let Some(file_path) = db.get_vfs().get_file_path(&file_id) else { + return Vec::new(); + }; + db.get_emmyrc() + .gmod + .scripted_class_scopes + .detect_all_scoped_class_matches_for_path(file_path) + .into_iter() + .filter(|scope_match| { + !scope_match + .definition + .class_global + .eq_ignore_ascii_case(primary_global_name) + }) + .map(|scope_match| { + ( + scope_match.class_name, + scope_match.definition.class_global, + scope_match.definition.class_name_prefix, + scope_match.definition.is_global_singleton, + scope_match.definition.hook_owner, + ) }) + .collect() } /// Returns the scripted class info `(class_name, global_name)` for a file, if it belongs to a @@ -3899,7 +4012,8 @@ fn collect_hook_method_site(db: &DbIndex, func_stat: LuaFuncStat) -> Option Option bool { - matches!(prefix_name, "GM" | "GAMEMODE" | "PLUGIN" | "SANDBOX") + BUILTIN_METHOD_HOOK_PREFIXES.contains(&prefix_name) } fn is_configured_method_hook_prefix(db: &DbIndex, prefix_name: &str) -> bool { @@ -3997,6 +4112,22 @@ fn is_configured_method_hook_prefix(db: &DbIndex, prefix_name: &str) -> bool { }) } +fn is_scripted_hook_owner_prefix(db: &DbIndex, prefix_name: &str) -> bool { + let emmyrc = db.get_emmyrc(); + emmyrc + .gmod + .scripted_owners + .hook_owner_names() + .iter() + .any(|name| name.eq_ignore_ascii_case(prefix_name)) + || emmyrc + .gmod + .scripted_class_scopes + .hook_owner_globals() + .iter() + .any(|name| name.eq_ignore_ascii_case(prefix_name)) +} + #[derive(Debug, Clone)] struct HookAnnotationMetadata { hook_name: Option, diff --git a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs index ba5fe07a..01c87334 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/mod.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/mod.rs @@ -347,12 +347,42 @@ impl AnalyzeContext { let scripted_scope_infos = scoped_matches .into_iter() .map(|(file_id, scope_match)| { + let aliases = scopes.aliases_for_global(&scope_match.definition.class_global); + let extra_scope_matches = db + .get_vfs() + .get_file_path(&file_id) + .map(|path| { + scopes + .detect_all_scoped_class_matches_for_path(path) + .into_iter() + .filter(|extra_match| { + !extra_match + .definition + .class_global + .eq_ignore_ascii_case(&scope_match.definition.class_global) + }) + .map(|extra_match| { + ( + extra_match.class_name, + extra_match.definition.class_global, + extra_match.definition.class_name_prefix, + extra_match.definition.is_global_singleton, + extra_match.definition.hook_owner, + ) + }) + .collect::>() + }) + .unwrap_or_default(); ( file_id, GmodScopedClassInfo { class_name: scope_match.class_name, global_name: scope_match.definition.class_global, class_name_prefix: scope_match.definition.class_name_prefix, + aliases, + is_global_singleton: scope_match.definition.is_global_singleton, + hook_owner: scope_match.definition.hook_owner, + extra_scope_matches, }, ) }) diff --git a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve_closure.rs b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve_closure.rs index 162810d5..1b9ac99d 100644 --- a/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve_closure.rs +++ b/crates/glua_code_analysis/src/compilation/analyzer/unresolve/resolve_closure.rs @@ -297,8 +297,28 @@ fn iter_hook_owner_names(db: &DbIndex) -> Vec { "GM".to_string(), "GAMEMODE".to_string(), "SANDBOX".to_string(), - "PLUGIN".to_string(), ]; + for scoped_hook_owner in db + .get_emmyrc() + .gmod + .scripted_class_scopes + .hook_owner_globals() + { + if !names + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&scoped_hook_owner)) + { + names.push(scoped_hook_owner); + } + } + for configured_name in db.get_emmyrc().gmod.scripted_owners.hook_owner_names() { + if !names + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&configured_name)) + { + names.push(configured_name); + } + } for configured_prefix in &db.get_emmyrc().gmod.hook_mappings.method_prefixes { let normalized = configured_prefix .trim() diff --git a/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs b/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs index a127527d..a532d1ac 100644 --- a/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs +++ b/crates/glua_code_analysis/src/compilation/test/gmod_scripted_class_test.rs @@ -6,8 +6,9 @@ mod test { use tokio_util::sync::CancellationToken; use crate::{ - DiagnosticCode, Emmyrc, EmmyrcGmodScriptedClassScopeEntry, GlobalId, GmodClassCallLiteral, - LuaMemberId, LuaMemberKey, LuaMemberOwner, LuaType, LuaTypeDeclId, VirtualWorkspace, + DiagnosticCode, Emmyrc, EmmyrcGmodScriptedClassDefinition, + EmmyrcGmodScriptedClassScopeEntry, GlobalId, GmodClassCallLiteral, LuaMemberId, + LuaMemberKey, LuaMemberOwner, LuaType, LuaTypeDeclId, VirtualWorkspace, }; fn legacy_scope(pattern: &str) -> EmmyrcGmodScriptedClassScopeEntry { @@ -417,9 +418,85 @@ mod test { .iter() .any(|ty| ty == &LuaType::Ref(LuaTypeDeclId::global("GM"))) ); + assert!( + super_types + .iter() + .any(|ty| ty == &LuaType::Ref(LuaTypeDeclId::global("GAMEMODE"))) + ); + assert!( + super_types + .iter() + .any(|ty| ty == &LuaType::Ref(LuaTypeDeclId::global("SANDBOX"))) + ); } } + #[gtest] + fn test_configured_hook_owner_scope_binds_matching_local_decl_without_plugin_literal() { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + emmyrc.gmod.scripted_class_scopes.include = + vec![EmmyrcGmodScriptedClassScopeEntry::Definition(Box::new( + EmmyrcGmodScriptedClassDefinition { + id: "custom-plugins".to_string(), + label: Some("Custom Plugins".to_string()), + path: Some(vec!["custom_plugins".to_string()]), + include: Some(vec!["custom_plugins/**".to_string()]), + exclude: None, + class_global: Some("CUSTOMPLUGIN".to_string()), + fixed_class_name: None, + is_global_singleton: None, + strip_file_prefix: None, + hide_from_outline: None, + aliases: None, + super_types: Some(vec!["GM".to_string()]), + hook_owner: Some(true), + parent_id: None, + icon: None, + root_dir: None, + scaffold: None, + class_name_prefix: None, + disabled: None, + }, + ))]; + ws.update_emmyrc(emmyrc); + + let file_id = ws.def_file( + "custom_plugins/weather/sh_plugin.lua", + r#" + local CUSTOMPLUGIN = {} + + function CUSTOMPLUGIN:PlayerSpawn(client) + end + "#, + ); + + let custom_decl_id = { + let db = ws.get_db_mut(); + let decl_tree = db + .get_decl_index() + .get_decl_tree(&file_id) + .expect("expected decl tree"); + decl_tree + .get_decls() + .values() + .find(|decl| decl.get_name() == "CUSTOMPLUGIN" && decl.is_local()) + .expect("expected local CUSTOMPLUGIN declaration") + .get_id() + }; + + let db = ws.get_db_mut(); + let type_cache = db + .get_type_index() + .get_type_cache(&custom_decl_id.into()) + .expect("expected CUSTOMPLUGIN declaration type cache"); + assert_eq!( + type_cache.as_type(), + &LuaType::Def(LuaTypeDeclId::global("weather")) + ); + } + #[gtest] fn test_plugin_scope_binds_plugin_decl_with_self_reference_initializer() { let mut ws = VirtualWorkspace::new(); diff --git a/crates/glua_code_analysis/src/config/configs/gmod.rs b/crates/glua_code_analysis/src/config/configs/gmod.rs index 7e9030c5..d7bb5ee7 100644 --- a/crates/glua_code_analysis/src/config/configs/gmod.rs +++ b/crates/glua_code_analysis/src/config/configs/gmod.rs @@ -97,6 +97,10 @@ pub struct EmmyrcGmod { /// Set to `false` to disable this detection. #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_detect_gamemode_base: Option, + /// Configures additional hook-owner globals beyond the built-in + /// `GM` / `GAMEMODE` / `SANDBOX` set. + #[serde(default)] + pub scripted_owners: EmmyrcGmodScriptedOwners, } #[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] @@ -131,6 +135,27 @@ pub struct EmmyrcGmodScriptedClassDefinition { pub exclude: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub class_global: Option, + /// When set, every file matched by this scope resolves to this class name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fixed_class_name: Option, + /// When true, the scope's class global is a workspace-global singleton. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_global_singleton: Option, + /// When true, strips sh_/sv_/cl_ from single-file class names. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub strip_file_prefix: Option, + /// When true, editor outline/class explorer views should hide this scope. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hide_from_outline: Option, + /// Additional global names exposed for the same class global. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub aliases: Option>, + /// Class globals this scope inherits from. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub super_types: Option>, + /// Whether this class global should be treated as a hook owner. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hook_owner: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -172,6 +197,20 @@ pub struct ResolvedGmodScriptedClassDefinition { pub exclude: Vec, pub class_global: String, #[serde(default, skip_serializing_if = "Option::is_none")] + pub fixed_class_name: Option, + #[serde(default)] + pub is_global_singleton: bool, + #[serde(default)] + pub strip_file_prefix: bool, + #[serde(default)] + pub hide_from_outline: bool, + #[serde(default)] + pub aliases: Vec, + #[serde(default)] + pub super_types: Vec, + #[serde(default)] + pub hook_owner: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub icon: Option, @@ -202,6 +241,13 @@ impl<'de> Deserialize<'de> for EmmyrcGmodScriptedClassDefinition { "include", "exclude", "classGlobal", + "fixedClassName", + "isGlobalSingleton", + "stripFilePrefix", + "hideFromOutline", + "aliases", + "superTypes", + "hookOwner", "parentId", "icon", "rootDir", @@ -229,6 +275,13 @@ impl<'de> Deserialize<'de> for EmmyrcGmodScriptedClassDefinition { let mut include = None; let mut exclude = None; let mut class_global = None; + let mut fixed_class_name = None; + let mut is_global_singleton = None; + let mut strip_file_prefix = None; + let mut hide_from_outline = None; + let mut aliases = None; + let mut super_types = None; + let mut hook_owner = None; let mut parent_id = None; let mut icon = None; let mut root_dir = None; @@ -246,6 +299,25 @@ impl<'de> Deserialize<'de> for EmmyrcGmodScriptedClassDefinition { "classGlobal" => { read_unique_field(&mut class_global, &mut map, "classGlobal")? } + "fixedClassName" => { + read_unique_field(&mut fixed_class_name, &mut map, "fixedClassName")? + } + "isGlobalSingleton" => read_unique_field( + &mut is_global_singleton, + &mut map, + "isGlobalSingleton", + )?, + "stripFilePrefix" => { + read_unique_field(&mut strip_file_prefix, &mut map, "stripFilePrefix")? + } + "hideFromOutline" => { + read_unique_field(&mut hide_from_outline, &mut map, "hideFromOutline")? + } + "aliases" => read_unique_field(&mut aliases, &mut map, "aliases")?, + "superTypes" => { + read_unique_field(&mut super_types, &mut map, "superTypes")? + } + "hookOwner" => read_unique_field(&mut hook_owner, &mut map, "hookOwner")?, "parentId" => read_unique_field(&mut parent_id, &mut map, "parentId")?, "icon" => read_unique_field(&mut icon, &mut map, "icon")?, "rootDir" => read_unique_field(&mut root_dir, &mut map, "rootDir")?, @@ -267,6 +339,13 @@ impl<'de> Deserialize<'de> for EmmyrcGmodScriptedClassDefinition { include: include.unwrap_or_default(), exclude: exclude.unwrap_or_default(), class_global: class_global.unwrap_or_default(), + fixed_class_name: fixed_class_name.unwrap_or_default(), + is_global_singleton: is_global_singleton.unwrap_or_default(), + strip_file_prefix: strip_file_prefix.unwrap_or_default(), + hide_from_outline: hide_from_outline.unwrap_or_default(), + aliases: aliases.unwrap_or_default(), + super_types: super_types.unwrap_or_default(), + hook_owner: hook_owner.unwrap_or_default(), parent_id: parent_id.unwrap_or_default(), icon: icon.unwrap_or_default(), root_dir: root_dir.unwrap_or_default(), @@ -349,6 +428,13 @@ impl<'de> Deserialize<'de> for ResolvedGmodScriptedClassDefinition { "include", "exclude", "classGlobal", + "fixedClassName", + "isGlobalSingleton", + "stripFilePrefix", + "hideFromOutline", + "aliases", + "superTypes", + "hookOwner", "parentId", "icon", "rootDir", @@ -375,6 +461,13 @@ impl<'de> Deserialize<'de> for ResolvedGmodScriptedClassDefinition { let mut include = None; let mut exclude = None; let mut class_global = None; + let mut fixed_class_name = None; + let mut is_global_singleton = None; + let mut strip_file_prefix = None; + let mut hide_from_outline = None; + let mut aliases = None; + let mut super_types = None; + let mut hook_owner = None; let mut parent_id = None; let mut icon = None; let mut root_dir = None; @@ -391,6 +484,25 @@ impl<'de> Deserialize<'de> for ResolvedGmodScriptedClassDefinition { "classGlobal" => { read_unique_field(&mut class_global, &mut map, "classGlobal")? } + "fixedClassName" => { + read_unique_field(&mut fixed_class_name, &mut map, "fixedClassName")? + } + "isGlobalSingleton" => read_unique_field( + &mut is_global_singleton, + &mut map, + "isGlobalSingleton", + )?, + "stripFilePrefix" => { + read_unique_field(&mut strip_file_prefix, &mut map, "stripFilePrefix")? + } + "hideFromOutline" => { + read_unique_field(&mut hide_from_outline, &mut map, "hideFromOutline")? + } + "aliases" => read_unique_field(&mut aliases, &mut map, "aliases")?, + "superTypes" => { + read_unique_field(&mut super_types, &mut map, "superTypes")? + } + "hookOwner" => read_unique_field(&mut hook_owner, &mut map, "hookOwner")?, "parentId" => read_unique_field(&mut parent_id, &mut map, "parentId")?, "icon" => read_unique_field(&mut icon, &mut map, "icon")?, "rootDir" => read_unique_field(&mut root_dir, &mut map, "rootDir")?, @@ -411,6 +523,13 @@ impl<'de> Deserialize<'de> for ResolvedGmodScriptedClassDefinition { include: required_field::<_, MapType::Error>(include, "include")?, exclude: required_field::<_, MapType::Error>(exclude, "exclude")?, class_global: required_field::<_, MapType::Error>(class_global, "classGlobal")?, + fixed_class_name: fixed_class_name.unwrap_or_default(), + is_global_singleton: is_global_singleton.unwrap_or(false), + strip_file_prefix: strip_file_prefix.unwrap_or(false), + hide_from_outline: hide_from_outline.unwrap_or(false), + aliases: aliases.unwrap_or_default(), + super_types: super_types.unwrap_or_default(), + hook_owner: hook_owner.unwrap_or(false), parent_id: parent_id.unwrap_or_default(), icon: icon.unwrap_or_default(), root_dir: required_field::<_, MapType::Error>(root_dir, "rootDir")?, @@ -522,6 +641,7 @@ impl Default for EmmyrcGmod { auto_load_annotations: None, template_path: None, auto_detect_gamemode_base: None, + scripted_owners: EmmyrcGmodScriptedOwners::default(), } } } @@ -642,18 +762,27 @@ fn scripted_scope_include_default() -> Vec { }], }), )), - EmmyrcGmodScriptedClassScopeEntry::Definition(default_scripted_class_definition( - "plugins", - "Plugins", - &["plugins"], - &["plugins/**"], - &[], - "PLUGIN", - None, - Some("extensions"), - Some("plugins"), - None, - )), + EmmyrcGmodScriptedClassScopeEntry::Definition({ + let mut definition = default_scripted_class_definition( + "plugins", + "Plugins", + &["plugins"], + &["plugins/**"], + &[], + "PLUGIN", + None, + Some("extensions"), + Some("plugins"), + None, + ); + definition.super_types = Some(vec![ + "GM".to_string(), + "GAMEMODE".to_string(), + "SANDBOX".to_string(), + ]); + definition.hook_owner = Some(true); + definition + }), EmmyrcGmodScriptedClassScopeEntry::Definition({ let mut definition = default_scripted_class_definition( "gamemodes", @@ -714,6 +843,13 @@ fn default_scripted_class_definition( root_dir: root_dir.map(str::to_string), scaffold, class_name_prefix: None, + fixed_class_name: None, + is_global_singleton: None, + strip_file_prefix: None, + hide_from_outline: None, + aliases: None, + super_types: None, + hook_owner: None, disabled: None, }) } @@ -786,6 +922,18 @@ fn resolve_scripted_class_definition( .collect(), exclude, class_global: class_global.to_string(), + fixed_class_name: definition + .fixed_class_name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string), + is_global_singleton: definition.is_global_singleton.unwrap_or(false), + strip_file_prefix: definition.strip_file_prefix.unwrap_or(false), + hide_from_outline: definition.hide_from_outline.unwrap_or(false), + aliases: normalize_name_list(definition.aliases.as_deref()), + super_types: normalize_name_list(definition.super_types.as_deref()), + hook_owner: definition.hook_owner.unwrap_or(false), parent_id: definition .parent_id .as_deref() @@ -857,6 +1005,13 @@ fn merge_scripted_class_definitions( include: Some(vec![trimmed.to_string()]), exclude: None, class_global: None, + fixed_class_name: None, + is_global_singleton: None, + strip_file_prefix: None, + hide_from_outline: None, + aliases: None, + super_types: None, + hook_owner: None, parent_id: None, icon: None, root_dir: None, @@ -931,6 +1086,29 @@ fn legacy_include_patterns(entries: &[EmmyrcGmodScriptedClassScopeEntry]) -> Vec .collect() } +fn normalize_name_list(items: Option<&[String]>) -> Vec { + let mut seen = HashSet::new(); + items + .unwrap_or(&[]) + .iter() + .filter_map(|item| { + let trimmed = item.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) + .filter(|item| seen.insert(item.to_ascii_lowercase())) + .collect() +} + +fn strip_realm_file_prefix(name: &str) -> &str { + if name.len() > 3 { + let prefix = &name[..3]; + if matches!(prefix, "sh_" | "sv_" | "cl_") { + return &name[3..]; + } + } + name +} + fn definition_matches_legacy_include( definition: &ResolvedGmodScriptedClassDefinition, legacy_include: &[String], @@ -1130,6 +1308,297 @@ fn matches_scope_patterns( true } +#[derive(Serialize, Deserialize, Debug, JsonSchema, Clone)] +#[serde(rename_all = "camelCase")] +pub struct EmmyrcGmodScriptedOwnerEntry { + pub id: String, + pub global: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub aliases: Option>, + pub include: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exclude: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hook_owner: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fallback_owners: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disabled: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedGmodScriptedOwnerDefinition { + pub id: String, + pub global: String, + pub aliases: Vec, + pub include: Vec, + pub exclude: Vec, + pub hook_owner: bool, + pub fallback_owners: Vec, +} + +#[derive(Serialize, Deserialize, Debug, JsonSchema, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct EmmyrcGmodScriptedOwners { + #[serde(default)] + pub include: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PartialOrd, Ord)] +struct PatternSpecificity { + literal_segs: usize, + inverse_wildcard_segs: usize, + literal_chars: usize, + pattern_len: usize, +} + +impl PatternSpecificity { + fn of(pattern: &str) -> Self { + let mut literal_segs = 0usize; + let mut wildcard_segs = 0usize; + let mut literal_chars = 0usize; + for segment in pattern.split('/').filter(|segment| !segment.is_empty()) { + if segment.contains('*') || segment.contains('?') { + wildcard_segs += 1; + } else { + literal_segs += 1; + literal_chars += segment.len(); + } + } + + Self { + literal_segs, + inverse_wildcard_segs: usize::MAX - wildcard_segs, + literal_chars, + pattern_len: pattern.len(), + } + } +} + +fn resolve_scripted_owner_entry( + entry: &EmmyrcGmodScriptedOwnerEntry, +) -> Option { + if entry.disabled.unwrap_or(false) { + return None; + } + + let id = entry.id.trim(); + let global = entry.global.trim(); + if id.is_empty() || global.is_empty() { + return None; + } + + let include = normalize_name_list(Some(&entry.include)); + if include.is_empty() { + return None; + } + + let exclude = normalize_name_list(entry.exclude.as_deref()); + let aliases = normalize_name_list(entry.aliases.as_deref()) + .into_iter() + .filter(|alias| alias != global) + .collect::>(); + let fallback_owners = normalize_name_list(entry.fallback_owners.as_deref()) + .into_iter() + .filter(|owner| owner != global && !aliases.iter().any(|alias| alias == owner)) + .collect::>(); + + Some(ResolvedGmodScriptedOwnerDefinition { + id: id.to_string(), + global: global.to_string(), + aliases, + include, + exclude, + hook_owner: entry.hook_owner.unwrap_or(false), + fallback_owners, + }) +} + +fn builtin_hook_owner_fallbacks(owner_name: &str) -> Vec { + if owner_name.eq_ignore_ascii_case("GM") || owner_name.eq_ignore_ascii_case("GAMEMODE") { + vec!["SANDBOX".to_string()] + } else if owner_name.eq_ignore_ascii_case("SANDBOX") { + vec!["GM".to_string(), "GAMEMODE".to_string()] + } else { + Vec::new() + } +} + +fn builtin_hook_owner_candidates(owner_name: &str) -> Vec { + if owner_name.eq_ignore_ascii_case("GM") || owner_name.eq_ignore_ascii_case("GAMEMODE") { + vec![ + "GM".to_string(), + "GAMEMODE".to_string(), + "SANDBOX".to_string(), + ] + } else if owner_name.eq_ignore_ascii_case("SANDBOX") { + vec![ + "SANDBOX".to_string(), + "GM".to_string(), + "GAMEMODE".to_string(), + ] + } else { + Vec::new() + } +} + +fn merge_owner_names_dedup(primary: Vec, secondary: Vec) -> Vec { + let mut merged = primary; + let mut seen = merged + .iter() + .map(|name| name.to_ascii_lowercase()) + .collect::>(); + for name in secondary { + if seen.insert(name.to_ascii_lowercase()) { + merged.push(name); + } + } + merged +} + +impl EmmyrcGmodScriptedOwners { + pub fn resolved_owners(&self) -> Vec { + let mut result = Vec::new(); + let mut seen_ids = HashSet::new(); + for entry in &self.include { + let id = entry.id.trim(); + if id.is_empty() { + continue; + } + let id_lower = id.to_ascii_lowercase(); + if seen_ids.contains(&id_lower) { + log::warn!("gmod.scriptedOwners: duplicate id '{id}' - first-valid entry wins"); + continue; + } + if let Some(definition) = resolve_scripted_owner_entry(entry) { + seen_ids.insert(id_lower); + result.push(definition); + } + } + result + } + + pub fn detect_owner_for_path( + &self, + file_path: &Path, + ) -> Option { + self.detect_owners_for_path_all(file_path) + .into_iter() + .next() + } + + pub fn detect_owners_for_path_all( + &self, + file_path: &Path, + ) -> Vec { + let candidate_paths = build_scope_candidate_paths(file_path); + let mut matches = Vec::new(); + + for (idx, definition) in self.resolved_owners().into_iter().enumerate() { + if !definition.exclude.is_empty() + && definition.exclude.iter().any(|pattern| { + wax::Glob::new(pattern) + .map(|glob| { + candidate_paths + .iter() + .any(|path| glob.is_match(Path::new(path))) + }) + .unwrap_or(false) + }) + { + continue; + } + + let best_score = definition + .include + .iter() + .filter_map(|pattern| { + let glob = wax::Glob::new(pattern).ok()?; + candidate_paths + .iter() + .any(|path| glob.is_match(Path::new(path))) + .then(|| PatternSpecificity::of(pattern)) + }) + .max(); + + if let Some(score) = best_score { + matches.push((score, idx, definition)); + } + } + + matches.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); + matches + .into_iter() + .map(|(_, _, definition)| definition) + .collect() + } + + pub fn hook_owner_names(&self) -> Vec { + let mut names = Vec::new(); + let mut seen = HashSet::new(); + for definition in self.resolved_owners() { + if !definition.hook_owner { + continue; + } + if seen.insert(definition.global.clone()) { + names.push(definition.global); + } + for alias in definition.aliases { + if seen.insert(alias.clone()) { + names.push(alias); + } + } + } + names + } + + pub fn hook_owner_fallbacks_configured(&self, owner_name: &str) -> Option> { + self.resolved_owners() + .into_iter() + .find(|definition| { + definition.global.eq_ignore_ascii_case(owner_name) + || definition + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(owner_name)) + }) + .map(|definition| { + merge_owner_names_dedup( + definition.fallback_owners, + builtin_hook_owner_fallbacks(owner_name), + ) + }) + } + + pub fn hook_owner_candidates_configured(&self, owner_name: &str) -> Option> { + self.resolved_owners() + .into_iter() + .find(|definition| { + definition.global.eq_ignore_ascii_case(owner_name) + || definition + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(owner_name)) + }) + .map(|definition| { + let mut names = vec![definition.global]; + names.extend(definition.aliases); + names.extend(definition.fallback_owners); + merge_owner_names_dedup(names, builtin_hook_owner_candidates(owner_name)) + }) + } + + pub fn is_configured_owner_name(&self, name: &str) -> bool { + self.resolved_owners().into_iter().any(|definition| { + definition.global.eq_ignore_ascii_case(name) + || definition + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(name)) + }) + } +} + fn merge_scripted_class_definition_override( base: &ResolvedGmodScriptedClassDefinition, override_definition: &EmmyrcGmodScriptedClassDefinition, @@ -1168,6 +1637,36 @@ fn merge_scripted_class_definition_override( .class_global .clone() .unwrap_or_else(|| base.class_global.clone()), + fixed_class_name: if override_definition.fixed_class_name.is_some() { + override_definition + .fixed_class_name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string) + } else { + base.fixed_class_name.clone() + }, + is_global_singleton: override_definition + .is_global_singleton + .unwrap_or(base.is_global_singleton), + strip_file_prefix: override_definition + .strip_file_prefix + .unwrap_or(base.strip_file_prefix), + hide_from_outline: override_definition + .hide_from_outline + .unwrap_or(base.hide_from_outline), + aliases: if override_definition.aliases.is_some() { + normalize_name_list(override_definition.aliases.as_deref()) + } else { + base.aliases.clone() + }, + super_types: if override_definition.super_types.is_some() { + normalize_name_list(override_definition.super_types.as_deref()) + } else { + base.super_types.clone() + }, + hook_owner: override_definition.hook_owner.unwrap_or(base.hook_owner), parent_id: if override_definition.parent_id.is_some() { override_definition .parent_id @@ -1293,7 +1792,7 @@ impl EmmyrcGmodScriptedClassScopes { let definitions = self.resolved_definitions(); let mut best_match: Option<(ResolvedGmodScriptedClassDefinition, usize, usize)> = None; - for definition in definitions { + for definition in &definitions { // Check THIS definition's include/exclude patterns — do not merge // excludes from other definitions, as they are definition-scoped. // E.g. SWEP's "weapons/gmod_tool/stools/**" exclude must not prevent @@ -1336,33 +1835,133 @@ impl EmmyrcGmodScriptedClassScopes { } } - let (definition, best_end_idx, _) = best_match?; - let class_idx = best_end_idx + 1; - if class_idx >= lower_segments.len() { - return None; + if let Some((definition, best_end_idx, _)) = best_match { + if let Some(class_name) = + derive_scripted_class_name(&definition, &original_segments, best_end_idx + 1) + { + return Some(ResolvedGmodScriptedClassMatch { + definition, + class_name, + }); + } } - let class_name = if class_idx == original_segments.len() - 1 { - original_segments[class_idx] - .strip_suffix(".lua") - .unwrap_or(original_segments[class_idx].as_str()) - .to_string() - } else { - original_segments[class_idx].clone() - }; - if class_name.is_empty() { - return None; + definitions.into_iter().find_map(|definition| { + let fixed_name = definition.fixed_class_name.clone()?; + matches_scope_patterns(file_path, &definition.include, &definition.exclude).then_some( + ResolvedGmodScriptedClassMatch { + definition, + class_name: fixed_name, + }, + ) + }) + } + + pub fn detect_all_class_globals_for_path(&self, file_path: &Path) -> Vec<(String, bool)> { + let mut seen = HashSet::new(); + self.resolved_definitions() + .into_iter() + .filter(|definition| { + matches_scope_patterns(file_path, &definition.include, &definition.exclude) + }) + .filter_map(|definition| { + seen.insert(definition.class_global.to_ascii_lowercase()) + .then_some((definition.class_global, definition.is_global_singleton)) + }) + .collect() + } + + pub fn detect_all_scoped_class_matches_for_path( + &self, + file_path: &Path, + ) -> Vec { + let normalized_path = file_path.to_string_lossy().replace('\\', "/"); + let original_segments = normalized_path + .split('/') + .filter(|segment| !segment.is_empty()) + .map(str::to_string) + .collect::>(); + let lower_segments = normalized_path + .to_ascii_lowercase() + .split('/') + .filter(|segment| !segment.is_empty()) + .map(str::to_string) + .collect::>(); + let mut matches = Vec::new(); + let mut seen_globals = HashSet::new(); + + for definition in self.resolved_definitions() { + if !matches_scope_patterns(file_path, &definition.include, &definition.exclude) { + continue; + } + if !seen_globals.insert(definition.class_global.to_ascii_lowercase()) { + continue; + } + + let class_name = if let Some(fixed_name) = definition.fixed_class_name.clone() { + Some(fixed_name) + } else { + find_scripted_class_path_end(&definition, &lower_segments).and_then(|end_idx| { + derive_scripted_class_name(&definition, &original_segments, end_idx + 1) + }) + }; + + if let Some(class_name) = class_name { + matches.push(ResolvedGmodScriptedClassMatch { + definition, + class_name, + }); + } } - let class_name = match definition.class_name_prefix.as_deref() { - Some(prefix) if !prefix.is_empty() => format!("{prefix}{class_name}"), - _ => class_name, - }; + matches + } - Some(ResolvedGmodScriptedClassMatch { - definition, - class_name, - }) + pub fn aliases_for_global(&self, global_name: &str) -> Vec { + self.resolved_definitions() + .into_iter() + .find(|definition| { + definition.class_global.eq_ignore_ascii_case(global_name) + || definition + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(global_name)) + }) + .map(|definition| definition.aliases) + .unwrap_or_default() + } + + pub fn super_types_for_global(&self, global_name: &str) -> Vec { + self.resolved_definitions() + .into_iter() + .find(|definition| { + definition.class_global.eq_ignore_ascii_case(global_name) + || definition + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(global_name)) + }) + .map(|definition| definition.super_types) + .unwrap_or_default() + } + + pub fn hook_owner_globals(&self) -> Vec { + let mut seen = HashSet::new(); + let mut globals = Vec::new(); + for definition in self.resolved_definitions() { + if !definition.hook_owner { + continue; + } + if seen.insert(definition.class_global.clone()) { + globals.push(definition.class_global); + } + for alias in definition.aliases { + if seen.insert(alias.clone()) { + globals.push(alias); + } + } + } + globals } pub fn scan_scripted_class_scope_files<'a, T>( @@ -1418,6 +2017,67 @@ enum ScopePatternSet<'a> { Invalid, } +fn find_scripted_class_path_end( + definition: &ResolvedGmodScriptedClassDefinition, + lower_segments: &[String], +) -> Option { + let rule_len = definition.path.len(); + if rule_len == 0 || lower_segments.len() < rule_len { + return None; + } + + for start_idx in (0..=lower_segments.len() - rule_len).rev() { + let matched = definition + .path + .iter() + .enumerate() + .all(|(offset, rule_segment)| { + lower_segments[start_idx + offset] == rule_segment.to_ascii_lowercase() + }); + if matched { + return Some(start_idx + rule_len - 1); + } + } + + None +} + +fn derive_scripted_class_name( + definition: &ResolvedGmodScriptedClassDefinition, + original_segments: &[String], + class_idx: usize, +) -> Option { + if let Some(fixed_name) = definition.fixed_class_name.clone() { + return Some(fixed_name); + } + if original_segments.is_empty() || class_idx >= original_segments.len() { + return None; + } + + let class_name = if definition.strip_file_prefix { + let raw = original_segments + .last()? + .strip_suffix(".lua") + .unwrap_or(original_segments.last()?.as_str()); + strip_realm_file_prefix(raw).to_string() + } else if class_idx == original_segments.len() - 1 { + original_segments[class_idx] + .strip_suffix(".lua") + .unwrap_or(original_segments[class_idx].as_str()) + .to_string() + } else { + original_segments[class_idx].clone() + }; + if class_name.is_empty() { + return None; + } + + Some(match definition.class_name_prefix.as_deref() { + Some(prefix) if !prefix.is_empty() => format!("{prefix}{class_name}"), + _ => class_name, + }) +} + fn compile_scope_definitions( definitions: &[ResolvedGmodScriptedClassDefinition], ) -> Vec> { @@ -1509,32 +2169,23 @@ fn detect_class_for_path_with_compiled_definitions( } } - let (definition, best_end_idx, _) = best_match?; - let class_idx = best_end_idx + 1; - if class_idx >= lower_segments.len() { - return None; - } - - let class_name = if class_idx == original_segments.len() - 1 { - original_segments[class_idx] - .strip_suffix(".lua") - .unwrap_or(original_segments[class_idx].as_str()) - .to_string() - } else { - original_segments[class_idx].clone() - }; - if class_name.is_empty() { - return None; + if let Some((definition, best_end_idx, _)) = best_match + && let Some(class_name) = + derive_scripted_class_name(definition, &original_segments, best_end_idx + 1) + { + return Some(ResolvedGmodScriptedClassMatch { + definition: definition.clone(), + class_name, + }); } - let class_name = match definition.class_name_prefix.as_deref() { - Some(prefix) if !prefix.is_empty() => format!("{prefix}{class_name}"), - _ => class_name, - }; - - Some(ResolvedGmodScriptedClassMatch { - definition: definition.clone(), - class_name, + definitions.iter().find_map(|definition| { + let fixed_name = definition.definition.fixed_class_name.clone()?; + matches_compiled_scope_patterns(candidate_paths, &definition.include, &definition.exclude) + .then(|| ResolvedGmodScriptedClassMatch { + definition: definition.definition.clone(), + class_name: fixed_name, + }) }) } @@ -1945,6 +2596,191 @@ mod tests { Ok(()) } + #[gtest] + fn test_detect_class_fixed_class_name_returns_fixed_name() -> Result<()> { + let scopes: EmmyrcGmodScriptedClassScopes = serde_json::from_str( + r#"{ + "include": [ + { + "id": "helix-schema", + "label": "Helix Schema", + "classGlobal": "SCHEMA", + "fixedClassName": "SCHEMA", + "path": ["schema"], + "include": ["schema/**", "gamemode/schema.lua"] + } + ] + }"#, + ) + .or_fail()?; + + let result = scopes + .detect_class_for_path(Path::new("schema/meta/sh_character.lua")) + .map(|entry| (entry.class_name, entry.definition.class_global)); + assert_eq!(result, Some(("SCHEMA".to_string(), "SCHEMA".to_string()))); + + let include_only = scopes + .detect_class_for_path(Path::new("gamemode/schema.lua")) + .map(|entry| entry.class_name); + assert_eq!(include_only, Some("SCHEMA".to_string())); + Ok(()) + } + + #[gtest] + fn test_detect_class_strip_file_prefix_for_single_file_classes() -> Result<()> { + let scopes: EmmyrcGmodScriptedClassScopes = serde_json::from_str( + r#"{ + "include": [ + { + "id": "helix-items", + "label": "Helix Items", + "classGlobal": "ITEM", + "path": ["schema", "items"], + "include": ["schema/items/**"], + "stripFilePrefix": true + } + ] + }"#, + ) + .or_fail()?; + + let result = scopes + .detect_class_for_path(Path::new("schema/items/books/sh_paper.lua")) + .map(|entry| entry.class_name); + assert_eq!(result, Some("paper".to_string())); + Ok(()) + } + + #[gtest] + fn test_scripted_class_metadata_normalizes_and_resolves_helpers() -> Result<()> { + let scopes: EmmyrcGmodScriptedClassScopes = serde_json::from_str( + r#"{ + "include": [ + { + "id": "helix-schema", + "label": "Helix Schema", + "classGlobal": "SCHEMA", + "fixedClassName": "SCHEMA", + "path": ["schema"], + "include": ["schema/**"], + "isGlobalSingleton": true, + "hideFromOutline": true, + "aliases": [" Schema ", " "], + "superTypes": [" GM ", " "], + "hookOwner": true + } + ] + }"#, + ) + .or_fail()?; + + let definitions = scopes.resolved_definitions(); + let schema = definitions + .iter() + .find(|definition| definition.id == "helix-schema") + .expect("expected helix schema definition"); + verify_that!(schema.fixed_class_name.as_deref(), eq(Some("SCHEMA")))?; + verify_that!(schema.is_global_singleton, eq(true))?; + verify_that!(schema.hide_from_outline, eq(true))?; + verify_that!(schema.aliases.as_slice(), eq(&["Schema".to_string()]))?; + verify_that!(schema.super_types.as_slice(), eq(&["GM".to_string()]))?; + verify_that!(schema.hook_owner, eq(true))?; + verify_that!( + scopes.aliases_for_global("SCHEMA").as_slice(), + eq(&["Schema".to_string()]) + )?; + verify_that!( + scopes.super_types_for_global("Schema").as_slice(), + eq(&["GM".to_string()]) + )?; + let hook_owner_globals = scopes.hook_owner_globals(); + assert!(hook_owner_globals.contains(&"SCHEMA".to_string())); + assert!(hook_owner_globals.contains(&"Schema".to_string())); + Ok(()) + } + + #[gtest] + fn test_detect_all_scoped_class_matches_keeps_overlapping_plugin_scope() -> Result<()> { + let scopes: EmmyrcGmodScriptedClassScopes = serde_json::from_str( + r#"{ + "include": [ + { + "id": "plugins", + "label": "Plugins", + "classGlobal": "PLUGIN", + "path": ["plugins"], + "include": ["plugins/**"] + }, + { + "id": "helix-items", + "label": "Helix Items", + "classGlobal": "ITEM", + "path": ["items"], + "include": ["plugins/*/items/**"], + "stripFilePrefix": true + } + ] + }"#, + ) + .or_fail()?; + + let matches = scopes.detect_all_scoped_class_matches_for_path(Path::new( + "plugins/writing/items/books/sh_paper.lua", + )); + let pairs = matches + .into_iter() + .map(|entry| (entry.definition.class_global, entry.class_name)) + .collect::>(); + assert_eq!( + pairs, + vec![ + ("PLUGIN".to_string(), "writing".to_string()), + ("ITEM".to_string(), "paper".to_string()), + ] + ); + Ok(()) + } + + #[gtest] + fn test_scripted_owners_resolve_candidates_and_fallbacks() -> Result<()> { + let owners: EmmyrcGmodScriptedOwners = serde_json::from_str( + r#"{ + "include": [ + { + "id": "schema", + "global": "SCHEMA", + "aliases": ["Schema", "schema"], + "include": ["schema/**"], + "hookOwner": true, + "fallbackOwners": ["GM"] + } + ] + }"#, + ) + .or_fail()?; + + let resolved = owners.resolved_owners(); + verify_that!(resolved.len(), eq(1usize))?; + verify_that!( + owners.hook_owner_names().as_slice(), + eq(&["SCHEMA".to_string(), "Schema".to_string()]) + )?; + verify_that!( + owners + .hook_owner_candidates_configured("Schema") + .unwrap() + .as_slice(), + eq(&["SCHEMA".to_string(), "Schema".to_string(), "GM".to_string()]) + )?; + verify_that!( + owners + .hook_owner_fallbacks_configured("SCHEMA") + .unwrap() + .as_slice(), + eq(&["GM".to_string()]) + ) + } + #[gtest] fn test_detect_class_for_path_preserves_original_case() -> Result<()> { let scopes = EmmyrcGmodScriptedClassScopes::default(); diff --git a/crates/glua_code_analysis/src/db_index/gmod_infer/mod.rs b/crates/glua_code_analysis/src/db_index/gmod_infer/mod.rs index c822d662..2eb70835 100644 --- a/crates/glua_code_analysis/src/db_index/gmod_infer/mod.rs +++ b/crates/glua_code_analysis/src/db_index/gmod_infer/mod.rs @@ -184,6 +184,12 @@ pub struct GmodScopedClassInfo { /// (e.g. parent-name alias for gamemodes) can strip it back off without a /// second path scan. pub class_name_prefix: Option, + pub aliases: Vec, + pub is_global_singleton: bool, + pub hook_owner: bool, + /// Additional matching scripted scopes for the same file, stored as + /// `(class_name, global_name, class_name_prefix, is_global_singleton, hook_owner)`. + pub extra_scope_matches: Vec<(String, String, Option, bool, bool)>, } /// Workspace-global registry of helper function definitions, used as a diff --git a/crates/glua_code_analysis/src/diagnostic/checker/undefined_global.rs b/crates/glua_code_analysis/src/diagnostic/checker/undefined_global.rs index a932194e..007cdf50 100644 --- a/crates/glua_code_analysis/src/diagnostic/checker/undefined_global.rs +++ b/crates/glua_code_analysis/src/diagnostic/checker/undefined_global.rs @@ -698,13 +698,37 @@ fn check_name_expr( return Some(()); } - if db.get_emmyrc().gmod.enabled - && db + if db.get_emmyrc().gmod.enabled { + if let Some(info) = db .get_gmod_infer_index() .get_scoped_class_info(&semantic_model.get_file_id()) - .is_some_and(|info| info.global_name == name_text.as_str()) - { - return Some(()); + && (info.global_name == name_text.as_str() + || info.aliases.iter().any(|alias| alias == name_text.as_str()) + || info + .extra_scope_matches + .iter() + .any(|(_, global_name, _, _, _)| global_name == name_text.as_str())) + { + return Some(()); + } + + if db + .get_emmyrc() + .gmod + .scripted_class_scopes + .resolved_definitions() + .into_iter() + .any(|definition| { + definition.is_global_singleton + && (definition.class_global == name_text.as_str() + || definition + .aliases + .iter() + .any(|alias| alias == name_text.as_str())) + }) + { + return Some(()); + } } if name_text == "BaseClass" diff --git a/crates/glua_code_analysis/src/diagnostic/test/undefined_global_test.rs b/crates/glua_code_analysis/src/diagnostic/test/undefined_global_test.rs index 9627345c..a7d723d8 100644 --- a/crates/glua_code_analysis/src/diagnostic/test/undefined_global_test.rs +++ b/crates/glua_code_analysis/src/diagnostic/test/undefined_global_test.rs @@ -499,6 +499,106 @@ mod test { )); } + #[test] + fn global_singleton_scripted_scope_alias_is_available_workspace_wide() { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + emmyrc.gmod.scripted_class_scopes = serde_json::from_str( + r#"{ + "include": [ + { + "id": "helix-schema", + "label": "Helix Schema", + "classGlobal": "SCHEMA", + "fixedClassName": "SCHEMA", + "path": ["schema"], + "include": ["gamemodes/*/schema/**"], + "isGlobalSingleton": true, + "aliases": ["Schema"], + "superTypes": ["GM"], + "hookOwner": true + } + ] + }"#, + ) + .unwrap(); + ws.update_emmyrc(emmyrc); + + ws.def_file( + "gamemodes/ixhl2rp/schema/sh_schema.lua", + r#" + SCHEMA.Name = "Half-Life 2 RP" + function SCHEMA:PlayerSpawn(client) + end + "#, + ); + + let consumer = r#" + print(SCHEMA.Name) + print(Schema.Name) + "#; + assert!(!has_undefined_global_name( + &mut ws, + "gamemodes/ixhl2rp/schema/cl_hooks.lua", + consumer, + "SCHEMA" + )); + assert!(!has_undefined_global_name( + &mut ws, + "gamemodes/ixhl2rp/schema/cl_hooks.lua", + consumer, + "Schema" + )); + } + + #[test] + fn overlapping_scripted_scopes_allow_primary_and_parent_globals() { + let mut ws = VirtualWorkspace::new(); + let mut emmyrc = Emmyrc::default(); + emmyrc.gmod.enabled = true; + emmyrc.gmod.scripted_class_scopes = serde_json::from_str( + r#"{ + "include": [ + { + "id": "plugins", + "label": "Plugins", + "classGlobal": "PLUGIN", + "path": ["plugins"], + "include": ["plugins/**"] + }, + { + "id": "helix-items", + "label": "Helix Items", + "classGlobal": "ITEM", + "path": ["items"], + "include": ["plugins/*/items/**"], + "stripFilePrefix": true + } + ] + }"#, + ) + .unwrap(); + ws.update_emmyrc(emmyrc); + + let item = r#" + PLUGIN.Name = "Writing" + ITEM.name = "Paper" + "#; + assert!(!has_undefined_global_name( + &mut ws, + "plugins/writing/items/books/sh_paper.lua", + item, + "PLUGIN" + )); + assert!(!has_undefined_global_name( + &mut ws, + "plugins/writing/items/books/sh_paper.lua", + item, + "ITEM" + )); + } + #[test] fn test_guarded_global_with_index_expr_and_condition() { let mut ws = VirtualWorkspace::new_with_init_std_lib(); diff --git a/crates/glua_code_analysis/src/semantic/infer/infer_name.rs b/crates/glua_code_analysis/src/semantic/infer/infer_name.rs index 67b950b7..1f691ac8 100644 --- a/crates/glua_code_analysis/src/semantic/infer/infer_name.rs +++ b/crates/glua_code_analysis/src/semantic/infer/infer_name.rs @@ -451,27 +451,48 @@ pub(crate) fn resolve_scoped_scripted_global_type_decl_id( .get_gmod_infer_index() .get_scoped_class_info(&cache.get_file_id()) { - return (info.global_name == name) - .then(|| get_scripted_class_type_decl_id(&info.global_name, &info.class_name)); + if info.global_name == name || info.aliases.iter().any(|alias| alias == name) { + return Some(get_scripted_class_type_decl_id( + &info.global_name, + &info.class_name, + )); + } + if let Some((class_name, global_name, _, _, _)) = info + .extra_scope_matches + .iter() + .find(|(_, global_name, _, _, _)| global_name == name) + { + return Some(get_scripted_class_type_decl_id(global_name, class_name)); + } } - if !db + for definition in db .get_emmyrc() .gmod .scripted_class_scopes .resolved_definitions() - .iter() - .any(|definition| definition.class_global == name) { - return None; - } - - let (global_name, class_name) = detect_scoped_global_from_path_cached(db, cache)?; - if global_name != name { - return None; + if definition.is_global_singleton + && (definition.class_global == name + || definition.aliases.iter().any(|alias| alias == name)) + { + let class_name = definition + .fixed_class_name + .as_deref() + .unwrap_or(definition.class_global.as_str()); + return Some(get_scripted_class_type_decl_id( + &definition.class_global, + class_name, + )); + } + if definition.class_global == name { + let (global_name, class_name) = detect_scoped_global_from_path_cached(db, cache)?; + return (global_name == name) + .then(|| get_scripted_class_type_decl_id(&global_name, &class_name)); + } } - Some(get_scripted_class_type_decl_id(&global_name, &class_name)) + None } fn detect_scoped_global_from_path_cached( diff --git a/crates/glua_ls/src/handlers/completion/providers/member_provider.rs b/crates/glua_ls/src/handlers/completion/providers/member_provider.rs index 3e995953..05935bd5 100644 --- a/crates/glua_ls/src/handlers/completion/providers/member_provider.rs +++ b/crates/glua_ls/src/handlers/completion/providers/member_provider.rs @@ -89,11 +89,12 @@ fn extend_gmod_hook_fallback_members( return; }; - let owner_candidates = gmod_hook_owner_candidates(owner_name.as_str()); - if owner_candidates.is_empty() { + if !gmod_has_hook_owner_candidates(builder.semantic_model.get_db(), owner_name.as_str()) { return; } + let owner_candidates = + gmod_hook_owner_candidates(builder.semantic_model.get_db(), owner_name.as_str()); let mut existing: HashMap>> = HashMap::new(); for (key, infos) in members.iter() { let entry = existing.entry(key.clone()).or_default(); @@ -103,7 +104,7 @@ fn extend_gmod_hook_fallback_members( } for owner_candidate in owner_candidates { - let owner_type = LuaType::Ref(LuaTypeDeclId::global(owner_candidate)); + let owner_type = LuaType::Ref(LuaTypeDeclId::global(&owner_candidate)); let Some(fallback_map) = builder .semantic_model .get_member_info_map_at_offset(&owner_type, builder.position_offset) @@ -123,18 +124,78 @@ fn extend_gmod_hook_fallback_members( } } -fn gmod_hook_owner_candidates(owner_name: &str) -> &'static [&'static str] { +fn gmod_hook_owner_candidates(db: &DbIndex, owner_name: &str) -> Vec { + if let Some(configured) = db + .get_emmyrc() + .gmod + .scripted_owners + .hook_owner_candidates_configured(owner_name) + { + return configured; + } + + for definition in db + .get_emmyrc() + .gmod + .scripted_class_scopes + .resolved_definitions() + { + if definition.hook_owner + && (definition.class_global.eq_ignore_ascii_case(owner_name) + || definition + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(owner_name))) + { + let mut candidates = vec![definition.class_global]; + candidates.extend(definition.aliases); + candidates.extend(definition.super_types); + return candidates; + } + } + if owner_name.eq_ignore_ascii_case("GM") || owner_name.eq_ignore_ascii_case("GAMEMODE") { - &["GM", "GAMEMODE", "SANDBOX"] - } else if owner_name.eq_ignore_ascii_case("PLUGIN") { - &["PLUGIN", "GM", "GAMEMODE", "SANDBOX"] + vec![ + "GM".to_string(), + "GAMEMODE".to_string(), + "SANDBOX".to_string(), + ] } else if owner_name.eq_ignore_ascii_case("SANDBOX") { - &["SANDBOX", "GM", "GAMEMODE"] + vec![ + "SANDBOX".to_string(), + "GM".to_string(), + "GAMEMODE".to_string(), + ] } else { - &[] + Vec::new() } } +fn gmod_has_hook_owner_candidates(db: &DbIndex, owner_name: &str) -> bool { + db.get_emmyrc() + .gmod + .scripted_owners + .hook_owner_candidates_configured(owner_name) + .is_some() + || db + .get_emmyrc() + .gmod + .scripted_class_scopes + .resolved_definitions() + .into_iter() + .any(|definition| { + definition.hook_owner + && (definition.class_global.eq_ignore_ascii_case(owner_name) + || definition + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(owner_name))) + }) + || owner_name.eq_ignore_ascii_case("GM") + || owner_name.eq_ignore_ascii_case("GAMEMODE") + || owner_name.eq_ignore_ascii_case("SANDBOX") +} + pub fn add_completions_for_members( builder: &mut CompletionBuilder, members: &HashMap>, @@ -383,10 +444,7 @@ fn is_gmod_hook_member_info(db: &DbIndex, info: &LuaMemberInfo) -> bool { }; let owner_name = owner_type_id.get_simple_name(); - owner_name.eq_ignore_ascii_case("GM") - || owner_name.eq_ignore_ascii_case("GAMEMODE") - || owner_name.eq_ignore_ascii_case("SANDBOX") - || owner_name.eq_ignore_ascii_case("PLUGIN") + gmod_has_hook_owner_candidates(db, owner_name) } fn is_member_realm_compatible(builder: &CompletionBuilder, info: &LuaMemberInfo) -> bool { diff --git a/crates/glua_ls/src/handlers/gmod_scripted_classes/build_gmod_scripted_classes.rs b/crates/glua_ls/src/handlers/gmod_scripted_classes/build_gmod_scripted_classes.rs index 00a3f653..0421947d 100644 --- a/crates/glua_ls/src/handlers/gmod_scripted_classes/build_gmod_scripted_classes.rs +++ b/crates/glua_ls/src/handlers/gmod_scripted_classes/build_gmod_scripted_classes.rs @@ -15,7 +15,11 @@ pub fn build_gmod_scripted_classes( } let scopes = &db.get_emmyrc().gmod.scripted_class_scopes; - let definitions = scopes.resolved_definitions(); + let definitions = scopes + .resolved_definitions() + .into_iter() + .filter(|definition| !definition.hide_from_outline) + .collect::>(); let mut file_paths = Vec::new(); for file_id in db.get_vfs().get_all_local_file_ids() { @@ -35,6 +39,9 @@ pub fn build_gmod_scripted_classes( if cancel_token.is_cancelled() { return None; } + if scope_match.definition.hide_from_outline { + continue; + } let Some(uri) = file_uri_string(db, file_id) else { continue; diff --git a/crates/glua_ls/src/handlers/hover/build_hover.rs b/crates/glua_ls/src/handlers/hover/build_hover.rs index 5865530e..d1b92e6c 100644 --- a/crates/glua_ls/src/handlers/hover/build_hover.rs +++ b/crates/glua_ls/src/handlers/hover/build_hover.rs @@ -566,7 +566,7 @@ fn extend_gmod_hook_semantic_decls( return; }; - let fallback_owner_names = gmod_hook_owner_fallbacks(owner_type_decl_id.get_simple_name()); + let fallback_owner_names = gmod_hook_owner_fallbacks(db, owner_type_decl_id.get_simple_name()); if fallback_owner_names.is_empty() { return; } @@ -585,7 +585,7 @@ fn extend_gmod_hook_semantic_decls( ); for fallback_owner_name in fallback_owner_names { - let fallback_type = LuaType::Ref(LuaTypeDeclId::global(fallback_owner_name)); + let fallback_type = LuaType::Ref(LuaTypeDeclId::global(&fallback_owner_name)); let member_infos = match trigger_position { Some(trigger_position) => builder.semantic_model.get_member_info_with_key_at_offset( &fallback_type, @@ -756,15 +756,31 @@ fn extend_gmod_hook_same_owner_semantic_decls( } } -fn gmod_hook_owner_fallbacks(owner_name: &str) -> &'static [&'static str] { +fn gmod_hook_owner_fallbacks(db: &DbIndex, owner_name: &str) -> Vec { + if let Some(configured) = db + .get_emmyrc() + .gmod + .scripted_owners + .hook_owner_fallbacks_configured(owner_name) + { + return configured; + } + + let super_types = db + .get_emmyrc() + .gmod + .scripted_class_scopes + .super_types_for_global(owner_name); + if !super_types.is_empty() { + return super_types; + } + if owner_name.eq_ignore_ascii_case("GM") || owner_name.eq_ignore_ascii_case("GAMEMODE") { - &["SANDBOX"] - } else if owner_name.eq_ignore_ascii_case("PLUGIN") { - &["GM", "GAMEMODE", "SANDBOX"] + vec!["SANDBOX".to_string()] } else if owner_name.eq_ignore_ascii_case("SANDBOX") { - &["GM", "GAMEMODE"] + vec!["GM".to_string(), "GAMEMODE".to_string()] } else { - &[] + Vec::new() } } diff --git a/crates/glua_ls/src/handlers/hover/mod.rs b/crates/glua_ls/src/handlers/hover/mod.rs index 03a8c9b5..b27237be 100644 --- a/crates/glua_ls/src/handlers/hover/mod.rs +++ b/crates/glua_ls/src/handlers/hover/mod.rs @@ -221,7 +221,7 @@ pub fn hover(analysis: &EmmyLuaAnalysis, file_id: FileId, position: Position) -> } } -const HOOK_OWNER_TYPES: &[&str] = &["GM", "GAMEMODE", "SANDBOX", "PLUGIN"]; +const HOOK_OWNER_TYPES: &[&str] = &["GM", "GAMEMODE", "SANDBOX"]; fn hover_gmod_hook_name_string( analysis: &EmmyLuaAnalysis, @@ -376,6 +376,27 @@ pub(crate) fn resolve_hook_property_owner( owner_names.push(normalized); } } + for configured_name in db.get_emmyrc().gmod.scripted_owners.hook_owner_names() { + if !owner_names + .iter() + .any(|name| name.eq_ignore_ascii_case(&configured_name)) + { + owner_names.push(configured_name); + } + } + for scoped_class_name in db + .get_emmyrc() + .gmod + .scripted_class_scopes + .hook_owner_globals() + { + if !owner_names + .iter() + .any(|name| name.eq_ignore_ascii_case(&scoped_class_name)) + { + owner_names.push(scoped_class_name); + } + } for owner_name in &owner_names { let owner_type = LuaType::Ref(LuaTypeDeclId::global(owner_name)); diff --git a/crates/glua_ls/src/handlers/test/completion_test.rs b/crates/glua_ls/src/handlers/test/completion_test.rs index 6a491f74..cdff20c8 100644 --- a/crates/glua_ls/src/handlers/test/completion_test.rs +++ b/crates/glua_ls/src/handlers/test/completion_test.rs @@ -3607,6 +3607,12 @@ mod tests { function GM:ZzzPluginHook(player, entity) end + ---@class SANDBOX + ---@type SANDBOX + SANDBOX = SANDBOX or {} + + function SANDBOX:ZzzSandboxHook(ply, class) end + local PLUGIN = {} function PLUGIN:() end @@ -3629,7 +3635,7 @@ mod tests { }; let item = items - .into_iter() + .iter() .find(|it| it.label == "ZzzPluginHook") .ok_or("missing ZzzPluginHook completion") .or_fail()?; @@ -3641,6 +3647,18 @@ mod tests { .and_then(|details| details.detail.clone()); verify_eq!(item_detail, Some("(player, entity)".to_string()))?; + let sandbox_item = items + .iter() + .find(|it| it.label == "ZzzSandboxHook") + .ok_or("missing ZzzSandboxHook completion") + .or_fail()?; + verify_eq!(sandbox_item.kind, Some(CompletionItemKind::FUNCTION))?; + let sandbox_detail = sandbox_item + .label_details + .as_ref() + .and_then(|details| details.detail.clone()); + verify_eq!(sandbox_detail, Some("(ply, class)".to_string()))?; + Ok(()) } } diff --git a/crates/glua_ls/src/handlers/test/hover_test.rs b/crates/glua_ls/src/handlers/test/hover_test.rs index 1157d399..ca2c44dd 100644 --- a/crates/glua_ls/src/handlers/test/hover_test.rs +++ b/crates/glua_ls/src/handlers/test/hover_test.rs @@ -1056,6 +1056,56 @@ mod tests { Ok(()) } + #[gtest] + fn test_hover_plugin_hook_method_uses_sandbox_docs_from_configured_super_types() -> Result<()> { + let mut ws = ProviderVirtualWorkspace::new(); + let mut emmyrc = ws.get_emmyrc(); + emmyrc.gmod.enabled = true; + ws.update_emmyrc(emmyrc); + + ws.def_file( + "library/lua/includes/extensions/sandbox_hooks.lua", + r#" + ---@class SANDBOX + ---@type SANDBOX + SANDBOX = SANDBOX or {} + + ---Sandbox-only plugin fallback docs. + ---@param ply Player + function SANDBOX:PluginSandboxFallback(ply) + end + "#, + ); + + let (content, position) = ProviderVirtualWorkspace::handle_file_content( + r#" + function PLUGIN:PluginSandboxFallback(ply) + end + "#, + )?; + let file_id = ws.def_file("plugins/weather/sh_plugin.lua", &content); + let hover = crate::handlers::hover::hover(&ws.analysis, file_id, position) + .ok_or("expected hover") + .or_fail()?; + + let HoverContents::Markup(markup) = hover.contents else { + return fail!("expected HoverContents::Markup"); + }; + + assert!( + markup.value.contains("Sandbox-only plugin fallback docs"), + "expected PLUGIN hover to include SANDBOX fallback docs, got: {}", + markup.value + ); + assert!( + markup.value.contains("PluginSandboxFallback"), + "expected PLUGIN hover to include fallback signature, got: {}", + markup.value + ); + + Ok(()) + } + #[gtest] fn test_hover_hook_add_string_uses_registered_hook_docs() -> Result<()> { let mut ws = ProviderVirtualWorkspace::new();