diff --git a/Cargo.lock b/Cargo.lock index 133c6e1d3..d16197b2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -234,15 +234,12 @@ dependencies = [ [[package]] name = "arma3-wiki" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95f79de9794487f9f324e911bafa01ddba0e69a242530813768c598a4d411cc4" +version = "0.4.4" dependencies = [ "directories", - "fs-err", "fs_extra", "git2", - "rand 0.9.4", + "rand 0.8.6", "regex", "rust-embed", "serde", @@ -3347,13 +3344,24 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -3368,6 +3376,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -3378,6 +3396,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rand_core" version = "0.9.5" @@ -3421,7 +3448,7 @@ dependencies = [ "paste", "profiling", "rand 0.9.4", - "rand_chacha", + "rand_chacha 0.9.0", "simd_helpers", "thiserror 2.0.18", "v_frame", diff --git a/Cargo.toml b/Cargo.toml index 6b1a72260..1d7502a19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,8 @@ future_incompatible = "warn" nonstandard_style = "warn" [workspace.dependencies] -arma3-wiki = "0.4.5" +# debug until merge # arma3-wiki = "0.4.4" +arma3-wiki = { path = "../arma3-wiki/clients/rust" } automod = "1.0.16" byteorder = "1.5.0" chumsky = "0.9.3" diff --git a/hls/src/sqf/hover.rs b/hls/src/sqf/hover.rs index 9544599da..cfc2409f0 100644 --- a/hls/src/sqf/hover.rs +++ b/hls/src/sqf/hover.rs @@ -47,11 +47,66 @@ impl SqfAnalyzer { let Symbol::Word(word) = token.symbol() else { return None; }; + // ToDo: this works for full func names, would need work to support FUNC(x) macroed code + if let Some(func) = database.external_functions_get(&word.to_lowercase()) { + return Some(hover_func(func)); + } database.wiki().commands().get(word)?; Some(hover(word, &database)) } } +// WIP +fn hover_func(func: &arma3_wiki::model::Function) -> Hover { + Hover { + contents: HoverContents::Array({ + let mut contents = Vec::new(); + contents.push(MarkedString::String(format!( + "## {}", + func.name().unwrap_or(&String::new()) + ))); + { + let mut string = String::new(); + for arg in func.params() { + writeln!( + string, + "- `{}`: {}{}", + arg.name(), + { + let typ = arg.typ().to_string(); + if typ == "Unknown" { + typ + } else { + format!( + "[{}](https://community.bistudio.com/wiki/{})", + typ, + typ.replace(' ', "_") + ) + } + }, + { arg.description().unwrap_or("?") } + ) + .expect("Failed to write to string"); + } + contents.push(MarkedString::String(format!("### Syntax\n{string}"))); + } + if let Some(ret) = func.ret() { + contents.push(MarkedString::String(format!( + "### Return Type\n- [{}](https://community.bistudio.com/wiki/{})", + ret, + ret.to_string().replace(' ', "_") + ))); + } + let example = func.example(); + if !example.is_empty() { + contents.push(MarkedString::String(format!("### Example\n{example}"))); + } + contents + }), + range: None, + } +} + fn hover(command: &str, database: &Database) -> Hover { database.wiki().commands().get(command).map_or_else( || Hover { diff --git a/libs/common/src/config/mod.rs b/libs/common/src/config/mod.rs index 50c11f495..32182e307 100644 --- a/libs/common/src/config/mod.rs +++ b/libs/common/src/config/mod.rs @@ -15,6 +15,7 @@ pub use pdrive::PDriveOption; pub use project::{ ProjectConfig, hemtt::{RuntimeArguments, launch::LaunchOptions}, + inspector::InspectorOptions, lint::{LintConfig, LintConfigOverride, LintEnabled}, preprocessor::PreprocessorOptions, }; diff --git a/libs/common/src/config/project/inspector.rs b/libs/common/src/config/project/inspector.rs new file mode 100644 index 000000000..bbdab1c70 --- /dev/null +++ b/libs/common/src/config/project/inspector.rs @@ -0,0 +1,152 @@ +use serde::{Deserialize, Serialize}; + +#[allow(clippy::module_name_repetitions)] +#[derive(PartialEq, Eq, Debug, Clone, Default)] +/// Configuration for inspector options +pub struct InspectorOptions { + /// variable names to ignore when checking for undefined variables + vars_to_ignore: Option>, + /// function prefixes to check for when calling + check_function_calls: Vec, + /// project function prefixes to export to `.hemtt/functions` + export_functions: Vec, + /// header regex + header_regex: String, + /// header regex for a line + header_line_regex: String, +} + +impl InspectorOptions { + #[must_use] + pub fn vars_to_ignore(&self) -> Option<&[String]> { + self.vars_to_ignore.as_deref() + } + #[must_use] + pub fn with_vars_to_ignore(mut self, value: Option>) -> Self { + self.vars_to_ignore = value; + self + } + #[must_use] + pub fn check_function_calls(&self) -> &[String] { + &self.check_function_calls + } + #[must_use] + pub fn with_check_function_calls(mut self, value: Vec) -> Self { + self.check_function_calls = value; + self + } + #[must_use] + pub fn export_functions(&self) -> &[String] { + &self.export_functions + } + #[must_use] + pub fn with_export_functions(mut self, value: Vec) -> Self { + self.export_functions = value; + self + } + #[must_use] + pub fn header_regex(&self) -> &str { + &self.header_regex + } + #[must_use] + pub fn with_header_regex(mut self, value: String) -> Self { + self.header_regex = value; + self + } + #[must_use] + pub fn header_line_regex(&self) -> &str { + &self.header_line_regex + } + #[must_use] + pub fn with_header_line_regex(mut self, value: String) -> Self { + self.header_line_regex = value; + self + } +} + +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +enum VectorOrBoolWildcard { + Vec(Vec), + Bool(bool), +} +impl VectorOrBoolWildcard { + /// false becomes empty vec, true becomes vec with wildcard "*" + fn to_vec(&self) -> Vec { + match self { + Self::Vec(v) => v.iter().map(|s| s.to_lowercase()).collect(), + Self::Bool(b) => { + if *b { + vec!["*".to_string()] + } else { + vec![] + } + } + } + } +} + +#[allow(clippy::module_name_repetitions)] +#[derive(PartialEq, Eq, Debug, Default, Clone, Serialize, Deserialize)] +pub struct InspectorOptionsFile { + #[serde(default)] + vars_to_ignore: Option>, + #[serde(default)] + check_function_calls: Option, + #[serde(default)] + export_functions: Option, + #[serde(default)] + header_regex: Option, + #[serde(default)] + header_line_regex: Option, +} + +impl From for InspectorOptions { + fn from(file: InspectorOptionsFile) -> Self { + Self { + vars_to_ignore: file.vars_to_ignore, // default is None, which will load the CfgFunction vars (_fnc_scriptName...) + check_function_calls: file + .check_function_calls + .unwrap_or(VectorOrBoolWildcard::Bool(false)) // opt-in to check func calls + .to_vec(), + export_functions: file + .export_functions + .unwrap_or(VectorOrBoolWildcard::Bool(true)) // default all + .to_vec(), + header_regex: file.header_regex.unwrap_or_default(), // opt-in to header parsing, either preset or actual regex + header_line_regex: file.header_line_regex.unwrap_or_default(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_inspector_options_file_1() { + let toml: &'static str = r""; + let file: InspectorOptionsFile = toml::from_str(toml).expect("Failed to parse TOML"); + let options: InspectorOptions = file.into(); + assert!(options.vars_to_ignore().is_none()); + assert!(options.check_function_calls().is_empty()); + assert_eq!(options.export_functions(), &vec!["*".to_string()]); + assert_eq!(options.header_regex, ""); + assert_eq!(options.header_line_regex, ""); + } + #[test] + fn test_inspector_options_file_2() { + let toml: &'static str = r#" + export_functions = ["abe_berry"] + check_function_calls = true + vars_to_ignore = [] + header_regex = "ace" + "#; + let file: InspectorOptionsFile = toml::from_str(toml).expect("Failed to parse TOML"); + let options: InspectorOptions = file.into(); + assert!(options.vars_to_ignore().expect("some").is_empty()); + assert_eq!(options.check_function_calls(), &vec!["*".to_string()]); + assert_eq!(options.export_functions(), &vec!["abe_berry".to_string()]); + assert_eq!(options.header_regex, "ace"); + assert_eq!(options.header_line_regex, ""); + } +} diff --git a/libs/common/src/config/project/mod.rs b/libs/common/src/config/project/mod.rs index ec66a82d6..588b22506 100644 --- a/libs/common/src/config/project/mod.rs +++ b/libs/common/src/config/project/mod.rs @@ -12,6 +12,7 @@ use super::deprecated; pub mod files; pub mod hemtt; +pub mod inspector; pub mod lint; pub mod preprocessor; pub mod signing; @@ -48,6 +49,9 @@ pub struct ProjectConfig { // Preprocessor options preprocessor: preprocessor::PreprocessorOptions, + /// Inspector specific configuration + inspector: inspector::InspectorOptions, + /// Signing specific configuration signing: signing::SigningConfig, @@ -119,6 +123,12 @@ impl ProjectConfig { &self.preprocessor } + #[must_use] + /// Inspector specific configuration + pub const fn inspector(&self) -> &inspector::InspectorOptions { + &self.inspector + } + #[must_use] /// Signing specific configuration pub const fn signing(&self) -> &signing::SigningConfig { @@ -199,6 +209,9 @@ pub struct ProjectFile { #[serde(default)] preprocessor: preprocessor::PreprocessorOptionsFile, + #[serde(default)] + inspector: inspector::InspectorOptionsFile, + #[serde(default)] signing: signing::SigningSectionFile, @@ -257,6 +270,7 @@ impl TryFrom for ProjectConfig { files: file.files.into(), lints: file.lints.into(), preprocessor: file.preprocessor.into(), + inspector: file.inspector.into(), signing: file.signing.into(), runtime: RuntimeArguments::default(), expected_path, @@ -290,7 +304,7 @@ impl TryFrom for ProjectConfig { mod test_helper { use std::collections::HashMap; - use super::{files, hemtt, lint, preprocessor, signing, version}; + use super::{files, hemtt, inspector, lint, preprocessor, signing, version}; impl super::ProjectConfig { #[must_use] @@ -310,6 +324,7 @@ mod test_helper { lints: lint::LintSectionFile::default(), hemtt: hemtt::HemttSectionFile::default(), preprocessor: preprocessor::PreprocessorOptionsFile::default(), + inspector: inspector::InspectorOptionsFile::default(), signing: signing::SigningSectionFile::default(), meta_path: std::path::PathBuf::default(), } diff --git a/libs/sqf/Cargo.toml b/libs/sqf/Cargo.toml index 584b95c4b..c33727f6b 100644 --- a/libs/sqf/Cargo.toml +++ b/libs/sqf/Cargo.toml @@ -28,6 +28,7 @@ thiserror = { workspace = true } toml = { workspace = true } tracing = { workspace = true } regex = { workspace = true } +fs-err = { workspace = true } [features] default = ["compiler", "parser"] diff --git a/libs/sqf/src/analyze/inspector/commands.rs b/libs/sqf/src/analyze/inspector/commands.rs index 13b24231b..4a0808717 100644 --- a/libs/sqf/src/analyze/inspector/commands.rs +++ b/libs/sqf/src/analyze/inspector/commands.rs @@ -5,8 +5,11 @@ use std::{ops::Range, vec}; use indexmap::IndexSet; use crate::{ - Expression, Statement, - analyze::inspector::{InvalidArgs, Issue, VarSource, game_value::NilSource}, + Expression, NularCommand, Statement, + analyze::inspector::{ + InvalidArgs, Issue, VarSource, + game_value::{FlagType, NilSource}, + }, }; use super::{Inspector, game_value::GameValue}; @@ -59,31 +62,50 @@ impl Inspector<'_> { IndexSet::new() } #[must_use] + /// # Panics pub fn cmd_generic_params( &mut self, rhs: &IndexSet, debug_type: &str, source: &Range, + unary: bool, ) -> IndexSet { let mut error_type = None; + // get header for type defaults + let header = if unary + && self.in_primary_scope() + && let Some(self_header) = &self.function_info + { + Some(self_header.clone()) + } else { + None + }; + for possible in rhs { let GameValue::Array(Some(gv_array), _) = possible else { continue; }; - for gv_index in gv_array { + for (i, gv_index) in gv_array.iter().enumerate() { + let set_from_header = header + .as_ref() + .and_then(|h| h.params().get(i)) + .map(|a| GameValue::from_wiki_value(a.typ(), NilSource::Generic)); for (element, element_span) in gv_index { match element { GameValue::Anything | GameValue::Array(None, _) => {} GameValue::String(_) => { if let Some(error) = self.cmd_generic_params_element( - &[vec![(element.clone(), element_span.clone())]], // put it in a dummy array + &[vec![(element.clone(), element_span.clone())]], // put it in a dummy + set_from_header.as_ref(), ) { error_type = Some(error); } } GameValue::Array(Some(arg_array), _) => { - if let Some(error) = self.cmd_generic_params_element(arg_array) { + if let Some(error) = + self.cmd_generic_params_element(arg_array, set_from_header.as_ref()) + { error_type = Some(error); } } @@ -115,7 +137,32 @@ impl Inspector<'_> { pub fn cmd_generic_params_element( &mut self, element: &[Vec<(GameValue, Range)>], + header_defaults: Option<&IndexSet>, ) -> Option { + /// get generic params and optionally check that they match header + fn match_defaults_to_header( + var_types: &mut IndexSet, + input: &[(GameValue, Range)], + header_defaults: Option<&IndexSet>, + ) -> Option { + let mut error_type = None; + for (v, v_span) in input { + let vg = v.make_generic(); + if let Some(header_defaults) = header_defaults + && !(header_defaults + .iter() + .any(|hd| GameValue::match_values(&vg, hd))) + { + error_type = Some(InvalidArgs::ExpectedDifferentTypeHeader { + expected: header_defaults.iter().cloned().collect(), + found: vec![vg.clone()], + span: v_span.clone(), + }); + } + var_types.insert(vg); + } + error_type + } if element.is_empty() || element[0].is_empty() { return None; } @@ -133,7 +180,13 @@ impl Inspector<'_> { match type_p { GameValue::Array(Some(type_array), _) => { for type_i in type_array { - var_types.extend(type_i.iter().map(|(v, _)| v.make_generic())); + if let Some(type_error) = match_defaults_to_header( + &mut var_types, + type_i, + header_defaults, + ) { + error_type = Some(type_error); + } } } GameValue::Array(None, _) | GameValue::Anything => {} @@ -147,6 +200,9 @@ impl Inspector<'_> { } } } + if let Some(header_defaults) = header_defaults { + var_types.extend(header_defaults.iter().cloned()); + } if var_types.is_empty() { var_types.insert(GameValue::Anything); } @@ -166,18 +222,23 @@ impl Inspector<'_> { expected: var_types.iter().cloned().collect(), found: vec![default_value.clone()], span: element[1][0].1.clone(), - default: Some( - (element[2] - .first() - .map(|(_, s)| s.clone()) - .unwrap_or_default() - .start) - ..(element[2] - .last() + // element[2] could be missing if expected are from header + default: if element.len() > 2 && !element[2].is_empty() { + Some( + (element[2] + .first() .map(|(_, s)| s.clone()) .unwrap_or_default() - .end), - ), + .start) + ..(element[2] + .last() + .map(|(_, s)| s.clone()) + .unwrap_or_default() + .end), + ) + } else { + None + }, }); } var_types.insert(default_value); @@ -208,6 +269,16 @@ impl Inspector<'_> { ) -> IndexSet { let mut return_value = IndexSet::new(); for possible in code_possibilities { + if let GameValue::Flag( + FlagType::FromProfilenamespace(source, var) + | FlagType::FromUinamespace(source, var), + ) = possible + { + self.error_insert(Issue::CallingUserCode { + span: source.span(), + var: var.clone(), + }); + } let GameValue::Code(Some(expression)) = possible else { return_value.insert(GameValue::Anything); continue; @@ -530,4 +601,35 @@ impl Inspector<'_> { VarSource::Ignore, ); } + pub fn cmd_b_getvariable( + &mut self, + lhs: &Expression, + rhs: &Expression, + ) -> Option> { + // matches `uiNamespace getVariable "var" and ["var", false]` + fn get_source_var_lower(e: &Expression) -> Option { + match e { + Expression::String(str, _, _) => Some(str.to_lowercase()), + Expression::Array(arr, _) => arr.first().and_then(get_source_var_lower), + _ => None, + } + } + let source_var_lower = get_source_var_lower(rhs)?; + if source_var_lower.contains("_fnc_") { + // assume functions are compileFinaled + return None; + } + let Expression::NularCommand(NularCommand { name: lhs_cmd }, _) = lhs else { + return None; + }; + let flag_type = match lhs_cmd.to_lowercase().as_str() { + "uinamespace" => FlagType::FromUinamespace(lhs.clone(), source_var_lower), + "profilenamespace" => FlagType::FromProfilenamespace(lhs.clone(), source_var_lower), + _ => return None, + }; + Some(IndexSet::from([ + GameValue::Anything, + GameValue::Flag(flag_type), + ])) + } } diff --git a/libs/sqf/src/analyze/inspector/external_functions.rs b/libs/sqf/src/analyze/inspector/external_functions.rs index 174f35440..69885c978 100644 --- a/libs/sqf/src/analyze/inspector/external_functions.rs +++ b/libs/sqf/src/analyze/inspector/external_functions.rs @@ -2,30 +2,121 @@ use std::ops::Range; +use arma3_wiki::model::Arg; use indexmap::IndexSet; +#[allow(unused_imports)] +use tracing::{info, trace, warn}; -use crate::{Expression, analyze::inspector::VarSource}; +use crate::{ + Expression, + analyze::inspector::{InvalidArgs, Issue, VarSource, game_value::NilSource}, +}; use super::{Inspector, game_value::GameValue}; impl Inspector<'_> { - pub fn external_function(&mut self, lhs: &IndexSet, rhs: &Expression) { - let Expression::Variable(ext_func, _) = rhs else { - return; + /// Analyze external function calls in database, checking parameters and getting return type + #[must_use] + pub fn external_function_call( + &mut self, + lhs: Option<&IndexSet>, + rhs: &Expression, + ) -> Option> { + let Expression::Variable(ext_func, span) = rhs else { + return None; }; + if ext_func.starts_with('_') { + return None; + } let ext_func_lower = ext_func.to_ascii_lowercase(); + if let Some(lhs) = lhs { + self.external_check_code_usage(lhs, &ext_func_lower); + } + + // check if we should analyze this function, either by wildcard or prefix match + let check_function_calls = self.database.inspector_config()?.check_function_calls(); + if !check_function_calls + .iter() + .any(|prefix| prefix == "*" || ext_func_lower.starts_with(&prefix.to_lowercase())) + { + return None; + } + + let Some(func) = self.database.external_functions_get(&ext_func_lower) else { + // trace!("TEMP_DEBUG: Unknown external function: {ext_func_lower}"); // could warn if we know that addon's funcs are all loaded? + return None; + }; + let cmd_name = ext_func.as_str(); + let params = func.params(); + let ret = func + .ret() + .map(|r| GameValue::from_wiki_value(r, NilSource::FunctionReturn)); + if params.is_empty() { + // no parameters to check + return ret; + } + let Some(lhs) = lhs else { + // Unary call, could retrive `_this` and check it as LHS, but for now it will just be `Anything` + return ret; + }; + // minimum required params (count from the end, first non-optional) + let min_required_param = params.len() + - params + .iter() + .rev() + .position(|p| !p.optional()) + .unwrap_or(params.len()); + let expected_singular = if min_required_param <= 1 { + // try matching raw first argument without array (`_unit call ace_common_fnc_isPlayer`) + let arg_dummy = Arg::Item(String::from("0")); + let (is_match, expected) = + GameValue::match_set_to_arg(cmd_name, lhs, &arg_dummy, params); + if is_match { + return ret; + } + Some(expected) + } else { + None + }; + let arg_dummy_vec = params + .iter() + .enumerate() + .map(|(i, _p)| Arg::Item(format!("{i}"))) + .collect::>(); + let arg_dummy = Arg::Array(arg_dummy_vec); + let (is_match, mut expected) = + GameValue::match_set_to_arg(cmd_name, lhs, &arg_dummy, params); + if !is_match { + if let Some(expected_singular) = expected_singular + && !lhs.iter().all(|gv| matches!(gv, GameValue::Array(..))) + { + // if it could be singular and LHS may not be an array, report the singular expected type + expected.extend(expected_singular); + } + self.errors.insert(Issue::InvalidArgs { + command: ext_func_lower.clone(), + span: span.clone(), + variant: InvalidArgs::FuncTypeNotExpected { + expected: expected.into_iter().collect(), + found: lhs.iter().cloned().collect(), + span: span.clone(), + }, + }); + } + ret + } + + /// Check usage of code blocks in external functions (e.g. `cba_fnc_execNextFrame`) + fn external_check_code_usage(&mut self, lhs: &IndexSet, ext_func_lower: &str) { for possible in lhs { match possible { - GameValue::Code(Some(statements)) - if ext_func_lower.as_str() == "cba_fnc_directcall" => - { - // handle `{} call cba_fnc_directcall` + GameValue::Code(Some(statements)) if ext_func_lower == "cba_fnc_directcall" => { self.external_current_scope( &vec![(GameValue::Code(Some(statements.clone())), statements.span())], &vec![], ); } - GameValue::Array(Some(gv_array), _) => match ext_func_lower.as_str() { + GameValue::Array(Some(gv_array), _) => match ext_func_lower { // Functions that will run in existing scope "cba_fnc_hasheachpair" | "cba_fnc_hashfilter" if gv_array.len() > 1 => { self.external_current_scope( @@ -122,7 +213,7 @@ impl Inspector<'_> { let Expression::Code(statements) = expression else { continue; }; - self.scope_push(false); + self.scope_push(false, None); let stack_index = self.stack_push(Some(expression), false); if stack_index.is_some() { // prevent infinite recursion diff --git a/libs/sqf/src/analyze/inspector/game_value.rs b/libs/sqf/src/analyze/inspector/game_value.rs index 3f4858d2b..d1cb9fb06 100644 --- a/libs/sqf/src/analyze/inspector/game_value.rs +++ b/libs/sqf/src/analyze/inspector/game_value.rs @@ -40,6 +40,7 @@ pub enum GameValue { TeamMember, WhileType, WithType, + Flag(FlagType), } #[derive(Debug, Clone, Hash, PartialEq, Eq)] @@ -61,11 +62,18 @@ pub enum NilSource { Generic, ExplicitNil, CommandReturn, + FunctionReturn, PrivateArray, EmptyStack, IfWithoutElse, } +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub enum FlagType { + FromUinamespace(Expression, String), + FromProfilenamespace(Expression, String), +} + impl GameValue { #[must_use] /// Gets cmd return types based on input types @@ -366,7 +374,9 @@ impl GameValue { /// Checks if type is a "poisoned" nil type (should not be used as input or assigned) pub fn is_poison_nil(&self) -> bool { match self { - Self::Nothing(NilSource::CommandReturn | NilSource::IfWithoutElse) + Self::Nothing( + NilSource::CommandReturn | NilSource::FunctionReturn | NilSource::IfWithoutElse, + ) | Self::Assignment => true, Self::Array(Some(outer), _) => outer .iter() @@ -472,6 +482,8 @@ impl std::fmt::Display for GameValue { Self::TeamMember => "TeamMember", Self::WhileType => "WhileType", Self::WithType => "WithType", + Self::Flag(FlagType::FromUinamespace(_, _)) => "FromUinamespace", + Self::Flag(FlagType::FromProfilenamespace(_, _)) => "FromProfilenamespace", } ) } diff --git a/libs/sqf/src/analyze/inspector/headers.rs b/libs/sqf/src/analyze/inspector/headers.rs new file mode 100644 index 000000000..43a1b3353 --- /dev/null +++ b/libs/sqf/src/analyze/inspector/headers.rs @@ -0,0 +1,307 @@ +use arma3_wiki::model::{Function, Param, Value}; +use hemtt_common::config::InspectorOptions; +use hemtt_workspace::reporting::Processed; +use regex::{Match, Regex}; +use std::sync::{Arc, OnceLock}; +use tracing::trace; + +const MAX_ARG_INDEX: usize = 100; + +#[must_use] +pub(crate) fn extract_from_header( + processed: &Processed, + inspector_config: Option<&InspectorOptions>, +) -> Option> { + let (re_header, re_arg_line) = get_regex(inspector_config?)?; + for (path, source) in processed.sources() { + let filename = path.filename().to_lowercase(); + if (filename.starts_with("fnc_") || filename.starts_with("fn_")) + && let Some(funcname) = filename.strip_suffix(".sqf") + { + match match_header(source, funcname, &re_header, &re_arg_line) { + Ok(header) => return Some(header), + // possible todo: warn on bad header parse, but for now just ignore + Err(HeaderError::NoMatch) => { + #[cfg(debug_assertions)] + println!("DEBUG: no match for header in source: {filename}"); + } + #[allow(unused_variables)] + Err(e) => { + #[cfg(debug_assertions)] + println!("DEBUG: error {e:?} matching header in source: {filename}"); + } + } + } + } + None +} + +#[derive(Debug)] +enum HeaderError { + NoMatch, + ArgsNoMatch, + ArgsBadIndex, +} + +#[must_use] +fn match_value(input_low: &str) -> Option { + const IGNORE_TAGS: &[&str] = &["br/", "&", "<", ">"]; + fn str_to_type(input: &str) -> Value { + if input.starts_with("array of ") { + return Value::ArrayUnknown; + } + if input.contains(',') { + // ToDo: could split on comma, but this tends to be used for array sub elements + return Value::Anything; + } + match input { + "any" | "anything" | "unknown" => Value::Anything, + "array" | "vector" => Value::ArrayUnknown, + "bool" | "boolean" => Value::Boolean, + "code" => Value::Code, + "config" => Value::Config, + "control" => Value::Control, + "display" => Value::Display, + "group" => Value::Group, + "hashmap" => Value::HashMapUnknown, + "location" => Value::Location, + "namespace" => Value::Namespace, + "number" | "scalar" => Value::Number, + "object" | "logic" => Value::Object, + "side" => Value::Side, + "string" | "text" => Value::String, + "structuredtext" | "structured text" => Value::StructuredText, + "nil" | "nothing" => Value::Nothing, + _ => { + trace!("header: unknown arg type '{input}'"); + Value::Anything + } + } + } + let re_arg_types = Regex::new(r"(?s)<(?[^>]*)>").expect("valid regex"); + let captures = re_arg_types.captures_iter(input_low).collect::>(); + let mut types = captures + .iter() + .flat_map(|c| { + let type_str = c.name("type").expect("re").as_str(); + type_str.split(" or ").collect::>() + }) + .filter(|i| !IGNORE_TAGS.contains(i)) + .map(str_to_type) + .collect::>(); + if types.is_empty() { + return None; + } + if types.len() == 1 { + return types.pop(); + } + Some(Value::OneOf( + types.into_iter().map(|t| (t, None)).collect::>(), + )) +} + +fn parse_args(input: Option>, re_arg_line: &Regex) -> Result, HeaderError> { + let mut out = Vec::new(); + let input = input.ok_or(HeaderError::ArgsNoMatch)?.as_str(); + for line in input.lines() { + let Some(caps) = re_arg_line.captures(line) else { + if !out.is_empty() && (line.trim().is_empty() || line == " *") { + break; + } + continue; + }; + if let Some(m) = caps.name("index") { + // regex has index, verify and pad if needed + let Ok(arg_index) = m.as_str().parse::() else { + return Err(HeaderError::ArgsBadIndex); + }; + if arg_index < out.len() || arg_index > MAX_ARG_INDEX { + return Err(HeaderError::ArgsBadIndex); + } + while out.len() < arg_index { + out.push(Param::new( + format!("{}", out.len()), + Some("padding for skipped args".to_string()), + Value::Anything, + true, + None, + None, + )); + } + } + + let arg_info = caps.name("info").map_or("#Unknown", |m| m.as_str()).trim(); + let arg_info_low = arg_info.to_lowercase(); + let arg_type = match_value(arg_info_low.as_str()); + let arg_optional = arg_type.is_none() + || arg_info_low.contains("(optional") + || arg_info_low.contains("(unused") + || arg_info_low.contains("(not used") + || arg_info_low.contains("(default"); + let arg_type = arg_type.unwrap_or(Value::Anything); + out.push(Param::new( + format!("{}", out.len()), + Some(arg_info.to_string()), + arg_type, + arg_optional, + None, + None, + )); + } + Ok(out) +} +#[must_use] +fn parse_return(input: Option>) -> Option { + fn allow_array(v: Value) -> Value { + match v { + Value::ArrayUnknown => v, + Value::OneOf(mut vec) => { + if !vec.contains(&(Value::ArrayUnknown, None)) { + vec.push((Value::ArrayUnknown, None)); + } + Value::OneOf(vec) + } + _ => Value::OneOf(vec![(v, None), (Value::ArrayUnknown, None)]), + } + } + let input = input?.as_str().to_lowercase(); + let Some(value) = match_value(input.as_str()) else { + if input.contains("none") || input.contains("nothing") { + // "None" often used to indicate no useable return value (may not be explicitly nil) + return Some(Value::Nothing); + } + return None; + }; + // multiple return types are often for an array, so allow arrays to reduce false positives + if let Value::OneOf(_) = &value { + return Some(allow_array(value)); + } + if input.contains("0: ") { + return Some(allow_array(value)); + } + Some(value) +} +#[must_use] +#[allow(dead_code)] +fn parse_example(input: Option>) -> String { + input.map_or_else(String::new, |m| m.as_str().to_string()) +} +#[must_use] +#[allow(dead_code)] +fn parse_public(input: Option>) -> bool { + if let Some(input) = input { + let s = input.as_str().to_lowercase(); + return s.contains("true") || s.contains("yes"); + } + false +} +#[must_use] +fn parse_function_name(input: &str, filename_low: &str) -> Option { + static RE_FUNC_FINDER: OnceLock = OnceLock::new(); + debug_assert_eq!(filename_low, filename_low.to_lowercase()); + let re = + RE_FUNC_FINDER.get_or_init(|| Regex::new(r"([\w\d_-]+_fnc_[\w\d_-]+)").expect("regex ok")); + for cap in re.captures_iter(input) { + let func_low = cap[1].to_lowercase(); + if func_low.ends_with(filename_low) { + return Some(func_low); + } + } + None +} +fn match_header( + source: &str, + filename: &str, + re_header: &Regex, + re_arg_line: &Regex, +) -> Result, HeaderError> { + let Some(capture) = re_header.captures(source) else { + return Err(HeaderError::NoMatch); + }; + let args = parse_args(capture.name("arg"), re_arg_line)?; + let ret = parse_return(capture.name("ret")); + let name = parse_function_name(source, filename); + let example = parse_example(capture.name("ex")); + // let public = parse_public(capture.name("pub")); + Ok(Arc::new(Function::new(name, ret, args, example))) +} +#[must_use] +fn get_regex_cba() -> (Regex, Regex) { + static RE_HEADER: OnceLock = OnceLock::new(); + static RE_ARG_LINE: OnceLock = OnceLock::new(); + (RE_HEADER.get_or_init(|| { + Regex::new(r"(?s)\/\*.*?Parameter[s]?:(?.+?)(?:Return[s]?:[\s\*]*(?.+?))?[\s\*]*(?:Examples:[\s\*]*(?.+?))?[\s\*]*(?:Public:(?.+?))?\*\/").expect("regex ok") + }).clone(), RE_ARG_LINE.get_or_init(|| { + Regex::new(r"^ _(?.*)").expect("regex ok") // 4 spaces before _ + }).clone()) +} +#[must_use] +fn get_regex_ace() -> (Regex, Regex) { + static RE_HEADER: OnceLock = OnceLock::new(); + static RE_ARG_LINE: OnceLock = OnceLock::new(); + (RE_HEADER.get_or_init(|| { + Regex::new(r"(?s)\/\*.*?Argument[s]?:(?.+?)(?:Return Value[s]?:[\s\*]*(?.+?))?[\s\*]*(?:Example:[\s\*]*(?.+?))?[\s\*]*(?:Public:(?.+?))?\*\/").expect("regex ok") + }).clone(), RE_ARG_LINE.get_or_init(|| { + Regex::new(r"\* (?\d*):(?.*)").expect("regex ok") // `* 0: description` + }).clone()) +} +// #[must_use] +// fn get_regex_clib() -> (Regex, Regex) { +// static RE_HEADER: OnceLock = OnceLock::new(); +// static RE_ARG_LINE: OnceLock = OnceLock::new(); +// (RE_HEADER.get_or_init(|| { +// Regex::new(r"(?s)\/\*.*?Parameter\(s\):(?.+?)(?:Return[s]?:[\s\*]*(?.+?))?[\s\*]*(?:Example[s]?:[\s\*]*(?.+?))?\*\/").expect("regex ok") +// }), RE_ARG_LINE.get_or_init(|| { +// Regex::new(r" (?\d*):(?.*)").expect("regex ok") // ` 0: description` +// })) +// } + +#[must_use] +fn get_regex(inspector_config: &InspectorOptions) -> Option<(Regex, Regex)> { + let header_regex = inspector_config.header_regex(); + let header_line_regex = inspector_config.header_line_regex(); + + match header_regex.to_lowercase().as_str() { + "" => None, + "ace" => Some(get_regex_ace()), + "cba" => Some(get_regex_cba()), + _ => { + let header_re = Regex::new(header_regex).ok()?; + let line_re = Regex::new(header_line_regex).ok()?; + Some((header_re, line_re)) + } + } +} + +mod tests { + #[test] + + pub fn test_header() { + let options = + hemtt_common::config::InspectorOptions::default().with_header_regex("ace".to_string()); + let (re_header, re_arg_line) = super::get_regex(&options).expect("regex for ace"); + let source = r#" +/* + * Arguments: + * 0: "test" + * + * Return Value: + * X + */ +"#; + let h = super::match_header( + source, + "fnc_test_header".to_lowercase().as_str(), + &re_header, + &re_arg_line, + ); + println!("Header: {h:?}"); + assert!(h.is_ok_and(|h| { + h.ret() + .is_some_and(|r| r == &arma3_wiki::model::Value::Boolean) + && h.params() + .first() + .is_some_and(|a| a.typ() == &arma3_wiki::model::Value::ArrayUnknown) + })); + } +} diff --git a/libs/sqf/src/analyze/inspector/issue.rs b/libs/sqf/src/analyze/inspector/issue.rs index f8a313f99..61530d7b4 100644 --- a/libs/sqf/src/analyze/inspector/issue.rs +++ b/libs/sqf/src/analyze/inspector/issue.rs @@ -16,6 +16,10 @@ pub enum Issue { InvalidReturnType { variant: InvalidArgs, }, + CallingUserCode { + span: Range, + var: String, + }, } #[derive(Debug, Clone, Hash, PartialEq, Eq)] diff --git a/libs/sqf/src/analyze/inspector/mod.rs b/libs/sqf/src/analyze/inspector/mod.rs index 59ef3faa2..7250c5835 100644 --- a/libs/sqf/src/analyze/inspector/mod.rs +++ b/libs/sqf/src/analyze/inspector/mod.rs @@ -1,11 +1,17 @@ //! Inspects code, checking code args and variable usage //! -use std::{hash::Hash, ops::Range, sync::OnceLock, vec}; +use std::{ + hash::Hash, + ops::Range, + sync::{Arc, OnceLock}, + vec, +}; use crate::{ BinaryCommand, Expression, Statement, Statements, UnaryCommand, analyze::inspector::game_value::NilSource, parser::database::Database, }; +use arma3_wiki::model::Function; use game_value::GameValue; use hemtt_workspace::reporting::Processed; use indexmap::{IndexMap, IndexSet}; @@ -17,6 +23,7 @@ use tracing::warn; mod commands; mod external_functions; mod game_value; +pub mod headers; mod issue; pub use issue::{InvalidArgs, Issue}; @@ -61,22 +68,13 @@ pub struct ScriptScope { vars_local: Vec, /// Set of possible return values from this scope returns_set: Vec>, + expected_returns: Option>, /// Error suppression for scopes below this one (for trial runs of loops) errors_suppressed: Vec, /// Orphan scopes are code blocks that are created but don't appear to be called in a known way is_orphan_scope: bool, } -impl ScriptScope { - /// # Panics - pub fn add_returns(&mut self, values: IndexSet) { - self.returns_set - .last_mut() - .expect("stack not empty") - .extend(values); - } -} - pub struct Inspector<'a> { errors: IndexSet, vars_global: Stack, @@ -85,12 +83,29 @@ pub struct Inspector<'a> { code_used: IndexSet, code_active: IndexSet, scopes: Vec, + function_info: Option>, + in_primary: bool, database: &'a Database, } impl<'a> Inspector<'a> { #[must_use] - pub fn new(ignored_vars: &IndexSet, database: &'a Database) -> Self { + pub fn new( + ignored_vars: &IndexSet, + function_info: Option>, + database: &'a Database, + ) -> Self { + let expected_returns = function_info.as_ref().and_then(|fi| { + let ret = fi + .ret() + .map(|m| GameValue::from_wiki_value(m, NilSource::Generic)); + if ret == Some(IndexSet::from([GameValue::Nothing(NilSource::Generic)])) { + // ret of just `Nothing` is just assumed to not be anything useful, so no need to check + None + } else { + ret + } + }); let mut inspector = Self { errors: IndexSet::new(), vars_global: Stack::new(), @@ -99,9 +114,11 @@ impl<'a> Inspector<'a> { code_used: IndexSet::new(), code_active: IndexSet::new(), scopes: Vec::new(), + function_info, + in_primary: true, database, }; - inspector.scope_push(false); + inspector.scope_push(false, expected_returns); inspector } /// # Panics @@ -109,11 +126,16 @@ impl<'a> Inspector<'a> { pub fn active_scope(&mut self) -> &mut ScriptScope { self.scopes.last_mut().expect("there is always a scope") } - pub fn scope_push(&mut self, is_orphan_scope: bool) { + pub fn scope_push( + &mut self, + is_orphan_scope: bool, + expected_returns: Option>, + ) { // println!("Creating ScriptScope, orphan: {is_orphan_scope}"); let scope = ScriptScope { vars_local: Vec::new(), returns_set: Vec::new(), + expected_returns, is_orphan_scope, errors_suppressed: Vec::new(), }; @@ -134,9 +156,43 @@ impl<'a> Inspector<'a> { self.scopes.pop(); } #[must_use] + pub fn in_primary_scope(&mut self) -> bool { + self.in_primary && self.scopes.len() == 1 && self.active_scope().vars_local.len() == 1 + } + /// # Panics + pub fn add_returns(&mut self, values: IndexSet, source: &Range) { + let scope = self.active_scope(); + + let err_opt = if scope.returns_set.len() == 1 + && let Some(expected_returns) = &scope.expected_returns + && !values.iter().any(|ret| match ret { + GameValue::Anything => true, + _ => expected_returns + .iter() + .any(|ev| GameValue::match_values(ev, ret)), + }) { + Some(Issue::InvalidReturnType { + variant: InvalidArgs::InvalidReturnType { + expected: expected_returns.iter().cloned().collect(), + found: values.iter().cloned().collect(), + span: source.clone(), + }, + }) + } else { + None + }; + scope + .returns_set + .last_mut() + .expect("stack not empty") + .extend(values); + self.errors.extend(err_opt); + } + #[must_use] pub fn finish(mut self) -> Vec { self.scope_pop(); - debug_assert!(self.scopes.is_empty()); + debug_assert!(self.in_primary && self.scopes.is_empty()); + self.in_primary = false; let unused = &self.code_seen - &self.code_used; for code in &unused { let Expression::Code(statements) = code else { @@ -144,7 +200,7 @@ impl<'a> Inspector<'a> { }; self.code_used(code); // println!("-- Checking external scope"); - self.scope_push(true); // create orphan scope + self.scope_push(true, None); // create orphan scope self.eval_statements(statements, false); self.scope_pop(); } @@ -395,8 +451,13 @@ impl<'a> Inspector<'a> { // Use custom return if it exists or just use wiki set let mut set = return_set.unwrap_or(cmd_set); // If a command could return multiple values, make the nil results generic - if set.len() > 1 && set.swap_remove(&GameValue::Nothing(NilSource::CommandReturn)) { - set.insert(GameValue::Nothing(NilSource::Generic)); + if set.len() > 1 { + if set.swap_remove(&GameValue::Nothing(NilSource::CommandReturn)) { + set.insert(GameValue::Nothing(NilSource::Generic)); + } + if set.swap_remove(&GameValue::Nothing(NilSource::FunctionReturn)) { + set.insert(GameValue::Nothing(NilSource::Generic)); + } } set.into_iter().map(|gv| (gv, source.clone())).collect() } @@ -459,12 +520,17 @@ impl<'a> Inspector<'a> { } let return_set = match cmd { UnaryCommand::Named(named) => match named.to_ascii_lowercase().as_str() { - "params" => Some(self.cmd_generic_params(&rhs_set, &debug_type, source)), + "params" => { + Some(self.cmd_generic_params(&rhs_set, &debug_type, source, true)) + } "private" => Some(self.cmd_u_private(&rhs_set)), - "call" => Some(self.cmd_generic_call(&rhs_set, None, false)), + "call" => Some( + self.external_function_call(None, rhs) + .unwrap_or_else(|| self.cmd_generic_call(&rhs_set, None, false)), + ), "default" => { let returns = self.cmd_generic_call(&rhs_set, None, false); - self.active_scope().add_returns(returns); + self.add_returns(returns, source); None } "isnil" => Some(self.cmd_u_is_nil(&rhs_set)), @@ -550,7 +616,7 @@ impl<'a> Inspector<'a> { BinaryCommand::Associate => { // the : from case ToDo: these run outside of the do scope let returns = self.cmd_generic_call(&rhs_set, None, false); - self.active_scope().add_returns(returns); + self.add_returns(returns, source); None } BinaryCommand::And | BinaryCommand::Or => { @@ -572,12 +638,22 @@ impl<'a> Inspector<'a> { self.cmd_generic_modify_lvalue(lhs); None } - "params" => Some(self.cmd_generic_params(&rhs_set, &debug_type, source)), - "call" => { - self.external_function(&lhs_set, rhs); - Some(self.cmd_generic_call(&rhs_set, None, false)) + "params" => { + Some(self.cmd_generic_params(&rhs_set, &debug_type, source, false)) } - "spawn" | "addpublicvariableeventhandler" => { + "call" => Some( + self.external_function_call(Some(&lhs_set), rhs) + .unwrap_or_else(|| self.cmd_generic_call(&rhs_set, None, false)), + ), + "spawn" => { + let _ = self.external_function_call(Some(&lhs_set), rhs); + self.external_new_scope( + &rhs_set.into_iter().map(|gv| (gv, source.clone())).collect(), + &vec![], + ); + None + } + "addpublicvariableeventhandler" => { self.external_new_scope( &rhs_set.into_iter().map(|gv| (gv, source.clone())).collect(), &vec![], @@ -586,7 +662,7 @@ impl<'a> Inspector<'a> { } "exitwith" => { let returns = self.cmd_generic_call(&rhs_set, None, false); - self.active_scope().add_returns(returns); + self.add_returns(returns, source); None } "do" => { @@ -656,6 +732,7 @@ impl<'a> Inspector<'a> { } None } + "getvariable" => self.cmd_b_getvariable(lhs, rhs), _ => None, }, _ => None, @@ -687,9 +764,9 @@ impl<'a> Inspector<'a> { /// Evaluate statements in the current scope and return possible return values of last command fn eval_statements(&mut self, statements: &Statements, add_to_stack: bool) { - let mut last_statement = IndexSet::new(); for statement in statements.content() { - last_statement = match statement { + let add_returns = add_to_stack && statements.content().last() == Some(statement); + match statement { Statement::AssignGlobal(var, expression, source) => { // x or _x let possible_values = self @@ -707,7 +784,9 @@ impl<'a> Inspector<'a> { expression.span().clone(), ), ); - IndexSet::from([GameValue::Assignment]) + if add_returns { + self.add_returns(IndexSet::from([GameValue::Assignment]), source); + } } Statement::AssignLocal(var, expression, source) => { // private _x @@ -726,18 +805,18 @@ impl<'a> Inspector<'a> { expression.span().clone(), ), ); - IndexSet::from([GameValue::Assignment]) + if add_returns { + self.add_returns(IndexSet::from([GameValue::Assignment]), source); + } + } + Statement::Expression(expression, source) => { + let returns = self.eval_expression(expression); + if add_returns { + self.add_returns(returns.into_iter().map(|(gv, _)| gv).collect(), source); + } } - Statement::Expression(expression, _) => self - .eval_expression(expression) - .into_iter() - .map(|(gv, _)| gv) - .collect(), } } - if add_to_stack { - self.active_scope().add_returns(last_statement); - } } } @@ -752,10 +831,26 @@ pub fn run_processed( static RE_IGNORE_VARIABLES: OnceLock = OnceLock::new(); static RE_IGNORE_VARIABLE_ENTRIES: OnceLock = OnceLock::new(); + let inspector_config = database.inspector_config(); + let function_info = headers::extract_from_header(processed, inspector_config); + if let Some(function_info) = &function_info + && function_info.name().is_some() + { + database.project_functions_push(function_info.clone()); + } + let mut ignored_vars = IndexSet::new(); ignored_vars.insert("_this".to_ascii_lowercase()); - ignored_vars.insert("_fnc_scriptName".to_ascii_lowercase()); // may be set via cfgFunctions - ignored_vars.insert("_fnc_scriptNameParent".to_ascii_lowercase()); + + if let Some(Some(vars_to_ignore)) = inspector_config.map(|c| c.vars_to_ignore()) { + for var in vars_to_ignore { + ignored_vars.insert(var.to_ascii_lowercase()); + } + } else { + ignored_vars.insert("_fnc_scriptName".to_ascii_lowercase()); + ignored_vars.insert("_fnc_scriptNameParent".to_ascii_lowercase()); + } + let re1 = RE_IGNORE_VARIABLES.get_or_init(|| { Regex::new(r"(?:\#pragma hemtt ignore_variables|\/\/ ?IGNORE_PRIVATE_WARNING) ?\[(.*)\]") .expect("regex ok") @@ -770,7 +865,7 @@ pub fn run_processed( } } - let mut inspector = Inspector::new(&ignored_vars, database); + let mut inspector = Inspector::new(&ignored_vars, function_info, database); inspector.eval_statements(statements, true); let issues = inspector.finish(); // for ig in ignored_vars.clone() { diff --git a/libs/sqf/src/analyze/lints/s34_invalid_return_type.rs b/libs/sqf/src/analyze/lints/s34_invalid_return_type.rs new file mode 100644 index 000000000..f39f9941d --- /dev/null +++ b/libs/sqf/src/analyze/lints/s34_invalid_return_type.rs @@ -0,0 +1,135 @@ + +use std::sync::Arc; + +use hemtt_common::config::LintConfig; +use hemtt_workspace::{ + lint::{AnyLintRunner, Lint, LintRunner}, + reporting::{Code, Codes, Diagnostic, Processed, Severity}, +}; + +use crate::{Statements, analyze::{LintData, inspector::{InvalidArgs, Issue}}}; + +crate::analyze::lint!(LintS34InvalidReturnType); + +impl Lint for LintS34InvalidReturnType { + fn ident(&self) -> &'static str { + "invalid_return_type" + } + + fn sort(&self) -> u32 { + 340 + } + + fn description(&self) -> &'static str { + "Checks for invalid return types from functions (requires function header)" + } + + fn documentation(&self) -> &'static str { + r"### Example + +**Incorrect** +```sqf +/* + * Arguments: + * None + * + * Return Value: + * X + */ + +1234 // Not a BOOLEAN +``` + +### Explanation + +" + } + fn default_config(&self) -> LintConfig { + LintConfig::warning() + } + fn runners(&self) -> Vec>> { + vec![Box::new(Runner)] + } +} + +pub struct Runner; +impl LintRunner for Runner { + type Target = Statements; + fn run( + &self, + _project: Option<&hemtt_common::config::ProjectConfig>, + config: &hemtt_common::config::LintConfig, + processed: Option<&hemtt_workspace::reporting::Processed>, + _runtime: &hemtt_common::config::RuntimeArguments, + target: &Statements, + _data: &LintData, + ) -> hemtt_workspace::reporting::Codes { + if target.issues().is_empty() { + return Vec::new(); + } + let Some(processed) = processed else { + return Vec::new(); + }; + let mut errors: Codes = Vec::new(); + for issue in target.issues() { + if let Issue::InvalidReturnType { variant } = issue { + errors.push(Arc::new(CodeS34InvalidReturnType::new( + variant.to_owned(), + config.severity(), + processed, + ))); + } + } + errors + } +} + +#[allow(clippy::module_name_repetitions)] +pub struct CodeS34InvalidReturnType { + variant: InvalidArgs, + severity: Severity, + diagnostic: Option, +} + +impl Code for CodeS34InvalidReturnType { + fn ident(&self) -> &'static str { + "L-S34" + } + fn link(&self) -> Option<&str> { + Some("/analysis/sqf.html#invalid_return_type") + } + fn message(&self) -> String { + self.variant.message("") + } + fn label_message(&self) -> String { + self.variant.label_message() + } + fn note(&self) -> Option { + Some(self.variant.note()) + } + fn severity(&self) -> Severity { + self.severity + } + fn diagnostic(&self) -> Option { + self.diagnostic.clone() + } +} + +impl CodeS34InvalidReturnType { + #[must_use] + pub fn new( + variant: InvalidArgs, + severity: Severity, + processed: &Processed, + ) -> Self { + Self { + variant, + severity, + diagnostic: None, + } .generate_processed(processed) + } + fn generate_processed(mut self, processed: &Processed) -> Self { + self.diagnostic = Diagnostic::from_code_processed(&self, self.variant.span(), processed); + self + } +} diff --git a/libs/sqf/src/analyze/lints/s37_calling_user_code.rs b/libs/sqf/src/analyze/lints/s37_calling_user_code.rs new file mode 100644 index 000000000..aad920c7a --- /dev/null +++ b/libs/sqf/src/analyze/lints/s37_calling_user_code.rs @@ -0,0 +1,139 @@ +use crate::{ + analyze::{inspector::Issue, LintData}, + Statements, +}; +use hemtt_common::config::LintConfig; +use hemtt_workspace::{ + lint::{AnyLintRunner, Lint, LintRunner}, + reporting::{Code, Codes, Diagnostic, Processed, Severity}, +}; +use std::{ops::Range, sync::Arc}; + +crate::analyze::lint!(LintS37CallingUserCode); + +impl Lint for LintS37CallingUserCode { + fn ident(&self) -> &'static str { + "calling_user_code" + } + fn sort(&self) -> u32 { + 370 + } + fn description(&self) -> &'static str { + "Checks for user code possibly being called" + } + fn documentation(&self) -> &'static str { + r#" + ### Example + +**Incorrect** +```sqf +setting = profileNamespace getVariable "test"; +x || setting +``` +**Correct** +```sqf +setting = profileNamespace getVariable "test"; +x || (setting isEqualTo true) +``` +"# + } + + fn default_config(&self) -> LintConfig { + LintConfig::warning() + } + fn runners(&self) -> Vec>> { + vec![Box::new(Runner)] + } +} + +pub struct Runner; +impl LintRunner for Runner { + type Target = Statements; + fn run( + &self, + _project: Option<&hemtt_common::config::ProjectConfig>, + config: &hemtt_common::config::LintConfig, + processed: Option<&hemtt_workspace::reporting::Processed>, + _runtime: &hemtt_common::config::RuntimeArguments, + target: &Statements, + _data: &LintData, + ) -> hemtt_workspace::reporting::Codes { + if target.issues().is_empty() { + return Vec::new(); + } + let Some(processed) = processed else { + return Vec::new(); + }; + let ignore = if let Some(toml::Value::Array(ignore)) = config.option("ignore") { + ignore.iter().map(|v| v.as_str().expect("ignore items must be strings")).collect::>() + } else { + vec![] + }; + let mut errors: Codes = Vec::new(); + for issue in target.issues() { + if let Issue::CallingUserCode { span, var } = issue { + if ignore.iter().any(|s| s.eq_ignore_ascii_case(var.as_str())) { + continue; + } + errors.push(Arc::new(CodeS37CallingUserCode::new( + span.to_owned(), + var.to_owned(), + config.severity(), + processed, + ))); + } + } + errors + } +} + +#[allow(clippy::module_name_repetitions)] +pub struct CodeS37CallingUserCode { + span: Range, + variable: String, + severity: Severity, + diagnostic: Option, +} + +impl Code for CodeS37CallingUserCode { + fn ident(&self) -> &'static str { + "L-S37" + } + fn link(&self) -> Option<&str> { + Some("/analysis/sqf.html#calling_user_code") + } + fn message(&self) -> String { + "Calling user code".to_string() + } + fn label_message(&self) -> String { + format!("variable `{}` may be user code", self.variable) + } + fn severity(&self) -> Severity { + self.severity + } + fn diagnostic(&self) -> Option { + self.diagnostic.clone() + } +} + +impl CodeS37CallingUserCode { + #[must_use] + pub fn new( + span: Range, + variable: String, + severity: Severity, + processed: &Processed, + ) -> Self { + Self { + span, + variable, + severity, + diagnostic: None, + } + .generate_processed(processed) + } + fn generate_processed(mut self, processed: &Processed) -> Self { + self.diagnostic = Diagnostic::from_code_processed(&self, self.span.clone(), processed); + self + } +} diff --git a/libs/sqf/src/analyze/mod.rs b/libs/sqf/src/analyze/mod.rs index a6820868c..d152cf332 100644 --- a/libs/sqf/src/analyze/mod.rs +++ b/libs/sqf/src/analyze/mod.rs @@ -389,6 +389,13 @@ pub fn lint_all( addons: &Vec, database: Arc, ) -> Codes { + if let Some(project_config) = project_config + && !project_config.runtime().is_just() + { + let prefix = project_config.prefix(); + // All sqf have been processed, export project functions only if not in "--just" + database.export_project_functions_to_file(prefix); + } let mut manager = LintManager::new( project_config.map_or_else(Default::default, |project| project.lints().sqf().clone()), project_config.map_or_else(RuntimeArguments::default, |p| p.runtime().clone()), diff --git a/libs/sqf/src/parser/database/functions.rs b/libs/sqf/src/parser/database/functions.rs new file mode 100644 index 000000000..fa0110915 --- /dev/null +++ b/libs/sqf/src/parser/database/functions.rs @@ -0,0 +1,122 @@ +//! Handles database of external functions + +use std::{io::Write, path::Path, sync::Arc}; + +const FUNCTION_DIR: &str = ".hemttout/functions"; + +use arma3_wiki::{Wiki, functions::Functions, model::Function}; +use indexmap::IndexMap; +use tracing::{error, trace}; + +use super::Database; + +impl Database { + pub(crate) fn project_functions_push(&self, func: Arc) { + let Ok(mut guard) = self.project_functions.lock() else { + unreachable!("Failed to lock project functions mutex"); + }; + guard.push(func); + } + #[must_use] + pub fn project_functions_testing(&self) -> Vec> { + let Ok(guard) = self.project_functions.lock() else { + unreachable!("Failed to lock project functions mutex"); + }; + guard.clone() + } + #[must_use] + pub fn external_functions_get(&self, name: &str) -> Option<&Function> { + self.external_functions.get(&name.to_lowercase()) + } + + /// Load ALL external functions from the function directory + #[must_use] + pub(crate) fn load_functions(wiki: &Wiki) -> IndexMap { + let mut result = IndexMap::new(); + Self::load_functions_wiki(&mut result, wiki); + Self::load_functions_local(&mut result); + trace!("Loaded {} external functions", result.len()); + result + } + /// Load external functions from the wiki + fn load_functions_wiki(map: &mut IndexMap, wiki: &Wiki) { + for (_source, functions) in wiki.functions().iter() { + // could filter by source if needed? + map.extend( + functions + .iter() + .filter_map(|f| f.name().map(|name| (name.to_lowercase(), f.clone()))), + ); + } + } + /// Load external functions from local files + fn load_functions_local(map: &mut IndexMap) { + let path = Path::new(FUNCTION_DIR); + let Ok(dir) = fs_err::read_dir(path) else { + trace!("Function directory {FUNCTION_DIR} does not exist, skipping"); + return; + }; + for entry in dir { + let Ok(entry) = entry else { + continue; + }; + let Ok(file) = fs_err::File::open(entry.path()) else { + continue; + }; + let Ok(functions) = Functions::from_file(file.into_file()) else { + continue; + }; + map.extend( + functions + .iter() + .filter_map(|f| f.name().map(|name| (name.to_lowercase(), f.clone()))), + ); + } + } + + pub(crate) fn export_project_functions_to_file(&self, prefix: &str) { + let Some(inspector_config) = self.inspector_config() else { + return; + }; + let export_prefixes = inspector_config.export_functions(); + let path = Path::new(FUNCTION_DIR); + let Ok(exists) = fs_err::exists(path) else { + trace!("Failed to even look at {FUNCTION_DIR}?"); + return; + }; + if !exists && fs_err::create_dir_all(path).is_err() { + error!("Failed to create directory {FUNCTION_DIR} for exporting functions"); + return; + } + let path = path.join(format!("{prefix}.yaml")); + let _ = fs_err::remove_file(&path); + let Ok(mut file) = fs_err::File::create(&path) else { + error!("Failed to create {} for writing", path.display()); + return; + }; + let Ok(guard) = self.project_functions.lock() else { + unreachable!("Failed to lock project functions mutex"); + }; + let mut funcs: Vec<&Function> = guard + .iter() + .filter(|f| { + f.name() + .is_some_and(|n| export_prefixes.iter().any(|p| p == "*" || n.starts_with(p))) + }) + .map(std::convert::AsRef::as_ref) + .collect(); + funcs.sort_by_key(|f| f.name()); + let funcs = funcs.into_iter().cloned().collect(); + let Ok(str) = Functions::to_string(&funcs) else { + error!( + "Failed to serialize functions for writing to {}", + path.display() + ); + return; + }; + let Ok(_) = file.write(str.as_bytes()) else { + error!("Failed to write functions to {}", path.display()); + return; + }; + } +} diff --git a/libs/sqf/src/parser/database/mod.rs b/libs/sqf/src/parser/database/mod.rs index 52a17dbfd..af2d384ee 100644 --- a/libs/sqf/src/parser/database/mod.rs +++ b/libs/sqf/src/parser/database/mod.rs @@ -1,16 +1,21 @@ //! Allows customization of the commands list at runtime in order to facilitate forwards-compatibility. -use std::collections::HashSet; +use std::{ + collections::HashSet, + sync::{Arc, Mutex}, +}; use arma3_wiki::{ Wiki, - model::{Call, Version}, + model::{Call, Function, Version}, }; +use hemtt_common::config::InspectorOptions; use hemtt_workspace::WorkspacePath; +use indexmap::IndexMap; use tracing::{error, trace, warn}; use crate::Error; - +pub mod functions; /// The list of commands that are valid nular command constants for the compiler. pub const NULAR_COMMANDS_CONSTANTS: &[&str] = &[ // NOTE: `netobjnull` is not included because it's broken @@ -52,6 +57,12 @@ pub struct Database { unary_commands: HashSet, binary_commands: HashSet, wiki: Wiki, + /// All external functions loaded from files. + external_functions: IndexMap, + /// project functions collected from parsed files during build to be exported + project_functions: Arc>>>, + /// Configuration for the inspector. + inspector_config: Option, } impl Database { @@ -63,6 +74,9 @@ impl Database { unary_commands: HashSet::new(), binary_commands: HashSet::new(), wiki: load_wiki(force_pull), + external_functions: IndexMap::new(), + project_functions: Arc::new(Mutex::new(Vec::new())), + inspector_config: None, } } @@ -96,12 +110,16 @@ impl Database { for &command in BINARY_COMMANDS_SPECIAL { binary_commands.remove(command); } + let external_functions = Self::load_functions(&wiki); Self { nular_commands, unary_commands, binary_commands, wiki, + external_functions, + project_functions: Arc::new(Mutex::new(Vec::new())), + inspector_config: None, } } @@ -153,7 +171,11 @@ impl Database { } } } - Ok(database) + let inspector_config = workspace + .workspace() + .project() + .map(|p| p.inspector().clone()); + Ok(database.with_inspector_config(inspector_config)) } pub fn add_nular_command(&mut self, command: &str) { @@ -224,6 +246,16 @@ impl Database { .get(command) .and_then(|c| c.since().arma_3()) } + + #[must_use] + pub const fn inspector_config(&self) -> Option<&InspectorOptions> { + self.inspector_config.as_ref() + } + #[must_use] + pub fn with_inspector_config(mut self, inspector_config: Option) -> Self { + self.inspector_config = inspector_config; + self + } } #[must_use] diff --git a/libs/sqf/tests/inspector.rs b/libs/sqf/tests/inspector.rs index dc83f3b63..082e9c51b 100644 --- a/libs/sqf/tests/inspector.rs +++ b/libs/sqf/tests/inspector.rs @@ -1,3 +1,4 @@ +use hemtt_common::config::InspectorOptions; use hemtt_sqf::Statements; use hemtt_workspace::reporting::Processed; @@ -20,6 +21,11 @@ macro_rules! inspect { }; } fn get_statements(file: &str) -> (Processed, Statements, Database) { + let database = Database::a3(false).with_inspector_config(Some( + InspectorOptions::default() + .with_check_function_calls(vec!["*".to_string()]) + .with_header_regex("ace".to_string()), + )); let folder = std::path::PathBuf::from(ROOT); let workspace = hemtt_workspace::Workspace::builder() .physical(&folder, LayerType::Source) @@ -31,8 +37,7 @@ fn get_statements(file: &str) -> (Processed, Statements, Database) { &hemtt_common::config::PreprocessorOptions::default(), ) .expect("for test"); - let statements = hemtt_sqf::parser::run(&Database::a3(false), &processed).expect("for test"); - let database = Database::a3(false); + let statements = hemtt_sqf::parser::run(&database, &processed).expect("for test"); (processed, statements, database) } @@ -42,11 +47,12 @@ mod tests { use hemtt_sqf::analyze::inspector::Issue; #[test] - pub fn test_0() { - let (_pro, sqf, _database) = get_statements("test_0.sqf"); - // let result = inspector::run_processed(&sqf, &pro, &database); - let result = sqf.issues(); - println!("done: {}, {result:?}", result.len()); + pub fn test_fnc_dev_testing() { + let (_pro, sqf, database) = get_statements("fnc_dev_testing.sqf"); + let issues = sqf.issues(); + println!("issues: {}, {issues:?}", issues.len()); + let headers = database.project_functions_testing(); + println!("headers: {headers:?}"); } inspect!(test_main); inspect!(test_optional_args); @@ -54,6 +60,8 @@ mod tests { inspect!(test_variadic); inspect!(test_code_usage); inspect!(test_variable_usage); + inspect!(fnc_header1); + inspect!(cba_funcs); #[test] #[ignore = "more of a test of the wiki than of hemtt, may break on bad edits to the wiki"] diff --git a/libs/sqf/tests/inspector/cba_funcs.sqf b/libs/sqf/tests/inspector/cba_funcs.sqf new file mode 100644 index 000000000..5bbb811e5 --- /dev/null +++ b/libs/sqf/tests/inspector/cba_funcs.sqf @@ -0,0 +1,7 @@ +// first arg needs to be STRING +[] call cba_fnc_localEvent; // missing +[6,7] call cba_fnc_localEvent; // wrong type +6 call cba_fnc_localEvent; // wrong type +["eventName", true] call cba_fnc_localEvent; // OK +"event" call cba_fnc_localEvent; // OK +["event", [], false] call cba_fnc_localEvent; // OK (extra args) diff --git a/libs/sqf/tests/inspector/fnc_dev_testing.sqf b/libs/sqf/tests/inspector/fnc_dev_testing.sqf new file mode 100644 index 000000000..df1245330 --- /dev/null +++ b/libs/sqf/tests/inspector/fnc_dev_testing.sqf @@ -0,0 +1,11 @@ +/* abe_fnc_dev_testing + * Arguments: + * 0: Gun Position ASL + * + * Return Value: + * X + */ + +[4, { + acre_sys_core_arsenalOpen = false; +}] call CBA_fnc_addEventHandler; diff --git a/libs/sqf/tests/inspector/fnc_header1.sqf b/libs/sqf/tests/inspector/fnc_header1.sqf new file mode 100644 index 000000000..05c0943c2 --- /dev/null +++ b/libs/sqf/tests/inspector/fnc_header1.sqf @@ -0,0 +1,11 @@ +/* + * Arguments: + * 0: Gun Position ASL + * + * Return Value: + * X + */ + +params ["_gunPos"]; +alive _gunPos; +"" diff --git a/libs/sqf/tests/inspector/test_0.sqf b/libs/sqf/tests/inspector/test_0.sqf deleted file mode 100644 index e69de29bb..000000000 diff --git a/libs/sqf/tests/inspector/test_main.sqf b/libs/sqf/tests/inspector/test_main.sqf index 167da747b..aa76028b0 100644 --- a/libs/sqf/tests/inspector/test_main.sqf +++ b/libs/sqf/tests/inspector/test_main.sqf @@ -90,7 +90,7 @@ private _varI = 55; filter = [orig, {_x + 1}] call CBA_fnc_filter; private _varJ = 123; -[player, {x = _varJ}] call ace_common_fnc_cachedcall; +[player, {x = _varJ}, SOME_NAMESPACE, "UID", 5] call ace_common_fnc_cachedcall; for "_test10" from 1 to 1 step 0.1 do {}; [5] params ["_test11"]; diff --git a/libs/sqf/tests/lints.rs b/libs/sqf/tests/lints.rs index 0e3bf3974..56fec78ca 100644 --- a/libs/sqf/tests/lints.rs +++ b/libs/sqf/tests/lints.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use hemtt_common::config::ProjectConfig; +use hemtt_common::config::{InspectorOptions, ProjectConfig}; use hemtt_sqf::{ analyze::{SqfReport, analyze}, parser::database::Database, @@ -71,8 +71,10 @@ lint!(s33_max, true); lint!(s33_min, true); lint!(s33_mod, true); lint!(s33_pi, true); +lint!(fnc_s34_invalid_return_type, false); lint!(s35_count_skipable, true); lint!(s36_global_var_in_local, true); +lint!(s37_calling_user_code, false); #[test] fn test_s29_function_undefined() { @@ -101,7 +103,9 @@ fn lint(file: &str, ignore_inspector: bool) -> (String, SqfReport) { &hemtt_common::config::PreprocessorOptions::default(), ) .unwrap(); - let database = Arc::new(Database::a3(false)); + let database = Arc::new(Database::a3(false).with_inspector_config(Some( + InspectorOptions::default().with_header_regex("ace".to_string()), + ))); let workspace_files = WorkspaceFiles::new(); let config_path_full = std::path::PathBuf::from(ROOT).join("project_tests.toml"); diff --git a/libs/sqf/tests/lints/fnc_s34_invalid_return_type.sqf b/libs/sqf/tests/lints/fnc_s34_invalid_return_type.sqf new file mode 100644 index 000000000..25fd46994 --- /dev/null +++ b/libs/sqf/tests/lints/fnc_s34_invalid_return_type.sqf @@ -0,0 +1,9 @@ +/* + * Arguments: + * None + * + * Return Value: + * X + */ + +1234 diff --git a/libs/sqf/tests/lints/project_tests.toml b/libs/sqf/tests/lints/project_tests.toml index e7c67421a..2e7573b8c 100644 --- a/libs/sqf/tests/lints/project_tests.toml +++ b/libs/sqf/tests/lints/project_tests.toml @@ -46,3 +46,7 @@ options.always = [ [lints.sqf.count_skipable] enabled = true + +[lints.sqf.calling_user_code] +enabled = true +options.ignore = ["my_cf_code"] diff --git a/libs/sqf/tests/lints/s37_calling_user_code.sqf b/libs/sqf/tests/lints/s37_calling_user_code.sqf new file mode 100644 index 000000000..b41fbfd98 --- /dev/null +++ b/libs/sqf/tests/lints/s37_calling_user_code.sqf @@ -0,0 +1,10 @@ +private _condition = alive player && uiNamespace getVariable ["someVar", false]; + +private _renderDistance = [3000, 500] select (profileNamespace getVariable ["performanceMode", false]); + +[] call (uiNamespace getVariable "cba_fnc_directCall"); // safe because _fnc_ + +[] call (uiNamespace getVariable "my_cf_code"); // ignored in option + + +[_condition, _renderDistance] // dummy use vars to avoid "unused variable" warnings diff --git a/libs/sqf/tests/snapshots/inspector__tests__cba_funcs.snap b/libs/sqf/tests/snapshots/inspector__tests__cba_funcs.snap new file mode 100644 index 000000000..f8d3dd766 --- /dev/null +++ b/libs/sqf/tests/snapshots/inspector__tests__cba_funcs.snap @@ -0,0 +1,5 @@ +--- +source: libs/sqf/tests/inspector.rs +expression: "(issues.len(), issues)" +--- +(3, [InvalidArgs { command: "cba_fnc_localevent", span: 9..27, variant: FuncTypeNotExpected { expected: [Array(Some([[(String(None), 0..0)]]), None)], found: [Array(Some([]), None)], span: 9..27 } }, InvalidArgs { command: "cba_fnc_localevent", span: 41..59, variant: FuncTypeNotExpected { expected: [Array(Some([[(String(None), 0..0)]]), None)], found: [Array(Some([[(Number(Some(Number(FloatOrd(6.0), 31..32))), 31..32)], [(Number(Some(Number(FloatOrd(7.0), 33..34))), 33..34)]]), None)], span: 41..59 } }, InvalidArgs { command: "cba_fnc_localevent", span: 69..87, variant: FuncTypeNotExpected { expected: [Array(None, None), String(None)], found: [Number(Some(Number(FloatOrd(6.0), 62..63)))], span: 69..87 } }]) diff --git a/libs/sqf/tests/snapshots/inspector__tests__fnc_header1.snap b/libs/sqf/tests/snapshots/inspector__tests__fnc_header1.snap new file mode 100644 index 000000000..b867982da --- /dev/null +++ b/libs/sqf/tests/snapshots/inspector__tests__fnc_header1.snap @@ -0,0 +1,5 @@ +--- +source: libs/sqf/tests/inspector.rs +expression: "(issues.len(), issues)" +--- +(2, [InvalidArgs { command: "[U:alive]", span: 22..27, variant: TypeNotExpected { expected: [Object], found: [Array(None, None)], span: 22..27 } }, InvalidReturnType { variant: InvalidReturnType { expected: [Boolean(None)], found: [String(Some(String("", 37..39, DoubleQuote)))], span: 37..39 } }]) diff --git a/libs/sqf/tests/snapshots/inspector__tests__main.snap b/libs/sqf/tests/snapshots/inspector__tests__main.snap index a8735b61c..547de217b 100644 --- a/libs/sqf/tests/snapshots/inspector__tests__main.snap +++ b/libs/sqf/tests/snapshots/inspector__tests__main.snap @@ -1,6 +1,5 @@ --- source: libs/sqf/tests/inspector.rs -assertion_line: 51 expression: "(issues.len(), issues)" --- -(22, [InvalidArgs { command: "[B:setFuel]", span: 31..38, variant: TypeNotExpected { expected: [Number(None)], found: [Boolean(Some(Boolean(true, 39..43)))], span: 39..43 } }, Undefined("_test2", 46..52, false), NotPrivate("_test3", 113..119), Unused("_test5", Params(248..256), true), InvalidArgs { command: "[B:addPublicVariableEventHandler]", span: 319..348, variant: TypeNotExpected { expected: [String(None)], found: [Array(Some([]), None)], span: 316..318 } }, InvalidArgs { command: "[B:addPublicVariableEventHandler]", span: 319..348, variant: TypeNotExpected { expected: [Anything, Array(None, None)], found: [Code(Some(Code(Statements { content: [], source: "}", span: 350..351, issues: [] })))], span: 350..351 } }, Undefined("_test8", 887..893, false), InvalidArgs { command: "[B:ctrlSetText]", span: 1098..1109, variant: TypeNotExpected { expected: [String(None)], found: [Object], span: 1110..1117 } }, Undefined("_test9", 1498..1504, false), Unused("_test10", ForLoop(1752..1761), false), InvalidArgs { command: "[B:drawIcon]", span: 1992..2000, variant: TypeNotExpected { expected: [Array(Some([[(String(None), 0..0)], [(Array(None, None), 0..0)], [(Anything, 0..0)], [(Number(None), 0..0)], [(Number(None), 0..0)], [(Number(None), 0..0)], [(String(None), 0..0)]]), None)], found: [Array(Some([[(String(Some(String("#(rgb,1,1,1)color(1,1,1,1)", 2008..2036, DoubleQuote))), 2008..2036)], [(Array(Some([[(Number(Some(Number(FloatOrd(0.0), 2043..2044))), 2043..2044)], [(Number(Some(Number(FloatOrd(1.0), 2045..2046))), 2045..2046)], [(Number(Some(Number(FloatOrd(0.0), 2047..2048))), 2047..2048)], [(Number(Some(Number(FloatOrd(1.0), 2049..2050))), 2049..2050)]]), None), 2043..2050)], [(Object, 2057..2063)], [(Number(Some(Number(FloatOrd(0.0), 2069..2070))), 2069..2070)], [(Number(Some(Number(FloatOrd(0.0), 2076..2077))), 2076..2077)], [(Number(Some(Number(FloatOrd(0.0), 2083..2084))), 2083..2084)], [(Number(Some(Number(FloatOrd(5555.0), 2090..2094))), 2090..2094)]]), None)], span: 2007..2095 } }, InvalidArgs { command: "[B:setGusts]", span: 2434..2442, variant: TypeNotExpected { expected: [Number(None)], found: [String(None), String(Some(String("abc", 2401..2406, DoubleQuote)))], span: 2443..2454 } }, Undefined("_test12", 2537..2544, false), CountArrayComparison(true, 2724..2742, "_test14", 2730..2737), InvalidArgs { command: "[U:case]", span: 3448..3452, variant: NilResultUsed { found: [Nothing(CommandReturn)], span: 3453..3460 } }, InvalidArgs { command: "[B:forEach]", span: 3512..3519, variant: TypeNotExpected { expected: [Array(None, None), HashMap], found: [Number(Some(Number(FloatOrd(5.0), 3504..3505)))], span: 3520..3527 } }, InvalidArgs { command: "=", span: 3599..3635, variant: NilResultUsed { found: [Assignment], span: 3620..3624 } }, InvalidArgs { command: "[U:hashValue]", span: 3638..3647, variant: NilResultUsed { found: [Assignment], span: 3648..3655 } }, InvalidArgs { command: "[U:sin]", span: 3775..3778, variant: TypeNotExpected { expected: [Number(None)], found: [Object, Display], span: 3775..3778 } }, Unused("_test4", Assignment(223..229, 232..237), false), Unused("_test11", Params(1802..1811), false), Undefined("_test13", 2610..2617, true)]) +(22, [InvalidArgs { command: "[B:setFuel]", span: 31..38, variant: TypeNotExpected { expected: [Number(None)], found: [Boolean(Some(Boolean(true, 39..43)))], span: 39..43 } }, Undefined("_test2", 46..52, false), NotPrivate("_test3", 113..119), Unused("_test5", Params(248..256), true), InvalidArgs { command: "[B:addPublicVariableEventHandler]", span: 319..348, variant: TypeNotExpected { expected: [String(None)], found: [Array(Some([]), None)], span: 316..318 } }, InvalidArgs { command: "[B:addPublicVariableEventHandler]", span: 319..348, variant: TypeNotExpected { expected: [Anything, Array(None, None)], found: [Code(Some(Code(Statements { content: [], source: "}", span: 350..351, issues: [] })))], span: 350..351 } }, Undefined("_test8", 887..893, false), InvalidArgs { command: "[B:ctrlSetText]", span: 1098..1109, variant: TypeNotExpected { expected: [String(None)], found: [Object], span: 1110..1117 } }, Undefined("_test9", 1498..1504, false), Unused("_test10", ForLoop(1778..1787), false), InvalidArgs { command: "[B:drawIcon]", span: 2018..2026, variant: TypeNotExpected { expected: [Array(Some([[(String(None), 0..0)], [(Array(None, None), 0..0)], [(Anything, 0..0)], [(Number(None), 0..0)], [(Number(None), 0..0)], [(Number(None), 0..0)], [(String(None), 0..0)]]), None)], found: [Array(Some([[(String(Some(String("#(rgb,1,1,1)color(1,1,1,1)", 2034..2062, DoubleQuote))), 2034..2062)], [(Array(Some([[(Number(Some(Number(FloatOrd(0.0), 2069..2070))), 2069..2070)], [(Number(Some(Number(FloatOrd(1.0), 2071..2072))), 2071..2072)], [(Number(Some(Number(FloatOrd(0.0), 2073..2074))), 2073..2074)], [(Number(Some(Number(FloatOrd(1.0), 2075..2076))), 2075..2076)]]), None), 2069..2076)], [(Object, 2083..2089)], [(Number(Some(Number(FloatOrd(0.0), 2095..2096))), 2095..2096)], [(Number(Some(Number(FloatOrd(0.0), 2102..2103))), 2102..2103)], [(Number(Some(Number(FloatOrd(0.0), 2109..2110))), 2109..2110)], [(Number(Some(Number(FloatOrd(5555.0), 2116..2120))), 2116..2120)]]), None)], span: 2033..2121 } }, InvalidArgs { command: "[B:setGusts]", span: 2460..2468, variant: TypeNotExpected { expected: [Number(None)], found: [String(None), String(Some(String("abc", 2427..2432, DoubleQuote)))], span: 2469..2480 } }, Undefined("_test12", 2563..2570, false), CountArrayComparison(true, 2750..2768, "_test14", 2756..2763), InvalidArgs { command: "[U:case]", span: 3474..3478, variant: NilResultUsed { found: [Nothing(CommandReturn)], span: 3479..3486 } }, InvalidArgs { command: "[B:forEach]", span: 3538..3545, variant: TypeNotExpected { expected: [Array(None, None), HashMap], found: [Number(Some(Number(FloatOrd(5.0), 3530..3531)))], span: 3546..3553 } }, InvalidArgs { command: "=", span: 3625..3661, variant: NilResultUsed { found: [Assignment], span: 3646..3650 } }, InvalidArgs { command: "[U:hashValue]", span: 3664..3673, variant: NilResultUsed { found: [Assignment], span: 3674..3681 } }, InvalidArgs { command: "[U:sin]", span: 3801..3804, variant: TypeNotExpected { expected: [Number(None)], found: [Object, Display], span: 3801..3804 } }, Unused("_test4", Assignment(223..229, 232..237), false), Unused("_test11", Params(1828..1837), false), Undefined("_test13", 2636..2643, true)]) diff --git a/libs/sqf/tests/snapshots/lints__simple_fnc_s34_invalid_return_type.snap b/libs/sqf/tests/snapshots/lints__simple_fnc_s34_invalid_return_type.snap new file mode 100644 index 000000000..9b7b4acb3 --- /dev/null +++ b/libs/sqf/tests/snapshots/lints__simple_fnc_s34_invalid_return_type.snap @@ -0,0 +1,11 @@ +--- +source: libs/sqf/tests/lints.rs +expression: "lint(stringify! (fnc_s34_invalid_return_type), false).0" +--- +warning[L-S34]: Invalid function return type (from Header) + ┌─ fnc_s34_invalid_return_type.sqf:9:1 + │ +9 │ 1234 + │ ^^^^ expected Bool + │ + = note: found type was Number diff --git a/libs/sqf/tests/snapshots/lints__simple_s37_calling_user_code.snap b/libs/sqf/tests/snapshots/lints__simple_s37_calling_user_code.snap new file mode 100644 index 000000000..127d109a9 --- /dev/null +++ b/libs/sqf/tests/snapshots/lints__simple_s37_calling_user_code.snap @@ -0,0 +1,16 @@ +--- +source: libs/sqf/tests/lints.rs +expression: "lint(stringify! (s37_calling_user_code), false).0" +--- +warning[L-S37]: Calling user code + ┌─ s37_calling_user_code.sqf:1:38 + │ +1 │ private _condition = alive player && uiNamespace getVariable ["someVar", false]; + │ ^^^^^^^^^^^ variable `somevar` may be user code + + +warning[L-S37]: Calling user code + ┌─ s37_calling_user_code.sqf:3:47 + │ +3 │ private _renderDistance = [3000, 500] select (profileNamespace getVariable ["performanceMode", false]); + │ ^^^^^^^^^^^^^^^^ variable `performancemode` may be user code