shuck-formatter 0.0.44

Shell script formatter with configurable style options
Documentation
use shuck_ast::{CaseCommand, IfCommand, IfSyntax, Span};
#[cfg(test)]
use shuck_ast::{Command, CompoundCommand, File};

use super::case_layout::{
    case_close_shares_line_with_last_item, case_command_was_inline_in_source,
    case_item_body_was_inline_without_terminator,
    case_item_pattern_body_terminator_was_inline_in_source,
    case_item_pattern_starts_on_case_header,
};
use super::conditions::{
    if_condition_has_explicit_statement_break, if_condition_starts_after_keyword,
    raw_grouped_if_condition,
};
use crate::comments::BranchPrefixComment;
use crate::context::RenderContext;
#[cfg(test)]
use crate::facts::FormatterFacts;
#[cfg(test)]
use crate::options::ShellFormatOptions;

use super::shape::{
    can_inline_body_with_upper_bound, can_inline_else_branch_close, can_inline_if_chain,
    then_branch_starts_with_inline_if,
};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct IfLayoutPlan {
    pub(crate) then_span: Span,
    pub(crate) fi_span: Span,
    pub(crate) style: IfLayoutStyle,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum IfLayoutStyle {
    RawGroupedCondition { raw_condition: String },
    SplitCondition,
    Inline(InlineIfLayout),
    Expanded(ExpandedIfLayout),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InlineIfLayout {
    Then,
    ThenElse,
    ThenMultilineElse,
    ThenNestedIf,
    Chain,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExpandedIfLayout {
    Compact,
    Multiline { inline_else_close: bool },
}

impl IfLayoutPlan {
    pub(crate) fn then_fi(command: &IfCommand, context: RenderContext<'_, '_>) -> Self {
        let IfSyntax::ThenFi { then_span, .. } = command.syntax else {
            unreachable!("brace if cannot be planned as then/fi");
        };
        let fi_span = context.facts.if_close_span(command);
        let style = then_fi_if_style(command, then_span, fi_span, context);

        Self {
            then_span,
            fi_span,
            style,
        }
    }
}

fn then_fi_if_style(
    command: &IfCommand,
    then_span: Span,
    fi_span: Span,
    context: RenderContext<'_, '_>,
) -> IfLayoutStyle {
    let fi_upper_bound = fi_span.start.offset;
    let no_elifs = command.elif_branches.is_empty();

    if no_elifs
        && let Some(raw_condition) = raw_grouped_if_condition(
            command,
            then_span,
            context.source,
            context.source_map(),
            context.facts,
        )
    {
        return IfLayoutStyle::RawGroupedCondition { raw_condition };
    }

    if if_condition_starts_after_keyword(command, then_span, context.source, context.facts)
        || if_condition_has_explicit_statement_break(
            command,
            then_span,
            context.source,
            context.source_map(),
            context.facts,
        )
    {
        return IfLayoutStyle::SplitCondition;
    }

    let can_inline_then = no_elifs
        && can_inline_body_with_upper_bound(
            context,
            &command.then_branch,
            command.span,
            Some(fi_upper_bound),
        );

    if no_elifs && command.else_branch.is_none() && can_inline_then {
        return IfLayoutStyle::Inline(InlineIfLayout::Then);
    }

    if can_inline_then && let Some(else_branch) = &command.else_branch {
        let can_inline_else = can_inline_body_with_upper_bound(
            context,
            else_branch,
            command.span,
            Some(fi_upper_bound),
        );
        if can_inline_else {
            return IfLayoutStyle::Inline(InlineIfLayout::ThenElse);
        }
        if !context.options.compact_layout() {
            return IfLayoutStyle::Inline(InlineIfLayout::ThenMultilineElse);
        }
    }

    if no_elifs
        && command.else_branch.is_none()
        && then_branch_starts_with_inline_if(context, command, then_span, fi_span)
    {
        return IfLayoutStyle::Inline(InlineIfLayout::ThenNestedIf);
    }

    if can_inline_if_chain(context, command, fi_span) {
        return IfLayoutStyle::Inline(InlineIfLayout::Chain);
    }

    if context.options.compact_layout() {
        IfLayoutStyle::Expanded(ExpandedIfLayout::Compact)
    } else {
        IfLayoutStyle::Expanded(ExpandedIfLayout::Multiline {
            inline_else_close: command
                .else_branch
                .as_ref()
                .is_some_and(|body| can_inline_else_branch_close(context, command, body, fi_span)),
        })
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CaseLayoutPlan {
    pub(crate) style: CaseLayoutStyle,
    pub(crate) body_fallback_upper_bound: usize,
    pub(crate) esac_span: Option<Span>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CaseLayoutStyle {
    Inline,
    Compact,
    Multiline {
        header_item_count: usize,
        blank_line_after_in: bool,
        close: CaseCloseLayout,
    },
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CaseCloseLayout {
    SameLine,
    NextLine { blank_before: bool },
    SuffixComments(Vec<BranchPrefixComment>),
}

impl CaseLayoutPlan {
    pub(crate) fn for_command(command: &CaseCommand, context: RenderContext<'_, '_>) -> Self {
        let case_facts = context.facts.case_command(command);
        let body_fallback_upper_bound = case_facts.body_fallback_upper_bound();
        let esac_span = case_facts.esac_span();
        let style = if !context.options.compact_layout()
            && case_command_was_inline_in_source(command, context.source)
            && case_can_format_inline(command, context)
        {
            CaseLayoutStyle::Inline
        } else if context.options.compact_layout() {
            CaseLayoutStyle::Compact
        } else {
            CaseLayoutStyle::Multiline {
                header_item_count: case_header_item_count(command, context),
                blank_line_after_in: case_facts.has_blank_line_after_in(),
                close: case_close_layout(command, context),
            }
        };

        Self {
            style,
            body_fallback_upper_bound,
            esac_span,
        }
    }
}

pub(crate) fn case_can_format_inline(
    command: &CaseCommand,
    context: RenderContext<'_, '_>,
) -> bool {
    command.cases.iter().all(|item| {
        item.body.is_empty()
            || item.body.len() == 1
                && (context.facts.case_item_was_inline_in_source(item)
                    || case_item_pattern_body_terminator_was_inline_in_source(item, context.source)
                    || case_item_body_was_inline_without_terminator(item))
                && !context
                    .facts
                    .sequence(&item.body, Some(command.span.end.offset))
                    .has_comments()
    })
}

fn case_header_item_count(command: &CaseCommand, context: RenderContext<'_, '_>) -> usize {
    let mut item_count = 0;
    for item in &command.cases {
        if !case_item_pattern_starts_on_case_header(command, item)
            || !context.facts.case_item(item).prefix_comments().is_empty()
        {
            break;
        }
        item_count += 1;
    }
    item_count
}

fn case_close_layout(command: &CaseCommand, context: RenderContext<'_, '_>) -> CaseCloseLayout {
    let case_facts = context.facts.case_command(command);
    let case_suffix_comments = case_facts.suffix_comments_before_esac().to_vec();
    if !case_suffix_comments.is_empty() {
        return CaseCloseLayout::SuffixComments(case_suffix_comments);
    }

    if case_close_shares_line_with_last_item(command, case_facts.esac_span(), context.source) {
        CaseCloseLayout::SameLine
    } else {
        CaseCloseLayout::NextLine {
            blank_before: case_facts.has_blank_line_before_esac(),
        }
    }
}

#[cfg(test)]
pub(super) fn parse_with_facts<'source>(
    source: &'source str,
    options: &ShellFormatOptions,
) -> (
    File,
    crate::options::ResolvedShellFormatOptions,
    FormatterFacts<'source>,
) {
    use shuck_parser::parser::Parser;

    let resolved = options.resolve(source, None);
    let file = Parser::with_dialect(source, resolved.dialect())
        .parse()
        .unwrap()
        .file;
    let facts = FormatterFacts::build(source, &file, &resolved);
    (file, resolved, facts)
}

#[cfg(test)]
pub(super) fn first_if_command(file: &File) -> &IfCommand {
    let Command::Compound(CompoundCommand::If(command)) = &file.body[0].command else {
        panic!("expected first statement to be if command");
    };
    command
}

#[cfg(test)]
pub(super) fn first_case_command(file: &File) -> &CaseCommand {
    let Command::Compound(CompoundCommand::Case(command)) = &file.body[0].command else {
        panic!("expected first statement to be case command");
    };
    command
}