Skip to main content

alint_rules/
indent_style.rs

1//! `indent_style` — every non-blank line in each file in scope must
2//! indent with the configured style: `tabs` or `spaces`.
3//!
4//! The check is byte-level and only inspects the *leading* run of
5//! whitespace on each line. Mid-line tabs or spaces are not the
6//! rule's business (many formatters use a mix for alignment after
7//! the indent column).
8//!
9//! Optional `width`: when `style: spaces`, the number of leading
10//! spaces must be an exact multiple of `width`. Ignored for
11//! `style: tabs` since a tab is a single character regardless of
12//! visual width.
13//!
14//! Check-only. Auto-converting tabs ↔ spaces requires knowing the
15//! visual tab width, and the correct conversion for continuation
16//! indentation is language-specific — so this rule flags but does
17//! not repair. Users typically pair it with their editor's own
18//! "reindent on save" feature.
19
20use 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        // The leading-indent scan inspects ASCII whitespace
77        // characters and uses `char_indices` to slice the prefix
78        // — we keep the UTF-8 validation pass for parity with
79        // the rule-major path. Non-UTF-8 files silently skip.
80        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
119/// Return the 1-based line number of the first line whose leading
120/// whitespace violates the configured style. Blank lines (empty or
121/// whitespace-only) are skipped so trailing indentation on an
122/// otherwise-blank line doesn't cause spurious failures.
123fn 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        // Blank / whitespace-only line: no indent to judge.
131        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}