From cc649c827abc9006d63545aee447b1f1e032880d Mon Sep 17 00:00:00 2001 From: PabstMirror Date: Fri, 28 Aug 2026 01:33:42 -0500 Subject: [PATCH 1/2] sqf: Add S53 - Detect substring comparison len mismatch --- .../analyze/lints/s53_select_substring_len.rs | 157 ++++++++++++++++++ libs/sqf/tests/lints.rs | 1 + .../tests/lints/s53_select_substring_len.sqf | 7 + ...ints__simple_s53_select_substring_len.snap | 23 +++ 4 files changed, 188 insertions(+) create mode 100644 libs/sqf/src/analyze/lints/s53_select_substring_len.rs create mode 100644 libs/sqf/tests/lints/s53_select_substring_len.sqf create mode 100644 libs/sqf/tests/snapshots/lints__simple_s53_select_substring_len.snap diff --git a/libs/sqf/src/analyze/lints/s53_select_substring_len.rs b/libs/sqf/src/analyze/lints/s53_select_substring_len.rs new file mode 100644 index 00000000..1c58e55b --- /dev/null +++ b/libs/sqf/src/analyze/lints/s53_select_substring_len.rs @@ -0,0 +1,157 @@ +use std::{ops::Range, sync::Arc}; + +use hemtt_common::config::LintConfig; +use hemtt_workspace::{ + lint::{AnyLintRunner, Lint, LintRunner}, + reporting::{Code, Codes, Diagnostic, Processed, Severity}, +}; + +use crate::{ + BinaryCommand::{self}, Expression, analyze::LintData, +}; + +crate::analyze::lint!(LintS53SelectSubstringLen); + +impl Lint for LintS53SelectSubstringLen { + fn ident(&self) -> &'static str { + "select_substring_len" + } + fn sort(&self) -> u32 { + 530 + } + fn description(&self) -> &'static str { + "Checks for substring length mismatch when using `select`" + } + fn documentation(&self) -> &'static str { + r#"### Example + +**Incorrect** +```sqf +if (((currentWeapon player) select [0, 3]) == "ABC_") then {}; +``` + +**Correct** +```sqf +if (((currentWeapon player) select [0, 4]) == "ABC_") then {}; +``` +"# + } + fn default_config(&self) -> LintConfig { + LintConfig::help() + } + fn runners(&self) -> Vec>> { + vec![Box::new(Runner)] + } +} + +struct Runner; +impl LintRunner for Runner { + type Target = crate::Expression; + + fn run( + &self, + _project: Option<&hemtt_common::config::ProjectConfig>, + config: &LintConfig, + processed: Option<&hemtt_workspace::reporting::Processed>, + _runtime: &hemtt_common::config::RuntimeArguments, + target: &Self::Target, + _data: &LintData, + ) -> Codes { + fn match_pair(e1: &Expression, e2: &Expression) -> Option<(usize, usize)> { + let Expression::String(str, _, _) = e1 else { + return None; + }; + let Expression::BinaryCommand(BinaryCommand::Named(cmd), _sel_lhs, sel_rhs, _) = e2 else { + return None; + }; + if !cmd.eq_ignore_ascii_case("select") { + return None; + } + let Expression::Array(arr, _) = sel_rhs.as_ref() else { + return None; + }; + if arr.len() != 2 { + return None; + } + let Expression::Number(sel_len, _) = arr[1] else { + return None; + }; + let str_len = str.len(); + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let sel_len = sel_len.0.round() as usize; + println!("str_len: {str_len}, sel_len: {sel_len}"); + if str_len == sel_len { + return None; + } + Some((str_len, sel_len)) + } + + let Some(processed) = processed else { + return Vec::new(); + }; + let Expression::BinaryCommand(bcmd, lhs, rhs, span) = target else { + return Vec::new(); + }; + if !(bcmd == &BinaryCommand::Eq || bcmd == &BinaryCommand::NotEq || bcmd.as_str().eq_ignore_ascii_case("isEqualTo") || bcmd.as_str().eq_ignore_ascii_case("isNotEqualTo")) { + return Vec::new(); + } + let len_pair = match_pair(lhs, rhs).or_else(|| match_pair(rhs, lhs)); + let Some((str_len, sel_len)) = len_pair else { + return Vec::new(); + }; + vec![Arc::new(CodeS53SelectSubstringLen::new( + span.clone(), + str_len, sel_len, + config.severity(), + processed, + ))] + } +} + +#[allow(clippy::module_name_repetitions)] +pub struct CodeS53SelectSubstringLen { + span: Range, + str_len: usize, + sel_len: usize, + severity: Severity, + diagnostic: Option, +} + +impl Code for CodeS53SelectSubstringLen { + fn ident(&self) -> &'static str { + "L-S53" + } + fn link(&self) -> Option<&str> { + Some("/lints/sqf.html#select_substring_len") + } + fn severity(&self) -> Severity { + self.severity + } + fn message(&self) -> String { + "Select substring length does not match string length".to_string() + } + fn label_message(&self) -> String { + format!("substring is length {} | string is length {}", self.sel_len, self.str_len) + } + fn diagnostic(&self) -> Option { + self.diagnostic.clone() + } +} + +impl CodeS53SelectSubstringLen { + #[must_use] + pub fn new(span: Range, str_len: usize, sel_len: usize, severity: Severity, processed: &Processed) -> Self { + Self { + span, + str_len, + sel_len, + 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/tests/lints.rs b/libs/sqf/tests/lints.rs index 0fbfafec..4518959b 100644 --- a/libs/sqf/tests/lints.rs +++ b/libs/sqf/tests/lints.rs @@ -87,6 +87,7 @@ lint!(s48_is_equal_type_all, true); lint!(s49_count_type, true); lint!(s50_count_side, true); lint!(s51_push_back_unique, true); +lint!(s53_select_substring_len, true); #[test] fn test_s29_function_undefined() { diff --git a/libs/sqf/tests/lints/s53_select_substring_len.sqf b/libs/sqf/tests/lints/s53_select_substring_len.sqf new file mode 100644 index 00000000..8b9790d9 --- /dev/null +++ b/libs/sqf/tests/lints/s53_select_substring_len.sqf @@ -0,0 +1,7 @@ +if (((currentWeapon player) select [0, 3]) == "ABC_") then {}; +if (x1 select [0, 3] isNotEqualTo "CasE") then {}; +"prefix" == addon_name select [0, 999]; + + +x1 select [0, 3] == "ABC"; // fine +x2 select [3] == "something"; // fine diff --git a/libs/sqf/tests/snapshots/lints__simple_s53_select_substring_len.snap b/libs/sqf/tests/snapshots/lints__simple_s53_select_substring_len.snap new file mode 100644 index 00000000..9dddd218 --- /dev/null +++ b/libs/sqf/tests/snapshots/lints__simple_s53_select_substring_len.snap @@ -0,0 +1,23 @@ +--- +source: libs/sqf/tests/lints.rs +expression: "lint(stringify! (s53_select_substring_len), true).0" +--- +help[L-S53]: Select substring length does not match string length + ┌─ s53_select_substring_len.sqf:1:44 + │ +1 │ if (((currentWeapon player) select [0, 3]) == "ABC_") then {}; + │ ^^ substring is length 3 | string is length 4 + + +help[L-S53]: Select substring length does not match string length + ┌─ s53_select_substring_len.sqf:2:22 + │ +2 │ if (x1 select [0, 3] isNotEqualTo "CasE") then {}; + │ ^^^^^^^^^^^^ substring is length 3 | string is length 4 + + +help[L-S53]: Select substring length does not match string length + ┌─ s53_select_substring_len.sqf:3:10 + │ +3 │ "prefix" == addon_name select [0, 999]; + │ ^^ substring is length 999 | string is length 6 From e6d4c50c30978b9a4884927583a66fa5cc1352e3 Mon Sep 17 00:00:00 2001 From: PabstMirror Date: Fri, 28 Aug 2026 01:42:49 -0500 Subject: [PATCH 2/2] Apply suggestion from @PabstMirror --- libs/sqf/src/analyze/lints/s53_select_substring_len.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/sqf/src/analyze/lints/s53_select_substring_len.rs b/libs/sqf/src/analyze/lints/s53_select_substring_len.rs index 1c58e55b..7dba8e80 100644 --- a/libs/sqf/src/analyze/lints/s53_select_substring_len.rs +++ b/libs/sqf/src/analyze/lints/s53_select_substring_len.rs @@ -79,7 +79,6 @@ impl LintRunner for Runner { let str_len = str.len(); #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] let sel_len = sel_len.0.round() as usize; - println!("str_len: {str_len}, sel_len: {sel_len}"); if str_len == sel_len { return None; }