1use std::path::Path;
21
22use alint_core::{
23 Context, Error, Level, PerFileRule, Result, Rule, RuleSpec, Scope, Violation, eval_per_file,
24};
25use serde::Deserialize;
26
27#[derive(Debug, Deserialize)]
28#[serde(deny_unknown_fields)]
29struct Options {
30 style: StyleName,
31 #[serde(default)]
32 width: Option<u32>,
33}
34
35#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "lowercase")]
37enum StyleName {
38 Tabs,
39 Spaces,
40}
41
42#[derive(Debug)]
43pub struct IndentStyleRule {
44 id: String,
45 level: Level,
46 policy_url: Option<String>,
47 message: Option<String>,
48 scope: Scope,
49 style: StyleName,
50 width: Option<u32>,
51}
52
53impl Rule for IndentStyleRule {
54 alint_core::rule_common_impl!();
55
56 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
57 eval_per_file(self, ctx)
58 }
59
60 fn as_per_file(&self) -> Option<&dyn PerFileRule> {
61 Some(self)
62 }
63}
64
65impl PerFileRule for IndentStyleRule {
66 fn path_scope(&self) -> &Scope {
67 &self.scope
68 }
69
70 fn evaluate_file(
71 &self,
72 _ctx: &Context<'_>,
73 path: &Path,
74 bytes: &[u8],
75 ) -> Result<Vec<Violation>> {
76 let Ok(text) = std::str::from_utf8(bytes) else {
81 return Ok(Vec::new());
82 };
83 let Some((line_no, reason)) = first_bad_line(text, self.style, self.width) else {
84 return Ok(Vec::new());
85 };
86 let msg = self.message.clone().unwrap_or_else(|| match reason {
87 BadReason::WrongChar => format!(
88 "line {line_no} indented with the wrong character (expected {})",
89 self.style_name()
90 ),
91 BadReason::WidthMismatch => format!(
92 "line {line_no} has leading spaces that are not a multiple of {}",
93 self.width.unwrap_or(0),
94 ),
95 });
96 Ok(vec![
97 Violation::new(msg)
98 .with_path(std::sync::Arc::<Path>::from(path))
99 .with_location(line_no, 1),
100 ])
101 }
102}
103
104impl IndentStyleRule {
105 fn style_name(&self) -> &'static str {
106 match self.style {
107 StyleName::Tabs => "tabs",
108 StyleName::Spaces => "spaces",
109 }
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114enum BadReason {
115 WrongChar,
116 WidthMismatch,
117}
118
119fn first_bad_line(text: &str, style: StyleName, width: Option<u32>) -> Option<(usize, BadReason)> {
124 for (idx, line) in text.split('\n').enumerate() {
125 let body = line.strip_suffix('\r').unwrap_or(line);
126 let lead: &str = body
127 .char_indices()
128 .find(|(_, c)| *c != ' ' && *c != '\t')
129 .map_or(body, |(i, _)| &body[..i]);
130 if lead.len() == body.len() {
132 continue;
133 }
134 let line_no = idx + 1;
135 match style {
136 StyleName::Tabs => {
137 if lead.bytes().any(|b| b == b' ') {
138 return Some((line_no, BadReason::WrongChar));
139 }
140 }
141 StyleName::Spaces => {
142 if lead.bytes().any(|b| b == b'\t') {
143 return Some((line_no, BadReason::WrongChar));
144 }
145 if let Some(w) = width
146 && w > 0
147 && lead.len() % (w as usize) != 0
148 {
149 return Some((line_no, BadReason::WidthMismatch));
150 }
151 }
152 }
153 }
154 None
155}
156
157pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
158 let _paths = spec
159 .paths
160 .as_ref()
161 .ok_or_else(|| Error::rule_config(&spec.id, "indent_style requires a `paths` field"))?;
162 let opts: Options = spec
163 .deserialize_options()
164 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
165 if spec.fix.is_some() {
166 return Err(Error::rule_config(
167 &spec.id,
168 "indent_style has no fix op — tab-width-aware reindentation is deferred",
169 ));
170 }
171 Ok(Box::new(IndentStyleRule {
172 id: spec.id.clone(),
173 level: spec.level,
174 policy_url: spec.policy_url.clone(),
175 message: spec.message.clone(),
176 scope: Scope::from_spec(spec)?,
177 style: opts.style,
178 width: opts.width,
179 }))
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn tabs_style_accepts_pure_tab_indent() {
188 assert_eq!(
189 first_bad_line("fn x() {\n\tlet a = 1;\n}\n", StyleName::Tabs, None),
190 None
191 );
192 }
193
194 #[test]
195 fn tabs_style_flags_space_indent() {
196 let (line, reason) =
197 first_bad_line("fn x() {\n let a = 1;\n}\n", StyleName::Tabs, None).unwrap();
198 assert_eq!(line, 2);
199 assert_eq!(reason, BadReason::WrongChar);
200 }
201
202 #[test]
203 fn spaces_style_accepts_pure_space_indent() {
204 assert_eq!(
205 first_bad_line("x:\n a: 1\n b: 2\n", StyleName::Spaces, Some(2)),
206 None
207 );
208 }
209
210 #[test]
211 fn spaces_style_flags_tab_indent() {
212 let (line, reason) = first_bad_line("x:\n\ta: 1\n", StyleName::Spaces, Some(2)).unwrap();
213 assert_eq!(line, 2);
214 assert_eq!(reason, BadReason::WrongChar);
215 }
216
217 #[test]
218 fn spaces_style_flags_width_mismatch() {
219 let (line, reason) = first_bad_line("x:\n a: 1\n", StyleName::Spaces, Some(2)).unwrap();
220 assert_eq!(line, 2);
221 assert_eq!(reason, BadReason::WidthMismatch);
222 }
223
224 #[test]
225 fn blank_lines_are_not_judged() {
226 assert_eq!(first_bad_line("\n \na\n", StyleName::Tabs, None), None);
227 }
228
229 #[test]
230 fn crlf_is_handled() {
231 assert_eq!(
232 first_bad_line("a\r\n b\r\n", StyleName::Spaces, Some(2)),
233 None
234 );
235 }
236}