diff --git a/Cargo.lock b/Cargo.lock index 05922dc1..32a4d6be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1696,6 +1696,7 @@ dependencies = [ "insta", "linkme", "paste", + "regex", "thiserror 2.0.8", "toml 0.8.19", "tracing", diff --git a/libs/sqf/Cargo.toml b/libs/sqf/Cargo.toml index 190e5ee8..80e56983 100644 --- a/libs/sqf/Cargo.toml +++ b/libs/sqf/Cargo.toml @@ -25,6 +25,7 @@ linkme = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } tracing = { workspace = true } +regex = { workspace = true } [features] default = ["compiler", "parser"] diff --git a/libs/sqf/src/analyze/inspector/commands.rs b/libs/sqf/src/analyze/inspector/commands.rs new file mode 100644 index 00000000..86aefd08 --- /dev/null +++ b/libs/sqf/src/analyze/inspector/commands.rs @@ -0,0 +1,406 @@ +//! Emulates engine commands + +use std::{collections::HashSet, ops::Range}; + +use crate::{analyze::inspector::VarSource, parser::database::Database, Expression}; + +use super::{game_value::GameValue, SciptScope}; + +impl SciptScope { + #[must_use] + pub fn cmd_u_private(&mut self, rhs: &HashSet) -> HashSet { + fn push_var(s: &mut SciptScope, var: &String, source: &Range) { + if s.ignored_vars.contains(&var.to_ascii_lowercase()) { + s.var_assign( + &var.to_string(), + true, + HashSet::from([GameValue::Anything]), + VarSource::Ignore, + ); + } else { + s.var_assign( + &var.to_string(), + true, + HashSet::from([GameValue::Nothing]), + VarSource::Private(source.clone()), + ); + } + } + for possible in rhs { + if let GameValue::Array(Some(gv_array)) = possible { + for gv_index in gv_array { + for element in gv_index { + let GameValue::String(Some(Expression::String(var, source, _))) = element + else { + continue; + }; + if var.is_empty() { + continue; + } + push_var(self, &var.to_string(), source); + } + } + } + if let GameValue::String(Some(Expression::String(var, source, _))) = possible { + if var.is_empty() { + continue; + } + push_var(self, &var.to_string(), source); + } + } + HashSet::new() + } + #[must_use] + pub fn cmd_generic_params(&mut self, rhs: &HashSet) -> HashSet { + for possible in rhs { + let GameValue::Array(Some(gv_array)) = possible else { + continue; + }; + + for gv_index in gv_array { + for element in gv_index { + match element { + GameValue::String(Some(Expression::String(var, source, _))) => { + if var.is_empty() { + continue; + } + self.var_assign( + var.as_ref(), + true, + HashSet::from([GameValue::Anything]), + VarSource::Params(source.clone()), + ); + } + GameValue::Array(Some(arg_array)) => { + if arg_array.is_empty() || arg_array[0].is_empty() { + continue; + } + let GameValue::String(Some(Expression::String(var_name, source, _))) = + &arg_array[0][0] + else { + continue; + }; + if var_name.is_empty() { + continue; + } + let mut var_types = HashSet::new(); + if arg_array.len() > 2 { + for type_p in &arg_array[2] { + if let GameValue::Array(Some(type_array)) = type_p { + for type_i in type_array { + var_types + .extend(type_i.iter().map(GameValue::make_generic)); + } + } + } + } + if var_types.is_empty() { + var_types.insert(GameValue::Anything); + } + // Add the default value to types + // It would be nice to move this above the is_empty check but not always safe + // ie: assume `params ["_z", ""]` is type string, but this is not guarentted + if arg_array.len() > 1 && !arg_array[1].is_empty() { + var_types.insert(arg_array[1][0].clone()); + } + self.var_assign( + var_name.as_ref(), + true, + var_types, + VarSource::Params(source.clone()), + ); + } + _ => {} + } + } + } + } + HashSet::from([GameValue::Boolean(None)]) + } + #[must_use] + pub fn cmd_generic_call( + &mut self, + rhs: &HashSet, + database: &Database, + ) -> HashSet { + for possible in rhs { + let GameValue::Code(Some(expression)) = possible else { + continue; + }; + let Expression::Code(statements) = expression else { + continue; + }; + if self.code_used.contains(expression) { + continue; + } + self.push(); + self.code_used.insert(expression.clone()); + self.eval_statements(statements, database); + self.pop(); + } + HashSet::from([GameValue::Anything]) + } + #[must_use] + pub fn cmd_b_do( + &mut self, + lhs: &HashSet, + rhs: &HashSet, + database: &Database, + ) -> HashSet { + for possible in rhs { + let GameValue::Code(Some(expression)) = possible else { + continue; + }; + let Expression::Code(statements) = expression else { + continue; + }; + if self.code_used.contains(expression) { + continue; + } + self.push(); + let mut do_run = false; + for possible in lhs { + if let GameValue::ForType(option) = possible { + let Some(for_args_array) = option else { + continue; + }; + do_run = true; + for stage in for_args_array { + match stage { + Expression::String(var, source, _) => { + self.var_assign( + var.as_ref(), + true, + HashSet::from([GameValue::Number(None)]), + VarSource::ForLoop(source.clone()), + ); + } + Expression::Code(stage_statement) => { + self.code_used.insert(stage.clone()); + self.eval_statements(stage_statement, database); + } + _ => {} + } + } + } else { + do_run = true; + } + } + self.code_used.insert(expression.clone()); + if do_run { + self.eval_statements(statements, database); + } + self.pop(); + } + HashSet::from([GameValue::Anything]) + } + #[must_use] + pub fn cmd_generic_call_magic( + &mut self, + code_possibilities: &HashSet, + magic: &Vec<(&str, GameValue)>, + source: &Range, + database: &Database, + ) -> HashSet { + for possible in code_possibilities { + let GameValue::Code(Some(expression)) = possible else { + continue; + }; + let Expression::Code(statements) = expression else { + continue; + }; + if self.code_used.contains(expression) { + continue; + } + self.push(); + for (var, value) in magic { + self.var_assign( + var, + true, + HashSet::from([value.clone()]), + VarSource::Magic(source.clone()), + ); + } + self.code_used.insert(expression.clone()); + self.eval_statements(statements, database); + self.pop(); + } + HashSet::from([GameValue::Anything]) + } + #[must_use] + pub fn cmd_for(&mut self, rhs: &HashSet) -> HashSet { + let mut return_value = HashSet::new(); + for possible in rhs { + let mut possible_array = Vec::new(); + match possible { + GameValue::String(option) => { + let Some(expression) = option else { + return_value.insert(GameValue::ForType(None)); + continue; + }; + possible_array.push(expression.clone()); + } + GameValue::Array(option) => { + let Some(for_stages) = option else { + return_value.insert(GameValue::ForType(None)); + continue; + }; + for stage in for_stages { + for gv in stage { + let GameValue::Code(Some(expression)) = gv else { + continue; + }; + possible_array.push(expression.clone()); + } + } + } + _ => {} + } + if possible_array.is_empty() { + return_value.insert(GameValue::ForType(None)); + } else { + return_value.insert(GameValue::ForType(Some(possible_array))); + } + } + return_value + } + #[must_use] + /// for (from, to, step) chained commands + pub fn cmd_b_from_chain( + &self, + lhs: &HashSet, + _rhs: &HashSet, + ) -> HashSet { + lhs.clone() + } + #[must_use] + pub fn cmd_u_is_nil( + &mut self, + rhs: &HashSet, + database: &Database, + ) -> HashSet { + let mut non_string = false; + for possible in rhs { + let GameValue::String(possible) = possible else { + non_string = true; + continue; + }; + let Some(expression) = possible else { + continue; + }; + let Expression::String(var, _, _) = expression else { + continue; + }; + let _ = self.var_retrieve(var, &expression.span(), true); + } + if non_string { + let _ = self.cmd_generic_call(rhs, database); + } + HashSet::from([GameValue::Boolean(None)]) + } + #[must_use] + pub fn cmd_b_then( + &mut self, + _lhs: &HashSet, + rhs: &HashSet, + database: &Database, + ) -> HashSet { + let mut return_value = HashSet::new(); + for possible in rhs { + if let GameValue::Code(Some(Expression::Code(_statements))) = possible { + return_value.extend(self.cmd_generic_call(rhs, database)); + } + if let GameValue::Array(Some(gv_array)) = possible { + for gv_index in gv_array { + for element in gv_index { + if let GameValue::Code(Some(expression)) = element { + return_value.extend(self.cmd_generic_call( + &HashSet::from([GameValue::Code(Some(expression.clone()))]), + database, + )); + } + } + } + } + } + return_value + } + #[must_use] + pub fn cmd_b_else( + &self, + lhs: &HashSet, + rhs: &HashSet, + ) -> HashSet { + let mut return_value = HashSet::new(); // just merge, not really the same but should be fine + for possible in rhs { + return_value.insert(possible.clone()); + } + for possible in lhs { + return_value.insert(possible.clone()); + } + return_value + } + #[must_use] + pub fn cmd_b_get_or_default_call( + &mut self, + rhs: &HashSet, + database: &Database, + ) -> HashSet { + let mut possible_code = HashSet::new(); + for possible_outer in rhs { + let GameValue::Array(Some(gv_array)) = possible_outer else { + continue; + }; + if gv_array.len() < 2 { + continue; + } + possible_code.extend(gv_array[1].clone()); + } + let _ = self.cmd_generic_call(&possible_code, database); + HashSet::from([GameValue::Anything]) + } + #[must_use] + pub fn cmd_u_to_string(&mut self, rhs: &HashSet) -> HashSet { + for possible in rhs { + let GameValue::Code(Some(expression)) = possible else { + continue; + }; + let Expression::Code(_) = expression else { + continue; + }; + // just skip because it will often use a _x + self.code_used.insert(expression.clone()); + } + HashSet::from([GameValue::String(None)]) + } + #[must_use] + pub fn cmd_b_select( + &mut self, + lhs: &HashSet, + rhs: &HashSet, + cmd_set: &HashSet, + source: &Range, + database: &Database, + ) -> HashSet { + let mut return_value = cmd_set.clone(); + // Check: `array select expression` + let _ = + self.cmd_generic_call_magic(rhs, &vec![("_x", GameValue::Anything)], source, database); + // if lhs is array, and rhs is bool/number then put array into return + if lhs.len() == 1 + && rhs + .iter() + .any(|r| matches!(r, GameValue::Boolean(..)) || matches!(r, GameValue::Number(..))) + { + if let Some(GameValue::Array(Some(gv_array))) = lhs.iter().next() { + // return_value.clear(); // todo: could clear if we handle pushBack + for gv_index in gv_array { + for element in gv_index { + return_value.insert(element.clone()); + } + } + } + } + return_value + } +} diff --git a/libs/sqf/src/analyze/inspector/external_functions.rs b/libs/sqf/src/analyze/inspector/external_functions.rs new file mode 100644 index 00000000..37f56f59 --- /dev/null +++ b/libs/sqf/src/analyze/inspector/external_functions.rs @@ -0,0 +1,189 @@ +//! Emulate how common external functions will handle code + +use std::collections::HashSet; + +use crate::{analyze::inspector::VarSource, parser::database::Database, Expression}; + +use super::{game_value::GameValue, SciptScope}; + +impl SciptScope { + #[allow(clippy::too_many_lines)] + pub fn external_function( + &mut self, + lhs: &HashSet, + rhs: &Expression, + database: &Database, + ) { + let Expression::Variable(ext_func, _) = rhs else { + return; + }; + for possible in lhs { + match possible { + GameValue::Code(Some(statements)) => { + // handle `{} call cba_fnc_directcall` + if ext_func.to_ascii_lowercase().as_str() == "cba_fnc_directcall" { + self.external_current_scope( + &vec![GameValue::Code(Some(statements.clone()))], + &vec![], + database, + ); + } + } + GameValue::Array(Some(gv_array)) => match ext_func.to_ascii_lowercase().as_str() { + // Functions that will run in existing scope + "cba_fnc_hasheachpair" | "cba_fnc_hashfilter" => { + if gv_array.len() > 1 { + self.external_current_scope( + &gv_array[1], + &vec![ + ("_key", GameValue::Anything), + ("_value", GameValue::Anything), + ], + database, + ); + } + } + "cba_fnc_filter" => { + if gv_array.len() > 1 { + self.external_current_scope( + &gv_array[1], + &vec![("_x", GameValue::Anything)], + database, + ); + } + } + "cba_fnc_inject" => { + if gv_array.len() > 2 { + self.external_current_scope( + &gv_array[2], + &vec![ + ("_x", GameValue::Anything), + ("_accumulator", GameValue::Anything), + ], + database, + ); + } + } + "cba_fnc_directcall" => { + if !gv_array.is_empty() { + self.external_current_scope(&gv_array[0], &vec![], database); + } + } + "ace_common_fnc_cachedcall" => { + if gv_array.len() > 1 { + self.external_current_scope(&gv_array[1], &vec![], database); + } + } + // Functions that will start in a new scope + "ace_interact_menu_fnc_createaction" => { + for index in 3..=5 { + if gv_array.len() > index { + self.external_new_scope( + &gv_array[index], + &vec![ + ("_target", GameValue::Object), + ("_player", GameValue::Object), + ], + database, + ); + } + } + } + "cba_fnc_addperframehandler" + | "cba_fnc_waitandexecute" + | "cba_fnc_execnextframe" => { + if !gv_array.is_empty() { + self.external_new_scope(&gv_array[0], &vec![], database); + } + } + "cba_fnc_addclasseventhandler" => { + if gv_array.len() > 2 { + self.external_new_scope(&gv_array[2], &vec![], database); + } + } + "cba_fnc_addbiseventhandler" => { + if gv_array.len() > 2 { + self.external_new_scope( + &gv_array[2], + &vec![ + ("_thisType", GameValue::String(None)), + ("_thisId", GameValue::Number(None)), + ("_thisFnc", GameValue::Code(None)), + ("_thisArgs", GameValue::Anything), + ], + database, + ); + } + } + "cba_fnc_addeventhandlerargs" => { + if gv_array.len() > 1 { + self.external_new_scope( + &gv_array[1], + &vec![ + ("_thisType", GameValue::String(None)), + ("_thisId", GameValue::Number(None)), + ("_thisFnc", GameValue::Code(None)), + ("_thisArgs", GameValue::Anything), + ], + database, + ); + } + } + _ => {} + }, + _ => {} + } + } + } + fn external_new_scope( + &mut self, + code_arg: &Vec, + vars: &Vec<(&str, GameValue)>, + database: &Database, + ) { + for element in code_arg { + let GameValue::Code(Some(expression)) = element else { + continue; + }; + let Expression::Code(statements) = expression else { + return; + }; + if self.code_used.contains(expression) { + return; + } + let mut ext_scope = Self::create(&self.ignored_vars, false); + + for (var, value) in vars { + ext_scope.var_assign(var, true, HashSet::from([value.clone()]), VarSource::Ignore); + } + self.code_used.insert(expression.clone()); + ext_scope.eval_statements(statements, database); + self.errors.extend(ext_scope.finish(false, database)); + } + } + fn external_current_scope( + &mut self, + code_arg: &Vec, + vars: &Vec<(&str, GameValue)>, + database: &Database, + ) { + for element in code_arg { + let GameValue::Code(Some(expression)) = element else { + continue; + }; + let Expression::Code(statements) = expression else { + continue; + }; + if self.code_used.contains(expression) { + continue; + } + self.push(); + for (var, value) in vars { + self.var_assign(var, true, HashSet::from([value.clone()]), VarSource::Ignore); + } + self.code_used.insert(expression.clone()); + self.eval_statements(statements, database); + self.pop(); + } + } +} diff --git a/libs/sqf/src/analyze/inspector/game_value.rs b/libs/sqf/src/analyze/inspector/game_value.rs new file mode 100644 index 00000000..99b2d254 --- /dev/null +++ b/libs/sqf/src/analyze/inspector/game_value.rs @@ -0,0 +1,315 @@ +//! Game Values and mapping them from commands + +use std::collections::HashSet; + +use arma3_wiki::model::{Arg, Call, Param, Value}; +use tracing::{trace, warn}; + +use crate::{parser::database::Database, Expression}; + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub enum GameValue { + Anything, + // Assignment, // as in z = call {x=1}??? + Array(Option>>), + Boolean(Option), + Code(Option), + Config, + Control, + DiaryRecord, + Display, + ForType(Option>), + Group, + HashMap, + IfType, + Location, + Namespace, + Number(Option), + Nothing, + Object, + ScriptHandle, + Side, + String(Option), + SwitchType, + Task, + TeamMember, + WhileType, + WithType, +} + +impl GameValue { + #[must_use] + /// Gets cmd return types based on input types + pub fn from_cmd( + expression: &Expression, + lhs_set: Option<&HashSet>, + rhs_set: Option<&HashSet>, + database: &Database, + ) -> HashSet { + let mut return_types = HashSet::new(); + let cmd_name = expression.command_name().expect("has a name"); + let Some(command) = database.wiki().commands().get(cmd_name) else { + println!("cmd {cmd_name} not in db?"); + return HashSet::from([Self::Anything]); + }; + + for syntax in command.syntax() { + // println!("syntax {:?}", syntax.call()); + match syntax.call() { + Call::Nular => { + if !matches!(expression, Expression::NularCommand(..)) { + continue; + } + } + Call::Unary(rhs_arg) => { + if !matches!(expression, Expression::UnaryCommand(..)) + || !Self::match_set_to_arg( + cmd_name, + rhs_set.expect("unary rhs"), + rhs_arg, + syntax.params(), + ) + { + continue; + } + } + Call::Binary(lhs_arg, rhs_arg) => { + if !matches!(expression, Expression::BinaryCommand(..)) + || !Self::match_set_to_arg( + cmd_name, + lhs_set.expect("binary lhs"), + lhs_arg, + syntax.params(), + ) + || !Self::match_set_to_arg( + cmd_name, + rhs_set.expect("binary rhs"), + rhs_arg, + syntax.params(), + ) + { + continue; + } + } + } + let value = &syntax.ret().0; + // println!("match syntax {syntax:?}"); + return_types.insert(Self::from_wiki_value(value)); + } + // println!("lhs_set {lhs_set:?}"); + // println!("rhs_set {rhs_set:?}"); + // println!( + // "cmd [{}] = {}:{:?}", + // cmd_name, + // return_types.len(), + // return_types + // ); + return_types + } + + #[must_use] + pub fn match_set_to_arg( + cmd_name: &str, + set: &HashSet, + arg: &Arg, + params: &[Param], + ) -> bool { + match arg { + Arg::Item(name) => { + let Some(param) = params.iter().find(|p| p.name() == name) else { + /// Varadic cmds which will be missing wiki param matches + const WIKI_CMDS_IGNORE_MISSING_PARAM: &[&str] = &[ + "format", + "formatText", + "param", + "params", + "setGroupId", + "setGroupIdGlobal", + "set3DENMissionAttributes", + "setPiPEffect", + "ppEffectCreate", + "inAreaArray", + ]; + if !WIKI_CMDS_IGNORE_MISSING_PARAM.contains(&cmd_name) { + // warn!("cmd {cmd_name} - param {name} not found"); + } + return true; + }; + // println!( + // "[arg {name}] typ: {:?}, opt: {:?}", + // param.typ(), + // param.optional() + // ); + Self::match_set_to_value(set, param.typ(), param.optional()) + } + Arg::Array(arg_array) => { + const WIKI_CMDS_IGNORE_ARGS: &[&str] = &["createHashMapFromArray"]; + if WIKI_CMDS_IGNORE_ARGS.contains(&cmd_name) { + return true; + } + + set.iter().any(|s| { + match s { + Self::Anything | Self::Array(None) => { + // println!("array (any/generic) pass"); + true + } + Self::Array(Some(gv_array)) => { + // println!("array (gv: {}) expected (arg: {})", gv_array.len(), arg_array.len()); + // note: some syntaxes take more than others + for (index, arg) in arg_array.iter().enumerate() { + let possible = if index < gv_array.len() { + gv_array[index].iter().cloned().collect() + } else { + HashSet::new() + }; + if !Self::match_set_to_arg(cmd_name, &possible, arg, params) { + return false; + } + } + true + } + _ => false, + } + }) + } + } + } + + #[must_use] + pub fn match_set_to_value(set: &HashSet, right_wiki: &Value, optional: bool) -> bool { + // println!("Checking {:?} against {:?} [O:{optional}]", set, right_wiki); + if optional && (set.is_empty() || set.contains(&Self::Nothing)) { + return true; + } + let right = Self::from_wiki_value(right_wiki); + set.iter().any(|gv| Self::match_values(gv, &right)) + } + + #[must_use] + /// matches values are compatible (Anything will always match) + pub fn match_values(left: &Self, right: &Self) -> bool { + if matches!(left, Self::Anything) { + return true; + } + if matches!(right, Self::Anything) { + return true; + } + std::mem::discriminant(left) == std::mem::discriminant(right) + } + + #[must_use] + /// Maps from Wiki:Value to Inspector:GameValue + pub fn from_wiki_value(value: &Value) -> Self { + match value { + Value::Anything | Value::EdenEntity => Self::Anything, + Value::ArrayColor + | Value::ArrayColorRgb + | Value::ArrayColorRgba + | Value::ArrayDate + | Value::ArraySized { .. } + | Value::ArrayUnknown + | Value::ArrayUnsized { .. } + | Value::Position + | Value::Waypoint => Self::Array(None), + Value::Boolean => Self::Boolean(None), + Value::Code => Self::Code(None), + Value::Config => Self::Config, + Value::Control => Self::Control, + Value::DiaryRecord => Self::DiaryRecord, + Value::Display => Self::Display, + Value::ForType => Self::ForType(None), + Value::IfType => Self::IfType, + Value::Group => Self::Group, + Value::Location => Self::Location, + Value::Namespace => Self::Namespace, + Value::Nothing => Self::Nothing, + Value::Number => Self::Number(None), + Value::Object => Self::Object, + Value::ScriptHandle => Self::ScriptHandle, + Value::Side => Self::Side, + Value::String => Self::String(None), + Value::SwitchType => Self::SwitchType, + Value::Task => Self::Task, + Value::TeamMember => Self::TeamMember, + Value::WhileType => Self::WhileType, + Value::WithType => Self::WithType, + Value::Unknown => { + trace!("wiki has syntax with [unknown] type"); + Self::Anything + } + _ => { + warn!("wiki type [{value:?}] not matched"); + Self::Anything + } + } + } + #[must_use] + /// Gets a generic version of a type + pub fn make_generic(&self) -> Self { + match self { + Self::Array(_) => Self::Array(None), + Self::Boolean(_) => Self::Boolean(None), + Self::Code(_) => Self::Code(None), + Self::ForType(_) => Self::ForType(None), + Self::Number(_) => Self::Number(None), + Self::String(_) => Self::String(None), + _ => self.clone(), + } + } + #[must_use] + /// Get as a string for debugging + pub fn as_debug(&self) -> String { + match self { + // Self::Assignment() => { + // format!("Assignment") + // } + Self::Anything => "Anything".to_string(), + Self::ForType(for_args_array) => { + if for_args_array.is_some() { + format!("ForType(var {for_args_array:?})") + } else { + "ForType(GENERIC)".to_string() + } + } + Self::Number(expression) => { + if let Some(Expression::Number(num, _)) = expression { + format!("Number({num:?})",) + } else { + "Number(GENERIC)".to_string() + } + } + Self::String(expression) => { + if let Some(Expression::String(str, _, _)) = expression { + format!("String({str})") + } else { + "String(GENERIC)".to_string() + } + } + Self::Boolean(expression) => { + if let Some(Expression::Boolean(bool, _)) = expression { + format!("Boolean({bool})") + } else { + "Boolean(GENERIC)".to_string() + } + } + Self::Array(gv_array_option) => + { + #[allow(clippy::option_if_let_else)] + if let Some(gv_array) = gv_array_option { + format!("ArrayExp(len {})", gv_array.len()) + } else { + "ArrayExp(GENERIC)".to_string() + } + } + Self::Code(expression) => { + if let Some(Expression::Code(statements)) = expression { + format!("Code(len {})", statements.content().len()) + } else { + "Code(GENERIC)".to_string() + } + } + _ => "Other(todo)".to_string(), + } + } +} diff --git a/libs/sqf/src/analyze/inspector/mod.rs b/libs/sqf/src/analyze/inspector/mod.rs new file mode 100644 index 00000000..0313a736 --- /dev/null +++ b/libs/sqf/src/analyze/inspector/mod.rs @@ -0,0 +1,459 @@ +//! Inspects code, checking code args and variable usage +//! +use std::{ + collections::{HashMap, HashSet}, + hash::Hash, + ops::Range, + vec, +}; + +use crate::{ + parser::database::Database, BinaryCommand, Expression, Statement, Statements, UnaryCommand, +}; +use game_value::GameValue; +use hemtt_workspace::reporting::Processed; +use regex::Regex; +use tracing::{error, trace}; + +mod commands; +mod external_functions; +mod game_value; + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub enum Issue { + InvalidArgs(String, Range), + Undefined(String, Range, bool), + Unused(String, VarSource), + Shadowed(String, Range), + NotPrivate(String, Range), +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub enum VarSource { + Assignment(Range), + ForLoop(Range), + Params(Range), + Private(Range), + Magic(Range), + Ignore, +} +impl VarSource { + #[must_use] + pub const fn skip_errors(&self) -> bool { + matches!(self, Self::Magic(..)) || matches!(self, Self::Ignore) + } + #[must_use] + pub fn get_range(&self) -> Option> { + match self { + Self::Assignment(range) + | Self::ForLoop(range) + | Self::Params(range) + | Self::Private(range) + | Self::Magic(range) => Some(range.clone()), + Self::Ignore => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VarHolder { + possible: HashSet, + usage: i32, + source: VarSource, +} + +pub type Stack = HashMap; + +pub struct SciptScope { + errors: HashSet, + global: Stack, + local: Vec, + code_seen: HashSet, + code_used: HashSet, + /// Orphan scopes are code blocks that are created but don't appear to be called in a known way + is_orphan_scope: bool, + ignored_vars: HashSet, +} + +impl SciptScope { + #[must_use] + pub fn create(ignored_vars: &HashSet, is_orphan_scope: bool) -> Self { + // trace!("Creating ScriptScope"); + let mut scope = Self { + errors: HashSet::new(), + global: Stack::new(), + local: Vec::new(), + code_seen: HashSet::new(), + code_used: HashSet::new(), + is_orphan_scope, + ignored_vars: ignored_vars.clone(), + }; + scope.push(); + for var in ignored_vars { + scope.var_assign( + var, + true, + HashSet::from([GameValue::Anything]), + VarSource::Ignore, + ); + } + scope + } + #[must_use] + pub fn finish(&mut self, check_child_scripts: bool, database: &Database) -> HashSet { + self.pop(); + if check_child_scripts { + let unused = &self.code_seen - &self.code_used; + for expression in unused { + let Expression::Code(statements) = expression else { + error!("non-code in unused"); + continue; + }; + // trace!("-- Checking external scope"); + let mut external_scope = Self::create(&self.ignored_vars, true); + external_scope.eval_statements(&statements, database); + self.errors + .extend(external_scope.finish(check_child_scripts, database)); + } + } + self.errors.clone() + } + + pub fn push(&mut self) { + // trace!("-- Stack Push {}", self.local.len()); + self.local.push(Stack::new()); + } + pub fn pop(&mut self) { + for (var, holder) in self.local.pop().unwrap_or_default() { + // trace!("-- Stack Pop {}:{} ", var, holder.usage); + if holder.usage == 0 && !holder.source.skip_errors() { + self.errors.insert(Issue::Unused(var, holder.source)); + } + } + } + + pub fn var_assign( + &mut self, + var: &str, + local: bool, + possible_values: HashSet, + source: VarSource, + ) { + trace!("var_assign: {} @ {}", var, self.local.len()); + let var_lower = var.to_ascii_lowercase(); + if !var_lower.starts_with('_') { + let holder = self.global.entry(var_lower).or_insert(VarHolder { + possible: HashSet::new(), + usage: 0, + source, + }); + holder.possible.extend(possible_values); + return; + } + + let stack_level_search = self + .local + .iter() + .rev() + .position(|s| s.contains_key(&var_lower)); + let mut stack_level = self.local.len() - 1; + if stack_level_search.is_none() { + if !local { + self.errors.insert(Issue::NotPrivate( + var.to_owned(), + source.get_range().unwrap_or_default(), + )); + } + } else if local { + // Only check shadowing inside the same scope-level (could make an option) + if stack_level_search.unwrap_or_default() == 0 && !source.skip_errors() { + self.errors.insert(Issue::Shadowed( + var.to_owned(), + source.get_range().unwrap_or_default(), + )); + } + } else { + stack_level -= stack_level_search.unwrap_or_default(); + } + let holder = self.local[stack_level] + .entry(var_lower) + .or_insert(VarHolder { + possible: HashSet::new(), + usage: 0, + source, + }); + holder.possible.extend(possible_values); + } + + #[must_use] + /// # Panics + pub fn var_retrieve( + &mut self, + var: &str, + source: &Range, + peek: bool, + ) -> HashSet { + let var_lower = var.to_ascii_lowercase(); + let holder_option = if var_lower.starts_with('_') { + let stack_level_search = self + .local + .iter() + .rev() + .position(|s| s.contains_key(&var_lower)); + let mut stack_level = self.local.len() - 1; + if stack_level_search.is_none() { + if !peek { + self.errors.insert(Issue::Undefined( + var.to_owned(), + source.clone(), + self.is_orphan_scope, + )); + } + } else { + stack_level -= stack_level_search.expect("is_some"); + }; + self.local[stack_level].get_mut(&var_lower) + } else if self.global.contains_key(&var_lower) { + self.global.get_mut(&var_lower) + } else { + return HashSet::from([GameValue::Anything]); + }; + if holder_option.is_none() { + // we've reported the error above, just return Any so it doesn't fail everything after + HashSet::from([GameValue::Anything]) + } else { + let holder = holder_option.expect("is_some"); + holder.usage += 1; + let mut set = holder.possible.clone(); + + if !var_lower.starts_with('_') && self.ignored_vars.contains(&var.to_ascii_lowercase()) + { + // Assume that a ignored global var could be anything + set.insert(GameValue::Anything); + } + set + } + } + + #[must_use] + #[allow(clippy::too_many_lines)] + /// Evaluate expression in current scope + pub fn eval_expression( + &mut self, + expression: &Expression, + database: &Database, + ) -> HashSet { + let mut debug_type = String::new(); + let possible_values = match expression { + Expression::Variable(var, source) => self.var_retrieve(var, source, false), + Expression::Number(..) => HashSet::from([GameValue::Number(Some(expression.clone()))]), + Expression::Boolean(..) => { + HashSet::from([GameValue::Boolean(Some(expression.clone()))]) + } + Expression::String(..) => HashSet::from([GameValue::String(Some(expression.clone()))]), + Expression::Array(array, _) => { + let gv_array: Vec> = array + .iter() + .map(|e| self.eval_expression(e, database).into_iter().collect()) + .collect(); + HashSet::from([GameValue::Array(Some(gv_array))]) + } + Expression::NularCommand(cmd, source) => { + debug_type = format!("[N:{}]", cmd.as_str()); + let cmd_set = GameValue::from_cmd(expression, None, None, database); + if cmd_set.is_empty() { + // is this possible? + self.errors + .insert(Issue::InvalidArgs(debug_type.clone(), source.clone())); + } + cmd_set + } + Expression::UnaryCommand(cmd, rhs, source) => { + debug_type = format!("[U:{}]", cmd.as_str()); + let rhs_set = self.eval_expression(rhs, database); + let cmd_set = GameValue::from_cmd(expression, None, Some(&rhs_set), database); + if cmd_set.is_empty() { + self.errors + .insert(Issue::InvalidArgs(debug_type.clone(), source.clone())); + } + let return_set = match cmd { + UnaryCommand::Named(named) => match named.to_ascii_lowercase().as_str() { + "params" => Some(self.cmd_generic_params(&rhs_set)), + "private" => Some(self.cmd_u_private(&rhs_set)), + "call" => Some(self.cmd_generic_call(&rhs_set, database)), + "isnil" => Some(self.cmd_u_is_nil(&rhs_set, database)), + "while" | "waituntil" | "default" => { + let _ = self.cmd_generic_call(&rhs_set, database); + None + } + "for" => Some(self.cmd_for(&rhs_set)), + "tostring" => Some(self.cmd_u_to_string(&rhs_set)), + _ => None, + }, + _ => None, + }; + // Use custom return from cmd or just use wiki set + return_set.unwrap_or(cmd_set) + } + Expression::BinaryCommand(cmd, lhs, rhs, source) => { + debug_type = format!("[B:{}]", cmd.as_str()); + let lhs_set = self.eval_expression(lhs, database); + let rhs_set = self.eval_expression(rhs, database); + let cmd_set = + GameValue::from_cmd(expression, Some(&lhs_set), Some(&rhs_set), database); + if cmd_set.is_empty() { + // we must have invalid args + self.errors + .insert(Issue::InvalidArgs(debug_type.clone(), source.clone())); + } + let return_set = match cmd { + BinaryCommand::Associate => { + // the : from case + let _ = self.cmd_generic_call(&rhs_set, database); + None + } + BinaryCommand::And | BinaryCommand::Or => { + let _ = self.cmd_generic_call(&rhs_set, database); + None + } + BinaryCommand::Else => Some(self.cmd_b_else(&lhs_set, &rhs_set)), + BinaryCommand::Named(named) => match named.to_ascii_lowercase().as_str() { + "params" => Some(self.cmd_generic_params(&rhs_set)), + "call" => { + self.external_function(&lhs_set, rhs, database); + Some(self.cmd_generic_call(&rhs_set, database)) + } + "exitwith" => { + // todo: handle scope exits + Some(self.cmd_generic_call(&rhs_set, database)) + } + "do" => { + // from While, With, For, and Switch + // todo: handle switch return value + Some(self.cmd_b_do(&lhs_set, &rhs_set, database)) + } + "from" | "to" | "step" => Some(self.cmd_b_from_chain(&lhs_set, &rhs_set)), + "then" => Some(self.cmd_b_then(&lhs_set, &rhs_set, database)), + "foreach" | "foreachreversed" => Some(self.cmd_generic_call_magic( + &lhs_set, + &vec![ + ("_x", GameValue::Anything), + ("_y", GameValue::Anything), + ("_forEachIndex", GameValue::Number(None)), + ], + source, + database, + )), + "count" => { + let _ = self.cmd_generic_call_magic( + &lhs_set, + &vec![("_x", GameValue::Anything)], + source, + database, + ); + None + } + "findif" | "apply" => { + let _ = self.cmd_generic_call_magic( + &rhs_set, + &vec![("_x", GameValue::Anything)], + source, + database, + ); + None + } + "getordefaultcall" => { + Some(self.cmd_b_get_or_default_call(&rhs_set, database)) + } + "select" => { + Some(self.cmd_b_select(&lhs_set, &rhs_set, &cmd_set, source, database)) + } + _ => None, + }, + _ => None, + }; + // Use custom return from cmd or just use wiki set + return_set.unwrap_or(cmd_set) + } + Expression::Code(statements) => { + self.code_seen.insert(expression.clone()); + debug_type = format!("CODE:{}", statements.content().len()); + HashSet::from([GameValue::Code(Some(expression.clone()))]) + } + Expression::ConsumeableArray(_, _) => unreachable!(""), + }; + trace!( + "eval expression{}->{:?}", + debug_type, + possible_values + .iter() + .map(GameValue::as_debug) + .collect::>() + ); + possible_values + } + + /// Evaluate statements in the current scope + fn eval_statements(&mut self, statements: &Statements, database: &Database) { + // let mut return_value = HashSet::new(); + for statement in statements.content() { + match statement { + Statement::AssignGlobal(var, expression, source) => { + // x or _x + let possible_values = self.eval_expression(expression, database); + self.var_assign( + var, + false, + possible_values, + VarSource::Assignment(source.clone()), + ); + // return_value = vec![GameValue::Assignment()]; + } + Statement::AssignLocal(var, expression, source) => { + // private _x + let possible_values = self.eval_expression(expression, database); + self.var_assign( + var, + true, + possible_values, + VarSource::Assignment(source.clone()), + ); + // return_value = vec![GameValue::Assignment()]; + } + Statement::Expression(expression, _) => { + let _possible_values = self.eval_expression(expression, database); + // return_value = possible_values; + } + } + } + // return_value + } +} + +#[must_use] +/// Run statements and return issues +pub fn run_processed( + statements: &Statements, + processed: &Processed, + database: &Database, +) -> Vec { + let mut ignored_vars = HashSet::new(); + ignored_vars.insert("_this".to_string()); + let Ok(re1) = Regex::new(r"\/\/ ?IGNORE_PRIVATE_WARNING ?\[(.*)\]") else { + return Vec::new(); + }; + let Ok(re2) = Regex::new(r#""(.*?)""#) else { + return Vec::new(); + }; + for (_path, raw_source) in processed.sources() { + for (_, [ignores]) in re1.captures_iter(&raw_source).map(|c| c.extract()) { + for (_, [var]) in re2.captures_iter(ignores).map(|c| c.extract()) { + ignored_vars.insert(var.to_ascii_lowercase()); + } + } + } + + let mut scope = SciptScope::create(&ignored_vars, false); + scope.eval_statements(statements, database); + scope.finish(true, database).into_iter().collect() +} diff --git a/libs/sqf/src/analyze/lints/s12_invalid_args.rs b/libs/sqf/src/analyze/lints/s12_invalid_args.rs new file mode 100644 index 00000000..7041b592 --- /dev/null +++ b/libs/sqf/src/analyze/lints/s12_invalid_args.rs @@ -0,0 +1,135 @@ +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!(LintS12InvalidArgs); + +impl Lint for LintS12InvalidArgs { + fn ident(&self) -> &'static str { + "invalid_args" + } + fn sort(&self) -> u32 { + 120 + } + fn description(&self) -> &'static str { + "Invalid Args" + } + fn documentation(&self) -> &'static str { + r"### Example + +**Incorrect** +```sqf +(vehicle player) setFuel true; // bad args: takes number 0-1 +``` + +### Explanation + +Checks correct syntax usage." + } + fn default_config(&self) -> LintConfig { + LintConfig::help() + } + 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>, + 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::InvalidArgs(cmd, range) = issue { + errors.push(Arc::new(CodeS12InvalidArgs::new( + range.to_owned(), + cmd.to_owned(), + None, + config.severity(), + processed, + ))); + } + } + errors + } +} + +#[allow(clippy::module_name_repetitions)] +pub struct CodeS12InvalidArgs { + span: Range, + token_name: String, + error_hint: Option, + severity: Severity, + diagnostic: Option, +} + +impl Code for CodeS12InvalidArgs { + fn ident(&self) -> &'static str { + "L-S12" + } + fn link(&self) -> Option<&str> { + Some("/analysis/sqf.html#invalid_args") + } + /// Top message + fn message(&self) -> String { + format!("Invalid Args - {}", self.token_name) + } + /// Under ^^^span hint + fn label_message(&self) -> String { + String::new() + } + /// bottom note + fn note(&self) -> Option { + self.error_hint.clone() + } + fn severity(&self) -> Severity { + self.severity + } + fn diagnostic(&self) -> Option { + self.diagnostic.clone() + } +} + +impl CodeS12InvalidArgs { + #[must_use] + pub fn new( + span: Range, + error_type: String, + error_hint: Option, + severity: Severity, + processed: &Processed, + ) -> Self { + Self { + span, + token_name: error_type, + error_hint, + 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/lints/s13_undefined.rs b/libs/sqf/src/analyze/lints/s13_undefined.rs new file mode 100644 index 00000000..ca531b9d --- /dev/null +++ b/libs/sqf/src/analyze/lints/s13_undefined.rs @@ -0,0 +1,149 @@ +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!(LintS13Undefined); + +impl Lint for LintS13Undefined { + fn ident(&self) -> &'static str { + "undefined" + } + fn sort(&self) -> u32 { + 130 + } + fn description(&self) -> &'static str { + "Undefined Variable" + } + fn documentation(&self) -> &'static str { + r"### Example + +**Incorrect** +```sqf +systemChat _neverDefined; +``` + +### Explanation + +Checks correct syntax usage." + } + fn default_config(&self) -> LintConfig { + LintConfig::help() + } + 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>, + 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 check_orphan_code = + if let Some(toml::Value::Boolean(b)) = config.option("check_orphan_code") { + *b + } else { + false + }; + let mut errors: Codes = Vec::new(); + for issue in target.issues() { + if let Issue::Undefined(var, range, is_orphan_scope) = issue { + let error_hint = if *is_orphan_scope { + if !check_orphan_code { + continue; + } + Some("From Orphan Code - may not be a problem".to_owned()) + } else { + None + }; + errors.push(Arc::new(CodeS13Undefined::new( + range.to_owned(), + var.to_owned(), + error_hint, + config.severity(), + processed, + ))); + } + } + errors + } +} + +#[allow(clippy::module_name_repetitions)] +pub struct CodeS13Undefined { + span: Range, + token_name: String, + error_hint: Option, + severity: Severity, + diagnostic: Option, +} + +impl Code for CodeS13Undefined { + fn ident(&self) -> &'static str { + "L-S13" + } + fn link(&self) -> Option<&str> { + Some("/analysis/sqf.html#undefined") + } + /// Top message + fn message(&self) -> String { + format!("Undefined Var - {}", self.token_name) + } + /// Under ^^^span hint + fn label_message(&self) -> String { + String::new() + } + /// bottom note + fn note(&self) -> Option { + self.error_hint.clone() + } + fn severity(&self) -> Severity { + self.severity + } + fn diagnostic(&self) -> Option { + self.diagnostic.clone() + } +} + +impl CodeS13Undefined { + #[must_use] + pub fn new( + span: Range, + error_type: String, + error_hint: Option, + severity: Severity, + processed: &Processed, + ) -> Self { + Self { + span, + token_name: error_type, + error_hint, + 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/lints/s14_unused.rs b/libs/sqf/src/analyze/lints/s14_unused.rs new file mode 100644 index 00000000..9a17be1e --- /dev/null +++ b/libs/sqf/src/analyze/lints/s14_unused.rs @@ -0,0 +1,155 @@ +use crate::{ + analyze::{ + inspector::{Issue, VarSource}, + 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!(LintS14Unused); + +impl Lint for LintS14Unused { + fn ident(&self) -> &'static str { + "unused" + } + fn sort(&self) -> u32 { + 120 + } + fn description(&self) -> &'static str { + "Unused Var" + } + fn documentation(&self) -> &'static str { + r"### Example + +**Incorrect** +```sqf +private _z = 5; // and never used +``` + +### Explanation + +Checks for vars that are never used." + } + fn default_config(&self) -> LintConfig { + LintConfig::help().with_enabled(false) + } + 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>, + 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 check_params = if let Some(toml::Value::Boolean(b)) = config.option("check_params") { + *b + } else { + false + }; + let mut errors: Codes = Vec::new(); + for issue in target.issues() { + if let Issue::Unused(var, source) = issue { + match source { + VarSource::Assignment(_) => {} + VarSource::Params(_) => { + if !check_params { + continue; + }; + } + _ => { + continue; + } + } + errors.push(Arc::new(CodeS14Unused::new( + source.get_range().unwrap_or_default(), + var.to_owned(), + None, + config.severity(), + processed, + ))); + } + } + errors + } +} + +#[allow(clippy::module_name_repetitions)] +pub struct CodeS14Unused { + span: Range, + token_name: String, + error_hint: Option, + severity: Severity, + diagnostic: Option, +} + +impl Code for CodeS14Unused { + fn ident(&self) -> &'static str { + "L-S14" + } + fn link(&self) -> Option<&str> { + Some("/analysis/sqf.html#unused") + } + /// Top message + fn message(&self) -> String { + format!("Unused Var - {}", self.token_name) + } + /// Under ^^^span hint + fn label_message(&self) -> String { + String::new() + } + /// bottom note + fn note(&self) -> Option { + self.error_hint.clone() + } + fn severity(&self) -> Severity { + self.severity + } + fn diagnostic(&self) -> Option { + self.diagnostic.clone() + } +} + +impl CodeS14Unused { + #[must_use] + pub fn new( + span: Range, + error_type: String, + error_hint: Option, + severity: Severity, + processed: &Processed, + ) -> Self { + Self { + span, + token_name: error_type, + error_hint, + 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/lints/s15_shadowed.rs b/libs/sqf/src/analyze/lints/s15_shadowed.rs new file mode 100644 index 00000000..d5370b0a --- /dev/null +++ b/libs/sqf/src/analyze/lints/s15_shadowed.rs @@ -0,0 +1,136 @@ +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!(LintS15Shadowed); + +impl Lint for LintS15Shadowed { + fn ident(&self) -> &'static str { + "shadowed" + } + fn sort(&self) -> u32 { + 150 + } + fn description(&self) -> &'static str { + "Shadowed Var" + } + fn documentation(&self) -> &'static str { + r"### Example + +**Incorrect** +```sqf +private _z = 5; +private _z = 5; +``` + +### Explanation + +Checks for variables being shadowed." + } + fn default_config(&self) -> LintConfig { + LintConfig::help().with_enabled(false) + } + 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>, + 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::Shadowed(var, range) = issue { + errors.push(Arc::new(CodeS15Shadowed::new( + range.to_owned(), + var.to_owned(), + None, + config.severity(), + processed, + ))); + } + } + errors + } +} + +#[allow(clippy::module_name_repetitions)] +pub struct CodeS15Shadowed { + span: Range, + token_name: String, + error_hint: Option, + severity: Severity, + diagnostic: Option, +} + +impl Code for CodeS15Shadowed { + fn ident(&self) -> &'static str { + "L-S15" + } + fn link(&self) -> Option<&str> { + Some("/analysis/sqf.html#shadowed") + } + /// Top message + fn message(&self) -> String { + format!("Shadowed Var - {}", self.token_name) + } + /// Under ^^^span hint + fn label_message(&self) -> String { + String::new() + } + /// bottom note + fn note(&self) -> Option { + self.error_hint.clone() + } + fn severity(&self) -> Severity { + self.severity + } + fn diagnostic(&self) -> Option { + self.diagnostic.clone() + } +} + +impl CodeS15Shadowed { + #[must_use] + pub fn new( + span: Range, + error_type: String, + error_hint: Option, + severity: Severity, + processed: &Processed, + ) -> Self { + Self { + span, + token_name: error_type, + error_hint, + 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/lints/s16_not_private.rs b/libs/sqf/src/analyze/lints/s16_not_private.rs new file mode 100644 index 00000000..2e83481e --- /dev/null +++ b/libs/sqf/src/analyze/lints/s16_not_private.rs @@ -0,0 +1,135 @@ +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!(LintS16NotPrivate); + +impl Lint for LintS16NotPrivate { + fn ident(&self) -> &'static str { + "not_private" + } + fn sort(&self) -> u32 { + 160 + } + fn description(&self) -> &'static str { + "Not Private Var" + } + fn documentation(&self) -> &'static str { + r"### Example + +**Incorrect** +```sqf +_z = 6; +``` + +### Explanation + +Checks local variables that are not private." + } + fn default_config(&self) -> LintConfig { + LintConfig::help().with_enabled(false) + } + 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>, + 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::NotPrivate(var, range) = issue { + errors.push(Arc::new(CodeS16NotPrivate::new( + range.to_owned(), + var.to_owned(), + None, + config.severity(), + processed, + ))); + } + } + errors + } +} + +#[allow(clippy::module_name_repetitions)] +pub struct CodeS16NotPrivate { + span: Range, + token_name: String, + error_hint: Option, + severity: Severity, + diagnostic: Option, +} + +impl Code for CodeS16NotPrivate { + fn ident(&self) -> &'static str { + "L-S16" + } + fn link(&self) -> Option<&str> { + Some("/analysis/sqf.html#not_private") + } + /// Top message + fn message(&self) -> String { + format!("Not Private - {}", self.token_name) + } + /// Under ^^^span hint + fn label_message(&self) -> String { + String::new() + } + /// bottom note + fn note(&self) -> Option { + self.error_hint.clone() + } + fn severity(&self) -> Severity { + self.severity + } + fn diagnostic(&self) -> Option { + self.diagnostic.clone() + } +} + +impl CodeS16NotPrivate { + #[must_use] + pub fn new( + span: Range, + error_type: String, + error_hint: Option, + severity: Severity, + processed: &Processed, + ) -> Self { + Self { + span, + token_name: error_type, + error_hint, + 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 369d2181..673246a2 100644 --- a/libs/sqf/src/analyze/mod.rs +++ b/libs/sqf/src/analyze/mod.rs @@ -2,6 +2,8 @@ pub mod lints { automod::dir!(pub "src/analyze/lints"); } +pub mod inspector; + use std::sync::Arc; use hemtt_common::config::ProjectConfig; diff --git a/libs/sqf/src/lib.rs b/libs/sqf/src/lib.rs index fb240601..0d0356b2 100644 --- a/libs/sqf/src/lib.rs +++ b/libs/sqf/src/lib.rs @@ -11,6 +11,7 @@ use std::{ops::Range, sync::Arc}; pub use self::error::Error; +use analyze::inspector::Issue; use arma3_wiki::model::Version; #[doc(no_inline)] pub use float_ord::FloatOrd as Scalar; @@ -23,6 +24,7 @@ pub struct Statements { /// This isn't required to actually be anything significant, but will be displayed in-game if a script error occurs. source: Arc, span: Range, + issues: Vec, } impl Statements { @@ -41,6 +43,10 @@ impl Statements { self.span.clone() } + #[must_use] + pub const fn issues(&self) -> &Vec { + &self.issues + } #[must_use] /// Gets the highest version required by any command in this code chunk. pub fn required_version(&self, database: &Database) -> (String, Version, Range) { @@ -104,6 +110,9 @@ impl Statements { } (command, version, span) } + pub fn testing_clear_issues(&mut self) { + self.issues.clear(); + } } #[derive(Debug, Clone, PartialEq, Eq, Hash)] diff --git a/libs/sqf/src/parser/mod.rs b/libs/sqf/src/parser/mod.rs index 10c09569..65c2427b 100644 --- a/libs/sqf/src/parser/mod.rs +++ b/libs/sqf/src/parser/mod.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use self::database::{is_special_command, Database}; use self::lexer::{Control, Operator, Token}; +use crate::analyze::inspector; use crate::{BinaryCommand, Expression, NularCommand, Statement, Statements, UnaryCommand}; use chumsky::prelude::*; @@ -42,6 +43,7 @@ pub fn run(database: &Database, processed: &Processed) -> Result( source: processed.extract(span.clone()), span, content, + issues: vec![], }) }) } diff --git a/libs/sqf/tests/inspector.rs b/libs/sqf/tests/inspector.rs new file mode 100644 index 00000000..2e5c630a --- /dev/null +++ b/libs/sqf/tests/inspector.rs @@ -0,0 +1,155 @@ +use hemtt_sqf::Statements; +use hemtt_workspace::reporting::Processed; + +pub use float_ord::FloatOrd as Scalar; +use hemtt_preprocessor::Processor; +use hemtt_sqf::parser::database::Database; +use hemtt_workspace::LayerType; +const ROOT: &str = "tests/inspector/"; + +fn get_statements(file: &str) -> (Processed, Statements, Database) { + let folder = std::path::PathBuf::from(ROOT); + let workspace = hemtt_workspace::Workspace::builder() + .physical(&folder, LayerType::Source) + .finish(None, false, &hemtt_common::config::PDriveOption::Disallow) + .expect("for test"); + let source = workspace.join(file).expect("for test"); + let processed = Processor::run(&source).expect("for test"); + let statements = hemtt_sqf::parser::run(&Database::a3(false), &processed).expect("for test"); + let database = Database::a3(false); + (processed, statements, database) +} + +#[cfg(test)] +mod tests { + use crate::get_statements; + use hemtt_sqf::analyze::inspector::{Issue, VarSource}; + + #[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()); + } + + #[test] + pub fn test_1() { + let (_pro, sqf, _database) = get_statements("test_1.sqf"); + let result = sqf.issues(); + assert_eq!(result.len(), 15); + // Order not guarenteed + assert!(result.iter().any(|i| { + if let Issue::InvalidArgs(cmd, _) = i { + cmd == "[B:setFuel]" + } else { + false + } + })); + assert!(result.iter().any(|i| { + if let Issue::Undefined(var, _, orphan) = i { + var == "_test2" && !orphan + } else { + false + } + })); + assert!(result.iter().any(|i| { + if let Issue::NotPrivate(var, _) = i { + var == "_test3" + } else { + false + } + })); + assert!(result.iter().any(|i| { + if let Issue::Unused(var, source) = i { + var == "_test4" && matches!(source, VarSource::Assignment(_)) + } else { + false + } + })); + assert!(result.iter().any(|i| { + if let Issue::Shadowed(var, _) = i { + var == "_test5" + } else { + false + } + })); + assert!(result.iter().any(|i| { + if let Issue::InvalidArgs(var, _) = i { + var == "[B:addPublicVariableEventHandler]" + } else { + false + } + })); + assert!(result.iter().any(|i| { + if let Issue::InvalidArgs(var, _) = i { + var == "[B:ctrlSetText]" + } else { + false + } + })); + assert!(result.iter().any(|i| { + if let Issue::Undefined(var, _, orphan) = i { + var == "_test8" && !orphan + } else { + false + } + })); + assert!(result.iter().any(|i| { + if let Issue::Undefined(var, _, orphan) = i { + var == "_test9" && !orphan + } else { + false + } + })); + assert!(result.iter().any(|i| { + if let Issue::Unused(var, source) = i { + var == "_test10" && matches!(source, VarSource::ForLoop(_)) + } else { + false + } + })); + assert!(result.iter().any(|i| { + if let Issue::Unused(var, source) = i { + var == "_test11" && matches!(source, VarSource::Params(_)) + } else { + false + } + })); + assert!(result.iter().any(|i| { + if let Issue::InvalidArgs(cmd, _) = i { + cmd == "[B:drawIcon]" + } else { + false + } + })); + assert!(result.iter().any(|i| { + if let Issue::InvalidArgs(cmd, _) = i { + cmd == "[B:setGusts]" + } else { + false + } + })); + assert!(result.iter().any(|i| { + if let Issue::Undefined(var, _, orphan) = i { + var == "_test12" && !orphan + } else { + false + } + })); + assert!(result.iter().any(|i| { + if let Issue::Undefined(var, _, orphan) = i { + var == "_test13" && *orphan + } else { + false + } + })); + } + + #[test] + pub fn test_2() { + let (_pro, sqf, _database) = get_statements("test_2.sqf"); + let result = sqf.issues(); + assert_eq!(result.len(), 0); + } +} diff --git a/libs/sqf/tests/inspector/test_0.sqf b/libs/sqf/tests/inspector/test_0.sqf new file mode 100644 index 00000000..e69de29b diff --git a/libs/sqf/tests/inspector/test_1.sqf b/libs/sqf/tests/inspector/test_1.sqf new file mode 100644 index 00000000..6505d61c --- /dev/null +++ b/libs/sqf/tests/inspector/test_1.sqf @@ -0,0 +1,140 @@ +// _var[LETTER] are safe +// _test[NUMBER] are errors + +a setFuel b; +a setFuel 0; +a setFuel true; // invalidArgs: takes number 0-1 +_test2 setDamage 1; // undefiend +private _varA = player; +_varA setDamage 0.5; + +_test3 = 7; // not private +systemChat str _test3; +private "_varB"; +_varB = 8; +private ["_varC"]; +_varC = _varB + 1; +private _test4 = _varC; // unused +params ["_test5"]; +private _test5 = 10; // shadow (same level) +diag_log text str _test5; +gx = []; +gx addPublicVariableEventHandler {}; // args: takes lhs string + +for "_varE" from 1 to 20 step 0.5 do { + systemChat str _varE; +}; +for [{private _varF = 0}, {_varF < 5}, {_varF = _varF + 1}] do { + systemChat str _varF; +}; + +//IGNORE_PRIVATE_WARNING["_fromUpper"]; +X = _fromUpper; + +[] call { + private "_weird"; + //IGNORE_PRIVATE_WARNING["_weird"] - // No way to know the order is different + for "_varG" from 1 to 5 do { + if (_varG%2 == 0) then { + truck lock _weird; + }; + if (_varG%2 == 1) then { + _weird = 0.5; + }; + }; +}; + +// IGNORE_PRIVATE_WARNING["somePFEH"] +if (z) then { + somePFEH = nil; // otherwise will assume it's always nil +}; +if (y) then { + setObjectViewDistance somePFEH; +}; + +somehash getOrDefaultCall ["key", {_test8}, true]; //undefined +private _varH = objNull; +somehash getOrDefaultCall ["key", {_varH}, true]; + +// Will have _player and _target +private _condition = { [_player, _target] call y }; +[ + "", + localize "STR_A3_Arsenal", + "", + { + x ctrlSetText _player; // bad arg type + [_target, _player] call z; + }, + _condition, + {systemChat str _player; []} +] call ace_interact_menu_fnc_createAction; + +private _hash = [] call CBA_fnc_hashCreate; +private _dumpHash = { + // Will have _key and _value + diag_log format ["Key: %1, Value: %2", _key, _value]; +}; +[_hash, _dumpHash] call CBA_fnc_hashEachPair; + +private _test9 = 555; +_test9= _test9 + 1; +[{ + systemChat str _test9; // invalid +}, 0, []] call CBA_fnc_addPerFrameHandler; + +private _varI = 55; +[{systemChat str _varI}] call CBA_fnc_directCall; + + // Will have _x +filter = [orig, {_x + 1}] call CBA_fnc_filter; + +private _varJ = 123; +[player, {x = _varJ}] call ace_common_fnc_cachedcall; + +for "_test10" from 1 to 1 step 0.1 do {}; +[5] params ["_test11"]; + +params [["_varK", objNull, [objNull]]]; +{ + private _varName = vehicleVarName _varK; + _varK setVehicleVarName (_varName + "ok"); +} call CBA_fnc_directCall; + +_this select 0 drawIcon [ // invalidArgs + "#(rgb,1,1,1)color(1,1,1,1)", + [0,1,0,1], + player, + 0, + 0, + 0, + 5555 // text - optional +]; + +private _varL = nil; +call ([{_varL = 0;}, {_varL = 1;}] select (x == 1)); +["A", "B"] select _varL; + +params xParams; +params []; +params [[""]]; +params [["_varM", "", [""]], ["_varN", 1, [0]], ["_varO", { systemChat _varM }]]; +_varM = _varM + ""; +_varN = _varN + 2; +call _varO; + +params [["_someString", "abc", [""]], ["_someCode", { 60 setGusts _someString }]]; +call _someCode; // InvalidArgs for setGusts + +// ensure we use a generic version of the array param types or format would have an error +params [["_varP", "", ["", []]]]; +format _varP; + + +[{ + [_test12] call some_func; // undef, not orphan because CBA_fnc_execNextFrame is a known clean scope +}, player] call CBA_fnc_execNextFrame; +[{ + [_test13] call some_func; // undef, is orphan +}, player] call unknown_fnc_Usage; + diff --git a/libs/sqf/tests/inspector/test_2.sqf b/libs/sqf/tests/inspector/test_2.sqf new file mode 100644 index 00000000..de085507 --- /dev/null +++ b/libs/sqf/tests/inspector/test_2.sqf @@ -0,0 +1,26 @@ +// Mainly checking wiki syntax for correct optionals + +// check inner nil +obj addWeaponItem ["weapon", ["item", nil, "muzzle"], true]; +obj addWeaponItem ["weapon", ["item"], true]; + + // check too many/few on variadic +format ["%1 %2 %3 %4 %5", 1, 2, 3, 4, 5]; +format [""]; +[] params []; + +// False positives on wiki +configProperties [configFile >> "ACE_Curator"]; +x selectionPosition [y, "Memory"]; +ropeCreate [obj1, pos1, objNull, [0, 0, 0], dist1]; +lineIntersectsSurfaces [[], [], objNull, objNull, true, 2]; +uuid insert [8, ["-"]]; +createTrigger["EmptyDetector", [1,2,3]]; +showHUD [true,false,false,false,false,false,false,true]; +createVehicle ["", [0,0,0]]; +x drawRectangle [getPos player, 20, 20, getDir player, [0,0,1,1],""]; + +createHashMapFromArray [["empty", {0}]]; +lineIntersectsObjs [eyePos player, ATLToASL screenToWorld [0.5, 0.5]]; +formatText ["%1%2%3", "line1", "
", "line2"]; +[] select [2]; diff --git a/libs/sqf/tests/lints.rs b/libs/sqf/tests/lints.rs index 22f2683c..530a0ae4 100644 --- a/libs/sqf/tests/lints.rs +++ b/libs/sqf/tests/lints.rs @@ -10,36 +10,41 @@ use hemtt_workspace::{addons::Addon, reporting::WorkspaceFiles, LayerType}; const ROOT: &str = "tests/lints/"; macro_rules! lint { - ($dir:ident) => { + ($dir:ident, $ignore:expr) => { paste::paste! { #[test] fn []() { - insta::assert_snapshot!(lint(stringify!($dir))); + insta::assert_snapshot!(lint(stringify!($dir), $ignore)); } } }; } -lint!(s02_event_handler_case); -lint!(s03_static_typename); -lint!(s04_command_case); -lint!(s05_if_assign); -lint!(s05_if_assign_emoji); -lint!(s06_find_in_str); -lint!(s07_select_parse_number); -lint!(s08_format_args); -lint!(s09_banned_command); -lint!(s11_if_not_else); -lint!(s17_var_all_caps); -lint!(s18_in_vehicle_check); -lint!(s19_extra_not); -lint!(s20_bool_static_comparison); -lint!(s21_invalid_comparisons); -lint!(s22_this_call); -lint!(s23_reassign_reserved_variable); -lint!(s24_marker_spam); +lint!(s02_event_handler_case, true); +lint!(s03_static_typename, true); +lint!(s04_command_case, true); +lint!(s05_if_assign, true); +lint!(s05_if_assign_emoji, true); +lint!(s06_find_in_str, true); +lint!(s07_select_parse_number, true); +lint!(s08_format_args, true); +lint!(s09_banned_command, true); +lint!(s11_if_not_else, true); +lint!(s12_invalid_args, false); +lint!(s13_undefined, false); +lint!(s14_unused, false); +lint!(s15_shadowed, false); +lint!(s16_not_private, false); +lint!(s17_var_all_caps, true); +lint!(s18_in_vehicle_check, true); +lint!(s19_extra_not, true); +lint!(s20_bool_static_comparison, true); +lint!(s21_invalid_comparisons, true); +lint!(s22_this_call, true); +lint!(s23_reassign_reserved_variable, true); +lint!(s24_marker_spam, true); -fn lint(file: &str) -> String { +fn lint(file: &str, ignore_inspector: bool) -> String { let folder = std::path::PathBuf::from(ROOT); let workspace = hemtt_workspace::Workspace::builder() .physical(&folder, LayerType::Source) @@ -54,7 +59,10 @@ fn lint(file: &str) -> String { let config = ProjectConfig::from_file(&config_path_full).unwrap(); match hemtt_sqf::parser::run(&database, &processed) { - Ok(sqf) => { + Ok(mut sqf) => { + if ignore_inspector { + sqf.testing_clear_issues(); + } let codes = analyze( &sqf, Some(&config), diff --git a/libs/sqf/tests/lints/project_tests.toml b/libs/sqf/tests/lints/project_tests.toml index af9a40c0..2fd943a6 100644 --- a/libs/sqf/tests/lints/project_tests.toml +++ b/libs/sqf/tests/lints/project_tests.toml @@ -9,6 +9,21 @@ options.ignore = [ [lints.sqf] if_not_else = true + +[lints.sqf.shadowed] +enabled = true + +[lints.sqf.not_private] +enabled = true + +[lints.sqf.unused] +enabled = true +options.check_params = false + +[lints.sqf.undefined] +enabled = true +options.check_orphan_code = true + [lints.sqf.var_all_caps] enabled = true options.ignore = [ diff --git a/libs/sqf/tests/lints/s06_find_in_str.sqf b/libs/sqf/tests/lints/s06_find_in_str.sqf index d35ed088..97e87e20 100644 --- a/libs/sqf/tests/lints/s06_find_in_str.sqf +++ b/libs/sqf/tests/lints/s06_find_in_str.sqf @@ -1,2 +1,2 @@ "foobar" find "bar" > -1; -private _hasBar = _things find "bar" > -1; +private _hasBar = things find "bar" > -1; diff --git a/libs/sqf/tests/lints/s12_invalid_args.sqf b/libs/sqf/tests/lints/s12_invalid_args.sqf new file mode 100644 index 00000000..c36004ae --- /dev/null +++ b/libs/sqf/tests/lints/s12_invalid_args.sqf @@ -0,0 +1,3 @@ +player setPos [0,1,0]; +alive player; +(vehicle player) setFuel true; // bad args: takes number 0-1 diff --git a/libs/sqf/tests/lints/s13_undefined.sqf b/libs/sqf/tests/lints/s13_undefined.sqf new file mode 100644 index 00000000..17a4d852 --- /dev/null +++ b/libs/sqf/tests/lints/s13_undefined.sqf @@ -0,0 +1 @@ +x = {systemChat _neverDefined;}; diff --git a/libs/sqf/tests/lints/s14_unused.sqf b/libs/sqf/tests/lints/s14_unused.sqf new file mode 100644 index 00000000..5c8f0821 --- /dev/null +++ b/libs/sqf/tests/lints/s14_unused.sqf @@ -0,0 +1,2 @@ +private _z = 5; // and never used +params ["_something"]; \ No newline at end of file diff --git a/libs/sqf/tests/lints/s15_shadowed.sqf b/libs/sqf/tests/lints/s15_shadowed.sqf new file mode 100644 index 00000000..c6af8b2b --- /dev/null +++ b/libs/sqf/tests/lints/s15_shadowed.sqf @@ -0,0 +1,4 @@ +private _z = 5; +private _z = 5; + +systemChat str _z; // dummy use diff --git a/libs/sqf/tests/lints/s16_not_private.sqf b/libs/sqf/tests/lints/s16_not_private.sqf new file mode 100644 index 00000000..8b7c5246 --- /dev/null +++ b/libs/sqf/tests/lints/s16_not_private.sqf @@ -0,0 +1,3 @@ +_z = 6; + +systemChat str _z; // dummy use diff --git a/libs/sqf/tests/optimizer.rs b/libs/sqf/tests/optimizer.rs index 953a5c82..8222e88c 100644 --- a/libs/sqf/tests/optimizer.rs +++ b/libs/sqf/tests/optimizer.rs @@ -31,7 +31,7 @@ fn optimize(file: &str) -> Statements { .unwrap(); let source = workspace.join(format!("{file}.sqf")).unwrap(); let processed = Processor::run(&source).unwrap(); - hemtt_sqf::parser::run(&Database::a3(false), &processed) - .unwrap() - .optimize() + let mut sqf = hemtt_sqf::parser::run(&Database::a3(false), &processed).unwrap(); + sqf.testing_clear_issues(); + sqf.optimize() } diff --git a/libs/sqf/tests/snapshots/lints__simple_s06_find_in_str.snap b/libs/sqf/tests/snapshots/lints__simple_s06_find_in_str.snap index 42e1733c..9d73e975 100644 --- a/libs/sqf/tests/snapshots/lints__simple_s06_find_in_str.snap +++ b/libs/sqf/tests/snapshots/lints__simple_s06_find_in_str.snap @@ -1,6 +1,6 @@ --- source: libs/sqf/tests/lints.rs -expression: lint(stringify! (s06_find_in_str)) +expression: "lint(stringify! (s06_find_in_str), true)" --- help[L-S06]: string search using `in` is faster than `find` ┌─ s06_find_in_str.sqf:1:1 @@ -14,7 +14,7 @@ expression: lint(stringify! (s06_find_in_str)) help[L-S06]: string search using `in` is faster than `find` ┌─ s06_find_in_str.sqf:2:19 │ -2 │ private _hasBar = _things find "bar" > -1; - │ ^^^^^^^^^^^^^^^^^^^^^^^ using `find` with -1 +2 │ private _hasBar = things find "bar" > -1; + │ ^^^^^^^^^^^^^^^^^^^^^^ using `find` with -1 │ - = try: "bar" in _things + = try: "bar" in things diff --git a/libs/sqf/tests/snapshots/lints__simple_s12_invalid_args.snap b/libs/sqf/tests/snapshots/lints__simple_s12_invalid_args.snap new file mode 100644 index 00000000..6babe1b4 --- /dev/null +++ b/libs/sqf/tests/snapshots/lints__simple_s12_invalid_args.snap @@ -0,0 +1,9 @@ +--- +source: libs/sqf/tests/lints.rs +expression: "lint(stringify! (s12_invalid_args), false)" +--- +help[L-S12]: Invalid Args - [B:setFuel] + ┌─ s12_invalid_args.sqf:3:18 + │ +3 │ (vehicle player) setFuel true; // bad args: takes number 0-1 + │ ^^^^^^^ diff --git a/libs/sqf/tests/snapshots/lints__simple_s13_undefined.snap b/libs/sqf/tests/snapshots/lints__simple_s13_undefined.snap new file mode 100644 index 00000000..71fe54bc --- /dev/null +++ b/libs/sqf/tests/snapshots/lints__simple_s13_undefined.snap @@ -0,0 +1,11 @@ +--- +source: libs/sqf/tests/lints.rs +expression: "lint(stringify! (s13_undefined), false)" +--- +help[L-S13]: Undefined Var - _neverDefined + ┌─ s13_undefined.sqf:1:17 + │ +1 │ x = {systemChat _neverDefined;}; + │ ^^^^^^^^^^^^^ + │ + = note: From Orphan Code - may not be a problem diff --git a/libs/sqf/tests/snapshots/lints__simple_s14_unused.snap b/libs/sqf/tests/snapshots/lints__simple_s14_unused.snap new file mode 100644 index 00000000..c68fafc5 --- /dev/null +++ b/libs/sqf/tests/snapshots/lints__simple_s14_unused.snap @@ -0,0 +1,9 @@ +--- +source: libs/sqf/tests/lints.rs +expression: "lint(stringify! (s14_unused), false)" +--- +help[L-S14]: Unused Var - _z + ┌─ s14_unused.sqf:1:1 + │ +1 │ private _z = 5; // and never used + │ ^^^^^^^^^^^^^^ diff --git a/libs/sqf/tests/snapshots/lints__simple_s15_shadowed.snap b/libs/sqf/tests/snapshots/lints__simple_s15_shadowed.snap new file mode 100644 index 00000000..ae970ed3 --- /dev/null +++ b/libs/sqf/tests/snapshots/lints__simple_s15_shadowed.snap @@ -0,0 +1,9 @@ +--- +source: libs/sqf/tests/lints.rs +expression: "lint(stringify! (s15_shadowed), false)" +--- +help[L-S15]: Shadowed Var - _z + ┌─ s15_shadowed.sqf:2:1 + │ +2 │ private _z = 5; + │ ^^^^^^^^^^^^^^ diff --git a/libs/sqf/tests/snapshots/lints__simple_s16_not_private.snap b/libs/sqf/tests/snapshots/lints__simple_s16_not_private.snap new file mode 100644 index 00000000..47dd8cd6 --- /dev/null +++ b/libs/sqf/tests/snapshots/lints__simple_s16_not_private.snap @@ -0,0 +1,9 @@ +--- +source: libs/sqf/tests/lints.rs +expression: "lint(stringify! (s16_not_private), false)" +--- +help[L-S16]: Not Private - _z + ┌─ s16_not_private.sqf:1:1 + │ +1 │ _z = 6; + │ ^^^^^^ diff --git a/libs/sqf/tests/snapshots/optimizer__simple_consume_array.snap b/libs/sqf/tests/snapshots/optimizer__simple_consume_array.snap index 3ea53afb..41eb91d6 100644 --- a/libs/sqf/tests/snapshots/optimizer__simple_consume_array.snap +++ b/libs/sqf/tests/snapshots/optimizer__simple_consume_array.snap @@ -300,6 +300,7 @@ Statements { ], source: "1;2;3;4;", span: 247..255, + issues: [], }, ), Code( @@ -326,6 +327,7 @@ Statements { ], source: "-1;-2;", span: 265..271, + issues: [], }, ), ], @@ -380,4 +382,5 @@ Statements { ], source: "params [\"_a\", \"_b\"];\n\nparams [\"_a\", \"_b\", [\"_c\", []]];\n\nmissionNamespace getVariable [\"a\", -1];\n\nz setVariable [\"b\", [], true];\n\n[1,0] vectorAdd p;\n\npositionCameraToWorld [10000, 0, 10000];\n\n\nrandom [0, _x, 1];\n\nprivate _z = if (time > 10) then { 1;2;3;4; } else { -1;-2; };\n\nparam [\"_d\"];\n\n[] param [\"_e\"];\n", span: 0..307, + issues: [], } diff --git a/libs/sqf/tests/snapshots/optimizer__simple_scalar.snap b/libs/sqf/tests/snapshots/optimizer__simple_scalar.snap index a1551d85..01cb2780 100644 --- a/libs/sqf/tests/snapshots/optimizer__simple_scalar.snap +++ b/libs/sqf/tests/snapshots/optimizer__simple_scalar.snap @@ -16,4 +16,5 @@ Statements { ], source: "-5;\n", span: 0..3, + issues: [], } diff --git a/libs/sqf/tests/snapshots/optimizer__simple_static_math.snap b/libs/sqf/tests/snapshots/optimizer__simple_static_math.snap index 0110c807..bd12765d 100644 --- a/libs/sqf/tests/snapshots/optimizer__simple_static_math.snap +++ b/libs/sqf/tests/snapshots/optimizer__simple_static_math.snap @@ -46,4 +46,5 @@ Statements { ], source: "1 + (2 * 2) + (36 % 31) + (36 / 6) + (sqrt 100) - 3;\n\nsqrt -100;\n\n\nz + z;\n", span: 0..73, + issues: [], } diff --git a/libs/sqf/tests/snapshots/optimizer__simple_string_case.snap b/libs/sqf/tests/snapshots/optimizer__simple_string_case.snap index e81e8564..3e6cabd8 100644 --- a/libs/sqf/tests/snapshots/optimizer__simple_string_case.snap +++ b/libs/sqf/tests/snapshots/optimizer__simple_string_case.snap @@ -15,4 +15,5 @@ Statements { ], source: "toLower \"A\" + toUpper \"b\" + toUpperAnsi \"C\" + toLowerAnsi \"d\";\n", span: 0..62, + issues: [], } diff --git a/libs/sqf/tests/snapshots/simple__simple_eventhandler-2.snap b/libs/sqf/tests/snapshots/simple__simple_eventhandler-2.snap index e09ece6f..09537998 100644 --- a/libs/sqf/tests/snapshots/simple__simple_eventhandler-2.snap +++ b/libs/sqf/tests/snapshots/simple__simple_eventhandler-2.snap @@ -27,6 +27,7 @@ expression: ast ], source: "deleteVehicle _x", span: 3..19, + issues: [], }, ), NularCommand( @@ -110,6 +111,7 @@ expression: ast ], source: "alive _x", span: 115..123, + issues: [], }, ), 106..112, @@ -140,6 +142,7 @@ expression: ast ], source: "deleteVehicle _x;", span: 149..166, + issues: [], }, ), NularCommand( @@ -155,6 +158,7 @@ expression: ast ], source: "allPlayers findIf { alive _x };\n {\n deleteVehicle _x;\n } forEach allPlayers;", span: 95..196, + issues: [], }, ), 80..84, @@ -164,6 +168,7 @@ expression: ast ], source: "if (alive player) then {\n allPlayers findIf { alive _x };\n {\n deleteVehicle _x;\n } forEach allPlayers;\n };", span: 62..203, + issues: [], }, ), ], @@ -242,6 +247,7 @@ expression: ast ], source: "deleteVehicle _x", span: 294..310, + issues: [], }, ), NularCommand( @@ -257,6 +263,7 @@ expression: ast ], source: "{ deleteVehicle _x } count allPlayers;", span: 292..330, + issues: [], }, ), 277..281, @@ -266,6 +273,7 @@ expression: ast ], source: "if (alive player) then {\n { deleteVehicle _x } count allPlayers;\n };", span: 259..337, + issues: [], }, ), ], diff --git a/libs/sqf/tests/snapshots/simple__simple_foreach-2.snap b/libs/sqf/tests/snapshots/simple__simple_foreach-2.snap index 24bc3e96..96cab024 100644 --- a/libs/sqf/tests/snapshots/simple__simple_foreach-2.snap +++ b/libs/sqf/tests/snapshots/simple__simple_foreach-2.snap @@ -27,6 +27,7 @@ expression: ast ], source: "deleteVehicle _x;", span: 7..24, + issues: [], }, ), NularCommand( @@ -106,6 +107,7 @@ expression: ast ], source: "_x setDamage 1;", span: 97..112, + issues: [], }, ), UnaryCommand( @@ -125,6 +127,7 @@ expression: ast ], source: "systemChat format [\"%1\", _x];\n {\n _x setDamage 1;\n } forEach crew _x;", span: 53..135, + issues: [], }, ), NularCommand( diff --git a/libs/sqf/tests/snapshots/simple__simple_get_visibility-2.snap b/libs/sqf/tests/snapshots/simple__simple_get_visibility-2.snap index 0e168ddf..3c935a5e 100644 --- a/libs/sqf/tests/snapshots/simple__simple_get_visibility-2.snap +++ b/libs/sqf/tests/snapshots/simple__simple_get_visibility-2.snap @@ -86,6 +86,7 @@ expression: ast ], source: "_arg1 = [eyePos _arg1, _arg1]", span: 66..95, + issues: [], }, ), 59..63, @@ -151,6 +152,7 @@ expression: ast ], source: "_arg2 = [eyePos _arg2, _arg2]", span: 138..167, + issues: [], }, ), 131..135, diff --git a/libs/sqf/tests/snapshots/simple__simple_include-2.snap b/libs/sqf/tests/snapshots/simple__simple_include-2.snap index 726ce909..1adfd6f8 100644 --- a/libs/sqf/tests/snapshots/simple__simple_include-2.snap +++ b/libs/sqf/tests/snapshots/simple__simple_include-2.snap @@ -130,6 +130,7 @@ expression: ast ], source: "private thinghi = _x + \"test\";", span: 94..124, + issues: [], }, ), Array( diff --git a/libs/sqf/tests/snapshots/simple__simple_semicolons-2.snap b/libs/sqf/tests/snapshots/simple__simple_semicolons-2.snap index ede82283..a982af3d 100644 --- a/libs/sqf/tests/snapshots/simple__simple_semicolons-2.snap +++ b/libs/sqf/tests/snapshots/simple__simple_semicolons-2.snap @@ -78,6 +78,7 @@ expression: ast ], source: "systemChat \"this is a test\";", span: 153..181, + issues: [], }, ), 136..140,