diff --git a/crates/glua_code_analysis/src/gamemode_base.rs b/crates/glua_code_analysis/src/gamemode_base.rs index 5bf02f46c..92e421add 100644 --- a/crates/glua_code_analysis/src/gamemode_base.rs +++ b/crates/glua_code_analysis/src/gamemode_base.rs @@ -1,14 +1,14 @@ //! Server-side detection of GMod gamemode base libraries. //! //! Garry's Mod gamemodes live under `/gamemodes//` and carry a -//! `.txt` KeyValues metadata file describing the gamemode. That file may +//! `.txt` KeyValues metadata file describing the gamemode. That file may //! contain a `"base"` field naming a parent gamemode whose folder name is //! ``. At runtime, gmod loads the parent's code first via //! `DeriveGamemode("")`, so for accurate static analysis we must resolve //! the inheritance chain and add each ancestor's gamemode folder as a library. //! //! This module: -//! * scans a workspace root for any `gamemodes//.txt`, +//! * scans a workspace root for gamemode metadata files, //! * parses just enough of the KeyValues format to extract the `"base"` field, //! * follows the `base` chain (e.g. `darkrp` -> `sandbox` -> `base`), //! * returns the absolute folder paths of all ancestor gamemodes that exist @@ -35,6 +35,40 @@ fn is_valid_gamemode_name(name: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') } +/// Locate the KeyValues metadata file for a gamemode folder. +/// +/// Most gamemodes use `.txt`, but Garry's Mod also accepts a +/// differently named metadata file (for example `helix-hl2rp/ixhl2rp.txt`). +/// Prefer the conventional filename, then inspect direct `.txt` children in a +/// deterministic order and accept the first valid KeyValues document. +pub fn find_gamemode_manifest(gamemode_root: &Path) -> Option { + let name = gamemode_root.file_name()?.to_str()?; + let conventional = gamemode_root.join(format!("{name}.txt")); + if conventional.is_file() { + return Some(conventional); + } + if !gamemode_root.join("gamemode").is_dir() { + return None; + } + + let mut candidates = std::fs::read_dir(gamemode_root) + .ok()? + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.is_file() + && path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("txt")) + }) + .collect::>(); + candidates.sort(); + candidates + .into_iter() + .find(|path| is_gamemode_metadata_file(path)) +} + /// Extract the `"base"` value from a gamemode `.txt` KeyValues file. /// /// Returns `None` when the file is missing, unreadable, malformed, or has an @@ -46,13 +80,23 @@ pub fn read_gamemode_base(txt_path: &Path) -> Option { parse_base_field(trimmed) } +fn is_gamemode_metadata_file(path: &Path) -> bool { + let Ok(content) = std::fs::read_to_string(path) else { + return false; + }; + let content = content.strip_prefix('\u{FEFF}').unwrap_or(&content); + let mut tokens = Tokenizer::new(content); + matches!(tokens.next_token(), Some(Token::Word(_))) + && matches!(tokens.next_token(), Some(Token::Open)) +} + /// Detect base gamemode library paths for a single workspace root. /// /// The detector handles two layouts: /// 1. **Game install root** (e.g. `.../garrysmod/`): scans `gamemodes/*` for -/// every gamemode that has a `/.txt` and follows each chain. +/// every gamemode that has a metadata file and follows each chain. /// 2. **Single gamemode root** (e.g. `.../gamemodes/darkrp/`): if the root -/// itself contains `/.txt`, follows that chain. +/// itself contains a metadata file, follows that chain. /// /// Returned paths: /// * are absolute, @@ -70,7 +114,7 @@ pub fn detect_gamemode_base_libraries(workspace_root: &Path) -> Vec { // as gamemodes. if let Some(name) = workspace_root.file_name().and_then(|s| s.to_str()) && is_valid_gamemode_name(name) - && workspace_root.join(format!("{name}.txt")).is_file() + && find_gamemode_manifest(workspace_root).is_some() && let Some(parent) = workspace_root.parent() && parent .file_name() @@ -105,7 +149,7 @@ pub fn detect_gamemode_base_libraries(workspace_root: &Path) -> Vec { if !is_valid_gamemode_name(name) { continue; } - if !gm_folder.join(format!("{name}.txt")).is_file() { + if find_gamemode_manifest(&gm_folder).is_none() { continue; } walk_chain( @@ -139,17 +183,11 @@ fn walk_chain( } loop { - let Some(name) = current - .file_name() - .and_then(|s| s.to_str()) - .map(str::to_string) + let Some(base) = + find_gamemode_manifest(¤t).and_then(|manifest| read_gamemode_base(&manifest)) else { return; }; - let txt = current.join(format!("{name}.txt")); - let Some(base) = read_gamemode_base(&txt) else { - return; - }; if base.is_empty() { return; } @@ -489,6 +527,24 @@ sandbox let _ = fs::remove_dir_all(root); } + #[test] + fn detect_uses_metadata_named_differently_from_gamemode_folder() { + let root = temp_dir(); + write_gamemode(&root, "helix", None); + let schema = root.join("gamemodes").join("helix-hl2rp"); + fs::create_dir_all(schema.join("gamemode")).expect("create schema folder"); + fs::write( + schema.join("ixhl2rp.txt"), + "\"ixhl2rp\"\n{\n\t\"base\"\t\"helix\"\n}\n", + ) + .expect("write schema metadata"); + + let libraries = detect_gamemode_base_libraries(&root); + + assert_eq!(libraries, vec![root.join("gamemodes").join("helix")]); + let _ = fs::remove_dir_all(root); + } + #[test] fn detect_breaks_cycles() { let root = temp_dir(); diff --git a/crates/glua_code_analysis/src/lib.rs b/crates/glua_code_analysis/src/lib.rs index 92d82e367..4b229e677 100644 --- a/crates/glua_code_analysis/src/lib.rs +++ b/crates/glua_code_analysis/src/lib.rs @@ -25,7 +25,9 @@ pub use compilation::*; pub use config::*; pub use db_index::*; pub use diagnostic::*; -pub use gamemode_base::detect_gamemode_base_libraries; +pub use gamemode_base::{ + detect_gamemode_base_libraries, find_gamemode_manifest, read_gamemode_base, +}; pub use glua_codestyle::*; use glua_parser::{ LineIndex, LuaAstNode, LuaCallExpr, LuaExpr, LuaIndexKey, LuaLocalStat, LuaNameExpr, diff --git a/crates/glua_code_analysis/src/vfs/collect_workspace_files.rs b/crates/glua_code_analysis/src/vfs/collect_workspace_files.rs index 3277ef653..0fa9e720c 100644 --- a/crates/glua_code_analysis/src/vfs/collect_workspace_files.rs +++ b/crates/glua_code_analysis/src/vfs/collect_workspace_files.rs @@ -9,6 +9,7 @@ use crate::{ pub enum WorkspaceImport { All, SubPaths(Vec), + AllExcept(Vec), } #[derive(Clone, Debug)] @@ -34,6 +35,18 @@ impl WorkspaceFolder { is_library, } } + + pub fn with_excluded_sub_paths( + root: PathBuf, + excluded_sub_paths: Vec, + is_library: bool, + ) -> Self { + Self { + root, + import: WorkspaceImport::AllExcept(excluded_sub_paths), + is_library, + } + } } #[derive(Debug)] @@ -117,7 +130,11 @@ pub fn collect_workspace_files( } match &workspace.import { - WorkspaceImport::All => { + WorkspaceImport::All | WorkspaceImport::AllExcept(_) => { + if let WorkspaceImport::AllExcept(paths) = &workspace.import { + workspace_exclude_dir + .extend(paths.iter().map(|path| workspace.root.join(path))); + } let loaded = if workspace.is_library { let (lib_exclude, lib_exclude_dir) = find_library_exclude(workspace, emmyrc); // Merge library exclude with workspace exclude @@ -275,8 +292,31 @@ fn find_library_exclude(library: &WorkspaceFolder, emmyrc: &Emmyrc) -> (Vec PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should be valid") + .as_nanos(); + let counter = COUNTER.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!("gluals_collect_{nanos}_{counter}")); + fs::create_dir_all(&root).expect("temp root should be created"); + root + } #[test] fn vfs_collect_dedupe_is_deterministic_with_workspace_priority() { @@ -303,4 +343,63 @@ mod tests { assert_eq!(deduped.len(), 1); assert_eq!(deduped[0].content, "from workspace 0"); } + + #[test] + fn all_except_excludes_unselected_project_and_keeps_existing_glob_exclusions() { + let root = temp_dir(); + fs::create_dir_all(root.join("gamemodes").join("selected")) + .expect("selected gamemode should be created"); + fs::create_dir_all(root.join("gamemodes").join("unselected")) + .expect("unselected gamemode should be created"); + fs::create_dir_all(root.join("addons").join("example").join("lua")) + .expect("addon should be created"); + fs::write( + root.join("gamemodes").join("selected").join("init.lua"), + "Selected = true", + ) + .expect("selected file should be written"); + fs::write( + root.join("gamemodes").join("unselected").join("init.lua"), + "Unselected = true", + ) + .expect("unselected file should be written"); + fs::write( + root.join("addons") + .join("example") + .join("lua") + .join("included.lua"), + "Included = true", + ) + .expect("included file should be written"); + fs::write( + root.join("addons") + .join("example") + .join("lua") + .join("ignored.lua"), + "Ignored = true", + ) + .expect("ignored file should be written"); + + let mut emmyrc = Emmyrc::default(); + emmyrc.workspace.ignore_globs = vec!["**/ignored.lua".to_string()]; + let workspace = WorkspaceFolder::with_excluded_sub_paths( + root.clone(), + vec![PathBuf::from("gamemodes").join("unselected")], + false, + ); + let paths = collect_workspace_files(&vec![workspace], &emmyrc, None, None) + .into_iter() + .map(|file| file.path.replace('\\', "/")) + .collect::>(); + + assert!(paths.iter().any(|path| path.ends_with("selected/init.lua"))); + assert!(paths.iter().any(|path| path.ends_with("included.lua"))); + assert!( + paths + .iter() + .all(|path| !path.ends_with("unselected/init.lua")) + ); + assert!(paths.iter().all(|path| !path.ends_with("ignored.lua"))); + fs::remove_dir_all(root).expect("temp root should be removed"); + } } diff --git a/crates/glua_code_analysis/src/vfs/mod.rs b/crates/glua_code_analysis/src/vfs/mod.rs index 0ee87c276..4e5b0ead4 100644 --- a/crates/glua_code_analysis/src/vfs/mod.rs +++ b/crates/glua_code_analysis/src/vfs/mod.rs @@ -4,6 +4,7 @@ mod file_id; mod file_uri_handler; mod loader; mod virtual_url; +mod workspace_topology; pub use collect_workspace_files::*; pub use document::LuaDocument; @@ -18,6 +19,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; pub use virtual_url::VirtualUrlGenerator; +pub use workspace_topology::*; use crate::Emmyrc; diff --git a/crates/glua_code_analysis/src/vfs/workspace_topology.rs b/crates/glua_code_analysis/src/vfs/workspace_topology.rs new file mode 100644 index 000000000..9de2dff24 --- /dev/null +++ b/crates/glua_code_analysis/src/vfs/workspace_topology.rs @@ -0,0 +1,488 @@ +use std::{ + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, +}; + +use crate::{find_gamemode_manifest, read_gamemode_base}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GmodProjectKind { + Addon, + Gamemode, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GmodProject { + pub id: String, + pub kind: GmodProjectKind, + pub name: String, + pub root: PathBuf, + pub base: Option, + pub selectable: bool, +} + +#[derive(Clone, Debug, Default)] +pub struct GmodWorkspaceTopology { + projects: Vec, +} + +impl GmodWorkspaceTopology { + pub fn discover(workspace_roots: &[PathBuf]) -> Self { + let mut projects_by_root = HashMap::::new(); + + for workspace_root in workspace_roots { + discover_from_root(workspace_root, &mut projects_by_root); + } + + let mut projects = projects_by_root.into_values().collect::>(); + projects.sort_by(|left, right| { + project_kind_order(left.kind) + .cmp(&project_kind_order(right.kind)) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) + .then_with(|| { + normalized_path_key(&left.root).cmp(&normalized_path_key(&right.root)) + }) + }); + + Self { projects } + } + + pub fn projects(&self) -> &[GmodProject] { + &self.projects + } + + pub fn addons(&self) -> impl Iterator { + self.projects + .iter() + .filter(|project| project.kind == GmodProjectKind::Addon) + } + + pub fn gamemodes(&self) -> impl Iterator { + self.projects + .iter() + .filter(|project| project.kind == GmodProjectKind::Gamemode) + } + + pub fn primary_gamemodes(&self) -> impl Iterator { + self.gamemodes().filter(|project| project.selectable) + } + + pub fn project_by_id(&self, id: &str) -> Option<&GmodProject> { + self.projects.iter().find(|project| project.id == id) + } + + pub fn project_containing(&self, path: &Path) -> Option<&GmodProject> { + self.projects + .iter() + .filter(|project| path.starts_with(&project.root)) + .max_by_key(|project| project.root.as_os_str().len()) + } + + pub fn gamemode_chain(&self, primary_id: &str) -> Vec<&GmodProject> { + let Some(primary) = self + .project_by_id(primary_id) + .filter(|project| project.kind == GmodProjectKind::Gamemode) + else { + return Vec::new(); + }; + + let gamemodes_by_name = self + .gamemodes() + .map(|project| (project.name.to_ascii_lowercase(), project)) + .collect::>(); + let mut chain = vec![primary]; + let mut visited = HashSet::from([primary.name.to_ascii_lowercase()]); + let mut current = primary; + + while let Some(base_name) = current.base.as_deref() { + let normalized_name = base_name.to_ascii_lowercase(); + if !visited.insert(normalized_name.clone()) { + break; + } + let Some(base) = gamemodes_by_name.get(&normalized_name).copied() else { + break; + }; + chain.push(base); + current = base; + } + + chain + } +} + +fn discover_from_root(workspace_root: &Path, projects_by_root: &mut HashMap) { + if !workspace_root.is_dir() { + return; + } + + if is_gamemode_root(workspace_root) { + insert_project( + workspace_root, + GmodProjectKind::Gamemode, + true, + projects_by_root, + ); + discover_gamemode_bases(workspace_root, projects_by_root); + } else if is_addon_root(workspace_root) { + insert_project( + workspace_root, + GmodProjectKind::Addon, + true, + projects_by_root, + ); + } + + let root_name = file_name_lower(workspace_root); + if root_name.as_deref() == Some("addons") { + discover_addons_container(workspace_root, projects_by_root); + return; + } + if root_name.as_deref() == Some("gamemodes") { + discover_gamemodes_container(workspace_root, projects_by_root); + return; + } + + let mut game_roots = Vec::new(); + if root_name.as_deref() == Some("garrysmod") { + game_roots.push(workspace_root.to_path_buf()); + } + if workspace_root.join("addons").is_dir() || workspace_root.join("gamemodes").is_dir() { + game_roots.push(workspace_root.to_path_buf()); + } + let nested_garrysmod = workspace_root.join("garrysmod"); + if nested_garrysmod.is_dir() { + game_roots.push(nested_garrysmod); + } + + game_roots.sort_by_key(|path| normalized_path_key(path)); + game_roots.dedup_by(|left, right| paths_equal(left, right)); + for game_root in game_roots { + discover_addons_container(&game_root.join("addons"), projects_by_root); + discover_gamemodes_container(&game_root.join("gamemodes"), projects_by_root); + } +} + +fn discover_addons_container( + addons_root: &Path, + projects_by_root: &mut HashMap, +) { + for directory in sorted_child_directories(addons_root) { + insert_project(&directory, GmodProjectKind::Addon, true, projects_by_root); + } +} + +fn discover_gamemodes_container( + gamemodes_root: &Path, + projects_by_root: &mut HashMap, +) { + for directory in sorted_child_directories(gamemodes_root) { + if is_gamemode_root(&directory) { + insert_project( + &directory, + GmodProjectKind::Gamemode, + true, + projects_by_root, + ); + } + } +} + +fn sorted_child_directories(root: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(root) else { + return Vec::new(); + }; + let mut directories = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect::>(); + directories.sort_by_key(|path| normalized_path_key(path)); + directories +} + +fn insert_project( + root: &Path, + kind: GmodProjectKind, + selectable: bool, + projects_by_root: &mut HashMap, +) { + let key = comparable_path(&canonicalize_or(root)); + if let Some(existing) = projects_by_root.get_mut(&key) { + existing.selectable |= selectable; + return; + } + + let Some(name) = root + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_owned) + else { + return; + }; + let root = root.to_path_buf(); + let base = (kind == GmodProjectKind::Gamemode) + .then(|| find_gamemode_manifest(&root)) + .flatten() + .and_then(|manifest| read_gamemode_base(&manifest)); + + projects_by_root.insert( + key, + GmodProject { + id: normalized_path_key(&root), + kind, + name, + root, + base, + selectable, + }, + ); +} + +fn discover_gamemode_bases( + primary_root: &Path, + projects_by_root: &mut HashMap, +) { + let Some(gamemodes_root) = primary_root.parent() else { + return; + }; + let Some(mut current_name) = primary_root + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_owned) + else { + return; + }; + let mut visited = HashSet::new(); + + loop { + if !visited.insert(current_name.to_ascii_lowercase()) { + return; + } + let current_root = gamemodes_root.join(¤t_name); + let Some(base_name) = find_gamemode_manifest(¤t_root) + .and_then(|manifest| read_gamemode_base(&manifest)) + else { + return; + }; + let base_root = gamemodes_root.join(&base_name); + if !is_gamemode_root(&base_root) { + return; + } + insert_project( + &base_root, + GmodProjectKind::Gamemode, + false, + projects_by_root, + ); + current_name = base_name; + } +} + +fn is_gamemode_root(root: &Path) -> bool { + find_gamemode_manifest(root).is_some() +} + +fn is_addon_root(root: &Path) -> bool { + root.join("lua").is_dir() + && !root + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("garrysmod")) +} + +fn file_name_lower(path: &Path) -> Option { + path.file_name() + .and_then(|name| name.to_str()) + .map(str::to_ascii_lowercase) +} + +fn canonicalize_or(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +fn comparable_path(path: &Path) -> PathBuf { + if cfg!(windows) { + PathBuf::from(path.to_string_lossy().to_ascii_lowercase()) + } else { + path.to_path_buf() + } +} + +fn normalized_path_key(path: &Path) -> String { + let normalized = path.to_string_lossy().replace('\\', "/"); + if cfg!(windows) { + normalized.to_ascii_lowercase() + } else { + normalized + } +} + +fn paths_equal(left: &Path, right: &Path) -> bool { + comparable_path(&canonicalize_or(left)) == comparable_path(&canonicalize_or(right)) +} + +const fn project_kind_order(kind: GmodProjectKind) -> u8 { + match kind { + GmodProjectKind::Addon => 0, + GmodProjectKind::Gamemode => 1, + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::*; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + + fn temp_dir() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should be valid") + .as_nanos(); + let counter = COUNTER.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!("gluals_topology_{nanos}_{counter}")); + fs::create_dir_all(&root).expect("temp root should be created"); + root + } + + fn create_addon(root: &Path, name: &str) -> PathBuf { + let addon = root.join("addons").join(name); + fs::create_dir_all(addon.join("lua")).expect("addon should be created"); + addon + } + + fn create_gamemode(root: &Path, name: &str, base: Option<&str>) -> PathBuf { + let gamemode = root.join("gamemodes").join(name); + fs::create_dir_all(&gamemode).expect("gamemode should be created"); + let base_line = base + .map(|base| format!("\"base\" \"{base}\"\n")) + .unwrap_or_default(); + fs::write( + gamemode.join(format!("{name}.txt")), + format!("\"{name}\"\n{{\n{base_line}}}\n"), + ) + .expect("manifest should be written"); + gamemode + } + + #[test] + fn discovers_whole_server_and_resolves_recursive_gamemode_chain() { + let server = temp_dir(); + let garrysmod = server.join("garrysmod"); + fs::create_dir_all(&garrysmod).expect("garrysmod should be created"); + create_addon(&garrysmod, "example"); + create_gamemode(&garrysmod, "framework", None); + create_gamemode(&garrysmod, "roleplay", Some("framework")); + + let topology = GmodWorkspaceTopology::discover(std::slice::from_ref(&server)); + let primary = topology + .gamemodes() + .find(|project| project.name == "roleplay") + .expect("roleplay should be discovered"); + let chain = topology + .gamemode_chain(&primary.id) + .into_iter() + .map(|project| project.name.as_str()) + .collect::>(); + + assert_eq!(topology.addons().count(), 1); + assert_eq!(chain, vec!["roleplay", "framework"]); + fs::remove_dir_all(server).expect("temp root should be removed"); + } + + #[test] + fn deduplicates_overlapping_container_and_project_roots() { + let garrysmod = temp_dir(); + let addon = create_addon(&garrysmod, "example"); + let gamemode = create_gamemode(&garrysmod, "roleplay", None); + let roots = vec![garrysmod.clone(), garrysmod.join("addons"), addon, gamemode]; + + let topology = GmodWorkspaceTopology::discover(&roots); + + assert_eq!(topology.addons().count(), 1); + assert_eq!(topology.primary_gamemodes().count(), 1); + fs::remove_dir_all(garrysmod).expect("temp root should be removed"); + } + + #[test] + fn standalone_gamemode_keeps_base_dependencies_out_of_primary_candidates() { + let garrysmod = temp_dir(); + create_gamemode(&garrysmod, "framework", None); + let roleplay = create_gamemode(&garrysmod, "roleplay", Some("framework")); + + let topology = GmodWorkspaceTopology::discover(std::slice::from_ref(&roleplay)); + let primary = topology + .primary_gamemodes() + .next() + .expect("standalone gamemode should be selectable"); + let chain = topology + .gamemode_chain(&primary.id) + .into_iter() + .map(|project| project.name.as_str()) + .collect::>(); + + assert_eq!(topology.primary_gamemodes().count(), 1); + assert_eq!(chain, vec!["roleplay", "framework"]); + fs::remove_dir_all(garrysmod).expect("temp root should be removed"); + } + + #[test] + fn discovers_addons_and_gamemodes_container_roots() { + let garrysmod = temp_dir(); + create_addon(&garrysmod, "one"); + create_addon(&garrysmod, "two"); + create_gamemode(&garrysmod, "roleplay", None); + + let topology = GmodWorkspaceTopology::discover(&[ + garrysmod.join("addons"), + garrysmod.join("gamemodes"), + ]); + + assert_eq!(topology.addons().count(), 2); + assert_eq!(topology.primary_gamemodes().count(), 1); + fs::remove_dir_all(garrysmod).expect("temp root should be removed"); + } + + #[test] + fn discovers_standalone_addon_root() { + let root = temp_dir(); + let addon = root.join("my_addon"); + fs::create_dir_all(addon.join("lua")).expect("addon should be created"); + + let topology = GmodWorkspaceTopology::discover(std::slice::from_ref(&addon)); + let projects = topology.addons().collect::>(); + + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].name, "my_addon"); + assert_eq!(projects[0].root, addon); + fs::remove_dir_all(root).expect("temp root should be removed"); + } + + #[test] + fn discovers_gamemode_with_manifest_named_differently_from_folder() { + let root = temp_dir(); + create_gamemode(&root, "helix", None); + let schema = root.join("gamemodes").join("helix-hl2rp"); + fs::create_dir_all(schema.join("gamemode")).expect("schema should be created"); + fs::write( + schema.join("ixhl2rp.txt"), + "\"ixhl2rp\"\n{\n\"base\" \"helix\"\n}\n", + ) + .expect("schema manifest should be written"); + + let topology = GmodWorkspaceTopology::discover(std::slice::from_ref(&root)); + let schema_project = topology + .primary_gamemodes() + .find(|project| project.name == "helix-hl2rp") + .expect("schema should be selectable"); + + assert_eq!(schema_project.base.as_deref(), Some("helix")); + fs::remove_dir_all(root).expect("temp root should be removed"); + } +} diff --git a/crates/glua_ls/src/codestyle.rs b/crates/glua_ls/src/codestyle.rs index b6440f735..0916c07f0 100644 --- a/crates/glua_ls/src/codestyle.rs +++ b/crates/glua_ls/src/codestyle.rs @@ -237,7 +237,9 @@ fn collect_workspace_editorconfigs(workspace_folders: &[WorkspaceFolder]) -> Vec let mut editorconfig_files = Vec::new(); for workspace in workspace_folders { match &workspace.import { - WorkspaceImport::All => collect_editorconfigs(&workspace.root, &mut editorconfig_files), + WorkspaceImport::All | WorkspaceImport::AllExcept(_) => { + collect_editorconfigs(&workspace.root, &mut editorconfig_files) + } WorkspaceImport::SubPaths(subs) => { for sub in subs { collect_editorconfigs(&workspace.root.join(sub), &mut editorconfig_files); @@ -253,7 +255,9 @@ fn collect_workspace_style_roots(workspace_folders: &[WorkspaceFolder]) -> Vec

roots.push(workspace.root.clone()), + WorkspaceImport::All | WorkspaceImport::AllExcept(_) => { + roots.push(workspace.root.clone()) + } WorkspaceImport::SubPaths(subs) => { for sub in subs { roots.push(workspace.root.join(sub)); diff --git a/crates/glua_ls/src/context/mod.rs b/crates/glua_ls/src/context/mod.rs index f64e16e08..4ee1b9a36 100644 --- a/crates/glua_ls/src/context/mod.rs +++ b/crates/glua_ls/src/context/mod.rs @@ -4,6 +4,7 @@ mod debounced_analysis; mod did_change_coalescer; mod file_diagnostic; mod lsp_features; +mod project_loading; mod snapshot; mod status_bar; mod workspace_manager; @@ -17,6 +18,7 @@ use glua_code_analysis::EmmyLuaAnalysis; pub use lsp_features::LspFeatures; use lsp_server::{Connection, ErrorCode, Message, RequestId, Response}; use lsp_types::{ClientCapabilities, Uri}; +pub use project_loading::*; pub use snapshot::ServerContextSnapshot; pub use status_bar::ProgressTask; pub use status_bar::StatusBar; diff --git a/crates/glua_ls/src/context/project_loading.rs b/crates/glua_ls/src/context/project_loading.rs new file mode 100644 index 000000000..e475dbe1a --- /dev/null +++ b/crates/glua_ls/src/context/project_loading.rs @@ -0,0 +1,587 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use glua_code_analysis::{ + GmodProject, GmodProjectKind, GmodWorkspaceTopology, WorkspaceFolder, WorkspaceImport, + file_path_to_uri, uri_to_file_path, +}; +use lsp_types::Uri; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GmodProjectLoadingOptions { + #[serde(default)] + pub interactive_gamemode_selection: bool, + pub selected_gamemode_uri: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum LoadedProjectKind { + Addon, + Gamemode, + Workspace, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum GamemodeRole { + Primary, + Base, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LoadedProject { + pub id: String, + pub kind: LoadedProjectKind, + pub name: String, + pub root_uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GamemodeCandidate { + pub id: String, + pub name: String, + pub root_uri: String, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum GamemodeChoiceReason { + Initial, + DocumentOpen, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChooseGamemodeParams { + pub candidates: Vec, + pub current_gamemode_id: Option, + pub requested_gamemode_id: Option, + pub reason: GamemodeChoiceReason, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectLoadingState { + pub candidates: Vec, + pub current_gamemode_id: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChooseGamemodeResult { + pub selected_gamemode_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenDocumentSnapshot { + pub uri: Uri, + pub text: String, + pub version: i32, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetActiveGamemodeParams { + pub selected_gamemode_id: String, + #[serde(default)] + pub open_documents: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetActiveGamemodeResult { + pub selected_gamemode_id: String, +} + +#[derive(Clone, Debug)] +pub struct TrackedOpenDocument { + pub text: String, + pub version: i32, +} + +#[derive(Clone, Debug)] +pub struct GmodProjectLoading { + topology: GmodWorkspaceTopology, + explicit_workspace_folders: Vec, + active_gamemode_id: Option, + interactive: bool, + open_documents: HashMap, +} + +impl GmodProjectLoading { + pub fn new( + topology: GmodWorkspaceTopology, + explicit_workspace_folders: Vec, + interactive: bool, + ) -> Self { + Self { + topology, + explicit_workspace_folders, + active_gamemode_id: None, + interactive, + open_documents: HashMap::new(), + } + } + + pub fn interactive(&self) -> bool { + self.interactive + } + + pub fn rediscover(&mut self, explicit_workspace_folders: Vec) { + self.topology = GmodWorkspaceTopology::discover( + &explicit_workspace_folders + .iter() + .map(|workspace| workspace.root.clone()) + .collect::>(), + ); + self.explicit_workspace_folders = explicit_workspace_folders; + if self + .active_gamemode_id + .as_deref() + .is_some_and(|id| !self.is_valid_primary_id(id)) + { + self.active_gamemode_id = self.sole_primary_gamemode_id(); + } + } + + pub fn set_active_gamemode(&mut self, id: Option) -> bool { + if id.as_deref() == self.active_gamemode_id.as_deref() { + return false; + } + self.active_gamemode_id = id; + true + } + + pub fn is_valid_primary_id(&self, id: &str) -> bool { + self.topology + .primary_gamemodes() + .any(|project| project.id == id) + } + + pub fn primary_gamemode_count(&self) -> usize { + self.topology.primary_gamemodes().count() + } + + pub fn sole_primary_gamemode_id(&self) -> Option { + let mut candidates = self.topology.primary_gamemodes(); + let candidate = candidates.next()?; + candidates.next().is_none().then(|| candidate.id.clone()) + } + + pub fn resolve_persisted_gamemode(&self, value: &str) -> Option { + self.topology + .primary_gamemodes() + .find(|project| { + project.id == value + || file_path_to_uri(&project.root).is_some_and(|uri| uri.as_str() == value) + }) + .map(|project| project.id.clone()) + } + + pub fn gamemode_candidates(&self) -> Vec { + self.topology + .primary_gamemodes() + .filter_map(|project| { + Some(GamemodeCandidate { + id: project.id.clone(), + name: project.name.clone(), + root_uri: file_path_to_uri(&project.root)?.to_string(), + }) + }) + .collect() + } + + pub fn choose_params( + &self, + requested_gamemode_id: Option, + reason: GamemodeChoiceReason, + ) -> ChooseGamemodeParams { + ChooseGamemodeParams { + candidates: self.gamemode_candidates(), + current_gamemode_id: self.active_gamemode_id.clone(), + requested_gamemode_id, + reason, + } + } + + pub fn state(&self) -> ProjectLoadingState { + ProjectLoadingState { + candidates: self.gamemode_candidates(), + current_gamemode_id: self.active_gamemode_id.clone(), + } + } + + pub fn gamemode_for_uri(&self, uri: &Uri) -> Option<&GmodProject> { + let path = uri_to_file_path(uri)?; + self.topology + .project_containing(&path) + .filter(|project| project.kind == GmodProjectKind::Gamemode) + } + + pub fn project_id_for_uri(&self, uri: &Uri) -> Option { + let path = uri_to_file_path(uri)?; + if let Some(project) = self.topology.project_containing(&path) { + return self.is_project_loaded(project).then(|| project.id.clone()); + } + + self.explicit_workspace_folders + .iter() + .filter(|workspace| self.has_fallback_workspace(workspace)) + .filter(|workspace| path.starts_with(&workspace.root)) + .max_by_key(|workspace| workspace.root.as_os_str().len()) + .and_then(|workspace| workspace_project_id(&workspace.root)) + } + + pub fn is_gamemode_loaded(&self, id: &str) -> bool { + self.loaded_gamemode_chain() + .iter() + .any(|project| project.id == id) + } + + pub fn loaded_projects(&self) -> Vec { + let mut projects = Vec::new(); + projects.extend(self.topology.addons().filter_map(|project| { + Some(LoadedProject { + id: project.id.clone(), + kind: LoadedProjectKind::Addon, + name: project.name.clone(), + root_uri: file_path_to_uri(&project.root)?.to_string(), + role: None, + }) + })); + projects.extend( + self.loaded_gamemode_chain() + .into_iter() + .enumerate() + .filter_map(|(index, project)| { + Some(LoadedProject { + id: project.id.clone(), + kind: LoadedProjectKind::Gamemode, + name: project.name.clone(), + root_uri: file_path_to_uri(&project.root)?.to_string(), + role: Some(if index == 0 { + GamemodeRole::Primary + } else { + GamemodeRole::Base + }), + }) + }), + ); + projects.extend( + self.explicit_workspace_folders + .iter() + .filter(|workspace| self.has_fallback_workspace(workspace)) + .filter_map(|workspace| { + Some(LoadedProject { + id: workspace_project_id(&workspace.root)?, + kind: LoadedProjectKind::Workspace, + name: workspace + .root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("Workspace") + .to_string(), + root_uri: file_path_to_uri(&workspace.root)?.to_string(), + role: None, + }) + }), + ); + projects.sort_by(|left, right| { + loaded_kind_order(left.kind) + .cmp(&loaded_kind_order(right.kind)) + .then_with(|| gamemode_role_order(left.role).cmp(&gamemode_role_order(right.role))) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) + .then_with(|| left.root_uri.cmp(&right.root_uri)) + }); + projects + } + + pub fn loaded_workspace_folders(&self) -> Vec { + let loaded_gamemode_roots = self + .loaded_gamemode_chain() + .into_iter() + .map(|project| project.root.clone()) + .collect::>(); + let loaded_project_roots = self + .topology + .addons() + .map(|project| project.root.clone()) + .chain(loaded_gamemode_roots.iter().cloned()) + .collect::>(); + let mut folders = self + .explicit_workspace_folders + .iter() + .map(|workspace| { + let has_logical_projects = self + .topology + .projects() + .iter() + .any(|project| project.root.starts_with(&workspace.root)); + if !has_logical_projects { + return workspace.clone(); + } + + let mut imported = loaded_project_roots + .iter() + .filter_map(|root| { + root.strip_prefix(&workspace.root) + .ok() + .map(Path::to_path_buf) + }) + .collect::>(); + imported.sort(); + imported.dedup(); + if imported.iter().any(|path| path.as_os_str().is_empty()) { + workspace.clone() + } else { + WorkspaceFolder { + root: workspace.root.clone(), + import: WorkspaceImport::SubPaths(imported), + is_library: workspace.is_library, + } + } + }) + .collect::>(); + + for base_root in loaded_gamemode_roots.into_iter().skip(1) { + if self + .explicit_workspace_folders + .iter() + .any(|workspace| base_root.starts_with(&workspace.root)) + { + continue; + } + folders.push(WorkspaceFolder::new(base_root, true)); + } + + folders + } + + pub fn loaded_gamemode_roots(&self) -> Vec { + self.loaded_gamemode_chain() + .into_iter() + .map(|project| project.root.clone()) + .collect() + } + + pub fn update_open_document(&mut self, uri: Uri, text: String, version: i32) { + self.open_documents + .insert(uri, TrackedOpenDocument { text, version }); + } + + pub fn remove_open_document(&mut self, uri: &Uri) { + self.open_documents.remove(uri); + } + + pub fn merge_open_document_snapshots(&mut self, snapshots: Vec) { + for snapshot in snapshots { + self.update_open_document(snapshot.uri, snapshot.text, snapshot.version); + } + } + + pub fn open_documents_in_loaded_projects(&self) -> Vec<(Uri, TrackedOpenDocument)> { + self.open_documents + .iter() + .filter(|(uri, _)| { + self.gamemode_for_uri(uri) + .is_none_or(|project| self.is_gamemode_loaded(&project.id)) + }) + .map(|(uri, document)| (uri.clone(), document.clone())) + .collect() + } + + fn is_project_loaded(&self, project: &GmodProject) -> bool { + project.kind == GmodProjectKind::Addon + || self + .loaded_gamemode_chain() + .iter() + .any(|loaded| loaded.id == project.id) + } + + fn loaded_gamemode_chain(&self) -> Vec<&GmodProject> { + let Some(primary_id) = self.active_gamemode_id.as_deref() else { + return Vec::new(); + }; + self.topology + .gamemode_chain(primary_id) + .into_iter() + .enumerate() + .filter(|(index, project)| { + *index == 0 || !is_annotation_backed_builtin_gamemode(&project.name) + }) + .map(|(_, project)| project) + .collect() + } + + fn has_fallback_workspace(&self, workspace: &WorkspaceFolder) -> bool { + !self + .topology + .projects() + .iter() + .any(|project| project.root.starts_with(&workspace.root)) + } +} + +fn workspace_project_id(root: &Path) -> Option { + let uri = file_path_to_uri(&root.to_path_buf())?; + Some(format!("workspace:{}", uri.as_str())) +} + +fn is_annotation_backed_builtin_gamemode(name: &str) -> bool { + name.eq_ignore_ascii_case("base") || name.eq_ignore_ascii_case("sandbox") +} + +const fn loaded_kind_order(kind: LoadedProjectKind) -> u8 { + match kind { + LoadedProjectKind::Addon => 0, + LoadedProjectKind::Gamemode => 1, + LoadedProjectKind::Workspace => 2, + } +} + +const fn gamemode_role_order(role: Option) -> u8 { + match role { + Some(GamemodeRole::Primary) => 0, + Some(GamemodeRole::Base) => 1, + None => 2, + } +} + +pub fn import_contains(workspace: &WorkspaceFolder, path: &Path) -> bool { + let Ok(relative) = path.strip_prefix(&workspace.root) else { + return false; + }; + match &workspace.import { + WorkspaceImport::All => true, + WorkspaceImport::SubPaths(paths) => { + paths.iter().any(|sub_path| relative.starts_with(sub_path)) + } + WorkspaceImport::AllExcept(paths) => { + !paths.iter().any(|sub_path| relative.starts_with(sub_path)) + } + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, + }; + + use glua_code_analysis::GmodWorkspaceTopology; + + use super::*; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + + fn temp_dir() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should be valid") + .as_nanos(); + let counter = COUNTER.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!("gluals_loading_{nanos}_{counter}")); + fs::create_dir_all(&root).expect("temp root should be created"); + root + } + + fn create_gamemode(root: &Path, name: &str, base: Option<&str>) -> PathBuf { + let gamemode = root.join("gamemodes").join(name); + fs::create_dir_all(&gamemode).expect("gamemode should be created"); + let base_line = base + .map(|base| format!("\"base\" \"{base}\"\n")) + .unwrap_or_default(); + fs::write( + gamemode.join(format!("{name}.txt")), + format!("\"{name}\"\n{{\n{base_line}}}\n"), + ) + .expect("manifest should be written"); + gamemode + } + + #[test] + fn logical_projects_exclude_unselected_gamemodes_and_unclassified_server_files() { + let root = temp_dir(); + fs::create_dir_all(root.join("addons").join("example").join("lua")) + .expect("addon should be created"); + let selected = create_gamemode(&root, "selected", None); + let unselected = create_gamemode(&root, "unselected", None); + let explicit = vec![WorkspaceFolder::new(root.clone(), false)]; + let topology = GmodWorkspaceTopology::discover(std::slice::from_ref(&root)); + let selected_id = topology + .primary_gamemodes() + .find(|project| project.name == "selected") + .expect("selected gamemode should exist") + .id + .clone(); + let mut loading = GmodProjectLoading::new(topology, explicit, false); + loading.set_active_gamemode(Some(selected_id)); + let folders = loading.loaded_workspace_folders(); + let workspace = folders + .iter() + .find(|workspace| workspace.root == root) + .expect("explicit root should remain"); + + assert!(import_contains( + workspace, + &root + .join("addons") + .join("example") + .join("lua") + .join("init.lua") + )); + assert!(import_contains(workspace, &selected.join("init.lua"))); + assert!(!import_contains(workspace, &unselected.join("init.lua"))); + assert!(!import_contains( + workspace, + &root.join("cfg").join("server.lua") + )); + fs::remove_dir_all(root).expect("temp root should be removed"); + } + + #[test] + fn custom_base_is_loaded_while_builtin_base_remains_annotation_backed() { + let root = temp_dir(); + create_gamemode(&root, "base", None); + create_gamemode(&root, "sandbox", Some("base")); + create_gamemode(&root, "framework", Some("sandbox")); + create_gamemode(&root, "roleplay", Some("framework")); + let explicit = vec![WorkspaceFolder::new(root.clone(), false)]; + let topology = GmodWorkspaceTopology::discover(std::slice::from_ref(&root)); + let roleplay_id = topology + .primary_gamemodes() + .find(|project| project.name == "roleplay") + .expect("roleplay should exist") + .id + .clone(); + let mut loading = GmodProjectLoading::new(topology, explicit, false); + loading.set_active_gamemode(Some(roleplay_id)); + let projects = loading.loaded_projects(); + let names = projects + .iter() + .filter(|project| project.kind == LoadedProjectKind::Gamemode) + .map(|project| (project.name.as_str(), project.role)) + .collect::>(); + + assert_eq!( + names, + vec![ + ("roleplay", Some(GamemodeRole::Primary)), + ("framework", Some(GamemodeRole::Base)), + ] + ); + fs::remove_dir_all(root).expect("temp root should be removed"); + } +} diff --git a/crates/glua_ls/src/context/workspace_manager.rs b/crates/glua_ls/src/context/workspace_manager.rs index 65f003155..a55f5cba4 100644 --- a/crates/glua_ls/src/context/workspace_manager.rs +++ b/crates/glua_ls/src/context/workspace_manager.rs @@ -4,15 +4,15 @@ use std::path::Path; use std::sync::atomic::{AtomicI64, AtomicU8, Ordering}; use std::{path::PathBuf, sync::Arc, time::Duration}; -use super::{ClientProxy, FileDiagnostic, StatusBar}; +use super::{ClientProxy, FileDiagnostic, GmodProjectLoading, StatusBar, import_contains}; use crate::codestyle::{apply_editorconfig_file, apply_workspace_code_style}; use crate::context::lsp_features::LspFeatures; use crate::handlers::{ClientConfig, init_analysis}; use crate::util::{LongRunningWatchdogStatus, spawn_long_running_watchdog}; use glua_code_analysis::uri_to_file_path; use glua_code_analysis::{ - EmmyLuaAnalysis, Emmyrc, LuaDiagnosticConfig, WorkspaceFolder, WorkspaceImport, - calculate_include_and_exclude, load_configs, + EmmyLuaAnalysis, Emmyrc, LuaDiagnosticConfig, WorkspaceFolder, calculate_include_and_exclude, + load_configs, }; use log::{debug, info}; use lsp_types::Uri; @@ -29,7 +29,10 @@ pub struct WorkspaceManager { file_diagnostic: Arc, lsp_features: Arc, pub client_config: ClientConfig, + pub explicit_workspace_folders: Vec, pub workspace_folders: Vec, + pub project_loading: Option, + pub workspace_emmyrcs: HashMap>, pub watcher: Option, pub current_open_files: HashSet, /// Fallback matcher used when no workspace-root-specific matcher applies @@ -39,6 +42,7 @@ pub struct WorkspaceManager { /// matcher for the first workspace root that is a prefix of the file path, /// so each root's `useDefaultIgnores` / `ignoreDirDefaults` stays isolated. pub per_root_matchers: HashMap, + gamemode_selection_lock: Arc>, workspace_diagnostic_level: Arc, workspace_version: Arc, } @@ -56,7 +60,10 @@ impl WorkspaceManager { client, status_bar, client_config: ClientConfig::default(), + explicit_workspace_folders: Vec::new(), workspace_folders: Vec::new(), + project_loading: None, + workspace_emmyrcs: HashMap::new(), update_token: Arc::new(Mutex::new(None)), file_diagnostic, lsp_features, @@ -64,6 +71,7 @@ impl WorkspaceManager { current_open_files: HashSet::new(), match_file_pattern: WorkspaceFileMatcher::default(), per_root_matchers: HashMap::new(), + gamemode_selection_lock: Arc::new(Mutex::new(())), workspace_diagnostic_level: Arc::new(AtomicU8::new( WorkspaceDiagnosticLevel::Fast.to_u8(), )), @@ -101,6 +109,7 @@ impl WorkspaceManager { let analysis = self.analysis.clone(); let workspace_folders = self.workspace_folders.clone(); + let explicit_workspace_folders = self.explicit_workspace_folders.clone(); let config_update_token = self.update_token.clone(); let client_config = self.client_config.clone(); let status_bar = self.status_bar.clone(); @@ -113,18 +122,20 @@ impl WorkspaceManager { return; } - let config_roots = collect_config_roots(&workspace_folders, Some(file_dir.clone())); + let config_roots = + collect_config_roots(&explicit_workspace_folders, Some(file_dir.clone())); let watchdog_status = LongRunningWatchdogStatus::new("Reloading GLuaLS configuration"); let _watchdog = spawn_long_running_watchdog("workspace config reload", watchdog_status.clone()); let loaded = load_emmy_config(config_roots, client_config); - apply_workspace_code_style(&workspace_folders, loaded.emmyrc.as_ref()); + apply_workspace_code_style(&explicit_workspace_folders, loaded.emmyrc.as_ref()); // Refresh per-root matchers before re-indexing so that // `is_workspace_file` is consistent with the new config. { let mut wm = workspace_manager.write().await; wm.per_root_matchers = loaded.workspace_matchers.clone(); + wm.workspace_emmyrcs = loaded.workspace_emmyrcs.clone(); let (include, exclude, exclude_dir) = calculate_include_and_exclude(&loaded.emmyrc); wm.match_file_pattern = WorkspaceFileMatcher::new(include, exclude, exclude_dir); } @@ -142,6 +153,15 @@ impl WorkspaceManager { watchdog_status, ) .await; + if let Some(state) = workspace_manager + .read() + .await + .project_loading + .as_ref() + .map(GmodProjectLoading::state) + { + client.send_notification("gluals/projectsChanged", state); + } if lsp_features.supports_workspace_diagnostic() { client.refresh_workspace_diagnostics(); } @@ -160,10 +180,11 @@ impl WorkspaceManager { &self, workspace_manager: Arc>, ) -> Option<()> { - let config_roots = collect_config_roots(&self.workspace_folders, None); + let config_roots = collect_config_roots(&self.explicit_workspace_folders, None); let loaded = load_emmy_config(config_roots, self.client_config.clone()); let analysis = self.analysis.clone(); let workspace_folders = self.workspace_folders.clone(); + let explicit_workspace_folders = self.explicit_workspace_folders.clone(); let status_bar = self.status_bar.clone(); let file_diagnostic = self.file_diagnostic.clone(); let lsp_features = self.lsp_features.clone(); @@ -173,12 +194,13 @@ impl WorkspaceManager { let watchdog_status = LongRunningWatchdogStatus::new("Reloading workspace"); let _watchdog = spawn_long_running_watchdog("workspace reload", watchdog_status.clone()); - apply_workspace_code_style(&workspace_folders, loaded.emmyrc.as_ref()); + apply_workspace_code_style(&explicit_workspace_folders, loaded.emmyrc.as_ref()); // Refresh per-root matchers before re-indexing. { let mut wm = workspace_manager.write().await; wm.per_root_matchers = loaded.workspace_matchers.clone(); + wm.workspace_emmyrcs = loaded.workspace_emmyrcs.clone(); let (include, exclude, exclude_dir) = calculate_include_and_exclude(&loaded.emmyrc); wm.match_file_pattern = WorkspaceFileMatcher::new(include, exclude, exclude_dir); } @@ -197,6 +219,15 @@ impl WorkspaceManager { watchdog_status, ) .await; + if let Some(state) = workspace_manager + .read() + .await + .project_loading + .as_ref() + .map(GmodProjectLoading::state) + { + client.send_notification("gluals/projectsChanged", state); + } // Cancel diagnostics and update status without holding analysis lock file_diagnostic.cancel_workspace_diagnostic().await; @@ -284,6 +315,10 @@ impl WorkspaceManager { &self.match_file_pattern, ) } + + pub fn gamemode_selection_lock(&self) -> Arc> { + self.gamemode_selection_lock.clone() + } } /// Inner logic for `WorkspaceManager::is_workspace_file`, extracted so it can @@ -325,12 +360,7 @@ fn is_workspace_file_inner( return false; }; - let inside_import = match &workspace.import { - WorkspaceImport::All => true, - WorkspaceImport::SubPaths(paths) => paths.iter().any(|p| relative.starts_with(p)), - }; - - if !inside_import { + if !import_contains(workspace, &file_path) { return false; } @@ -988,6 +1018,10 @@ fn inject_gamemode_base_libraries( emmyrc: &mut Emmyrc, workspace_root: Option<&Path>, ) { + if client_config.logical_project_loading { + return; + } + // Check if explicitly disabled in config if matches!(emmyrc.gmod.auto_detect_gamemode_base, Some(false)) { log::info!("Gamemode base auto-detection explicitly disabled in config"); 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 c1b0b9b7c..d7ebe4189 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 @@ -45,6 +45,7 @@ pub fn build_gmod_scripted_classes( class_type: scope_match.definition.class_global.clone(), class_name: scope_match.class_name, definition_id: Some(scope_match.definition.id), + project_id: None, range: None, }); } @@ -88,6 +89,7 @@ pub fn build_gmod_scripted_classes( Some(GmodScriptedClassesResult { definitions, entries, + projects: Vec::new(), }) } @@ -119,6 +121,7 @@ fn push_vgui_panel_entries( class_type: "VGUI".to_string(), class_name: panel_name.to_string(), definition_id: None, + project_id: None, range, }); } diff --git a/crates/glua_ls/src/handlers/gmod_scripted_classes/gmod_scripted_classes_request.rs b/crates/glua_ls/src/handlers/gmod_scripted_classes/gmod_scripted_classes_request.rs index dc97a8fec..1db037f4f 100644 --- a/crates/glua_ls/src/handlers/gmod_scripted_classes/gmod_scripted_classes_request.rs +++ b/crates/glua_ls/src/handlers/gmod_scripted_classes/gmod_scripted_classes_request.rs @@ -1,6 +1,7 @@ use lsp_types::request::Request; use serde::{Deserialize, Serialize}; +use crate::context::LoadedProject; use glua_code_analysis::ResolvedGmodScriptedClassDefinition; #[derive(Debug)] @@ -30,6 +31,8 @@ pub struct GmodScriptedClassesParams {} pub struct GmodScriptedClassesResult { pub definitions: Vec, pub entries: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub projects: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -51,5 +54,7 @@ pub struct GmodScriptedClassEntry { #[serde(skip_serializing_if = "Option::is_none")] pub definition_id: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub project_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub range: Option, } diff --git a/crates/glua_ls/src/handlers/gmod_scripted_classes/mod.rs b/crates/glua_ls/src/handlers/gmod_scripted_classes/mod.rs index 2aa3dc575..c9aad8999 100644 --- a/crates/glua_ls/src/handlers/gmod_scripted_classes/mod.rs +++ b/crates/glua_ls/src/handlers/gmod_scripted_classes/mod.rs @@ -34,7 +34,20 @@ pub async fn on_gmod_scripted_classes_v2_handler( _params: GmodScriptedClassesParams, cancel_token: CancellationToken, ) -> Option { - let analysis = context.read_analysis(&cancel_token).await?; - let db = analysis.compilation.get_db(); - build_gmod_scripted_classes(db, &cancel_token) + let mut result = { + let analysis = context.read_analysis(&cancel_token).await?; + let db = analysis.compilation.get_db(); + build_gmod_scripted_classes(db, &cancel_token)? + }; + let workspace = context.workspace_manager().read().await; + if let Some(project_loading) = workspace.project_loading.as_ref() { + result.projects = project_loading.loaded_projects(); + for entry in &mut result.entries { + let Ok(uri) = entry.uri.parse() else { + continue; + }; + entry.project_id = project_loading.project_id_for_uri(&uri); + } + } + Some(result) } 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 b7eae5569..8d188ebaa 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,7 @@ pub struct ClientConfig { pub partial_emmyrcs: Option>, pub gmod_annotations_path: Option, pub gamemode_base_libraries: Vec, + pub logical_project_loading: bool, } pub async fn get_client_config( @@ -32,6 +33,7 @@ pub async fn get_client_config( partial_emmyrcs: None, gmod_annotations_path: None, gamemode_base_libraries: Vec::new(), + logical_project_loading: false, }; match client_id { ClientId::VSCode => { diff --git a/crates/glua_ls/src/handlers/initialized/mod.rs b/crates/glua_ls/src/handlers/initialized/mod.rs index bb7d075bc..93b0328ee 100644 --- a/crates/glua_ls/src/handlers/initialized/mod.rs +++ b/crates/glua_ls/src/handlers/initialized/mod.rs @@ -10,7 +10,8 @@ use std::{ use crate::{ cmd_args::CmdArgs, context::{ - FileDiagnostic, LspFeatures, ProgressTask, ServerContextSnapshot, StatusBar, + ChooseGamemodeResult, FileDiagnostic, GamemodeChoiceReason, GmodProjectLoading, + GmodProjectLoadingOptions, LspFeatures, ProgressTask, ServerContextSnapshot, StatusBar, WorkspaceFileMatcher, get_client_id, load_emmy_config, validate_gmod_annotations_for_ls, }, handlers::text_document::register_files_watch, @@ -20,11 +21,13 @@ use crate::{ pub use client_config::{ClientConfig, get_client_config}; use codestyle::load_editorconfig; use glua_code_analysis::{ - EmmyLuaAnalysis, Emmyrc, LuaDiagnosticConfig, WorkspaceFolder, calculate_include_and_exclude, - collect_workspace_files, fetch_schema_urls, uri_to_file_path, + EmmyLuaAnalysis, Emmyrc, GmodWorkspaceTopology, LuaDiagnosticConfig, WorkspaceFolder, + calculate_include_and_exclude, collect_workspace_files, fetch_schema_urls, uri_to_file_path, }; +use lsp_server::RequestId; use lsp_types::{InitializeParams, MessageType, ShowMessageParams}; use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; /// Initialize the workspace. /// @@ -39,8 +42,8 @@ pub async fn initialized_handler( cmd_args: CmdArgs, ) -> Result<(), String> { log::info!("initialized handler started"); - let workspace_folders = get_workspace_folders(¶ms); - let main_root: Option<&str> = match workspace_folders.first() { + let explicit_workspace_folders = get_workspace_folders(¶ms); + let main_root: Option<&str> = match explicit_workspace_folders.first() { Some(path) => path.root.to_str(), None => None, }; @@ -64,18 +67,12 @@ pub async fn initialized_handler( .unwrap_or_default(); log::info!("client_id: {:?}", client_id); - { - log::info!("set workspace folders: {:?}", workspace_folders); - let mut workspace_manager = context.workspace_manager().write().await; - workspace_manager.workspace_folders = workspace_folders.clone(); - log::info!("workspace folders set"); - } - let client_config = get_client_config(&context, client_id, supports_config_request).await; // Extract gmodAnnotationsPath from initialization options if provided // CLI argument takes precedence over VSCode extension-provided path let mut client_config = client_config; + client_config.logical_project_loading = true; if let Some(ref init_options) = params.initialization_options { if let Some(gmod_path) = init_options.get("gmodAnnotationsPath") { if let Some(path_str) = gmod_path.as_str() { @@ -125,10 +122,58 @@ pub async fn initialized_handler( let params_json = serde_json::to_string_pretty(¶ms).unwrap(); log::info!("initialization_params: {}", params_json); + let project_loading_options = params + .initialization_options + .as_ref() + .and_then(|options| options.get("gmodProjectLoading")) + .and_then(|options| { + serde_json::from_value::(options.clone()).ok() + }); + let topology = GmodWorkspaceTopology::discover( + &explicit_workspace_folders + .iter() + .map(|workspace| workspace.root.clone()) + .collect::>(), + ); + let mut project_loading = GmodProjectLoading::new( + topology, + explicit_workspace_folders.clone(), + project_loading_options + .as_ref() + .is_some_and(|options| options.interactive_gamemode_selection), + ); + if let Some(persisted) = project_loading_options + .as_ref() + .and_then(|options| options.selected_gamemode_uri.as_deref()) + .and_then(|value| project_loading.resolve_persisted_gamemode(value)) + { + project_loading.set_active_gamemode(Some(persisted)); + } else if let Some(sole_gamemode) = project_loading.sole_primary_gamemode_id() { + project_loading.set_active_gamemode(Some(sole_gamemode)); + } else if project_loading.interactive() && project_loading.primary_gamemode_count() > 1 { + let choice = request_gamemode_choice( + &context, + project_loading.choose_params(None, GamemodeChoiceReason::Initial), + ) + .await; + if let Some(selected) = choice.filter(|id| project_loading.is_valid_primary_id(id)) { + project_loading.set_active_gamemode(Some(selected)); + } + } + let workspace_folders = project_loading.loaded_workspace_folders(); + { + log::info!("set logical workspace folders: {:?}", workspace_folders); + let mut workspace_manager = context.workspace_manager().write().await; + workspace_manager.explicit_workspace_folders = explicit_workspace_folders.clone(); + workspace_manager.workspace_folders = workspace_folders.clone(); + workspace_manager.project_loading = Some(project_loading); + log::info!("logical workspace folders set"); + } + // init config watchdog_status.set_phase("Loading GLuaLS configuration"); log::info!("loading GLuaLS configuration"); - let config_roots = workspace_folders + let config_roots = explicit_workspace_folders .iter() .map(|workspace| workspace.root.clone()) .collect(); @@ -137,7 +182,7 @@ pub async fn initialized_handler( let workspace_diagnostic_configs = loaded.workspace_diagnostic_configs; let workspace_emmyrcs = loaded.workspace_emmyrcs; let workspace_matchers = loaded.workspace_matchers; - load_editorconfig(workspace_folders.clone(), emmyrc.as_ref()); + load_editorconfig(explicit_workspace_folders.clone(), emmyrc.as_ref()); log::info!("configuration loaded"); // LS-only fail-fast: when GMod mode is enabled, require a resolved, @@ -177,6 +222,7 @@ pub async fn initialized_handler( workspace_manager.match_file_pattern = WorkspaceFileMatcher::new(include, exclude, exclude_dir); workspace_manager.per_root_matchers = workspace_matchers; + workspace_manager.workspace_emmyrcs = workspace_emmyrcs.clone(); log::info!("workspace manager updated with client config and watch file patterns") } @@ -194,12 +240,48 @@ pub async fn initialized_handler( ) .await; + let project_loading_state = { + let workspace_manager = context.workspace_manager().read().await; + workspace_manager + .project_loading + .as_ref() + .map(GmodProjectLoading::state) + }; + if let Some(state) = project_loading_state { + context + .client() + .send_notification("gluals/projectsChanged", state); + } + register_files_watch(context.clone(), ¶ms.capabilities).await; log::info!("initialized handler completed; notifying workspace loaded"); context.file_diagnostic().notify_workspace_loaded(); Ok(()) } +async fn request_gamemode_choice( + context: &ServerContextSnapshot, + params: crate::context::ChooseGamemodeParams, +) -> Option { + let request_id: RequestId = context.client().next_id(); + let response = context + .client() + .send_request( + request_id, + "gluals/chooseGamemode", + params, + CancellationToken::new(), + ) + .await?; + let result = response.result?; + if result.is_null() { + return None; + } + serde_json::from_value::(result) + .ok()? + .selected_gamemode_id +} + pub async fn init_analysis( analysis: &RwLock, client: &crate::context::ClientProxy, @@ -227,26 +309,24 @@ pub async fn init_analysis( ); log::info!("preparing workspace folders for initial indexing"); - let workspace_roots = workspace_folders - .into_iter() - .map(|workspace| workspace.root) - .collect::>(); - let mut workspace_collection_groups: Vec<(Arc, Vec)> = Vec::new(); - if workspace_roots.is_empty() { + if workspace_folders.is_empty() { workspace_collection_groups.push(( emmyrc.clone(), build_workspace_collection_folders(None, emmyrc.as_ref()), )); } else { - for workspace_root in workspace_roots { + for workspace in workspace_folders { let workspace_config = workspace_emmyrcs - .get(&workspace_root) + .iter() + .filter(|(config_root, _)| workspace.root.starts_with(config_root)) + .max_by_key(|(config_root, _)| config_root.as_os_str().len()) + .map(|(_, config)| config) .cloned() .unwrap_or_else(|| emmyrc.clone()); workspace_collection_groups.push(( workspace_config.clone(), - build_workspace_collection_folders(Some(workspace_root), workspace_config.as_ref()), + build_workspace_collection_folders(Some(workspace), workspace_config.as_ref()), )); } } @@ -443,13 +523,13 @@ pub async fn init_analysis( } fn build_workspace_collection_folders( - workspace_root: Option, + workspace_root: Option, emmyrc: &Emmyrc, ) -> Vec { let mut workspaces = Vec::new(); if let Some(workspace_root) = workspace_root { - workspaces.push(WorkspaceFolder::new(workspace_root, false)); + workspaces.push(workspace_root); } for extra_root in &emmyrc.workspace.workspace_roots { diff --git a/crates/glua_ls/src/handlers/mod.rs b/crates/glua_ls/src/handlers/mod.rs index f6b17a217..a596bbbd7 100644 --- a/crates/glua_ls/src/handlers/mod.rs +++ b/crates/glua_ls/src/handlers/mod.rs @@ -27,6 +27,7 @@ mod initialized; mod inlay_hint; mod inline_values; mod notification_handler; +mod project_loading; mod references; mod rename; mod request_handler; @@ -45,6 +46,7 @@ mod test_lib; pub use initialized::{ClientConfig, init_analysis, initialized_handler}; use lsp_types::{ClientCapabilities, ServerCapabilities}; pub use notification_handler::on_notification_handler; +pub use project_loading::*; pub use request_handler::on_request_handler; pub use response_handler::on_response_handler; pub use text_document::on_did_change_text_document; diff --git a/crates/glua_ls/src/handlers/notification_handler.rs b/crates/glua_ls/src/handlers/notification_handler.rs index 2728d9c19..b068c8359 100644 --- a/crates/glua_ls/src/handlers/notification_handler.rs +++ b/crates/glua_ls/src/handlers/notification_handler.rs @@ -70,6 +70,10 @@ pub async fn on_notification_handler( ) { let uri = params.text_document.uri.clone(); + let text = params + .content_changes + .first() + .map(|change| change.text.clone()); let snapshot = server_context.snapshot(); snapshot .note_document_seen_version(&uri, params.text_document.version) @@ -78,6 +82,16 @@ pub async fn on_notification_handler( let workspace = snapshot.workspace_manager().read().await; workspace.update_workspace_version(WorkspaceDiagnosticLevel::Fast, true); } + if let Some(text) = text { + let mut workspace = snapshot.workspace_manager().write().await; + if let Some(project_loading) = workspace.project_loading.as_mut() { + project_loading.update_open_document( + uri.clone(), + text, + params.text_document.version, + ); + } + } // Keep stale-aware UI requests alive so they can wait for fresh // data instead of flickering while typing. server_context @@ -107,6 +121,13 @@ pub async fn on_notification_handler( { let mut workspace = snapshot.workspace_manager().write().await; workspace.current_open_files.insert(uri.clone()); + if let Some(project_loading) = workspace.project_loading.as_mut() { + project_loading.update_open_document( + uri.clone(), + params.text_document.text.clone(), + params.text_document.version, + ); + } workspace.update_workspace_version(WorkspaceDiagnosticLevel::Fast, true); } server_context.cancel_text_requests_for_uri(&uri).await; @@ -141,6 +162,9 @@ pub async fn on_notification_handler( { let mut workspace = snapshot.workspace_manager().write().await; workspace.current_open_files.remove(&uri); + if let Some(project_loading) = workspace.project_loading.as_mut() { + project_loading.remove_open_document(&uri); + } workspace.update_workspace_version(WorkspaceDiagnosticLevel::Fast, true); } server_context.cancel_text_requests_for_uri(&uri).await; diff --git a/crates/glua_ls/src/handlers/project_loading.rs b/crates/glua_ls/src/handlers/project_loading.rs new file mode 100644 index 000000000..da02ed53e --- /dev/null +++ b/crates/glua_ls/src/handlers/project_loading.rs @@ -0,0 +1,297 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use glua_code_analysis::{ + WorkspaceFolder, collect_workspace_files, file_path_to_uri, uri_to_file_path, +}; +use lsp_types::{Uri, request::Request}; +use tokio_util::sync::CancellationToken; + +use crate::context::{ + GamemodeChoiceReason, ServerContextSnapshot, SetActiveGamemodeParams, SetActiveGamemodeResult, +}; + +#[derive(Debug)] +pub enum SetActiveGamemodeRequest {} + +impl Request for SetActiveGamemodeRequest { + type Params = SetActiveGamemodeParams; + type Result = Option; + const METHOD: &'static str = "gluals/setActiveGamemode"; +} + +pub async fn on_set_active_gamemode( + context: ServerContextSnapshot, + params: SetActiveGamemodeParams, + _cancel_token: CancellationToken, +) -> Option { + let selection_lock = { + let workspace = context.workspace_manager().read().await; + workspace.gamemode_selection_lock() + }; + let _selection_guard = selection_lock.lock().await; + let selected_gamemode_id = params.selected_gamemode_id.clone(); + switch_active_gamemode(&context, params.selected_gamemode_id, params.open_documents).await?; + Some(SetActiveGamemodeResult { + selected_gamemode_id, + }) +} + +pub async fn ensure_gamemode_loaded_for_document( + context: &ServerContextSnapshot, + uri: &Uri, +) -> bool { + let selection_lock = { + let workspace = context.workspace_manager().read().await; + workspace.gamemode_selection_lock() + }; + let _selection_guard = selection_lock.lock().await; + + let (requested_id, interactive, already_loaded, choice_params) = { + let workspace = context.workspace_manager().read().await; + let Some(project_loading) = workspace.project_loading.as_ref() else { + return true; + }; + let Some(gamemode) = project_loading.gamemode_for_uri(uri) else { + return true; + }; + let requested_id = gamemode.id.clone(); + ( + requested_id.clone(), + project_loading.interactive(), + project_loading.is_gamemode_loaded(&requested_id), + project_loading.choose_params(Some(requested_id), GamemodeChoiceReason::DocumentOpen), + ) + }; + + if already_loaded { + return true; + } + + let selected_id = if interactive { + request_document_gamemode_choice(context, choice_params).await + } else { + Some(requested_id.clone()) + }; + let Some(selected_id) = selected_id else { + return false; + }; + if selected_id != requested_id { + return false; + } + + switch_active_gamemode(context, selected_id, Vec::new()) + .await + .is_some() +} + +async fn request_document_gamemode_choice( + context: &ServerContextSnapshot, + params: crate::context::ChooseGamemodeParams, +) -> Option { + let response = context + .client() + .send_request( + context.client().next_id(), + "gluals/chooseGamemode", + params, + CancellationToken::new(), + ) + .await?; + let result = response.result?; + if result.is_null() { + return None; + } + serde_json::from_value::(result) + .ok()? + .selected_gamemode_id +} + +async fn switch_active_gamemode( + context: &ServerContextSnapshot, + selected_gamemode_id: String, + open_document_snapshots: Vec, +) -> Option<()> { + let ( + old_roots, + new_roots, + new_base_roots, + workspace_emmyrcs, + merged_emmyrc, + open_documents, + changed, + project_loading_state, + ) = { + let merged_emmyrc = context.analysis().read().await.get_emmyrc(); + let mut workspace = context.workspace_manager().write().await; + let project_loading = workspace.project_loading.as_mut()?; + if !project_loading.is_valid_primary_id(&selected_gamemode_id) { + return None; + } + + let old_roots = project_loading.loaded_gamemode_roots(); + project_loading.merge_open_document_snapshots(open_document_snapshots); + let changed = project_loading.set_active_gamemode(Some(selected_gamemode_id)); + let new_roots = project_loading.loaded_gamemode_roots(); + let new_base_roots = new_roots.iter().skip(1).cloned().collect::>(); + let open_documents = project_loading.open_documents_in_loaded_projects(); + let project_loading_state = project_loading.state(); + let loaded_workspace_folders = project_loading.loaded_workspace_folders(); + workspace.workspace_folders = loaded_workspace_folders; + workspace.update_workspace_version(crate::context::WorkspaceDiagnosticLevel::Fast, true); + + ( + old_roots, + new_roots, + new_base_roots, + workspace.workspace_emmyrcs.clone(), + merged_emmyrc, + open_documents, + changed, + project_loading_state, + ) + }; + + let old_only_roots = old_roots + .iter() + .filter(|old_root| { + !new_roots + .iter() + .any(|new_root| paths_equal(old_root, new_root)) + }) + .cloned() + .collect::>(); + let new_only_roots = new_roots + .iter() + .filter(|new_root| { + !old_roots + .iter() + .any(|old_root| paths_equal(old_root, new_root)) + }) + .cloned() + .collect::>(); + + if !changed && open_documents.is_empty() { + return Some(()); + } + + let removed_uris = { + let analysis = context.analysis().read().await; + let vfs = analysis.compilation.get_db().get_vfs(); + vfs.get_all_file_ids() + .into_iter() + .filter_map(|file_id| { + let path = vfs.get_file_path(&file_id)?; + old_only_roots + .iter() + .any(|root| path.starts_with(root)) + .then(|| vfs.get_uri(&file_id)) + .flatten() + }) + .collect::>() + }; + + let mut updates = HashMap::>::new(); + for uri in &removed_uris { + updates.insert(uri.clone(), None); + } + for root in &new_only_roots { + let config = nearest_config(root, &workspace_emmyrcs).unwrap_or(&merged_emmyrc); + let is_library = new_base_roots + .iter() + .any(|base_root| paths_equal(base_root, root)); + for file in collect_workspace_files( + &vec![WorkspaceFolder::new(root.clone(), is_library)], + config.as_ref(), + None, + None, + ) { + if let Some(uri) = file_path_to_uri(&PathBuf::from(&file.path)) { + updates.insert(uri, Some(file.content)); + } + } + } + for (uri, document) in &open_documents { + if is_uri_in_roots(uri, &new_roots) { + updates.insert(uri.clone(), Some(document.text.clone())); + } + } + + let updated_file_ids = { + let mut analysis = context.analysis().write().await; + for base_root in new_base_roots { + analysis.add_library_workspace(base_root); + } + let mut updates = updates.into_iter().collect::>(); + updates.sort_by(|left, right| left.0.as_str().cmp(right.0.as_str())); + let updated = analysis.update_files_by_uri(updates); + context + .file_diagnostic() + .invalidate_shared_diagnostic_data(); + updated + }; + + if !context.lsp_features().supports_pull_diagnostic() { + for uri in removed_uris { + context + .file_diagnostic() + .clear_push_file_diagnostics(uri) + .await; + } + let interval = merged_emmyrc.diagnostics.diagnostic_interval.unwrap_or(500); + context + .file_diagnostic() + .add_files_diagnostic_task( + updated_file_ids, + interval, + Some(context.debounced_analysis_arc()), + ) + .await; + } + + context.client().refresh_semantic_tokens(); + context.client().refresh_inlay_hints(); + context.client().refresh_code_lens(); + if context.lsp_features().supports_workspace_diagnostic() { + context.client().refresh_workspace_diagnostics(); + } + context + .client() + .send_notification("gluals/projectsChanged", project_loading_state); + + for (uri, document) in open_documents { + if is_uri_in_roots(&uri, &new_roots) { + context + .note_document_applied_version(&uri, document.version) + .await; + } + } + + Some(()) +} + +fn nearest_config<'a>( + path: &Path, + configs: &'a HashMap>, +) -> Option<&'a std::sync::Arc> { + configs + .iter() + .filter(|(root, _)| path.starts_with(root)) + .max_by_key(|(root, _)| root.as_os_str().len()) + .map(|(_, config)| config) +} + +fn is_uri_in_roots(uri: &Uri, roots: &[PathBuf]) -> bool { + uri_to_file_path(uri).is_some_and(|path| roots.iter().any(|root| path.starts_with(root))) +} + +fn paths_equal(left: &Path, right: &Path) -> bool { + if cfg!(windows) { + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) + } else { + left == right + } +} diff --git a/crates/glua_ls/src/handlers/request_handler.rs b/crates/glua_ls/src/handlers/request_handler.rs index b966ec294..7ec0794d3 100644 --- a/crates/glua_ls/src/handlers/request_handler.rs +++ b/crates/glua_ls/src/handlers/request_handler.rs @@ -59,6 +59,7 @@ use super::{ implementation::on_implementation_handler, inlay_hint::{on_inlay_hint_handler, on_resolve_inlay_hint}, inline_values::on_inline_values_handler, + project_loading::{SetActiveGamemodeRequest, on_set_active_gamemode}, references::on_references_handler, rename::{on_prepare_rename_handler, on_rename_handler}, semantic_token::on_semantic_token_handler, @@ -189,6 +190,7 @@ pub async fn on_request_handler( GluaHoverExpandRequest => on_hover_expand_handler, GmodScriptedClassesRequest => on_gmod_scripted_classes_handler, GmodScriptedClassesV2Request => on_gmod_scripted_classes_v2_handler, + SetActiveGamemodeRequest => on_set_active_gamemode, InlayHintRequest => on_inlay_hint_handler, Formatting => on_formatting_handler, RangeFormatting => on_range_formatting_handler, diff --git a/crates/glua_ls/src/handlers/text_document/text_document_handler.rs b/crates/glua_ls/src/handlers/text_document/text_document_handler.rs index 49cf6af11..bfe279435 100644 --- a/crates/glua_ls/src/handlers/text_document/text_document_handler.rs +++ b/crates/glua_ls/src/handlers/text_document/text_document_handler.rs @@ -221,6 +221,11 @@ pub async fn on_did_open_text_document( let version = params.text_document.version; let supports_pull = context.lsp_features().supports_pull_diagnostic(); + if !crate::handlers::ensure_gamemode_loaded_for_document(&context, &uri).await { + context.mark_document_closed(&uri).await; + return None; + } + // Check if file should be filtered before acquiring locks // Follow lock order: workspace_manager (read) -> analysis (write) let should_process = { 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 36d6f2808..d4aa9bd79 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 @@ -39,19 +39,28 @@ pub async fn on_did_change_workspace_folders( if !removed_roots.is_empty() { workspace_manager - .workspace_folders + .explicit_workspace_folders .retain(|workspace| !removed_roots.contains(&workspace.root)); } for added_workspace in added_folders { let already_exists = workspace_manager - .workspace_folders + .explicit_workspace_folders .iter() .any(|workspace| workspace.root == added_workspace.root); if !already_exists { - workspace_manager.workspace_folders.push(added_workspace); + workspace_manager + .explicit_workspace_folders + .push(added_workspace); } } + let explicit_workspace_folders = workspace_manager.explicit_workspace_folders.clone(); + if let Some(project_loading) = workspace_manager.project_loading.as_mut() { + project_loading.rediscover(explicit_workspace_folders); + workspace_manager.workspace_folders = project_loading.loaded_workspace_folders(); + } else { + workspace_manager.workspace_folders = explicit_workspace_folders; + } ( workspace_manager.client_config.client_id, @@ -59,7 +68,8 @@ pub async fn on_did_change_workspace_folders( ) }; - let client_config = get_client_config(&context, client_id, supports_config_request).await; + let mut client_config = get_client_config(&context, client_id, supports_config_request).await; + client_config.logical_project_loading = true; let mut workspace_manager = context.workspace_manager().write().await; workspace_manager.client_config = client_config;