diff --git a/Cargo.lock b/Cargo.lock index c3b09e5b..ee219b2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1616,6 +1616,7 @@ dependencies = [ "byteorder", "chumsky", "fs-err", + "glob", "hemtt-common", "hemtt-preprocessor", "hemtt-workspace", @@ -1626,7 +1627,9 @@ dependencies = [ "paste", "serde", "serde_json", + "thiserror 2.0.19", "toml", + "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0da2120e..6d549177 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ dirs = "6.0.0" fs_extra = "1.3.0" fs-err = "3.3.1" git2 = "0.21.0" +glob = "0.3.4" indexmap = "2.14.0" insta = "1.48.0" interprocess = "2.4.2" diff --git a/bin/Cargo.toml b/bin/Cargo.toml index 6dfa5af1..137a7394 100644 --- a/bin/Cargo.toml +++ b/bin/Cargo.toml @@ -41,7 +41,7 @@ dirs = { workspace = true } fs_extra = { workspace = true } fs-err = { workspace = true } git2 = { workspace = true } -glob = "0.3.4" +glob = { workspace = true } image = "0.25.10" indicatif = "0.18.6" interprocess = { workspace = true } diff --git a/bin/src/error.rs b/bin/src/error.rs index 72bc9a4b..1203c680 100644 --- a/bin/src/error.rs +++ b/bin/src/error.rs @@ -46,6 +46,8 @@ pub enum Error { Dialoguer(#[from] dialoguer::Error), #[error("Git Error: {0}")] Git(#[from] git2::Error), + #[error("Config file selection error: {0}")] + ConfigFiles(#[from] hemtt_config::files::Error), #[error("Glob Error: {0}")] GlobError(#[from] glob::GlobError), #[error("Glob Pattern Error: {0}")] diff --git a/bin/src/modules/rapifier.rs b/bin/src/modules/rapifier.rs index 8978eb43..cddd3901 100644 --- a/bin/src/modules/rapifier.rs +++ b/bin/src/modules/rapifier.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, path::PathBuf, sync::RwLock}; +use std::{collections::HashMap, sync::RwLock}; use hemtt_config::{ Config, @@ -11,7 +11,6 @@ use hemtt_workspace::{ addons::{Addon, Location}, }; use rayon::prelude::{IntoParallelRefIterator, ParallelIterator}; -use vfs::VfsFileType; use crate::{context::Context, error::Error, progress::progress_bar, report::Report}; @@ -53,39 +52,12 @@ impl Module for Rapifier { fn pre_build(&self, ctx: &Context) -> Result { ctx.state().set(AddonConfigs::default()); let mut report = Report::new(); - let glob_options = glob::MatchOptions { - require_literal_separator: true, - ..Default::default() - }; let mut entries = Vec::new(); - ctx.addons() - .iter() - .map(|addon| { - let mut globs = Vec::new(); - if let Some(config) = addon.config() { - if !config.rapify().enabled() { - debug!("rapify disabled for {}", addon.name()); - return Ok(()); - } - for file in config.rapify().exclude() { - globs.push(glob::Pattern::new(file)?); - } - } - for entry in ctx.workspace_path().join(addon.folder())?.walk_dir()? { - if entry.metadata()?.file_type == VfsFileType::File && can_rapify(&entry)? { - if globs - .iter() - .any(|pat| pat.matches_with(entry.as_str(), glob_options)) - { - debug!("skipping {}", entry.as_str()); - continue; - } - entries.push((addon, entry)); - } - } - Ok(()) - }) - .collect::, Error>>()?; + for addon in ctx.addons() { + for entry in hemtt_config::files::checkable(ctx.workspace_path(), addon)? { + entries.push((addon, entry)); + } + } let progress = progress_bar(entries.len() as u64).with_message("Rapifying Configs"); let reports = entries @@ -196,28 +168,3 @@ pub fn rapify(addon: &Addon, path: &WorkspacePath, ctx: &Context) -> Result Result { - let path = entry.as_str(); - let pathbuf = PathBuf::from(&path); - let ext = pathbuf - .extension() - .unwrap_or_else(|| std::ffi::OsStr::new("")) - .to_str() - .expect("osstr should be valid utf8"); - if ext == "cpp" && pathbuf.file_name() != Some(std::ffi::OsStr::new("config.cpp")) { - warn!( - "{} - cpp files other than config.cpp are usually not intentional. use hpp for includes", - path.trim_start_matches('/') - ); - } - if !["cpp", "rvmat", "ext", "sqm", "bikb", "bisurf"].contains(&ext) { - return Ok(false); - } - let mut buffer = vec![0; 4]; - if entry.open_file()?.read_exact(&mut buffer).is_err() { - // The file is less than 4 bytes, so it is not rapified - return Ok(true); - } - Ok(buffer != b"\0raP") -} diff --git a/hls/src/config/lints.rs b/hls/src/config/lints.rs index 61d54e4b..5d3622df 100644 --- a/hls/src/config/lints.rs +++ b/hls/src/config/lints.rs @@ -4,7 +4,7 @@ use std::{ }; use hemtt_preprocessor::Processor; -use hemtt_workspace::{WorkspacePath, reporting::WorkspaceFiles}; +use hemtt_workspace::{WorkspacePath, addons::Addon, reporting::WorkspaceFiles}; use tokio::{sync::RwLock, task::JoinSet}; use tower_lsp::Client; use tracing::{debug, warn}; @@ -38,12 +38,26 @@ impl Cache { fn check_addons(workspace: &EditorWorkspace, client: Client) { let mut futures = JoinSet::new(); - for config in workspace.root().addons() { - let Ok(source) = workspace.root().join(config.as_str()) else { - warn!("failed to join config {:?}", config); - continue; + // Every rapifiable file, not just `config.cpp`, and honouring the addon's + // `rapify` settings - the same set the CLI checks + let addons = match Addon::scan(workspace.root_disk()) { + Ok(addons) => addons, + Err(e) => { + warn!("not checking configs, failed to scan addons: {e}"); + return; + } + }; + for addon in addons { + let files = match hemtt_config::files::checkable(workspace.root(), &addon) { + Ok(files) => files, + Err(e) => { + warn!("not checking `{}`: {e}", addon.folder()); + continue; + } }; - futures.spawn(check_addon(source, workspace.clone())); + for source in files { + futures.spawn(check_addon(source, workspace.clone())); + } } tokio::spawn(async move { futures.join_all().await; diff --git a/libs/config/Cargo.toml b/libs/config/Cargo.toml index a20aef0f..b95ea5bf 100644 --- a/libs/config/Cargo.toml +++ b/libs/config/Cargo.toml @@ -20,10 +20,13 @@ automod = { workspace = true } byteorder = { workspace = true } chumsky = { workspace = true } fs-err = { workspace = true } +glob = { workspace = true } indexmap = { workspace = true } linkme = { workspace = true } lsp-types = { workspace = true } +thiserror = { workspace = true } toml = { workspace = true } +tracing = { workspace = true } serde = { workspace = true, features = ["derive"], optional = true } diff --git a/libs/config/src/files.rs b/libs/config/src/files.rs new file mode 100644 index 00000000..0527150e --- /dev/null +++ b/libs/config/src/files.rs @@ -0,0 +1,90 @@ +//! Which files in an addon HEMTT config-checks. +//! +//! Shared so the CLI and the language server cannot disagree about it. They +//! did: the CLI checked every rapifiable file and honoured the addon's +//! `rapify` settings, while the editor only ever looked at `config.cpp` and +//! ignored `exclude` and `enabled` entirely. + +use std::io::Read; + +use hemtt_workspace::{WorkspacePath, addons::Addon}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Workspace error: {0}")] + Workspace(#[from] hemtt_workspace::Error), + #[error("Invalid rapify exclude pattern: {0}")] + Pattern(#[from] glob::PatternError), +} + +/// Extensions that HEMTT rapifies. +const EXTENSIONS: &[&str] = &["cpp", "rvmat", "ext", "sqm", "bikb", "bisurf"]; + +/// Can this file be rapified? +/// +/// A file that is already rapified is skipped - it starts with `\0raP`. +/// +/// # Errors +/// [`hemtt_workspace::Error`] if the file cannot be read +pub fn can_rapify(entry: &WorkspacePath) -> Result { + let path = entry.as_str(); + let pathbuf = std::path::PathBuf::from(&path); + let Some(ext) = pathbuf.extension().and_then(std::ffi::OsStr::to_str) else { + return Ok(false); + }; + if ext == "cpp" && pathbuf.file_name() != Some(std::ffi::OsStr::new("config.cpp")) { + tracing::warn!( + "{} - cpp files other than config.cpp are usually not intentional. use hpp for includes", + path.trim_start_matches('/') + ); + } + if !EXTENSIONS.contains(&ext) { + return Ok(false); + } + let mut buffer = vec![0; 4]; + if entry.open_file()?.read_exact(&mut buffer).is_err() { + // The file is less than 4 bytes, so it is not rapified + return Ok(true); + } + Ok(buffer != b"\0raP") +} + +/// The files in an addon that should be config-checked. +/// +/// Honours the addon's `rapify` settings: nothing if it is disabled, and +/// anything matching an `exclude` pattern is left out. +/// +/// # Errors +/// [`Error::Workspace`] if the addon cannot be walked +/// [`Error::Pattern`] if an `exclude` pattern is not a valid glob +pub fn checkable(root: &WorkspacePath, addon: &Addon) -> Result, Error> { + let mut excludes = Vec::new(); + if let Some(config) = addon.config() { + if !config.rapify().enabled() { + tracing::debug!("rapify disabled for {}", addon.name()); + return Ok(Vec::new()); + } + for pattern in config.rapify().exclude() { + excludes.push(glob::Pattern::new(pattern)?); + } + } + let options = glob::MatchOptions { + require_literal_separator: true, + ..Default::default() + }; + let mut files = Vec::new(); + for entry in root.join(addon.folder())?.walk_dir()? { + if !entry.is_file()? || !can_rapify(&entry)? { + continue; + } + if excludes + .iter() + .any(|pattern| pattern.matches_with(entry.as_str(), options)) + { + tracing::debug!("skipping {}", entry.as_str()); + continue; + } + files.push(entry); + } + Ok(files) +} diff --git a/libs/config/src/lib.rs b/libs/config/src/lib.rs index da67e899..7c45d3e6 100644 --- a/libs/config/src/lib.rs +++ b/libs/config/src/lib.rs @@ -11,6 +11,7 @@ use std::{ pub mod analyze; pub mod display; +pub mod files; mod model; pub mod parse; pub mod rapify; diff --git a/libs/config/tests/files.rs b/libs/config/tests/files.rs new file mode 100644 index 00000000..ad962f35 --- /dev/null +++ b/libs/config/tests/files.rs @@ -0,0 +1,63 @@ +#![allow(clippy::unwrap_used)] + +//! Which files get config checked. Both the CLI and the language server rely +//! on this answer, so a change here changes what the editor shows. + +use hemtt_config::files::{can_rapify, checkable}; +use hemtt_workspace::{LayerType, addons::Addon}; + +const ROOT: &str = "tests/files"; + +fn workspace() -> hemtt_workspace::WorkspacePath { + hemtt_workspace::Workspace::builder() + .physical(&std::path::PathBuf::from(ROOT), LayerType::Source) + .finish(None, false, &hemtt_common::config::PDriveOption::Disallow) + .unwrap() +} + +fn addon() -> Addon { + Addon::new( + &std::path::PathBuf::from(ROOT), + "main".to_string(), + hemtt_workspace::addons::Location::Addons, + ) + .unwrap() +} + +fn names(mut files: Vec) -> Vec { + files.sort_by_key(hemtt_workspace::WorkspacePath::filename); + files + .iter() + .map(hemtt_workspace::WorkspacePath::filename) + .collect() +} + +#[test] +fn rapifiable_extensions() { + let workspace = workspace(); + for name in ["config.cpp", "checked.rvmat", "mission.ext"] { + let path = workspace.join(format!("/addons/main/{name}")).unwrap(); + assert!(can_rapify(&path).unwrap(), "{name} should be rapifiable"); + } + let path = workspace.join("/addons/main/notes.txt").unwrap(); + assert!(!can_rapify(&path).unwrap(), "notes.txt is not a config"); +} + +/// The editor used to check only `config.cpp`, so an error in a `.rvmat` or +/// `.ext` was reported by `hemtt check` and never shown. +#[test] +fn checks_every_rapifiable_file() { + let files = names(checkable(&workspace(), &addon()).unwrap()); + assert!(files.contains(&"config.cpp".to_string()), "{files:?}"); + assert!(files.contains(&"checked.rvmat".to_string()), "{files:?}"); + assert!(files.contains(&"mission.ext".to_string()), "{files:?}"); + assert!(!files.contains(&"notes.txt".to_string()), "{files:?}"); +} + +/// The editor used to ignore `rapify.exclude`, so it reported diagnostics on +/// files the project had deliberately excluded. +#[test] +fn honours_exclude() { + let files = names(checkable(&workspace(), &addon()).unwrap()); + assert!(!files.contains(&"skipped.rvmat".to_string()), "{files:?}"); +} diff --git a/libs/config/tests/files/addons/main/$PBOPREFIX$ b/libs/config/tests/files/addons/main/$PBOPREFIX$ new file mode 100644 index 00000000..fd1b0e37 --- /dev/null +++ b/libs/config/tests/files/addons/main/$PBOPREFIX$ @@ -0,0 +1 @@ +z\test\addons\main diff --git a/libs/config/tests/files/addons/main/addon.toml b/libs/config/tests/files/addons/main/addon.toml new file mode 100644 index 00000000..3ef5c0fc --- /dev/null +++ b/libs/config/tests/files/addons/main/addon.toml @@ -0,0 +1,2 @@ +[rapify] +exclude = ["/addons/main/skipped.rvmat"] diff --git a/libs/config/tests/files/addons/main/checked.rvmat b/libs/config/tests/files/addons/main/checked.rvmat new file mode 100644 index 00000000..0d42577c --- /dev/null +++ b/libs/config/tests/files/addons/main/checked.rvmat @@ -0,0 +1 @@ +class Stage0 { texture = "a.paa"; }; diff --git a/libs/config/tests/files/addons/main/config.cpp b/libs/config/tests/files/addons/main/config.cpp new file mode 100644 index 00000000..4a0a2823 --- /dev/null +++ b/libs/config/tests/files/addons/main/config.cpp @@ -0,0 +1,3 @@ +class CfgPatches { + class test_main {}; +}; diff --git a/libs/config/tests/files/addons/main/mission.ext b/libs/config/tests/files/addons/main/mission.ext new file mode 100644 index 00000000..48af4738 --- /dev/null +++ b/libs/config/tests/files/addons/main/mission.ext @@ -0,0 +1 @@ +class Params {}; diff --git a/libs/config/tests/files/addons/main/notes.txt b/libs/config/tests/files/addons/main/notes.txt new file mode 100644 index 00000000..19e1ad3c --- /dev/null +++ b/libs/config/tests/files/addons/main/notes.txt @@ -0,0 +1 @@ +not a config diff --git a/libs/config/tests/files/addons/main/skipped.rvmat b/libs/config/tests/files/addons/main/skipped.rvmat new file mode 100644 index 00000000..420d10ab --- /dev/null +++ b/libs/config/tests/files/addons/main/skipped.rvmat @@ -0,0 +1 @@ +class Stage0 { texture = "b.paa"; };