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 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
110/// Return the 1-based line number of the first line whose leading
111/// whitespace violates the configured style. Blank lines (empty or
112/// whitespace-only) are skipped so trailing indentation on an
113/// otherwise-blank line doesn't cause spurious failures.
114fn 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        // Blank / whitespace-only line: no indent to judge.
122        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}