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