Skip to main content

aft/
indent.rs

1//! Shared indentation detection utility (D042).
2//!
3//! Analyzes source file content to determine the indentation style (tabs vs
4//! spaces, width) used. Falls back to language-specific defaults when the
5//! file has insufficient indented lines or mixed signals.
6
7use crate::parser::LangId;
8
9/// Detected indentation style.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum IndentStyle {
12    Tabs,
13    Spaces(u8),
14}
15
16impl IndentStyle {
17    /// Returns the whitespace string for one level of this indent.
18    pub fn as_str(&self) -> &'static str {
19        match self {
20            IndentStyle::Tabs => "\t",
21            IndentStyle::Spaces(2) => "  ",
22            IndentStyle::Spaces(4) => "    ",
23            IndentStyle::Spaces(8) => "        ",
24            IndentStyle::Spaces(n) => {
25                // For uncommon widths, leak a static string. In practice
26                // this only fires for exotic indent widths (1, 3, 5, 6, 7).
27                let s: String = " ".repeat(*n as usize);
28                Box::leak(s.into_boxed_str())
29            }
30        }
31    }
32
33    /// Language-specific default when detection has low confidence.
34    pub fn default_for(lang: LangId) -> Self {
35        match lang {
36            LangId::Python => IndentStyle::Spaces(4),
37            LangId::TypeScript
38            | LangId::Tsx
39            | LangId::JavaScript
40            | LangId::Vue
41            | LangId::Json
42            | LangId::Scala
43            | LangId::Ruby
44            | LangId::Lua
45            | LangId::Scss
46            | LangId::Yaml
47            | LangId::Toml => IndentStyle::Spaces(2),
48            LangId::Rust => IndentStyle::Spaces(4),
49            LangId::Go => IndentStyle::Tabs,
50            LangId::C
51            | LangId::Cpp
52            | LangId::Cuda
53            | LangId::Metal
54            | LangId::Zig
55            | LangId::CSharp
56            | LangId::Bash => IndentStyle::Spaces(4),
57            LangId::Solidity
58            | LangId::ObjC
59            | LangId::Java
60            | LangId::Kotlin
61            | LangId::Swift
62            | LangId::Php
63            | LangId::Perl
64            | LangId::Groovy => IndentStyle::Spaces(4),
65            LangId::Html => IndentStyle::Spaces(2),
66            LangId::Markdown => IndentStyle::Spaces(4),
67            LangId::Pascal | LangId::R => IndentStyle::Spaces(2),
68        }
69    }
70}
71
72/// Detect the indentation style of a source file.
73///
74/// Examines indented lines (those starting with whitespace) and determines
75/// whether tabs or spaces dominate. For spaces, determines the most common
76/// indent width by looking at the smallest indent unit.
77///
78/// Returns detected style if >50% of indented lines agree, otherwise falls
79/// back to the language default.
80pub fn detect_indent(source: &str, lang: LangId) -> IndentStyle {
81    let mut tab_count: u32 = 0;
82    let mut space_count: u32 = 0;
83    let mut indent_widths: [u32; 9] = [0; 9]; // index 1..8
84
85    for line in source.lines() {
86        if line.is_empty() {
87            continue;
88        }
89        let first = line.as_bytes()[0];
90        if first == b'\t' {
91            tab_count += 1;
92        } else if first == b' ' {
93            space_count += 1;
94            // Count leading spaces
95            let leading = line.len() - line.trim_start_matches(' ').len();
96            if leading > 0 && leading <= 8 {
97                indent_widths[leading] += 1;
98            }
99        }
100    }
101
102    let total = tab_count + space_count;
103    if total == 0 {
104        return IndentStyle::default_for(lang);
105    }
106
107    // Tabs win if >50% of indented lines use tabs
108    if tab_count > total / 2 {
109        return IndentStyle::Tabs;
110    }
111
112    // Spaces win if >50% of indented lines use spaces
113    if space_count > total / 2 {
114        // Determine the most likely indent unit width.
115        // The unit is the GCD of observed indent widths, or equivalently,
116        // the smallest width that has significant usage.
117        let width = determine_space_width(&indent_widths);
118        return IndentStyle::Spaces(width);
119    }
120
121    // Mixed / no clear winner — fall back
122    IndentStyle::default_for(lang)
123}
124
125/// Determine the most likely space indent width from observed leading-space counts.
126///
127/// Strategy: find the smallest observed indent width that forms a consistent
128/// pattern (all other widths are multiples of it). Prefer the smallest actual
129/// indent seen, not just the GCD.
130fn determine_space_width(widths: &[u32; 9]) -> u8 {
131    // Find the smallest observed indent width
132    let smallest = (1..=8usize).find(|&i| widths[i] > 0);
133    let smallest = match smallest {
134        Some(s) => s,
135        None => return 4,
136    };
137
138    // Check if all observed widths are multiples of this smallest
139    let all_multiples = (1..=8).all(|i| widths[i] == 0 || i % smallest == 0);
140
141    if all_multiples && smallest >= 2 {
142        return smallest as u8;
143    }
144
145    // If smallest is 1 or doesn't divide evenly, try common widths
146    for &candidate in &[4u8, 2, 8] {
147        let c = candidate as usize;
148        let mut matching: u32 = 0;
149        let mut non_matching: u32 = 0;
150        for i in 1..=8 {
151            if widths[i] > 0 {
152                if i % c == 0 {
153                    matching += widths[i];
154                } else {
155                    non_matching += widths[i];
156                }
157            }
158        }
159        if matching > 0 && non_matching == 0 {
160            return candidate;
161        }
162    }
163
164    smallest as u8
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn detect_indent_tabs() {
173        let source = "fn main() {\n\tlet x = 1;\n\tlet y = 2;\n}\n";
174        assert_eq!(detect_indent(source, LangId::Rust), IndentStyle::Tabs);
175    }
176
177    #[test]
178    fn detect_indent_two_spaces() {
179        let source = "class Foo {\n  bar() {}\n  baz() {}\n}\n";
180        assert_eq!(
181            detect_indent(source, LangId::TypeScript),
182            IndentStyle::Spaces(2)
183        );
184    }
185
186    #[test]
187    fn detect_indent_four_spaces() {
188        let source =
189            "class Foo:\n    def bar(self):\n        pass\n    def baz(self):\n        pass\n";
190        assert_eq!(
191            detect_indent(source, LangId::Python),
192            IndentStyle::Spaces(4)
193        );
194    }
195
196    #[test]
197    fn detect_indent_empty_source_uses_default() {
198        assert_eq!(detect_indent("", LangId::Python), IndentStyle::Spaces(4));
199        assert_eq!(
200            detect_indent("", LangId::TypeScript),
201            IndentStyle::Spaces(2)
202        );
203        assert_eq!(detect_indent("", LangId::Go), IndentStyle::Tabs);
204    }
205
206    #[test]
207    fn detect_indent_no_indented_lines_uses_default() {
208        let source = "x = 1\ny = 2\n";
209        assert_eq!(
210            detect_indent(source, LangId::Python),
211            IndentStyle::Spaces(4)
212        );
213    }
214
215    #[test]
216    fn indent_style_as_str() {
217        assert_eq!(IndentStyle::Tabs.as_str(), "\t");
218        assert_eq!(IndentStyle::Spaces(2).as_str(), "  ");
219        assert_eq!(IndentStyle::Spaces(4).as_str(), "    ");
220    }
221
222    #[test]
223    fn detect_indent_four_spaces_with_nested() {
224        // Lines indented at 4 and 8 should detect 4-space indent
225        let source = "impl Foo {\n    fn bar() {\n        let x = 1;\n    }\n}\n";
226        assert_eq!(detect_indent(source, LangId::Rust), IndentStyle::Spaces(4));
227    }
228}