shuck-formatter 0.0.41

Shell script formatter with configurable style options
Documentation
use std::path::Path;

use shuck_parser::ShellDialect as ParseDialect;

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum IndentStyle {
    #[default]
    Tab,
    Space,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum LineEnding {
    #[default]
    Lf,
    CrLf,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ShellDialect {
    #[default]
    Auto,
    Bash,
    Posix,
    Mksh,
    Zsh,
}

impl ShellDialect {
    #[must_use]
    pub fn resolve(self, source: &str, path: Option<&Path>) -> ParseDialect {
        match self {
            Self::Auto => infer_dialect(source, path),
            Self::Bash => ParseDialect::Bash,
            Self::Posix => ParseDialect::Posix,
            Self::Mksh => ParseDialect::Mksh,
            Self::Zsh => ParseDialect::Zsh,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShellFormatOptions {
    dialect: ShellDialect,
    indent_style: IndentStyle,
    indent_width: u8,
    binary_next_line: bool,
    switch_case_indent: bool,
    space_redirects: bool,
    keep_padding: bool,
    function_next_line: bool,
    never_split: bool,
    simplify: bool,
    minify: bool,
}

impl Default for ShellFormatOptions {
    fn default() -> Self {
        Self {
            dialect: ShellDialect::Auto,
            indent_style: IndentStyle::Tab,
            indent_width: 8,
            binary_next_line: false,
            switch_case_indent: false,
            space_redirects: false,
            keep_padding: false,
            function_next_line: false,
            never_split: false,
            simplify: false,
            minify: false,
        }
    }
}

macro_rules! option_getters {
    ($($method:ident: $field:ident -> $ty:ty;)+) => {
        $(
            #[must_use]
            pub fn $method(&self) -> $ty {
                self.$field
            }
        )+
    };
}

macro_rules! option_builders {
    ($($method:ident: $field:ident -> $ty:ty;)+) => {
        $(
            #[must_use]
            pub fn $method(mut self, value: $ty) -> Self {
                self.$field = value;
                self
            }
        )+
    };
}

macro_rules! resolved_option_getters {
    ($($method:ident: $field:ident -> $ty:ty;)+) => {
        $(
            #[must_use]
            pub fn $method(&self) -> $ty {
                self.options.$field
            }
        )+
    };
}

impl ShellFormatOptions {
    option_getters! {
        dialect: dialect -> ShellDialect;
        indent_style: indent_style -> IndentStyle;
        indent_width: indent_width -> u8;
        binary_next_line: binary_next_line -> bool;
        switch_case_indent: switch_case_indent -> bool;
        space_redirects: space_redirects -> bool;
        keep_padding: keep_padding -> bool;
        function_next_line: function_next_line -> bool;
        never_split: never_split -> bool;
        simplify: simplify -> bool;
        minify: minify -> bool;
    }

    option_builders! {
        with_dialect: dialect -> ShellDialect;
        with_indent_style: indent_style -> IndentStyle;
        with_binary_next_line: binary_next_line -> bool;
        with_switch_case_indent: switch_case_indent -> bool;
        with_space_redirects: space_redirects -> bool;
        with_keep_padding: keep_padding -> bool;
        with_function_next_line: function_next_line -> bool;
        with_never_split: never_split -> bool;
        with_simplify: simplify -> bool;
        with_minify: minify -> bool;
    }

    #[must_use]
    pub fn with_indent_width(mut self, indent_width: u8) -> Self {
        self.indent_width = indent_width.max(1);
        self
    }

    #[must_use]
    pub fn resolve(&self, source: &str, path: Option<&Path>) -> ResolvedShellFormatOptions {
        let mut resolved = self.resolve_for_format(source, path);
        resolved.line_ending = line_ending_from_source_index(source);
        resolved
    }

    pub(crate) fn resolve_for_format(
        &self,
        source: &str,
        path: Option<&Path>,
    ) -> ResolvedShellFormatOptions {
        let mut options = self.clone();
        options.indent_width = options.indent_width.max(1);
        ResolvedShellFormatOptions {
            dialect: self.dialect.resolve(source, path),
            options,
            line_ending: LineEnding::Lf,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedShellFormatOptions {
    options: ShellFormatOptions,
    dialect: ParseDialect,
    line_ending: LineEnding,
}

impl ResolvedShellFormatOptions {
    option_getters! {
        dialect: dialect -> ParseDialect;
    }

    resolved_option_getters! {
        indent_style: indent_style -> IndentStyle;
        indent_width: indent_width -> u8;
    }

    pub(crate) fn indent_unit_columns(&self) -> usize {
        match self.indent_style() {
            IndentStyle::Tab => 1,
            IndentStyle::Space => usize::from(self.indent_width()),
        }
    }

    pub(crate) fn indent_columns(&self, levels: usize) -> usize {
        levels * self.indent_unit_columns()
    }

    pub(crate) fn push_indent_units(&self, target: &mut String, levels: usize) {
        self.push_indent_columns(target, self.indent_columns(levels));
    }

    pub(crate) fn push_indent_columns(&self, target: &mut String, columns: usize) {
        let ch = match self.indent_style() {
            IndentStyle::Tab => '\t',
            IndentStyle::Space => ' ',
        };
        target.extend(std::iter::repeat_n(ch, columns));
    }

    pub(crate) fn indent_prefix(&self, levels: usize) -> String {
        let mut prefix = String::new();
        self.push_indent_units(&mut prefix, levels);
        prefix
    }

    resolved_option_getters! {
        binary_next_line: binary_next_line -> bool;
        switch_case_indent: switch_case_indent -> bool;
        space_redirects: space_redirects -> bool;
        keep_padding: keep_padding -> bool;
        function_next_line: function_next_line -> bool;
        never_split: never_split -> bool;
        simplify: simplify -> bool;
        minify: minify -> bool;
    }

    #[must_use]
    pub fn compact_layout(&self) -> bool {
        self.minify() || self.never_split()
    }

    option_getters! {
        line_ending: line_ending -> LineEnding;
    }

    pub(crate) fn with_line_ending(mut self, line_ending: LineEnding) -> Self {
        self.line_ending = line_ending;
        self
    }
}

fn line_ending_from_source_index(source: &str) -> LineEnding {
    match shuck_indexer::LineIndex::new(source).line_ending() {
        shuck_indexer::LineEndingStyle::Lf => LineEnding::Lf,
        shuck_indexer::LineEndingStyle::CrLf => LineEnding::CrLf,
    }
}

fn infer_dialect(source: &str, path: Option<&Path>) -> ParseDialect {
    if let Some(first_line) = source.lines().next()
        && let Some(interpreter) = shuck_parser::shebang::interpreter_name(first_line)
    {
        return ParseDialect::from_name(interpreter);
    }

    path.and_then(Path::extension)
        .and_then(|extension| extension.to_str())
        .map(ParseDialect::from_name)
        .unwrap_or(ParseDialect::Bash)
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use super::*;

    #[test]
    fn zsh_extension_resolves_to_zsh_dialect() {
        let resolved = ShellFormatOptions::default()
            .resolve("print ${(m)name}\n", Some(Path::new("script.zsh")));

        assert_eq!(resolved.dialect(), ParseDialect::Zsh);
    }

    #[test]
    fn zsh_shebang_resolves_to_zsh_dialect() {
        let resolved = ShellFormatOptions::default().resolve(
            "#!/bin/zsh\nprint ${(m)name}\n",
            Some(Path::new("script.sh")),
        );

        assert_eq!(resolved.dialect(), ParseDialect::Zsh);
    }

    #[test]
    fn explicit_zsh_dialect_overrides_path_inference() {
        let options = ShellFormatOptions::default().with_dialect(ShellDialect::Zsh);
        let resolved = options.resolve("print ${(m)name}\n", Some(Path::new("script.sh")));

        assert_eq!(resolved.dialect(), ParseDialect::Zsh);
    }
}