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::{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        // The leading-indent scan inspects ASCII whitespace
94        // characters and uses `char_indices` to slice the prefix
95        // — we keep the UTF-8 validation pass for parity with
96        // the rule-major path. Non-UTF-8 files silently skip.
97        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
136/// Return the 1-based line number of the first line whose leading
137/// whitespace violates the configured style. Blank lines (empty or
138/// whitespace-only) are skipped so trailing indentation on an
139/// otherwise-blank line doesn't cause spurious failures.
140fn 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        // Blank / whitespace-only line: no indent to judge.
148        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}