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