Skip to main content

sqrust_rules/layout/
comment_spacing.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2
3pub struct CommentSpacing;
4
5impl Rule for CommentSpacing {
6    fn name(&self) -> &'static str {
7        "Layout/CommentSpacing"
8    }
9
10    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
11        find_violations(&ctx.source, self.name())
12    }
13
14    fn fix(&self, ctx: &FileContext) -> Option<String> {
15        let violations = find_violations(&ctx.source, self.name());
16        if violations.is_empty() {
17            return None;
18        }
19
20        // Build a set of byte offsets where we need to insert a space.
21        // Each violation points to the first `-` of `--`. We need to insert
22        // a space at offset+2 (right after `--`).
23        let bytes = ctx.source.as_bytes();
24        let len = bytes.len();
25
26        // Collect insert positions (byte offset of the character right after `--`).
27        let mut inserts: Vec<usize> = Vec::new();
28        // Re-scan to get byte offsets directly rather than going via line/col.
29        let mut i = 0;
30        let mut in_string = false;
31        let mut block_depth: usize = 0;
32
33        while i < len {
34            // Single-quoted string
35            if !in_string && block_depth == 0 && bytes[i] == b'\'' {
36                in_string = true;
37                i += 1;
38                continue;
39            }
40            if in_string {
41                if bytes[i] == b'\'' {
42                    // SQL '' escape
43                    if i + 1 < len && bytes[i + 1] == b'\'' {
44                        i += 2;
45                        continue;
46                    }
47                    in_string = false;
48                }
49                i += 1;
50                continue;
51            }
52
53            // Block comment open
54            if block_depth == 0 && i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
55                block_depth += 1;
56                i += 2;
57                continue;
58            }
59            // Block comment close
60            if block_depth > 0 {
61                if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'/' {
62                    block_depth -= 1;
63                    i += 2;
64                } else {
65                    i += 1;
66                }
67                continue;
68            }
69
70            // Line comment: `--`
71            if i + 1 < len && bytes[i] == b'-' && bytes[i + 1] == b'-' {
72                let after = i + 2;
73                // Check the byte immediately after `--`
74                let next_byte = if after < len { Some(bytes[after]) } else { None };
75                match next_byte {
76                    // `---` or more dashes → divider, exempt
77                    Some(b'-') => {}
78                    // Space, newline, or EOF → OK (empty comment or has space)
79                    None | Some(b' ') | Some(b'\n') | Some(b'\r') | Some(b'\t') => {}
80                    // Any other character → violation; record insert position
81                    Some(_) => {
82                        inserts.push(after);
83                    }
84                }
85                // Skip to end of line
86                i += 2;
87                while i < len && bytes[i] != b'\n' {
88                    i += 1;
89                }
90                continue;
91            }
92
93            i += 1;
94        }
95
96        if inserts.is_empty() {
97            return None;
98        }
99
100        // Build fixed string by inserting a space at each recorded position.
101        // inserts is in ascending order because we scanned left-to-right.
102        let mut result = Vec::with_capacity(len + inserts.len());
103        let mut prev = 0;
104        for &pos in &inserts {
105            result.extend_from_slice(&bytes[prev..pos]);
106            result.push(b' ');
107            prev = pos;
108        }
109        result.extend_from_slice(&bytes[prev..]);
110
111        Some(String::from_utf8(result).expect("source was valid UTF-8"))
112    }
113}
114
115/// Scan `source` and return Diagnostics for every `--` that is:
116/// - outside single-quoted strings and block comments
117/// - immediately followed by a non-space, non-newline, non-dash character
118fn find_violations(source: &str, rule_name: &'static str) -> Vec<Diagnostic> {
119    let bytes = source.as_bytes();
120    let len = bytes.len();
121    let mut diags = Vec::new();
122
123    let mut i = 0;
124    let mut in_string = false;
125    let mut block_depth: usize = 0;
126
127    while i < len {
128        // Single-quoted string
129        if !in_string && block_depth == 0 && bytes[i] == b'\'' {
130            in_string = true;
131            i += 1;
132            continue;
133        }
134        if in_string {
135            if bytes[i] == b'\'' {
136                // SQL '' escape
137                if i + 1 < len && bytes[i + 1] == b'\'' {
138                    i += 2;
139                    continue;
140                }
141                in_string = false;
142            }
143            i += 1;
144            continue;
145        }
146
147        // Block comment open
148        if block_depth == 0 && i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
149            block_depth += 1;
150            i += 2;
151            continue;
152        }
153        // Block comment content / close
154        if block_depth > 0 {
155            if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'/' {
156                block_depth -= 1;
157                i += 2;
158            } else {
159                i += 1;
160            }
161            continue;
162        }
163
164        // Line comment: `--`
165        if i + 1 < len && bytes[i] == b'-' && bytes[i + 1] == b'-' {
166            let after = i + 2;
167            let next_byte = if after < len { Some(bytes[after]) } else { None };
168            match next_byte {
169                // `---` or more dashes → divider, exempt
170                Some(b'-') => {}
171                // Space, tab, newline, CR, or EOF → OK
172                None | Some(b' ') | Some(b'\t') | Some(b'\n') | Some(b'\r') => {}
173                // Any other character → violation
174                Some(_) => {
175                    let (line, col) = byte_offset_to_line_col(source, i);
176                    diags.push(Diagnostic {
177                        rule: rule_name,
178                        message:
179                            "Line comment should have a space after '--'; write '-- comment'"
180                                .to_string(),
181                        line,
182                        col,
183                    });
184                }
185            }
186            // Skip to end of line
187            i += 2;
188            while i < len && bytes[i] != b'\n' {
189                i += 1;
190            }
191            continue;
192        }
193
194        i += 1;
195    }
196
197    diags
198}
199
200/// Converts a byte offset into a 1-indexed (line, col) pair.
201fn byte_offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
202    let mut line = 1usize;
203    let mut line_start = 0usize;
204    for (i, ch) in source.char_indices() {
205        if i == offset {
206            break;
207        }
208        if ch == '\n' {
209            line += 1;
210            line_start = i + 1;
211        }
212    }
213    let col = offset - line_start + 1;
214    (line, col)
215}