use super::raw_rewrites::*;
use super::*;
pub(super) fn push_raw_word_with_normalized_command_redirect_spacing(
rendered: &mut String,
word: &Word,
raw: &str,
source: &str,
options: &ResolvedShellFormatOptions,
) {
let mut spans = Vec::new();
collect_raw_command_substitution_spans(word.parts.as_slice(), &mut spans);
spans.sort_by_key(|span| span.start.offset());
let mut cursor = word.span.start.offset();
let word_end = word.span.end.offset().min(source.len());
let source_view = SourceView::new(source);
let mut wrote_span = false;
for span in spans {
let start = span.start.offset();
let end = span.end.offset();
if start < cursor || end > word_end || start >= end {
continue;
}
if let Some(prefix) = source_view.slice_between(cursor, start) {
rendered.push_str(prefix);
}
if let Some(command) = source_view.slice_between(start, end) {
push_raw_command_substitution_with_normalized_spacing(
rendered, command, source, start, options,
);
wrote_span = true;
}
cursor = end;
}
if wrote_span {
if let Some(suffix) = source_view.slice_between(cursor, word_end) {
rendered.push_str(suffix);
}
} else {
rendered.push_str(raw);
}
}
pub(super) fn push_raw_command_substitution_with_normalized_spacing(
target: &mut String,
raw: &str,
source: &str,
start_offset: usize,
options: &ResolvedShellFormatOptions,
) {
if let Some(normalized) = normalize_raw_backtick_command_substitution(raw) {
target.push_str(&normalized);
return;
}
if !raw.contains('\n') {
push_raw_shell_text_with_normalized_redirect_spacing(target, raw);
return;
}
RawShellBlockFormatter::format_to(
target,
raw,
source,
start_offset,
options,
RawShellBlockPolicy::command_substitution_word(),
);
}
#[derive(Clone, Copy)]
struct RawShellBlockPolicy {
normalize_comment_continuations: bool,
scan_first_line: bool,
seed_open_continuation_indent: bool,
multiline_literal_policy: RawShellBlockLiteralPolicy,
pipeline_policy: RawShellBlockPipelinePolicy,
body_indent_policy: RawShellBlockBodyIndentPolicy,
}
impl RawShellBlockPolicy {
fn command_substitution_word() -> Self {
Self {
normalize_comment_continuations: false,
scan_first_line: true,
seed_open_continuation_indent: true,
multiline_literal_policy: RawShellBlockLiteralPolicy::NormalizeContinuations,
pipeline_policy: RawShellBlockPipelinePolicy::NestedWord,
body_indent_policy: RawShellBlockBodyIndentPolicy::None,
}
}
fn block_command_substitution_body() -> Self {
Self {
normalize_comment_continuations: true,
scan_first_line: false,
seed_open_continuation_indent: false,
multiline_literal_policy: RawShellBlockLiteralPolicy::PreserveSource,
pipeline_policy: RawShellBlockPipelinePolicy::BodyLine,
body_indent_policy: RawShellBlockBodyIndentPolicy::StripFirstBodyIndent,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum RawShellBlockLiteralPolicy {
NormalizeContinuations,
PreserveSource,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum RawShellBlockPipelinePolicy {
NestedWord,
BodyLine,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum RawShellBlockBodyIndentPolicy {
None,
StripFirstBodyIndent,
}
#[derive(Default)]
struct RawShellBlockLinePlan {
force_preserve_line_indent: bool,
forced_rendered_indent: Option<String>,
used_continuation_indent: bool,
in_compound_body: bool,
closes_substitution_wrapper: bool,
}
struct RawShellBlockFormatter<'source, 'options> {
options: &'options ResolvedShellFormatOptions,
outer_indent: &'source str,
policy: RawShellBlockPolicy,
normalized_pipeline_continuation: bool,
quote: QuoteState,
previous_pipeline_indent: Option<String>,
continuation_pipeline_stage_indent: Option<String>,
continuation_indent: Option<String>,
literal_exit_continuation_indent: Option<String>,
compound_indents: RawCompoundIndentState,
body_indent: Option<String>,
}
impl<'source, 'options> RawShellBlockFormatter<'source, 'options> {
fn format_to(
target: &mut String,
raw: &'source str,
source: &'source str,
start_offset: usize,
options: &'options ResolvedShellFormatOptions,
policy: RawShellBlockPolicy,
) {
let normalized_pipeline = normalize_raw_pipeline_continuations(raw);
let normalized_pipeline_continuation = normalized_pipeline.is_some();
let raw = normalized_pipeline.as_deref().unwrap_or(raw);
let normalized_comment_continuations = policy
.normalize_comment_continuations
.then(|| normalize_continuations_before_comment_lines(raw))
.flatten();
let raw = normalized_comment_continuations.as_deref().unwrap_or(raw);
let normalized_close_continuations =
normalize_continuations_before_substitution_close_lines(raw);
let raw = normalized_close_continuations.as_deref().unwrap_or(raw);
let outer_indent = SourceView::new(source)
.line_indent_before_offset(start_offset)
.unwrap_or("");
let raw_lines = raw.split('\n').collect::<Vec<_>>();
let Some((first, lines)) = raw_lines.split_first() else {
return;
};
target.push_str(first);
let mut formatter = Self {
options,
outer_indent,
policy,
normalized_pipeline_continuation,
quote: QuoteState::default(),
previous_pipeline_indent: None,
continuation_pipeline_stage_indent: None,
continuation_indent: None,
literal_exit_continuation_indent: None,
compound_indents: RawCompoundIndentState::default(),
body_indent: None,
};
formatter.scan_and_seed_first_line(first);
for (line_index, line) in lines.iter().enumerate() {
formatter.push_line(target, line, lines, line_index);
}
}
fn scan_and_seed_first_line(&mut self, first: &str) {
if self.policy.scan_first_line {
self.quote.scan_line(first);
}
if self.policy.seed_open_continuation_indent {
let outer_shell_indent = normalized_raw_shell_indent(self.outer_indent, self.options);
self.continuation_indent =
line_without_continuation_backslash(first).and_then(|continued| {
let starts_command_substitution =
first.trim_start_matches([' ', '\t']).starts_with("$(");
(starts_command_substitution && !continued.contains(')'))
.then(|| source_indent_plus_one_unit(&outer_shell_indent, self.options))
});
}
}
fn push_line(&mut self, target: &mut String, line: &str, lines: &[&str], line_index: usize) {
target.push('\n');
if self.quote.in_multiline_literal() {
self.push_multiline_literal_line(target, line);
return;
}
let mut line =
strip_outer_indent_or_one_unit(line, self.outer_indent, self.options).to_string();
let source_indent_for_compound_shift = line_leading_shell_indent(&line).to_string();
if let Some(shifted) = self.compound_indents.shifted_line(&line, self.options) {
line = shifted;
}
let carried_pipeline_indent = self.previous_pipeline_indent.clone();
let plan = self.plan_line_adjustments(
&mut line,
carried_pipeline_indent.as_deref(),
lines,
line_index,
);
let (indent, content) = raw_line_parts(&line);
let body_indent_for_line =
self.body_indent_for_line(&plan, carried_pipeline_indent.is_some(), indent, content);
let rendered_indent = if plan.closes_substitution_wrapper {
push_raw_shell_line_with_rendered_indent(target, &line, self.options, "");
String::new()
} else if let Some(rendered_indent) = plan.forced_rendered_indent.as_deref() {
push_raw_shell_line_with_rendered_indent(target, &line, self.options, rendered_indent);
rendered_indent.to_string()
} else {
push_raw_shell_line_with_normalized_source_indent(
target,
&line,
self.options,
body_indent_for_line.as_deref(),
);
rendered_raw_shell_indent_for_line(
indent,
content,
body_indent_for_line.as_deref(),
self.options,
)
};
let line_is_pipeline_continuation_stage = carried_pipeline_indent.is_some();
self.previous_pipeline_indent =
self.next_pipeline_indent(&line, content, carried_pipeline_indent, &rendered_indent);
let line_continues = line_without_continuation_backslash(&line).is_some();
let line_indent = line_leading_shell_indent(&line).to_string();
self.quote.scan_line(&line);
self.update_continuation_pipeline_stage(
line_continues,
line_is_pipeline_continuation_stage,
plan.used_continuation_indent,
&line_indent,
);
if self.quote.in_multiline_literal()
&& plan.used_continuation_indent
&& self.policy.multiline_literal_policy
== RawShellBlockLiteralPolicy::NormalizeContinuations
{
self.literal_exit_continuation_indent = Some(line_indent.clone());
}
self.continuation_indent = self.next_continuation_indent(
line_continues,
content,
plan.used_continuation_indent,
&rendered_indent,
&line_indent,
);
self.compound_indents.update_line(
content,
&source_indent_for_compound_shift,
&rendered_indent,
indent,
line_is_pipeline_continuation_stage,
self.options,
);
}
fn push_multiline_literal_line(&mut self, target: &mut String, line: &str) {
match self.policy.multiline_literal_policy {
RawShellBlockLiteralPolicy::NormalizeContinuations => {
let line_continues = line_without_continuation_backslash(line).is_some();
if let Some(previous_indent) = self.continuation_indent.as_deref() {
let stripped = line
.strip_prefix(self.outer_indent)
.unwrap_or_else(|| strip_one_indent_unit(line, self.options));
let content = stripped.trim_start_matches([' ', '\t']);
target.push_str(previous_indent);
target.push_str(content);
} else {
target.push_str(line);
}
self.quote.scan_line(line);
self.continuation_indent = if line_continues {
if self.quote.in_multiline_literal() {
self.continuation_indent.clone()
} else {
self.continuation_indent
.clone()
.or_else(|| self.literal_exit_continuation_indent.take())
.or_else(|| Some(source_indent_plus_one_unit("", self.options)))
}
} else {
if !self.quote.in_multiline_literal() {
self.literal_exit_continuation_indent = None;
}
None
};
}
RawShellBlockLiteralPolicy::PreserveSource => {
target.push_str(line);
self.quote.scan_line(line);
let (indent, content) = raw_line_parts(line);
self.previous_pipeline_indent = if content.trim().is_empty() {
None
} else if line_ends_with_raw_continuation_operator(line) {
Some(indent.to_string())
} else {
None
};
}
}
}
fn plan_line_adjustments(
&mut self,
line: &mut String,
carried_pipeline_indent: Option<&str>,
lines: &[&str],
line_index: usize,
) -> RawShellBlockLinePlan {
match self.policy.pipeline_policy {
RawShellBlockPipelinePolicy::NestedWord => {
self.plan_nested_word_line(line, carried_pipeline_indent, lines, line_index)
}
RawShellBlockPipelinePolicy::BodyLine => {
self.plan_body_line(line, carried_pipeline_indent, lines, line_index)
}
}
}
fn plan_nested_word_line(
&mut self,
line: &mut String,
carried_pipeline_indent: Option<&str>,
lines: &[&str],
line_index: usize,
) -> RawShellBlockLinePlan {
let mut plan = RawShellBlockLinePlan::default();
let (indent, content) = raw_line_parts(line);
if let Some(previous_indent) = carried_pipeline_indent
&& !content.trim().is_empty()
&& raw_indent_units(indent, self.options)
< raw_indent_units(previous_indent, self.options)
{
*line = format!("{previous_indent}{content}");
}
let (indent, content) = raw_line_parts(line);
plan.closes_substitution_wrapper = raw_line_closes_substitution_wrapper(content)
&& raw_block_line_is_outer_substitution_close(lines, line_index);
if let Some(previous_indent) = self.continuation_indent.as_deref()
&& !content.trim().is_empty()
&& !content.starts_with('#')
&& !plan.closes_substitution_wrapper
&& normalized_raw_shell_indent(indent, self.options) != previous_indent
{
*line = format!("{previous_indent}{content}");
}
let (indent, content) = raw_line_parts(line);
if let Some(child_indent) =
self.compound_indents
.child_indent_if_underindented(indent, content, self.options)
{
*line = format!("{child_indent}{content}");
}
plan.used_continuation_indent = self.continuation_indent.is_some();
plan
}
fn plan_body_line(
&mut self,
line: &mut String,
carried_pipeline_indent: Option<&str>,
lines: &[&str],
line_index: usize,
) -> RawShellBlockLinePlan {
let mut plan = RawShellBlockLinePlan::default();
let (indent, content) = raw_line_parts(line);
if let Some(previous_indent) = carried_pipeline_indent
&& !content.trim().is_empty()
&& !raw_line_closes_substitution_wrapper(content)
{
let desired_indent = if self.normalized_pipeline_continuation {
previous_indent.to_string()
} else {
source_indent_plus_one_unit(previous_indent, self.options)
};
if raw_indent_units(indent, self.options)
< raw_indent_units(&desired_indent, self.options)
{
*line = format!("{desired_indent}{content}");
plan.force_preserve_line_indent = true;
}
}
let (indent, content) = raw_line_parts(line);
plan.in_compound_body = self.compound_indents.in_body(content);
if let Some(child_indent) =
self.compound_indents
.child_indent_if_underindented(indent, content, self.options)
{
*line = format!("{child_indent}{content}");
plan.force_preserve_line_indent = true;
}
let (_, content) = raw_line_parts(line);
if let Some(previous_indent) = self.continuation_indent.take()
&& !content.trim().is_empty()
&& !content.starts_with('#')
&& !raw_line_closes_substitution_wrapper(content)
{
plan.forced_rendered_indent = Some(previous_indent);
plan.force_preserve_line_indent = true;
plan.used_continuation_indent = true;
}
if self.compound_indents.comments.len() > 1 && self.compound_indents.closes_last(content) {
plan.force_preserve_line_indent = true;
}
plan.closes_substitution_wrapper = raw_line_closes_substitution_wrapper(content)
&& raw_block_line_is_outer_substitution_close(lines, line_index);
plan
}
fn body_indent_for_line(
&mut self,
plan: &RawShellBlockLinePlan,
carried_pipeline_indent: bool,
indent: &str,
content: &str,
) -> Option<String> {
if self.policy.body_indent_policy == RawShellBlockBodyIndentPolicy::None {
return None;
}
let leading_block_comment = self.body_indent.is_none() && content.starts_with('#');
if self.body_indent.is_none()
&& !content.trim().is_empty()
&& !content.starts_with('#')
&& !plan.closes_substitution_wrapper
{
self.body_indent = Some(indent.to_string());
}
let is_pipeline_continuation = carried_pipeline_indent && !content.trim().is_empty();
if plan.force_preserve_line_indent || is_pipeline_continuation || plan.in_compound_body {
None
} else if leading_block_comment {
Some(String::new())
} else {
self.body_indent.clone()
}
}
fn next_pipeline_indent(
&self,
line: &str,
content: &str,
carried_pipeline_indent: Option<String>,
rendered_indent: &str,
) -> Option<String> {
if content.trim().is_empty() {
return None;
}
if content.starts_with('#') {
return carried_pipeline_indent;
}
if !line_ends_with_raw_continuation_operator(line) {
return None;
}
match self.policy.pipeline_policy {
RawShellBlockPipelinePolicy::NestedWord => carried_pipeline_indent.or_else(|| {
let indent = line_leading_shell_indent(line);
let line_closes_pipeline_stage_compound =
self.compound_indents.closes_pipeline_stage(content);
let continued_pipeline_stage_indent =
self.continuation_pipeline_stage_indent.clone();
Some(
if content.starts_with('-') || line_closes_pipeline_stage_compound {
if raw_line_closes_inline_brace_group_before_pipeline(content) {
continued_pipeline_stage_indent.unwrap_or_else(|| {
source_indent_minus_one_unit(indent, self.options)
})
} else {
indent.to_string()
}
} else {
source_indent_plus_one_unit(indent, self.options)
},
)
}),
RawShellBlockPipelinePolicy::BodyLine => {
carried_pipeline_indent.or_else(|| Some(rendered_indent.to_string()))
}
}
}
fn update_continuation_pipeline_stage(
&mut self,
line_continues: bool,
line_is_pipeline_continuation_stage: bool,
used_continuation_indent: bool,
line_indent: &str,
) {
if self.policy.pipeline_policy != RawShellBlockPipelinePolicy::NestedWord {
return;
}
if line_continues {
if line_is_pipeline_continuation_stage && !used_continuation_indent {
self.continuation_pipeline_stage_indent = Some(line_indent.to_string());
}
} else if used_continuation_indent {
self.continuation_pipeline_stage_indent = None;
}
}
fn next_continuation_indent(
&self,
line_continues: bool,
content: &str,
used_continuation_indent: bool,
rendered_indent: &str,
line_indent: &str,
) -> Option<String> {
match self.policy.pipeline_policy {
RawShellBlockPipelinePolicy::NestedWord => line_continues.then(|| {
if self.quote.in_multiline_literal() || used_continuation_indent {
line_indent.to_string()
} else {
source_indent_plus_one_unit(line_indent, self.options)
}
}),
RawShellBlockPipelinePolicy::BodyLine => (line_continues && !content.starts_with('#'))
.then(|| {
if used_continuation_indent {
rendered_indent.to_string()
} else {
source_indent_plus_one_unit(rendered_indent, self.options)
}
}),
}
}
}
pub(super) fn collect_raw_command_substitution_spans(
parts: &[shuck_ast::WordPartNode],
spans: &mut Vec<shuck_ast::Span>,
) {
for part in parts {
match &part.kind {
WordPart::CommandSubstitution { .. } => spans.push(part.span),
WordPart::DoubleQuoted { parts, .. } => {
collect_raw_command_substitution_spans(parts.as_slice(), spans);
}
_ => {}
}
}
}
#[derive(Clone, Copy)]
pub(super) struct RawCommandSubstitutionCommentFallback<'source, 'facts> {
pub(super) raw: &'source str,
pub(super) body: &'source shuck_ast::StmtSeq,
pub(super) span_start: usize,
pub(super) context: RenderContext<'source, 'facts>,
}
pub(super) fn push_raw_command_substitution_comment_fallback(
rendered: &mut String,
fallback: RawCommandSubstitutionCommentFallback<'_, '_>,
try_normalized_body: bool,
) {
let RawCommandSubstitutionCommentFallback {
raw,
body,
span_start,
context,
} = fallback;
let source = context.source;
let options = context.options;
if push_inline_raw_command_substitution_as_block(rendered, raw, options) {
return;
}
if command_substitution_source_starts_with_body_line(raw)
&& !stmt_seq_has_heredoc(context.facts, body)
{
push_raw_block_command_substitution_without_outer_indent(
rendered, raw, source, span_start, options,
);
return;
}
if try_normalized_body
&& push_inline_raw_command_substitution_with_normalized_body(rendered, raw, options)
{
return;
}
push_raw_shell_text_with_normalized_redirect_spacing(rendered, raw);
}
#[allow(clippy::too_many_arguments)]
pub(super) fn render_command_substitution(
rendered: &mut String,
body: &shuck_ast::StmtSeq,
upper_bound: usize,
context: RenderContext<'_, '_>,
layout: CommandSubstitutionLayout,
inline_continuation_indent_levels: usize,
raw: Option<&str>,
) -> Option<()> {
let source = context.source;
let options = context.options;
let mut nested = String::new();
context
.format_stmt_sequence_to_buf(body, Some(upper_bound), &mut nested)
.ok()?;
let trimmed = trim_trailing_line_endings(&nested);
let normalized_backtick_body;
let trimmed = if raw.is_some_and(|raw| raw.starts_with('`')) && trimmed.contains("\\\\$") {
normalized_backtick_body = normalize_backtick_body_escaped_dollars(trimmed);
normalized_backtick_body.as_str()
} else {
trimmed
};
if trimmed.is_empty() {
if raw
.and_then(raw_dollar_command_substitution_body)
.is_some_and(|body| !body.trim_matches([' ', '\t', '\r', '\n']).is_empty())
{
return None;
}
rendered.push_str("$()");
return Some(());
}
let normalized_close_continuation = trim_rendered_close_line_continuation(trimmed);
let trimmed = normalized_close_continuation.as_deref().unwrap_or(trimmed);
let trailing_escaped_whitespace = raw
.and_then(raw_command_substitution_trailing_escaped_horizontal_whitespace)
.or_else(|| {
source_trailing_escaped_horizontal_whitespace_before_offset(source, upper_bound)
});
match layout {
CommandSubstitutionLayout::Inline | CommandSubstitutionLayout::InlineContinued => {
rendered.push_str("$(");
let trimmed = trim_inline_command_substitution_padding(trimmed);
if let Some(body) =
restore_trailing_escaped_horizontal_whitespace(trimmed, trailing_escaped_whitespace)
{
push_command_substitution_inline_body(
rendered,
&body,
options,
inline_continuation_indent_levels,
);
} else {
push_command_substitution_inline_body(
rendered,
trimmed,
options,
inline_continuation_indent_levels,
);
}
rendered.push(')');
}
CommandSubstitutionLayout::InlineSourceIndented => {
rendered.push_str("$(");
push_source_indented_inline_command_substitution(rendered, trimmed, raw?, options);
rendered.push(')');
}
CommandSubstitutionLayout::Block => {
rendered.push_str("$(\n");
push_indented_rendered_block(rendered, trimmed, options, 1);
rendered.push_str("\n)");
}
}
Some(())
}
pub(super) fn restore_trailing_escaped_horizontal_whitespace(
body: &str,
escaped_whitespace: Option<char>,
) -> Option<String> {
let whitespace = escaped_whitespace?;
body.ends_with('\\').then(|| {
let mut restored = body.to_string();
restored.push(whitespace);
restored
})
}
pub(super) fn raw_command_substitution_trailing_escaped_horizontal_whitespace(
raw: &str,
) -> Option<char> {
let body = raw_dollar_command_substitution_body(raw)?;
trailing_escaped_horizontal_whitespace(body)
}
pub(super) fn source_trailing_escaped_horizontal_whitespace_before_offset(
source: &str,
upper_bound: usize,
) -> Option<char> {
let close_offset = upper_bound.checked_sub(1)?;
if source.as_bytes().get(close_offset) != Some(&b')') {
return None;
}
trailing_escaped_horizontal_whitespace(SourceView::new(source).slice_between(0, close_offset)?)
}
pub(super) fn trailing_escaped_horizontal_whitespace(body: &str) -> Option<char> {
let (whitespace_start, whitespace) = body.char_indices().next_back()?;
if !matches!(whitespace, ' ' | '\t') {
return None;
}
let backslash_count = body.as_bytes()[..whitespace_start]
.iter()
.rev()
.take_while(|byte| **byte == b'\\')
.count();
(backslash_count % 2 == 1).then_some(whitespace)
}
pub(super) fn trim_rendered_close_line_continuation(rendered: &str) -> Option<String> {
let trimmed = rendered.trim_end_matches([' ', '\t']);
if let Some((before_close, close_line)) = trimmed.rsplit_once('\n')
&& close_line.trim_matches([' ', '\t', '\r']) == ")"
{
let before_close = before_close.trim_end_matches([' ', '\t']);
return has_odd_trailing_backslashes(before_close).then(|| {
before_close[..before_close.len().saturating_sub(1)]
.trim_end_matches([' ', '\t'])
.to_string()
});
}
has_odd_trailing_backslashes(trimmed).then(|| {
trimmed[..trimmed.len().saturating_sub(1)]
.trim_end_matches([' ', '\t'])
.to_string()
})
}
pub(super) fn has_odd_trailing_backslashes(text: &str) -> bool {
text.as_bytes()
.iter()
.rev()
.take_while(|byte| **byte == b'\\')
.count()
% 2
== 1
}
pub(super) fn commented_command_substitution_can_use_structural_formatter(body: &StmtSeq) -> bool {
let [stmt] = body.as_slice() else {
return false;
};
!stmt.negated
&& stmt.redirects.is_empty()
&& stmt.terminator.is_none()
&& (matches!(
&stmt.command,
Command::Compound(CompoundCommand::Case(_) | CompoundCommand::If(_))
) || command_is_pipeline_of_compound_groups(&stmt.command))
}
pub(super) fn command_is_pipeline_of_compound_groups(command: &Command) -> bool {
let Command::Binary(binary) = command else {
return false;
};
matches!(binary.op, BinaryOp::Pipe | BinaryOp::PipeAll)
&& stmt_is_compound_group_pipeline_operand(&binary.left)
&& stmt_is_compound_group_pipeline_operand(&binary.right)
}
pub(super) fn stmt_is_compound_group_pipeline_operand(stmt: &Stmt) -> bool {
if stmt.negated || !stmt.redirects.is_empty() || stmt.terminator.is_some() {
return false;
}
match &stmt.command {
Command::Binary(_) => command_is_pipeline_of_compound_groups(&stmt.command),
Command::Compound(CompoundCommand::BraceGroup(_) | CompoundCommand::Subshell(_)) => true,
_ => false,
}
}
pub(super) fn restore_raw_case_terminator_suffix_comments(
rendered: &mut String,
rendered_start: usize,
raw: &str,
) {
let comments = raw_case_terminator_suffix_comments_by_line(raw);
if comments.iter().all(Option::is_none) || rendered_start >= rendered.len() {
return;
}
let mut body = rendered[rendered_start..].to_string();
let mut search_start = 0usize;
for comment in comments {
let Some((line_start, line_end)) =
next_uncommented_case_terminator_line(&body, search_start)
else {
break;
};
if let Some(comment) = comment {
let insert_at = line_end;
body.insert_str(insert_at, &format!(" {comment}"));
search_start = line_start + (line_end - line_start) + comment.len() + 1;
} else {
search_start = line_end.saturating_add(1);
}
}
rendered.truncate(rendered_start);
rendered.push_str(&body);
}
pub(super) fn raw_case_terminator_suffix_comments_by_line(raw: &str) -> Vec<Option<String>> {
raw.lines()
.filter_map(|line| {
if !case_terminator_text_appears(line) {
return None;
}
let comment = line.find('#').and_then(|comment_start| {
let before_comment = line.get(..comment_start)?;
if !case_terminator_text_appears(before_comment) {
return None;
}
Some(
line.get(comment_start..)?
.trim_end_matches([' ', '\t', '\r'])
.to_string(),
)
});
Some(comment)
})
.collect()
}
pub(super) fn next_uncommented_case_terminator_line(
body: &str,
start: usize,
) -> Option<(usize, usize)> {
let mut offset = start.min(body.len());
while offset < body.len() {
let relative_end = body[offset..]
.find('\n')
.unwrap_or(body.len().saturating_sub(offset));
let line_end = offset + relative_end;
let line = body.get(offset..line_end)?;
if case_terminator_text_appears(line) && !line.contains('#') {
return Some((offset, line_end));
}
offset = line_end.saturating_add(1);
}
None
}
pub(super) fn case_terminator_text_appears(text: &str) -> bool {
text.contains(";;") || text.contains(";&") || text.contains(";;&")
}
pub(super) fn normalize_backtick_body_escaped_dollars(body: &str) -> String {
let mut normalized = String::with_capacity(body.len());
let mut chars = body.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\' && chars.peek() == Some(&'\\') {
chars.next();
if chars.peek() == Some(&'$') {
normalized.push('\\');
normalized.push('$');
chars.next();
continue;
}
normalized.push('\\');
normalized.push('\\');
continue;
}
normalized.push(ch);
}
normalized
}
pub(super) fn push_command_substitution_inline_body(
target: &mut String,
body: &str,
options: &ResolvedShellFormatOptions,
inline_continuation_indent_levels: usize,
) {
let expanded_pipeline_brace_group = expand_inline_pipeline_brace_group_body(body, options);
let body = expanded_pipeline_brace_group.as_deref().unwrap_or(body);
let adjusted_body = indent_inline_case_command_body(body, options).or_else(|| {
indent_inline_pipeline_continuations(body, options, inline_continuation_indent_levels)
});
let body = adjusted_body.as_deref().unwrap_or(body);
if body.starts_with('(') {
target.push(' ');
}
if options.space_redirects() {
target.push_str(body);
} else if options.binary_next_line() {
push_raw_shell_text_with_normalized_redirect_spacing_preserving_continuations(target, body);
} else {
push_raw_shell_text_with_normalized_redirect_spacing(target, body);
}
}
pub(super) fn expand_inline_pipeline_brace_group_body(
body: &str,
options: &ResolvedShellFormatOptions,
) -> Option<String> {
if body.contains('\n') || !raw_body_contains_pipeline_multistatement_brace_group(body) {
return None;
}
let fragment = FragmentFormatter::parse(body, options)?;
let mut nested = String::new();
fragment.format_body_to_buf(None, &mut nested).ok()?;
let trimmed = trim_trailing_line_endings(&nested);
trimmed.contains('\n').then(|| trimmed.to_string())
}
pub(super) fn indent_inline_case_command_body(
body: &str,
options: &ResolvedShellFormatOptions,
) -> Option<String> {
if !body.contains('\n') || !body.trim_start_matches([' ', '\t']).starts_with("case ") {
return None;
}
let prefix = options.indent_prefix(1);
let mut rendered = String::with_capacity(body.len() + prefix.len());
let mut changed = false;
for (index, line) in body.split('\n').enumerate() {
if index > 0 {
rendered.push('\n');
}
if index > 0 && !line.trim().is_empty() {
rendered.push_str(&prefix);
changed = true;
}
rendered.push_str(line);
}
changed.then_some(rendered)
}
pub(super) fn trim_inline_command_substitution_padding(body: &str) -> &str {
body.trim_matches([' ', '\t'])
}
pub(super) fn indent_inline_pipeline_continuations(
body: &str,
options: &ResolvedShellFormatOptions,
indent_levels: usize,
) -> Option<String> {
if !body.contains('\n') {
return None;
}
let unit = options.indent_prefix(1);
let prefix = unit.repeat(indent_levels.max(1));
let mut rendered = String::with_capacity(body.len() + prefix.len());
let mut changed = false;
let mut previous_ends_pipeline = false;
let mut pipeline_comment_continuation = false;
let mut continuation_indent: Option<String> = None;
let mut quote = QuoteState::default();
for (index, line) in body.split('\n').enumerate() {
if index > 0 {
rendered.push('\n');
}
let mut rendered_line = String::new();
let used_continuation_indent = if let Some(indent) = continuation_indent.take()
&& !line.trim().is_empty()
{
rendered_line.push_str(&indent);
rendered_line.push_str(line.trim_start_matches([' ', '\t']));
changed = true;
true
} else {
false
};
let continues_pipeline_operand = previous_ends_pipeline || pipeline_comment_continuation;
if !used_continuation_indent
&& continues_pipeline_operand
&& !line.is_empty()
&& !line.starts_with([' ', '\t'])
{
rendered_line.push_str(&prefix);
rendered_line.push_str(line);
changed = true;
} else if !used_continuation_indent
&& continues_pipeline_operand
&& indent_levels > 1
&& !line.trim().is_empty()
&& line_leading_shell_indent(line) != prefix
{
rendered_line.push_str(&prefix);
rendered_line.push_str(line.trim_start_matches([' ', '\t']));
changed = true;
} else if !used_continuation_indent {
rendered_line.push_str(line);
}
rendered.push_str(&rendered_line);
let line_is_pipeline_comment = continues_pipeline_operand
&& rendered_line
.trim_start_matches([' ', '\t'])
.starts_with('#');
let line_continues = line_without_continuation_backslash(&rendered_line).is_some();
quote.scan_line(&rendered_line);
previous_ends_pipeline = line_ends_with_raw_continuation_operator(&rendered_line);
pipeline_comment_continuation = line_is_pipeline_comment;
continuation_indent = line_continues.then(|| {
let indent = line_leading_shell_indent(&rendered_line);
if quote.in_multiline_literal() || used_continuation_indent {
indent.to_string()
} else {
source_indent_plus_one_unit(indent, options)
}
});
}
changed.then_some(rendered)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum CommandSubstitutionLayout {
Inline,
InlineContinued,
InlineSourceIndented,
Block,
}
pub(super) fn command_substitution_layout(
raw: Option<&str>,
body: &shuck_ast::StmtSeq,
context: RenderContext<'_, '_>,
force_block: bool,
allow_source_indented_inline: bool,
) -> CommandSubstitutionLayout {
if force_block {
return CommandSubstitutionLayout::Block;
}
if stmt_seq_has_heredoc(context.facts, body) {
return CommandSubstitutionLayout::Block;
}
if let Some(raw) = raw {
if command_substitution_source_starts_with_body_line(raw) {
return CommandSubstitutionLayout::Block;
}
if command_substitution_source_closes_on_own_line(raw) {
return CommandSubstitutionLayout::Block;
}
if command_substitution_source_prefers_continued_inline_body(raw) {
return CommandSubstitutionLayout::InlineContinued;
}
if allow_source_indented_inline && raw.contains('\n') {
return CommandSubstitutionLayout::InlineSourceIndented;
}
}
if body.len() > 1
|| body
.span
.slice(context.source)
.trim_start_matches([' ', '\t', '\r'])
.starts_with('\n')
{
CommandSubstitutionLayout::Block
} else {
CommandSubstitutionLayout::Inline
}
}
pub(super) fn push_inline_raw_command_substitution_as_block(
target: &mut String,
raw: &str,
options: &ResolvedShellFormatOptions,
) -> bool {
let Some(after_open) = raw.strip_prefix("$(") else {
return false;
};
if after_open.starts_with(['\n', '\r']) || !command_substitution_source_closes_on_own_line(raw)
{
return false;
}
let Some(close_offset) = raw.rfind(')') else {
return false;
};
let Some(close_line_start) = raw[..close_offset].rfind('\n').map(|index| index + 1) else {
return false;
};
let Some(body_source) = raw.get(2..close_line_start) else {
return false;
};
let body_source = body_source.trim_end_matches(['\n', '\r']);
if body_source.trim().is_empty() {
target.push_str("$()");
return true;
}
let nested = normalize_inline_raw_command_substitution_body(body_source, options);
target.push_str("$(\n");
push_indented_rendered_block(target, &nested, options, 1);
target.push_str("\n)");
true
}
pub(super) fn push_inline_raw_command_substitution_with_normalized_body(
target: &mut String,
raw: &str,
options: &ResolvedShellFormatOptions,
) -> bool {
if command_substitution_source_starts_with_body_line(raw)
|| command_substitution_source_closes_on_own_line(raw)
{
return false;
}
let Some(body_source) = raw_dollar_command_substitution_body(raw) else {
return false;
};
if !body_source.contains('\n') {
return false;
}
let body_source = body_source.trim_start_matches([' ', '\t', '\r']);
if !body_source.starts_with('(') {
return false;
}
let nested = normalize_inline_raw_command_substitution_body_preserving_nested_comments(
body_source,
options,
);
target.push_str("$(");
if nested.starts_with('(') {
target.push(' ');
}
target.push_str(&nested);
target.push(')');
true
}
pub(super) fn normalize_inline_raw_command_substitution_body(
body_source: &str,
options: &ResolvedShellFormatOptions,
) -> String {
normalize_inline_raw_command_substitution_body_with_options(body_source, options, false)
}
pub(super) fn normalize_inline_raw_command_substitution_body_preserving_nested_comments(
body_source: &str,
options: &ResolvedShellFormatOptions,
) -> String {
normalize_inline_raw_command_substitution_body_with_options(body_source, options, true)
}
pub(super) fn normalize_inline_raw_command_substitution_body_with_options(
body_source: &str,
options: &ResolvedShellFormatOptions,
preserve_nested_comment_indent: bool,
) -> String {
let normalized = normalize_raw_pipeline_continuations(body_source);
let normalized_pipeline_continuation = normalized.is_some();
let body_source = normalized.as_deref().unwrap_or(body_source);
let normalized_comment_continuations =
normalize_continuations_before_comment_lines(body_source);
let body_source = normalized_comment_continuations
.as_deref()
.unwrap_or(body_source);
let normalized_close_continuations =
normalize_continuations_before_substitution_close_lines(body_source);
let body_source = normalized_close_continuations
.as_deref()
.unwrap_or(body_source);
let lines = body_source.lines().map(str::to_string).collect::<Vec<_>>();
let source_base_indent = inline_raw_body_source_base_indent(&lines);
let mut rendered = String::new();
let mut previous_pipeline_indent_units: Option<usize> = None;
let mut continuation_indent_units: Option<usize> = None;
let mut pipeline_compounds = Vec::<InlinePipelineCompound>::new();
for (index, line) in lines.iter().enumerate() {
if index > 0 {
rendered.push('\n');
}
let content = line.trim_start_matches([' ', '\t']);
if content.trim().is_empty() {
previous_pipeline_indent_units = None;
continuation_indent_units = None;
continue;
}
let carried_pipeline_indent = previous_pipeline_indent_units;
let pipeline_base_units = pipeline_compounds
.last()
.map(|compound| compound.base_units)
.unwrap_or(0);
let relative_source_indent =
inline_raw_body_relative_source_indent(line, index, source_base_indent.as_deref());
let relative_indent = if content.starts_with('#')
&& carried_pipeline_indent.is_none()
&& pipeline_compounds.is_empty()
&& (!preserve_nested_comment_indent || relative_source_indent.is_empty())
{
""
} else {
relative_source_indent
};
let mut indent_units = pipeline_base_units + raw_indent_units(relative_indent, options);
if let Some(previous_units) = carried_pipeline_indent {
let extra_units = usize::from(!normalized_pipeline_continuation);
indent_units = indent_units.max(previous_units + extra_units);
}
let mut used_continuation_indent = false;
if let Some(units) = continuation_indent_units.take()
&& !content.starts_with('#')
{
indent_units = units;
used_continuation_indent = true;
}
rendered.extend(std::iter::repeat_n('\t', indent_units));
push_raw_shell_line_with_normalized_redirect_spacing(&mut rendered, content);
let line_is_pipeline_continuation_stage = carried_pipeline_indent.is_some();
if content.starts_with('#') {
previous_pipeline_indent_units = carried_pipeline_indent;
} else {
previous_pipeline_indent_units =
line_ends_with_raw_continuation_operator(content).then_some(indent_units);
if line_without_continuation_backslash(content).is_some() {
continuation_indent_units = Some(if used_continuation_indent {
indent_units
} else {
indent_units + 1
});
} else {
continuation_indent_units = None;
}
}
if let Some(close_keyword) = raw_compound_close_keyword(content)
&& (line_is_pipeline_continuation_stage || !pipeline_compounds.is_empty())
{
pipeline_compounds.push(InlinePipelineCompound {
close_keyword,
base_units: if line_is_pipeline_continuation_stage {
indent_units
} else {
pipeline_base_units
},
});
}
if pipeline_compounds
.last()
.is_some_and(|compound| raw_line_closes_compound(content, compound.close_keyword))
{
pipeline_compounds.pop();
}
}
rendered
}
pub(super) struct InlinePipelineCompound {
close_keyword: &'static str,
base_units: usize,
}
pub(super) fn inline_raw_body_source_base_indent(lines: &[String]) -> Option<String> {
let mut common: Option<String> = None;
for line in lines.iter().skip(1) {
if line.trim_matches([' ', '\t', '\r']).is_empty() {
continue;
}
let indent = line_leading_shell_indent(line);
if refine_common_indent(&mut common, indent) {
return None;
}
}
common
}
pub(super) fn inline_raw_body_relative_source_indent<'a>(
line: &'a str,
index: usize,
source_base_indent: Option<&str>,
) -> &'a str {
let indent = line_leading_shell_indent(line);
if index == 0 {
return indent;
}
let Some(source_base_indent) = source_base_indent else {
return indent;
};
indent.strip_prefix(source_base_indent).unwrap_or("")
}
pub(super) fn push_raw_block_command_substitution_without_outer_indent(
target: &mut String,
raw: &str,
source: &str,
start_offset: usize,
options: &ResolvedShellFormatOptions,
) {
RawShellBlockFormatter::format_to(
target,
raw,
source,
start_offset,
options,
RawShellBlockPolicy::block_command_substitution_body(),
);
}
#[allow(clippy::too_many_arguments)]
pub(super) fn render_process_substitution(
rendered: &mut String,
body: &shuck_ast::StmtSeq,
is_input: bool,
span: shuck_ast::Span,
context: RenderContext<'_, '_>,
multiline: bool,
raw: Option<&str>,
) -> Option<()> {
let source = context.source;
let options = context.options;
let has_heredoc = stmt_seq_has_heredoc(context.facts, body);
let mut nested = String::new();
context
.format_stmt_sequence_to_buf(body, span.end.offset().checked_sub(1), &mut nested)
.ok()?;
let prefix = if is_input { '<' } else { '>' };
let trimmed = trim_trailing_line_endings(&nested);
if trimmed.is_empty() {
rendered.push(prefix);
rendered.push_str("()");
return Some(());
}
let rendered_multiline = trimmed.contains('\n');
if multiline || has_heredoc || rendered_multiline {
if rendered_multiline
&& !has_heredoc
&& raw.is_some_and(process_substitution_source_starts_with_inline_brace_group)
{
rendered.push(prefix);
rendered.push('(');
rendered.push_str(trimmed);
rendered.push(')');
} else if let Some(raw) = raw
&& process_substitution_source_starts_with_body_line(raw)
&& raw.contains('\n')
&& !substitution_source_closes_on_own_line(raw)
{
rendered.push(prefix);
rendered.push('(');
push_source_indented_inline_command_substitution(rendered, trimmed, raw, options);
rendered.push(')');
} else {
let outer_levels =
source_indent_units_before_offset(source, span.start.offset(), options);
rendered.push(prefix);
rendered.push_str("(\n");
push_indented_rendered_block(rendered, trimmed, options, outer_levels + 1);
rendered.push('\n');
options.push_indent_units(rendered, outer_levels);
rendered.push(')');
}
} else {
rendered.push(prefix);
rendered.push('(');
rendered.push_str(trimmed);
rendered.push(')');
}
Some(())
}
pub(super) fn process_substitution_source_starts_with_inline_brace_group(raw: &str) -> bool {
raw.get(2..).is_some_and(|body| {
(raw.starts_with("<(") || raw.starts_with(">("))
&& !body.starts_with(['\n', '\r'])
&& body.trim_start_matches([' ', '\t']).starts_with('{')
})
}
pub(super) fn process_substitution_source_starts_with_body_line(raw: &str) -> bool {
raw.get(2..).is_some_and(|body| {
(raw.starts_with("<(") || raw.starts_with(">(")) && !body.starts_with('\n')
})
}
pub(super) fn process_substitution_source_opens_to_body_line(raw: &str) -> bool {
raw.get(2..).is_some_and(|body| {
(raw.starts_with("<(") || raw.starts_with(">(")) && body.starts_with(['\n', '\r'])
})
}
pub(super) fn trim_trailing_line_endings(rendered: &str) -> &str {
rendered.trim_end_matches(&['\r', '\n'][..])
}
pub(super) fn push_source_indented_inline_command_substitution(
target: &mut String,
rendered: &str,
raw: &str,
options: &ResolvedShellFormatOptions,
) {
let raw_indents = raw
.lines()
.skip(1)
.map(line_leading_shell_indent)
.map(|indent| normalized_source_inline_indent(indent, options))
.collect::<Vec<_>>();
let fallback_indent = raw_indents.first().map(String::as_str).unwrap_or("");
for (index, line) in rendered.lines().enumerate() {
if index > 0 {
target.push('\n');
let indent = raw_indents
.get(index - 1)
.map(String::as_str)
.unwrap_or(fallback_indent);
target.push_str(indent);
}
if index == 0 {
target.push_str(line);
} else {
target.push_str(line.trim_start_matches([' ', '\t']));
}
}
}
pub(super) fn normalized_source_inline_indent(
indent: &str,
options: &ResolvedShellFormatOptions,
) -> String {
match options.indent_style() {
IndentStyle::Tab if indent.chars().all(|ch| ch == ' ') => {
let unit = usize::from(options.indent_width()).clamp(1, 4);
if indent.len().is_multiple_of(unit) {
"\t".repeat(indent.len() / unit)
} else {
indent.to_string()
}
}
IndentStyle::Space if indent.chars().all(|ch| ch == '\t') => {
" ".repeat(indent.len() * usize::from(options.indent_width()))
}
_ => indent.to_string(),
}
}
pub(super) fn normalized_raw_shell_indent(
indent: &str,
options: &ResolvedShellFormatOptions,
) -> String {
match options.indent_style() {
IndentStyle::Tab if !indent.is_empty() && indent.chars().all(|ch| ch == ' ') => {
let unit = usize::from(options.indent_width()).clamp(1, 4);
"\t".repeat(indent.len().div_ceil(unit))
}
_ => normalized_source_inline_indent(indent, options),
}
}
pub(super) fn push_indented_rendered_block(
target: &mut String,
rendered: &str,
options: &ResolvedShellFormatOptions,
levels: usize,
) {
let prefix = options.indent_prefix(levels);
let normalized_literal_continuations =
normalize_literal_continuation_indent_for_block(rendered);
let rendered = normalized_literal_continuations
.as_deref()
.unwrap_or(rendered);
let common_source_indent = common_rendered_block_indent(rendered, options);
let mut active_heredoc: Option<CommandSubstitutionHeredocIndent> = None;
for (index, line) in rendered.lines().enumerate() {
if index > 0 {
target.push('\n');
}
if let Some(heredoc) = active_heredoc.as_ref() {
let closes = heredoc_line_closes_command_substitution_heredoc(line, heredoc);
if heredoc.strip_tabs {
if closes {
target.push_str(&prefix);
target.push_str(&heredoc.command_indent);
target.push_str(line.trim_start_matches('\t'));
active_heredoc = None;
continue;
}
if line_needs_command_substitution_indent(line, options) {
target.push_str(&prefix);
}
}
target.push_str(line);
if closes {
active_heredoc = None;
}
continue;
}
let line = strip_common_rendered_block_indent(line, &common_source_indent);
if line_needs_command_substitution_indent(line, options) {
target.push_str(&prefix);
}
target.push_str(line);
active_heredoc = command_substitution_heredoc_indent(line);
}
}
pub(super) fn normalize_literal_continuation_indent_for_block(rendered: &str) -> Option<String> {
if !rendered.contains('\n') {
return None;
}
let mut quote = QuoteState::default();
let mut continuation_indent: Option<String> = None;
let mut normalized = String::with_capacity(rendered.len());
let mut changed = false;
for (index, line) in rendered.split('\n').enumerate() {
if index > 0 {
normalized.push('\n');
}
let mut line = line.to_string();
if let Some(indent) = continuation_indent.take()
&& !line.trim().is_empty()
{
let content = line.trim_start_matches([' ', '\t']);
if line_leading_shell_indent(&line) != indent {
line = format!("{indent}{content}");
changed = true;
}
}
let line_continues = line_without_continuation_backslash(&line).is_some();
quote.scan_line(&line);
continuation_indent = (line_continues && quote.in_multiline_literal())
.then(|| line_leading_shell_indent(&line).to_string());
normalized.push_str(&line);
}
changed.then_some(normalized)
}
pub(super) fn common_rendered_block_indent(
rendered: &str,
options: &ResolvedShellFormatOptions,
) -> String {
let mut active_heredoc: Option<CommandSubstitutionHeredocIndent> = None;
let mut common: Option<String> = None;
for line in rendered.lines() {
if let Some(heredoc) = active_heredoc.as_ref() {
if heredoc_line_closes_command_substitution_heredoc(line, heredoc) {
active_heredoc = None;
}
continue;
}
if line_needs_command_substitution_indent(line, options) {
let indent = line_leading_shell_indent(line);
if indent.is_empty() {
return String::new();
}
if refine_common_indent(&mut common, indent) {
return String::new();
}
}
active_heredoc = command_substitution_heredoc_indent(line);
}
common.unwrap_or_default()
}
pub(super) fn strip_common_rendered_block_indent<'a>(
line: &'a str,
common_indent: &str,
) -> &'a str {
if common_indent.is_empty() {
line
} else {
line.strip_prefix(common_indent).unwrap_or(line)
}
}
#[derive(Debug, Clone)]
pub(super) struct CommandSubstitutionHeredocIndent {
delimiter: String,
strip_tabs: bool,
command_indent: String,
}
pub(super) fn command_substitution_heredoc_indent(
line: &str,
) -> Option<CommandSubstitutionHeredocIndent> {
let start = heredoc_start(line)?;
Some(CommandSubstitutionHeredocIndent {
delimiter: start.delimiter.to_string(),
strip_tabs: start.strip_tabs,
command_indent: line_leading_shell_indent(line).to_string(),
})
}
pub(super) fn heredoc_line_closes_command_substitution_heredoc(
line: &str,
heredoc: &CommandSubstitutionHeredocIndent,
) -> bool {
if heredoc.strip_tabs {
line.trim_start_matches('\t') == heredoc.delimiter
} else {
line == heredoc.delimiter
}
}
pub(super) fn line_needs_command_substitution_indent(
line: &str,
options: &ResolvedShellFormatOptions,
) -> bool {
if line.is_empty() {
return false;
}
match options.indent_style() {
IndentStyle::Tab => !line.starts_with(' '),
IndentStyle::Space => true,
}
}