sqrust_rules/layout/max_statement_length.rs
1use sqrust_core::{Diagnostic, FileContext, Rule};
2
3pub struct MaxStatementLength {
4 pub max_lines: usize,
5}
6
7impl Default for MaxStatementLength {
8 fn default() -> Self {
9 MaxStatementLength { max_lines: 50 }
10 }
11}
12
13impl Rule for MaxStatementLength {
14 fn name(&self) -> &'static str {
15 "Layout/MaxStatementLength"
16 }
17
18 fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
19 let mut diags = Vec::new();
20 let statements = split_statements(&ctx.source);
21
22 for (byte_offset, stmt_text) in statements {
23 let line_count = count_lines(stmt_text);
24 if line_count == 0 {
25 // Empty / whitespace-only statement — skip
26 continue;
27 }
28
29 if line_count > self.max_lines {
30 // Determine the 1-indexed line number where this statement starts
31 // in the full source by counting newlines before the statement.
32 let start_line = ctx.source[..byte_offset]
33 .chars()
34 .filter(|&c| c == '\n')
35 .count()
36 + 1;
37
38 // Find the first non-whitespace character column (1-indexed) on
39 // the starting line.
40 let start_col = ctx
41 .source
42 .lines()
43 .nth(start_line - 1)
44 .map(|l| {
45 l.chars()
46 .position(|c| !c.is_whitespace())
47 .map(|p| p + 1)
48 .unwrap_or(1)
49 })
50 .unwrap_or(1);
51
52 diags.push(Diagnostic {
53 rule: self.name(),
54 message: format!(
55 "Statement spans {} lines, exceeding the maximum of {} lines",
56 line_count, self.max_lines
57 ),
58 line: start_line,
59 col: start_col,
60 });
61 }
62 }
63
64 diags
65 }
66}
67
68/// Count lines spanned by the statement text (including trailing `;` line).
69/// Returns 0 for whitespace-only text so empty statements are skipped.
70fn count_lines(text: &str) -> usize {
71 if text.trim().is_empty() {
72 return 0;
73 }
74 // Trim only *leading* blank lines (a statement may have trailing content
75 // on the `;` line). We keep the trailing `;` line in the count.
76 // `lines()` splits on `\n` and includes partial lines at the end.
77 text.lines().count()
78}
79
80/// Split `source` into (byte_offset_of_start, statement_text) pairs.
81///
82/// Each returned slice extends *through* the `;` terminator (inclusive) so
83/// that the `;` line is counted as part of the statement span. Splitting
84/// respects single-quoted string literals so embedded `;` are not treated as
85/// terminators.
86///
87/// The trailing remainder (no `;`) is also returned if non-empty.
88fn split_statements(source: &str) -> Vec<(usize, &str)> {
89 let mut statements = Vec::new();
90 let mut start = 0usize;
91 let mut in_string = false;
92 let bytes = source.as_bytes();
93 let len = bytes.len();
94 let mut i = 0usize;
95
96 while i < len {
97 let b = bytes[i];
98
99 if in_string {
100 if b == b'\'' {
101 // Doubled-quote escape: ''
102 if i + 1 < len && bytes[i + 1] == b'\'' {
103 i += 2;
104 continue;
105 }
106 in_string = false;
107 }
108 } else {
109 match b {
110 b'\'' => {
111 in_string = true;
112 }
113 b';' => {
114 // Include the `;` in the statement text (end is i+1).
115 statements.push((start, &source[start..=i]));
116 start = i + 1;
117 }
118 _ => {}
119 }
120 }
121
122 i += 1;
123 }
124
125 // Remainder after last `;` (or entire source if no `;` present)
126 if start < source.len() {
127 let remainder = &source[start..];
128 if !remainder.trim().is_empty() {
129 statements.push((start, remainder));
130 }
131 }
132
133 statements
134}