Skip to main content

sqrust_rules/ambiguous/
ambiguous_date_format.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2use crate::capitalisation::SkipMap;
3
4pub struct AmbiguousDateFormat;
5
6impl Rule for AmbiguousDateFormat {
7    fn name(&self) -> &'static str {
8        "Ambiguous/AmbiguousDateFormat"
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            // Find a single-quote that begins a string literal.
22            // The SkipMap marks the opening quote as skip (not code), but the
23            // byte before it (or the start of source) is code.
24            // A string start is detected when: bytes[i] == '\'' AND
25            //   (i == 0 OR skip.is_code(i - 1))
26            if bytes[i] == b'\'' && (i == 0 || skip.is_code(i - 1)) {
27                let str_start = i;
28                // Step past the opening quote — the SkipMap already consumed the whole string,
29                // so find the closing quote by advancing through non-code bytes.
30                i += 1;
31                let content_start = i;
32                // Walk forward while the bytes are inside the string (skip == true)
33                // or until we hit the matching closing quote.
34                // The closing quote is also marked as skip, and the byte after it is code again.
35                while i < len && !skip.is_code(i) {
36                    i += 1;
37                }
38                // At this point i is either past the end or at a code byte.
39                // The closing quote was at i - 1 (marked skip).
40                let content_end = if i > content_start { i - 1 } else { content_start };
41                let content = &bytes[content_start..content_end];
42
43                if is_ambiguous_slash_date(content) {
44                    let (line, col) = offset_to_line_col(source, str_start);
45                    diags.push(Diagnostic {
46                        rule: self.name(),
47                        message: "Date literal uses slash-separated format which is locale-dependent (MM/DD vs DD/MM); use ISO 8601 format ('YYYY-MM-DD') instead".to_string(),
48                        line,
49                        col,
50                    });
51                }
52                continue;
53            }
54            i += 1;
55        }
56
57        diags
58    }
59}
60
61/// Returns true if `s` looks like an ambiguous slash-separated date.
62/// Pattern: 1-2 digits / 1-2 digits / 2-4 digits, where the first segment <= 31 (not a year).
63fn is_ambiguous_slash_date(s: &[u8]) -> bool {
64    // Trim whitespace
65    let s = trim_bytes(s);
66    if s.len() < 5 { return false; }
67
68    // Parse first segment
69    let (seg1, rest) = read_digits(s);
70    if seg1.is_empty() || seg1.len() > 2 { return false; }
71    let n1: u32 = match std::str::from_utf8(seg1).ok().and_then(|s| s.parse().ok()) {
72        Some(v) => v,
73        None => return false,
74    };
75    if n1 > 31 { return false; } // year-first format — not ambiguous
76
77    // Must have slash
78    if rest.is_empty() || rest[0] != b'/' { return false; }
79    let rest = &rest[1..];
80
81    // Parse second segment
82    let (seg2, rest) = read_digits(rest);
83    if seg2.is_empty() || seg2.len() > 2 { return false; }
84
85    // Must have slash
86    if rest.is_empty() || rest[0] != b'/' { return false; }
87    let rest = &rest[1..];
88
89    // Parse third segment (year: exactly 2 or 4 digits)
90    let (seg3, rest) = read_digits(rest);
91    if !(seg3.len() == 2 || seg3.len() == 4) { return false; }
92
93    // Must be end of string (allow time component after space)
94    if !rest.is_empty() && rest[0] != b' ' && rest[0] != b'T' {
95        return false;
96    }
97
98    true
99}
100
101fn read_digits(s: &[u8]) -> (&[u8], &[u8]) {
102    let end = s.iter().position(|&b| !b.is_ascii_digit()).unwrap_or(s.len());
103    (&s[..end], &s[end..])
104}
105
106fn trim_bytes(s: &[u8]) -> &[u8] {
107    let start = s.iter().position(|&b| b != b' ' && b != b'\t').unwrap_or(0);
108    let end = s.iter().rposition(|&b| b != b' ' && b != b'\t').map(|i| i + 1).unwrap_or(0);
109    if start >= end { &[] } else { &s[start..end] }
110}
111
112fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
113    let before = &source[..offset.min(source.len())];
114    let line = before.chars().filter(|&c| c == '\n').count() + 1;
115    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
116    (line, col)
117}