use shuck_ast::{Command, CompoundCommand, IfCommand, Span, Stmt, StmtSeq};
use crate::command::{branch_open_keyword_start, command_format_span, stmt_span};
use crate::comments::SourceMap;
use crate::facts::FormatterFacts;
use crate::raw_syntax::{skip_double_quoted, skip_single_quoted};
pub(crate) fn if_condition_starts_after_keyword(
command: &IfCommand,
then_span: Span,
source: &str,
facts: &FormatterFacts,
) -> bool {
if raw_if_condition_starts_with_negation_continuation(command, then_span, source, facts) {
return false;
}
command
.condition
.first()
.is_some_and(|stmt| facts.stmt(stmt).rendered_start_line() > command.span.start.line())
}
pub(crate) fn if_condition_has_explicit_statement_break(
command: &IfCommand,
then_span: Span,
source: &str,
source_map: &SourceMap<'_>,
facts: &FormatterFacts,
) -> bool {
if raw_if_condition_starts_with_negation_continuation(command, then_span, source, facts) {
return false;
}
condition_sequence_has_explicit_statement_break(
&command.condition,
then_span.start.offset(),
source,
source_map,
)
}
fn raw_if_condition_starts_with_negation_continuation(
command: &IfCommand,
then_span: Span,
source: &str,
facts: &FormatterFacts,
) -> bool {
let condition_start = command.span.start.offset().saturating_add("if".len());
let condition_end = then_span.start.offset().min(source.len());
let Some(raw) = source.get(condition_start..condition_end) else {
return false;
};
let raw = raw.trim_start_matches([' ', '\t', '\r']);
let Some(after_negation) = raw.strip_prefix('!') else {
return false;
};
let after_negation = after_negation.trim_start_matches([' ', '\t', '\r']);
let continuation_offset = condition_end - after_negation.len();
facts.has_raw_continuation_backslash_between(
continuation_offset,
continuation_offset.saturating_add(1),
)
}
fn condition_sequence_has_explicit_statement_break(
condition: &StmtSeq,
upper_bound: usize,
source: &str,
source_map: &SourceMap<'_>,
) -> bool {
if condition.len() == 1 {
let Some(stmt) = condition.first() else {
return false;
};
if !matches!(stmt.command, Command::Simple(_)) {
return false;
}
let start = stmt_span(stmt).start.offset();
let command_end = condition_stmt_command_end(stmt).min(upper_bound);
return source
.get(start..command_end)
.is_some_and(has_unescaped_line_break);
}
condition.as_slice().windows(2).any(|pair| {
let previous_start = stmt_span(&pair[0]).start.offset();
let next_start = stmt_span(&pair[1]).start.offset();
source_map.contains_newline_between(previous_start, next_start)
})
}
fn condition_stmt_command_end(stmt: &Stmt) -> usize {
let mut end = command_format_span(&stmt.command).end.offset();
if end == 0 {
end = stmt_span(stmt).end.offset();
}
for redirect in &stmt.redirects {
end = end.max(redirect.span.end.offset());
}
end
}
pub(crate) fn elif_condition_has_explicit_statement_break(
condition: &StmtSeq,
body: &StmtSeq,
source: &str,
source_map: &SourceMap<'_>,
) -> bool {
let upper_bound =
branch_open_keyword_start(body, source, "then").unwrap_or(body.span.start.offset());
condition_sequence_has_explicit_statement_break(condition, upper_bound, source, source_map)
}
fn has_unescaped_line_break(text: &str) -> bool {
let mut cursor = 0usize;
let upper = text.len();
while cursor < upper {
let Some(ch) = text[cursor..].chars().next() else {
break;
};
match ch {
'\'' => {
cursor = skip_single_quoted(text, cursor + ch.len_utf8(), upper);
continue;
}
'"' => {
cursor = skip_double_quoted(text, cursor + ch.len_utf8(), upper);
continue;
}
'\n' => {
let before = text[..cursor].trim_end_matches([' ', '\t', '\r']);
if !before.ends_with('\\') {
return true;
}
}
_ => {}
}
cursor += ch.len_utf8();
}
false
}
pub(crate) fn loop_condition_starts_after_keyword(condition: &StmtSeq, span: Span) -> bool {
condition
.first()
.is_some_and(|stmt| stmt_span(stmt).start.line() > span.start.line())
}
pub(crate) fn condition_keyword_on_previous_non_empty_line(
condition: &StmtSeq,
source: &str,
source_map: &SourceMap<'_>,
keyword: &str,
) -> bool {
let Some(first) = condition.first() else {
return false;
};
let Some((mut line_start, _)) =
source_map.line_bounds_for_offset(stmt_span(first).start.offset())
else {
return false;
};
while let Some((start, end)) = source_map.previous_line_bounds(line_start) {
let Some(line) = source.get(start..end) else {
return false;
};
let trimmed = line.trim();
if !trimmed.is_empty() {
return trimmed == keyword;
}
line_start = start;
}
false
}
pub(crate) fn raw_grouped_if_condition(
command: &IfCommand,
then_span: Span,
source: &str,
source_map: &SourceMap<'_>,
facts: &FormatterFacts,
) -> Option<String> {
if !if_condition_starts_after_keyword(command, then_span, source, facts) {
return None;
}
let start = command.span.start.offset().checked_add("if".len())?;
let end = then_span.start.offset();
if start >= end || end > source.len() {
return None;
}
let raw = source.get(start..end)?;
if !(raw.trim_start().starts_with('{') && raw.contains('}') && raw.contains('\n')) {
return None;
}
let outer_indent = source_map
.line_indent_before_offset(command.span.start.offset())
.unwrap_or("");
Some(strip_outer_indent_after_first_line(raw, outer_indent))
}
fn strip_outer_indent_after_first_line(raw: &str, outer_indent: &str) -> String {
if outer_indent.is_empty() {
return raw.to_string();
}
let mut normalized = String::with_capacity(raw.len());
let mut lines = raw.split('\n');
if let Some(first) = lines.next() {
normalized.push_str(first);
}
for line in lines {
normalized.push('\n');
normalized.push_str(line.strip_prefix(outer_indent).unwrap_or(line));
}
normalized
}
pub(crate) fn stmt_sequence_renders_with_subshell_open(commands: &StmtSeq) -> bool {
commands
.first()
.is_some_and(stmt_renders_with_subshell_open)
}
fn stmt_renders_with_subshell_open(stmt: &Stmt) -> bool {
if stmt.negated {
return false;
}
let command_start = command_format_span(&stmt.command).start.offset();
if stmt
.redirects
.iter()
.any(|redirect| redirect.span.start.offset() < command_start)
{
return false;
}
match &stmt.command {
Command::Binary(command) => stmt_renders_with_subshell_open(&command.left),
Command::Compound(CompoundCommand::Subshell(_)) => true,
_ => false,
}
}