Skip to main content

sqrust_rules/ambiguous/
floating_point_comparison.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2use crate::capitalisation::SkipMap;
3
4pub struct FloatingPointComparison;
5
6impl Rule for FloatingPointComparison {
7    fn name(&self) -> &'static str {
8        "Ambiguous/FloatingPointComparison"
9    }
10
11    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
12        let source = &ctx.source;
13        let bytes = source.as_bytes();
14        let len = bytes.len();
15        let skip = SkipMap::build(source);
16
17        let mut diags = Vec::new();
18        let mut i = 0;
19
20        while i < len {
21            if !skip.is_code(i) {
22                i += 1;
23                continue;
24            }
25
26            // Look for = != <> operators
27            let (is_eq, op_len) = if bytes[i] == b'=' && (i == 0 || (bytes[i - 1] != b'!' && bytes[i - 1] != b'<' && bytes[i - 1] != b'>')) {
28                (true, 1)
29            } else if i + 1 < len && bytes[i] == b'!' && bytes[i + 1] == b'=' {
30                (true, 2)
31            } else if i + 1 < len && bytes[i] == b'<' && bytes[i + 1] == b'>' {
32                (true, 2)
33            } else {
34                (false, 1)
35            };
36
37            if !is_eq {
38                i += 1;
39                continue;
40            }
41
42            let op_start = i;
43            i += op_len;
44
45            // Skip whitespace after operator
46            while i < len && (bytes[i] == b' ' || bytes[i] == b'\t' || bytes[i] == b'\n' || bytes[i] == b'\r') {
47                i += 1;
48            }
49
50            // Check if what follows is a float literal: optional sign, digits, '.', digits
51            let float_start = i;
52            // Skip optional sign
53            if i < len && (bytes[i] == b'+' || bytes[i] == b'-') {
54                i += 1;
55            }
56            // Must have at least one digit
57            let digit_start = i;
58            while i < len && bytes[i].is_ascii_digit() {
59                i += 1;
60            }
61            // Must have a '.'
62            if i < len && bytes[i] == b'.' && i > digit_start {
63                i += 1;
64                // Must have at least one digit after '.'
65                let frac_start = i;
66                while i < len && bytes[i].is_ascii_digit() {
67                    i += 1;
68                }
69                if i > frac_start {
70                    // Make sure it's not followed by more word chars (like 'e10' making it scientific)
71                    let followed_by_word = i < len && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'_');
72                    if !followed_by_word {
73                        // Confirmed float literal
74                        let (line, col) = offset_to_line_col(source, op_start);
75                        diags.push(Diagnostic {
76                            rule: self.name(),
77                            message: "Exact equality comparison with floating-point literal; floating-point values are imprecise — consider using a range check or ROUND()".to_string(),
78                            line,
79                            col,
80                        });
81                        continue;
82                    }
83                }
84            }
85            // Not a float — reset i to after operator
86            i = float_start;
87        }
88
89        diags
90    }
91}
92
93fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
94    let before = &source[..offset.min(source.len())];
95    let line = before.chars().filter(|&c| c == '\n').count() + 1;
96    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
97    (line, col)
98}