Skip to main content

sqrust_rules/convention/
leading_zero_numeric.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2
3use crate::capitalisation::SkipMap;
4
5pub struct LeadingZeroNumeric;
6
7/// Characters that can immediately precede a bare `.N` numeric literal.
8/// A dot preceded by anything else (letter, digit, `_`) is not a numeric literal.
9const TRIGGER_CHARS: &[u8] = &[
10    b' ', b'\t', b'\n', b'\r', // whitespace
11    b'(', b')', b'[', b']',    // brackets
12    b'=', b'<', b'>',          // comparison operators
13    b'+', b'-', b'*', b'/',    // arithmetic operators
14    b',', b';',                // punctuation
15    b'!',                      // negation (for !=)
16];
17
18impl Rule for LeadingZeroNumeric {
19    fn name(&self) -> &'static str {
20        "Convention/LeadingZeroNumeric"
21    }
22
23    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
24        let source = &ctx.source;
25        let bytes = source.as_bytes();
26        let len = bytes.len();
27        let skip_map = SkipMap::build(source);
28
29        let mut diags = Vec::new();
30
31        for i in 0..len {
32            // Only consider '.' characters in code (not inside strings/comments).
33            if bytes[i] != b'.' || !skip_map.is_code(i) {
34                continue;
35            }
36
37            // The character after '.' must be a digit.
38            if i + 1 >= len || !bytes[i + 1].is_ascii_digit() {
39                continue;
40            }
41
42            // The character before '.' must be a trigger character or we are
43            // at the start of file. A letter, digit, or underscore before '.'
44            // means this is a qualified identifier (t.col) or a decimal number
45            // (1.5), neither of which should be flagged.
46            let preceded_by_trigger = if i == 0 {
47                true
48            } else {
49                let prev = bytes[i - 1];
50                TRIGGER_CHARS.contains(&prev)
51            };
52
53            if !preceded_by_trigger {
54                continue;
55            }
56
57            let (line, col) = line_col(source, i);
58            diags.push(Diagnostic {
59                rule: self.name(),
60                message: "Numeric literal missing leading zero (e.g. use 0.5 instead of .5)"
61                    .to_string(),
62                line,
63                col,
64            });
65        }
66
67        diags
68    }
69}
70
71/// Converts a byte offset in `source` to a 1-indexed (line, col) pair.
72fn line_col(source: &str, offset: usize) -> (usize, usize) {
73    let before = &source[..offset];
74    let line = before.chars().filter(|&c| c == '\n').count() + 1;
75    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
76    (line, col)
77}