Skip to main content

sqrust_rules/layout/
space_before_comma.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2use crate::capitalisation::SkipMap;
3
4pub struct SpaceBeforeComma;
5
6impl Rule for SpaceBeforeComma {
7    fn name(&self) -> &'static str {
8        "Layout/SpaceBeforeComma"
9    }
10
11    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
12        let source = ctx.source.as_bytes();
13        let len = source.len();
14        if len == 0 {
15            return Vec::new();
16        }
17
18        let skip_map = SkipMap::build(&ctx.source);
19        let mut diags = Vec::new();
20
21        for i in 0..len {
22            if source[i] != b',' {
23                continue;
24            }
25            if !skip_map.is_code(i) {
26                continue;
27            }
28
29            // If no preceding character, nothing to check.
30            if i == 0 {
31                continue;
32            }
33
34            let prev = source[i - 1];
35            if prev != b' ' && prev != b'\t' {
36                // No space before comma — not a violation.
37                continue;
38            }
39
40            // There is at least one space/tab before the comma.
41            // Determine whether this is leading-comma style:
42            // scan backwards from (i-1) to find the nearest newline.
43            // If every byte between that newline and i is whitespace, it's
44            // a leading-comma — skip it.
45            let mut is_leading_comma = true;
46            let mut j = i.wrapping_sub(1);
47            loop {
48                let ch = source[j];
49                if ch == b'\n' {
50                    // Reached a newline — all chars between newline and comma
51                    // were whitespace, so this is leading-comma style.
52                    break;
53                }
54                if ch != b' ' && ch != b'\t' {
55                    // Found non-whitespace before the comma on the same line.
56                    is_leading_comma = false;
57                    break;
58                }
59                if j == 0 {
60                    // Reached start of file — everything before comma is whitespace.
61                    break;
62                }
63                j -= 1;
64            }
65
66            if is_leading_comma {
67                continue;
68            }
69
70            // Find the byte offset of the first space/tab before the comma.
71            // Walk backwards from i-1 to find the run of spaces/tabs.
72            let mut space_start = i - 1;
73            while space_start > 0
74                && (source[space_start - 1] == b' ' || source[space_start - 1] == b'\t')
75            {
76                space_start -= 1;
77            }
78
79            let (line, col) = byte_offset_to_line_col(&ctx.source, space_start);
80            diags.push(Diagnostic {
81                rule: self.name(),
82                message: "Remove space before comma".to_string(),
83                line,
84                col,
85            });
86        }
87
88        diags
89    }
90
91    fn fix(&self, ctx: &FileContext) -> Option<String> {
92        let source = ctx.source.as_bytes();
93        let len = source.len();
94        if len == 0 {
95            return None;
96        }
97
98        let skip_map = SkipMap::build(&ctx.source);
99        let mut result = Vec::with_capacity(len);
100        let mut changed = false;
101
102        // Collect the offsets of spaces that should be removed.
103        // We mark bytes to skip in a separate pass.
104        let mut remove = vec![false; len];
105
106        for i in 0..len {
107            if source[i] != b',' {
108                continue;
109            }
110            if !skip_map.is_code(i) {
111                continue;
112            }
113            if i == 0 {
114                continue;
115            }
116            let prev = source[i - 1];
117            if prev != b' ' && prev != b'\t' {
118                continue;
119            }
120
121            // Check for leading-comma style.
122            let mut is_leading_comma = true;
123            let mut j = i.wrapping_sub(1);
124            loop {
125                let ch = source[j];
126                if ch == b'\n' {
127                    break;
128                }
129                if ch != b' ' && ch != b'\t' {
130                    is_leading_comma = false;
131                    break;
132                }
133                if j == 0 {
134                    break;
135                }
136                j -= 1;
137            }
138
139            if is_leading_comma {
140                continue;
141            }
142
143            // Mark the run of spaces/tabs before the comma for removal.
144            let mut space_start = i - 1;
145            while space_start > 0
146                && (source[space_start - 1] == b' ' || source[space_start - 1] == b'\t')
147            {
148                space_start -= 1;
149            }
150            for k in space_start..i {
151                remove[k] = true;
152                changed = true;
153            }
154        }
155
156        if !changed {
157            return None;
158        }
159
160        for (idx, &byte) in source.iter().enumerate() {
161            if !remove[idx] {
162                result.push(byte);
163            }
164        }
165
166        Some(String::from_utf8(result).expect("source was valid UTF-8"))
167    }
168}
169
170/// Converts a byte offset into a 1-indexed (line, col) pair.
171fn byte_offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
172    let mut line = 1usize;
173    let mut line_start = 0usize;
174    for (i, ch) in source.char_indices() {
175        if i == offset {
176            break;
177        }
178        if ch == '\n' {
179            line += 1;
180            line_start = i + 1;
181        }
182    }
183    let col = offset - line_start + 1;
184    (line, col)
185}