sqrust_rules/ambiguous/
ambiguous_date_format.rs1use 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 if bytes[i] == b'\'' && (i == 0 || skip.is_code(i - 1)) {
27 let str_start = i;
28 i += 1;
31 let content_start = i;
32 while i < len && !skip.is_code(i) {
36 i += 1;
37 }
38 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
61fn is_ambiguous_slash_date(s: &[u8]) -> bool {
64 let s = trim_bytes(s);
66 if s.len() < 5 { return false; }
67
68 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; } if rest.is_empty() || rest[0] != b'/' { return false; }
79 let rest = &rest[1..];
80
81 let (seg2, rest) = read_digits(rest);
83 if seg2.is_empty() || seg2.len() > 2 { return false; }
84
85 if rest.is_empty() || rest[0] != b'/' { return false; }
87 let rest = &rest[1..];
88
89 let (seg3, rest) = read_digits(rest);
91 if !(seg3.len() == 2 || seg3.len() == 4) { return false; }
92
93 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}