Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 70 additions & 14 deletions crates/glua_code_analysis/src/gamemode_base.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
//! Server-side detection of GMod gamemode base libraries.
//!
//! Garry's Mod gamemodes live under `<gameroot>/gamemodes/<name>/` and carry a
//! `<name>.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
//! `<base>`. At runtime, gmod loads the parent's code first via
//! `DeriveGamemode("<base>")`, 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/<name>/<name>.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
Expand All @@ -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 `<folder-name>.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<PathBuf> {
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::<Vec<_>>();
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
Expand All @@ -46,13 +80,23 @@ pub fn read_gamemode_base(txt_path: &Path) -> Option<String> {
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 `<name>/<name>.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 `<basename>/<basename>.txt`, follows that chain.
/// itself contains a metadata file, follows that chain.
///
/// Returned paths:
/// * are absolute,
Expand All @@ -70,7 +114,7 @@ pub fn detect_gamemode_base_libraries(workspace_root: &Path) -> Vec<PathBuf> {
// 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()
Expand Down Expand Up @@ -105,7 +149,7 @@ pub fn detect_gamemode_base_libraries(workspace_root: &Path) -> Vec<PathBuf> {
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(
Expand Down Expand Up @@ -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(&current).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;
}
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion crates/glua_code_analysis/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
105 changes: 102 additions & 3 deletions crates/glua_code_analysis/src/vfs/collect_workspace_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use crate::{
pub enum WorkspaceImport {
All,
SubPaths(Vec<PathBuf>),
AllExcept(Vec<PathBuf>),
}

#[derive(Clone, Debug)]
Expand All @@ -34,6 +35,18 @@ impl WorkspaceFolder {
is_library,
}
}

pub fn with_excluded_sub_paths(
root: PathBuf,
excluded_sub_paths: Vec<PathBuf>,
is_library: bool,
) -> Self {
Self {
root,
import: WorkspaceImport::AllExcept(excluded_sub_paths),
is_library,
}
}
}

#[derive(Debug)]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -275,8 +292,31 @@ fn find_library_exclude(library: &WorkspaceFolder, emmyrc: &Emmyrc) -> (Vec<Stri

#[cfg(test)]
mod tests {
use super::{WorkspaceFileCandidate, dedupe_workspace_files_deterministic};
use crate::LuaFileInfo;
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};

use super::{
WorkspaceFileCandidate, WorkspaceFolder, collect_workspace_files,
dedupe_workspace_files_deterministic,
};
use crate::{Emmyrc, LuaFileInfo};

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_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() {
Expand All @@ -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::<Vec<_>>();

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");
}
}
2 changes: 2 additions & 0 deletions crates/glua_code_analysis/src/vfs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down
Loading