shuck-formatter 0.0.45

Shell script formatter with configurable style options
Documentation
use shuck_ast::Span;

use crate::raw_syntax::{RawShellScanner, matching_raw_command_substitution_close};

#[derive(Debug, Clone, Copy)]
pub(crate) struct SourceView<'source> {
    source: &'source str,
}

impl<'source> SourceView<'source> {
    pub(crate) fn new(source: &'source str) -> Self {
        Self { source }
    }

    pub(crate) fn slice_between(self, start: usize, end: usize) -> Option<&'source str> {
        let lower = start.min(end).min(self.source.len());
        let upper = start.max(end).min(self.source.len());
        self.source.get(lower..upper)
    }

    pub(crate) fn span_slice(self, span: Span) -> Option<&'source str> {
        if span.start.offset >= span.end.offset || span.end.offset > self.source.len() {
            return None;
        }
        Some(span.slice(self.source))
    }

    pub(crate) fn line_indent_before_offset(self, offset: usize) -> Option<&'source str> {
        let offset = offset.min(self.source.len());
        let line_start = self.line_start_before(offset)?;
        let prefix = self.source.get(line_start..offset)?;
        let indent_end = prefix
            .char_indices()
            .find(|(_, ch)| !matches!(ch, ' ' | '\t'))
            .map_or(prefix.len(), |(index, _)| index);
        prefix.get(..indent_end)
    }

    pub(crate) fn line_has_shell_comment_before(self, offset: usize) -> bool {
        let upper = offset.min(self.source.len());
        let Some(line_start) = self.line_start_before(upper) else {
            return false;
        };
        self.shell_comment_start_between(line_start, upper)
            .is_some()
    }

    pub(crate) fn shell_comment_start_between(self, start: usize, end: usize) -> Option<usize> {
        let lower = start.min(end).min(self.source.len());
        let upper = start.max(end).min(self.source.len());
        RawShellScanner::bounded(self.source, upper).find_comment(lower, upper)
    }

    pub(crate) fn branch_keyword_offset(
        self,
        start: usize,
        end: usize,
        keyword: &str,
    ) -> Option<usize> {
        let start = start.min(end).min(self.source.len());
        let end = end.min(self.source.len());
        let mut line_start = start;
        while line_start < end {
            let line_end = self.source[line_start..end]
                .find('\n')
                .map_or(end, |offset| line_start + offset);
            let line = self.source.get(line_start..line_end)?;
            let mut search_start = 0;
            while let Some(relative) = line[search_start..].find(keyword) {
                let keyword_start = search_start + relative;
                let keyword_end = keyword_start + keyword.len();
                if branch_keyword_candidate_matches(line, keyword_start, keyword_end) {
                    return Some(line_start + keyword_start);
                }
                search_start = keyword_end;
            }
            line_start = line_end.saturating_add(1);
        }
        None
    }

    pub(crate) fn last_uncommented_shell_keyword_before(
        self,
        search_end: usize,
        keyword: &str,
    ) -> Option<usize> {
        let mut search_end = search_end.min(self.source.len());
        loop {
            let offset = self.source.get(..search_end)?.rfind(keyword)?;
            let end = offset + keyword.len();
            if shell_keyword_boundaries_match(self.source, offset, end)
                && !self.line_has_shell_comment_before(offset)
            {
                return Some(offset);
            }
            search_end = offset;
        }
    }

    pub(crate) fn last_shell_keyword_start(self, span: Span, keyword: &str) -> Option<usize> {
        let upper = span.end.offset.min(self.source.len());
        let lower = span.start.offset.min(upper);
        self.last_shell_keyword_start_between(lower, upper, keyword)
    }

    pub(crate) fn last_shell_keyword_start_between(
        self,
        lower: usize,
        upper: usize,
        keyword: &str,
    ) -> Option<usize> {
        let upper = upper.min(self.source.len());
        let lower = lower.min(upper);
        let slice = self.source.get(lower..upper)?;
        slice
            .match_indices(keyword)
            .filter_map(|(start, _)| {
                let end = start + keyword.len();
                shell_keyword_boundaries_match(slice, start, end).then_some(lower + start)
            })
            .last()
    }

    pub(crate) fn shell_keyword_at(self, offset: usize, upper: usize, keyword: &str) -> bool {
        let end = offset.saturating_add(keyword.len());
        end <= upper
            && self.source.get(offset..end) == Some(keyword)
            && shell_keyword_boundaries_match(self.source, offset, end)
    }

    pub(crate) fn dollar_command_substitution(
        self,
    ) -> Option<DollarCommandSubstitutionSource<'source>> {
        self.source.strip_prefix("$(")?;
        let close_offset = matching_raw_command_substitution_close(self.source, 2)?;
        Some(DollarCommandSubstitutionSource {
            view: self,
            close_offset,
        })
    }

    pub(crate) fn substitution_closes_on_own_line(self) -> bool {
        let Some(close_offset) = self.source.rfind(')') else {
            return false;
        };
        let line_start = self.source[..close_offset]
            .rfind('\n')
            .map_or(0, |newline| newline.saturating_add(1));
        line_start > 0 && self.source[line_start..close_offset].trim().is_empty()
    }

    fn line_start_before(self, offset: usize) -> Option<usize> {
        let prefix = self.source.get(..offset)?;
        Some(
            prefix
                .rfind('\n')
                .map_or(0, |newline| newline.saturating_add(1)),
        )
    }
}

#[derive(Debug, Clone, Copy)]
pub(crate) struct DollarCommandSubstitutionSource<'source> {
    view: SourceView<'source>,
    close_offset: usize,
}

impl<'source> DollarCommandSubstitutionSource<'source> {
    pub(crate) fn body(self) -> Option<&'source str> {
        self.view.source.get(2..self.close_offset)
    }

    pub(crate) fn closed_slice(self) -> Option<&'source str> {
        self.view.source.get(..self.close_offset.saturating_add(1))
    }

    pub(crate) fn closes_on_own_line(self) -> bool {
        self.view.substitution_closes_on_own_line()
    }
}

pub(crate) fn dollar_command_substitution_body(raw: &str) -> Option<&str> {
    SourceView::new(raw).dollar_command_substitution()?.body()
}

pub(crate) fn dollar_command_substitution_slice(raw: &str) -> Option<&str> {
    SourceView::new(raw)
        .dollar_command_substitution()?
        .closed_slice()
}

pub(crate) fn command_substitution_source_starts_with_body_line(raw: &str) -> bool {
    raw.starts_with(['\n', '\r'])
        || raw
            .strip_prefix("$(")
            .is_some_and(|after_open| after_open.starts_with(['\n', '\r']))
}

pub(crate) fn command_substitution_source_closes_on_own_line(raw: &str) -> bool {
    SourceView::new(raw)
        .dollar_command_substitution()
        .is_some_and(DollarCommandSubstitutionSource::closes_on_own_line)
}

pub(crate) fn command_substitution_source_prefers_continued_inline_body(raw: &str) -> bool {
    let Some(after_open) = raw.strip_prefix("$(") else {
        return false;
    };
    if after_open.starts_with(['\n', '\r']) {
        return false;
    }

    raw.lines()
        .any(|line| line.trim_end_matches([' ', '\t', '\r']).ends_with('\\'))
}

pub(crate) fn substitution_source_closes_on_own_line(raw: &str) -> bool {
    SourceView::new(raw).substitution_closes_on_own_line()
}

fn branch_keyword_candidate_matches(line: &str, start: usize, end: usize) -> bool {
    if !shell_keyword_boundaries_match(line, start, end) {
        return false;
    }

    let prefix = &line[..start];
    let trimmed = prefix.trim_start_matches([' ', '\t']);
    if trimmed.starts_with('#') {
        return false;
    }

    let before = prefix.trim_end_matches([' ', '\t']);
    before.is_empty() || before.ends_with(';') || before.ends_with('&')
}

fn shell_keyword_boundaries_match(text: &str, start: usize, end: usize) -> bool {
    let before = text[..start].chars().next_back();
    let after = text[end..].chars().next();
    before.is_none_or(|ch| !is_shell_keyword_char(ch))
        && after.is_none_or(|ch| !is_shell_keyword_char(ch))
}

fn is_shell_keyword_char(ch: char) -> bool {
    ch.is_ascii_alphanumeric() || ch == '_'
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn command_substitution_view_slices_body_with_nested_close() {
        let raw = "$(echo $(date); echo done) trailing";
        let view = SourceView::new(raw).dollar_command_substitution().unwrap();

        assert_eq!(view.body(), Some("echo $(date); echo done"));
        assert_eq!(view.closed_slice(), Some("$(echo $(date); echo done)"));
    }

    #[test]
    fn command_substitution_view_reports_source_shape() {
        assert!(command_substitution_source_starts_with_body_line(
            "$(\necho ok\n)"
        ));
        assert!(command_substitution_source_closes_on_own_line(
            "$(echo ok\n)"
        ));
        assert!(!command_substitution_source_prefers_continued_inline_body(
            "$(\necho ok \\"
        ));
        assert!(command_substitution_source_prefers_continued_inline_body(
            "$(echo ok \\\n  && echo again)"
        ));
    }
}