shuck-formatter 0.0.21

Shell script formatter with configurable style options
Documentation
use shuck_ast::{Redirect, RedirectKind};
use shuck_format::{FormatResult, space, text, token, write};

use crate::FormatNodeRule;
use crate::prelude::ShellFormatter;
use crate::word::render_word_syntax;

#[derive(Debug, Default, Clone, Copy)]
pub struct FormatRedirect;

impl FormatNodeRule<Redirect> for FormatRedirect {
    fn fmt(&self, redirect: &Redirect, formatter: &mut ShellFormatter<'_, '_>) -> FormatResult<()> {
        let source = formatter.context().source();
        let options = formatter.context().options().clone();
        if !options.simplify()
            && !options.minify()
            && let Some(raw) = raw_redirect_source_slice(redirect, source)
            && should_preserve_raw_redirect(raw)
        {
            return write!(formatter, [text(raw.to_string())]);
        }

        if let Some(name) = &redirect.fd_var {
            write!(
                formatter,
                [token("{"), text(name.as_str().to_string()), token("}")]
            )?;
        } else if let Some(fd) = redirect
            .fd
            .filter(|fd| should_render_explicit_fd(*fd, redirect.kind))
        {
            write!(formatter, [text(fd.to_string())])?;
        }

        write!(
            formatter,
            [token(match redirect.kind {
                RedirectKind::Output => ">",
                RedirectKind::Clobber => ">|",
                RedirectKind::Append => ">>",
                RedirectKind::Input => "<",
                RedirectKind::ReadWrite => "<>",
                RedirectKind::HereDoc => "<<",
                RedirectKind::HereDocStrip => "<<-",
                RedirectKind::HereString => "<<<",
                RedirectKind::DupOutput => ">&",
                RedirectKind::DupInput => "<&",
                RedirectKind::OutputBoth => "&>",
            })]
        )?;

        let target = match (redirect.word_target(), redirect.heredoc()) {
            (Some(word), None) => render_word_syntax(word, source, &options),
            (None, Some(heredoc)) => render_word_syntax(&heredoc.delimiter.raw, source, &options),
            (None, None) => String::new(),
            (Some(_), Some(_)) => unreachable!("redirect target cannot be both word and heredoc"),
        };
        if needs_space_before_target(redirect.kind, &target, options.space_redirects()) {
            write!(formatter, [space()])?;
        }
        write!(formatter, [text(target)])
    }
}

fn should_render_explicit_fd(fd: i32, kind: RedirectKind) -> bool {
    match kind {
        RedirectKind::Output
        | RedirectKind::Clobber
        | RedirectKind::Append
        | RedirectKind::DupOutput
        | RedirectKind::OutputBoth => fd != 1,
        RedirectKind::Input
        | RedirectKind::ReadWrite
        | RedirectKind::HereDoc
        | RedirectKind::HereDocStrip
        | RedirectKind::HereString
        | RedirectKind::DupInput => fd != 0,
    }
}

fn needs_space_before_target(kind: RedirectKind, target: &str, space_redirects: bool) -> bool {
    if target.is_empty() {
        return false;
    }

    if space_redirects && !matches!(kind, RedirectKind::DupOutput | RedirectKind::DupInput) {
        return true;
    }

    !matches!(kind, RedirectKind::DupOutput | RedirectKind::DupInput)
        && target
            .as_bytes()
            .first()
            .is_some_and(|byte| matches!(byte, b'<' | b'>' | b'&'))
}

fn raw_redirect_source_slice<'a>(redirect: &Redirect, source: &'a str) -> Option<&'a str> {
    let span = redirect.span;
    (span.start.offset < span.end.offset && span.end.offset <= source.len())
        .then(|| span.slice(source))
}

fn should_preserve_raw_redirect(raw: &str) -> bool {
    raw.contains(">&$") || raw.contains("<&$")
}