ecformat 0.2.0

command line tool to keep files correct in respect of your EditorConfig
Documentation
// SPDX-FileCopyrightText: Contributors to ecformat project <https://codeberg.org/BaumiCoder/ecformat>
//
// SPDX-License-Identifier: BlueOak-1.0.0

//! Module for the indentation related properties of EditorConfig,
//! i.e., `indent_style`, `indent_size` and `tab_width`

use ec4rs::property::{self, IndentSize, IndentStyle, TabWidth};
use itertools::Itertools;
use line_ending::LineEnding;
use snafu::ensure;

use crate::{errors, files};

use super::{PropertyHandler, charset, end_of_line};

/// Handles the indentation related properties for a single file,
/// i.e., `indent_style`, `indent_size` and `tab_width`
pub struct IndentationHandler {
    charset: property::Charset,
    line_ending: LineEnding,
    indent_style: Option<property::IndentStyle>,
    indent_size: Option<usize>,
    tab_width: Option<usize>,
}

impl PropertyHandler for IndentationHandler {
    fn check(&self, file_path: &std::path::Path) -> anyhow::Result<()> {
        let content = files::read_file(file_path, &self.charset)?;
        let lines_with_wrong_indentation = self
            .get_lines_with_wrong_indentation(&content)
            .collect_vec();

        ensure!(
            lines_with_wrong_indentation.is_empty(),
            errors::IndentationSnafu {
                lines_with_wrong_indentation
            }
        );
        Ok(())
    }

    fn fix(&self, file_path: &std::path::Path) -> anyhow::Result<()> {
        let mut content = files::read_file(file_path, &self.charset)?;
        if self.fix_indentations(&mut content) {
            files::overwrite_file(file_path, &self.charset, &content)?;
        }
        Ok(())
    }
}

impl IndentationHandler {
    /// Creates a [`IndentationHandler`] for the given properties,
    /// if a handler is necessary for these properties.
    pub fn build(properties: &ec4rs::Properties) -> Option<IndentationHandler> {
        let indent_style = properties.get::<property::IndentStyle>().ok();
        let mut tab_width = match properties.get::<property::TabWidth>() {
            Ok(TabWidth::Value(tab_width)) => Some(tab_width),
            Err(_) => None,
        };
        let indent_size = match properties.get::<property::IndentSize>() {
            Ok(IndentSize::Value(indent_size)) => Some(indent_size),
            Ok(IndentSize::UseTabWidth) => tab_width,
            Err(_) => None,
        };

        if indent_style.is_some() || indent_size.is_some() {
            if tab_width.is_none() {
                // Specification 0.17.2 says that tab_width uses indent_size as fallback
                tab_width = indent_size;
            }
            Some(IndentationHandler {
                charset: charset::get_charset(properties),
                line_ending: end_of_line::get_line_ending(properties),
                indent_style,
                indent_size,
                tab_width,
            })
        } else {
            // Only a tab_width has only visual implications in an editor
            None
        }
    }

    /// Returns an iterator over the indices of lines with a wrong indentation
    fn get_lines_with_wrong_indentation(&self, content: &str) -> impl Iterator<Item = usize> {
        content
            .split(self.line_ending.as_str())
            .enumerate()
            .filter_map(|(index, line)| {
                if self.check_line_indentation(line) {
                    None
                } else {
                    Some(index)
                }
            })
    }

    /// Checks the indentation of a given line
    /// and returns true if it respects the EditorConfig properties and false otherwise.
    fn check_line_indentation(&self, line: &str) -> bool {
        let indent = line
            .chars()
            .position(Self::is_no_indentation_char)
            .map(|i| line.chars().take(i));
        if let Some(indent_chars) = indent {
            if let Some(tab_width) = self.tab_width {
                let line_indent_size = Self::get_indentation_size(indent_chars.clone(), tab_width);
                if let Some(indent_size) = self.indent_size
                    && !line_indent_size.is_multiple_of(indent_size)
                {
                    // line has wrong indentation size
                    false
                } else {
                    match self.indent_style {
                        Some(IndentStyle::Spaces) => {
                            // Only spaces for the indentation allowed
                            indent_chars.clone().all(|c| c == ' ')
                        }
                        Some(IndentStyle::Tabs) => {
                            let mut tabs = 0;
                            let mut spaces = 0;
                            for c in indent_chars {
                                if c == '\t' {
                                    tabs += 1;
                                } else {
                                    spaces += 1;
                                }
                            }
                            let expected_tabs = line_indent_size / tab_width;
                            let expected_spaces = line_indent_size % tab_width;
                            // Use the correct number of tabs and spaces
                            tabs == expected_tabs || spaces == expected_spaces
                        }
                        None => true,
                    }
                }
            } else {
                // Without a tab_width value, also indent_size is not set
                // which allows only limited validations.
                match self.indent_style {
                    Some(IndentStyle::Spaces) => {
                        // Only spaces for the indentation allowed
                        indent_chars.clone().all(|c| c == ' ')
                    }
                    Some(IndentStyle::Tabs) => {
                        // Without a tab_width anything is allowed,
                        // e.g., smaller indentations can be without any tabs
                        true
                    }
                    None => {
                        debug_assert!(
                            false,
                            "Without indent_style and indent_size a handler was created"
                        );
                        true
                    }
                }
            }
        } else {
            // line is empty or only contains indentation characters
            true
        }
    }

    /// Fix the indentation of the lines, where it is necessary.
    /// Returns true if there were lines to fix (i.e., content was changed).
    fn fix_indentations(&self, content: &mut String) -> bool {
        let mut lines_with_wrong_indentation =
            self.get_lines_with_wrong_indentation(content).peekable();
        let wrong_indentations = lines_with_wrong_indentation.peek().is_some();

        if wrong_indentations {
            let mut lines = content
                .split(self.line_ending.as_str())
                .map(String::from)
                .collect_vec();

            for index in lines_with_wrong_indentation {
                lines[index] = self.fix_line_indentation(lines[index].as_str());
            }
            *content = lines.join(self.line_ending.as_str());
        }
        wrong_indentations
    }

    /// Fixes the indentation of a given line
    /// and returns the fixed line.
    fn fix_line_indentation(&self, line: &str) -> String {
        let new_indent: String;
        let after_indent = line.chars().position(Self::is_no_indentation_char);
        if let Some(index_after_indent) = after_indent {
            let indent_chars = line.chars().take(index_after_indent);
            // Without tab_width set, use a default to allow an indent_style only fix
            let tab_width = self.tab_width.unwrap_or(4);
            let line_indent_size = Self::get_indentation_size(indent_chars.clone(), tab_width);
            if let Some(indent_size) = self.indent_size {
                let new_indent_size = if line_indent_size.is_multiple_of(indent_size) {
                    indent_size
                } else {
                    // Heuristic: Round the indentation level up
                    let indent_levels = (line_indent_size / indent_size) + 1;
                    indent_levels * indent_size
                };
                let indent_style = self.indent_style.unwrap_or_else(|| {
                    // Heuristic: If a tab is included it is tabs style
                    if indent_chars.clone().contains(&'\t') {
                        IndentStyle::Tabs
                    } else {
                        IndentStyle::Spaces
                    }
                });

                new_indent =
                    IndentationHandler::get_indentation(indent_style, new_indent_size, tab_width);
            } else {
                // Without indent_size only fix the indent_style and respect the tab_width
                new_indent = IndentationHandler::get_indentation(
                    self.indent_style
                        .expect("Without indent style and size no wrong indents possible"),
                    line_indent_size, // keep size unchanged as indent_size is not set
                    tab_width,
                );
            }
            new_indent + &line[index_after_indent..]
        } else {
            unreachable!("Without an indentation it cannot be wrong")
        }
    }

    /// Produces an indentation in the given style
    /// and with the given size (in columns), respecting the given tab width.
    fn get_indentation(
        indent_style: property::IndentStyle,
        size: usize,
        tab_width: usize,
    ) -> String {
        match indent_style {
            IndentStyle::Tabs => {
                let tabs = size / tab_width;
                let spaces = size % tab_width;

                // The example in the specification 0.17.2 talks about
                // "indented with one tab, and one space".
                // Therefore, assume that tabs should come first.
                // (Although `check_line_indentation()` accepts any order)
                "\t".repeat(tabs) + " ".repeat(spaces).as_str()
            }
            IndentStyle::Spaces => " ".repeat(size),
        }
    }

    /// Returns true if the given character cannot be part of an indentation.
    fn is_no_indentation_char(character: char) -> bool {
        // Specification 0.17.2 only mentions "spaces" and "tabs" for indentations
        // and does not mention the bigger group of whitespace characters for indentations.
        character != ' ' && character != '\t'
    }

    /// Returns the size of the given indentation chars in number of columns
    /// with respect to the given tab_width.
    fn get_indentation_size(indent_chars: impl Iterator<Item = char>, tab_width: usize) -> usize {
        indent_chars
            .map(|c| if c == '\t' { tab_width } else { 1 })
            .sum()
    }
}

#[cfg(test)]
mod tests;