use super::command_substitution::*;
use super::parameter::*;
use super::*;
pub(crate) fn normalize_raw_unquoted_word_continuations(raw: &str) -> Option<String> {
if !raw.contains("\\\n") && !raw.contains("\\\r\n") {
return None;
}
let mut normalized = String::with_capacity(raw.len());
let mut chars = raw.chars().peekable();
let mut in_single_quotes = false;
let mut in_double_quotes = false;
let mut changed = false;
while let Some(ch) = chars.next() {
if ch == '\'' && !in_double_quotes {
in_single_quotes = !in_single_quotes;
normalized.push(ch);
continue;
}
if ch == '"' && !in_single_quotes {
in_double_quotes = !in_double_quotes;
normalized.push(ch);
continue;
}
if ch == '\\'
&& !in_single_quotes
&& !in_double_quotes
&& let Some(skipped_indent) = consume_escaped_newline_indent(&mut chars)
{
changed = true;
if chars
.peek()
.is_some_and(|next| matches!(next, '|' | '&' | ';' | '<' | '>' | '(' | ')'))
{
return None;
}
if skipped_indent {
normalized.push(' ');
}
continue;
}
normalized.push(ch);
}
changed.then_some(normalized)
}
pub(super) fn consume_escaped_newline_indent(
chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
) -> Option<bool> {
let mut probe = chars.clone();
let newline_len = match probe.next() {
Some('\n') => 1,
Some('\r') if probe.next().is_some_and(|next| next == '\n') => 2,
_ => return None,
};
for _ in 0..newline_len {
chars.next();
}
let mut skipped_indent = false;
while chars.peek().is_some_and(|next| matches!(next, ' ' | '\t')) {
skipped_indent = true;
chars.next();
}
Some(skipped_indent)
}
pub(super) fn normalize_raw_compound_assignment_word_continuations(raw: &str) -> Option<String> {
if (!raw.contains("\\\n") && !raw.contains("\\\r\n"))
|| raw.contains("$(")
|| raw.contains('`')
|| raw.contains("<(")
|| raw.contains(">(")
{
return None;
}
let open = raw.find("=(").or_else(|| raw.find("+=("))?;
let open_paren = open + raw[open..].find('(')?;
let head = raw.get(..=open_paren)?;
if !raw_compound_assignment_head_is_simple(head) {
return None;
}
let close = raw.rfind(')')?;
if close <= open_paren {
return None;
}
let body = raw.get(open_paren + 1..close)?;
let tail = raw.get(close..)?;
let body_lines = body
.lines()
.map(|line| {
line_without_continuation_backslash(line)
.unwrap_or_else(|| line.trim_end_matches([' ', '\t', '\r']))
})
.collect::<Vec<_>>();
if body_lines.len() < 2 {
return None;
}
let common_indent =
common_nonempty_shell_indent(body_lines.get(1..).unwrap_or_default().iter().copied());
let mut normalized = String::with_capacity(raw.len());
normalized.push_str(head);
normalized.push_str(body_lines[0].trim_start_matches([' ', '\t']));
for line in &body_lines[1..] {
normalized.push('\n');
if line.trim().is_empty() {
continue;
}
normalized.push('\t');
normalized.push_str(
line.strip_prefix(&common_indent)
.unwrap_or_else(|| line.trim_start_matches([' ', '\t'])),
);
}
normalized.push_str(tail);
Some(normalized)
}
pub(super) fn raw_compound_assignment_head_is_simple(head: &str) -> bool {
let Some(name) = head.strip_suffix("+=(").or_else(|| head.strip_suffix("=(")) else {
return false;
};
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
matches!(first, '_' | 'a'..='z' | 'A'..='Z')
&& chars.all(|ch| matches!(ch, '_' | 'a'..='z' | 'A'..='Z' | '0'..='9'))
}
pub(super) fn parameter_bourne_operand_needs_subscript_compaction(
parameter: &shuck_ast::ParameterExpansion,
source: &str,
) -> bool {
let operand = match parameter.bourne() {
Some(
BourneParameterExpansion::Indirect {
operand: Some(operand),
..
}
| BourneParameterExpansion::Operation {
operand: Some(operand),
..
},
) => operand.slice(source),
_ => return false,
};
compact_parameter_operand_subscripts(operand) != operand
}
pub(super) fn parameter_needs_special_rendering(parameter: &shuck_ast::ParameterExpansion) -> bool {
parameter.bourne().is_some_and(|syntax| match syntax {
BourneParameterExpansion::Operation { operator, .. } => {
matches!(
operator.as_ref(),
ParameterOp::ReplaceFirst { .. } | ParameterOp::ReplaceAll { .. }
)
}
BourneParameterExpansion::Slice { .. } => true,
_ => false,
})
}
pub(super) fn parameter_prefers_raw_source(
parameter: &shuck_ast::ParameterExpansion,
span: shuck_ast::Span,
source: &str,
) -> bool {
parameter.bourne().is_none_or(|syntax| match syntax {
BourneParameterExpansion::Operation { operator, .. } => match operator.as_ref() {
ParameterOp::ReplaceFirst { replacement, .. }
| ParameterOp::ReplaceAll { replacement, .. } => {
!replacement.slice(source).is_empty()
|| raw_source_slice(span, source).is_some_and(|raw| raw.ends_with("/}"))
}
_ => true,
},
BourneParameterExpansion::Slice {
offset_ast,
length_ast,
..
} => offset_ast.is_none() && length_ast.is_none(),
_ => true,
})
}
pub(super) fn stmt_seq_contains_comments(facts: &FormatterFacts<'_>, sequence: &StmtSeq) -> bool {
facts.sequence_contains_comments(sequence)
}
pub(super) fn stmt_seq_has_heredoc(facts: &FormatterFacts<'_>, sequence: &StmtSeq) -> bool {
facts.sequence_contains_heredoc(sequence)
}
pub(super) fn normalize_raw_backtick_command_substitution(raw: &str) -> Option<String> {
let body = raw.strip_prefix('`')?.strip_suffix('`')?;
let body = normalize_backtick_body_escaped_dollars(body);
Some(format!("$({body})"))
}
pub(super) fn raw_block_line_is_outer_substitution_close(lines: &[&str], index: usize) -> bool {
lines
.get(index.saturating_add(1)..)
.is_none_or(|remaining| {
remaining
.iter()
.all(|line| line.trim_matches([' ', '\t', '\r']).is_empty())
})
}
pub(super) fn normalize_continuations_before_comment_lines(text: &str) -> Option<String> {
normalize_continuations_before_matching_lines(text, false, |next| next.starts_with('#'))
}
pub(super) fn normalize_continuations_before_substitution_close_lines(
text: &str,
) -> Option<String> {
normalize_continuations_before_matching_lines(text, true, raw_line_closes_substitution_wrapper)
}
pub(super) fn normalize_continuations_before_matching_lines(
text: &str,
trim_prefix: bool,
next_line_matches: impl Fn(&str) -> bool,
) -> Option<String> {
let mut lines = text.lines().map(str::to_string).collect::<Vec<_>>();
let mut changed = false;
for index in 0..lines.len().saturating_sub(1) {
let next_content = lines[index + 1].trim_start_matches([' ', '\t']);
if next_line_matches(next_content)
&& let Some(prefix) = line_without_continuation_backslash(&lines[index])
{
lines[index] = if trim_prefix {
prefix.trim_end_matches([' ', '\t']).to_string()
} else {
prefix.to_string()
};
changed = true;
}
}
changed.then(|| lines.join("\n"))
}
#[derive(Debug)]
pub(super) struct RawCompoundIndentShift {
pub(super) source_indent: String,
pub(super) extra_units: usize,
pub(super) close_keyword: &'static str,
}
pub(super) struct RawCompoundCommentIndent {
pub(super) child_indent: String,
pub(super) close_keyword: &'static str,
pub(super) pipeline_continuation: bool,
}
#[derive(Default)]
pub(super) struct RawCompoundIndentState {
pub(super) shifts: Vec<RawCompoundIndentShift>,
pub(super) comments: Vec<RawCompoundCommentIndent>,
}
impl RawCompoundIndentState {
pub(super) fn shifted_line(
&self,
line: &str,
options: &ResolvedShellFormatOptions,
) -> Option<String> {
let shift = self.shifts.last()?;
raw_line_indent_matches_shift(line, shift)
.then(|| add_raw_indent_units(line, shift.extra_units, options))
}
pub(super) fn in_body(&self, content: &str) -> bool {
self.comments.last().is_some_and(|compound| {
!content.trim().is_empty()
&& !raw_line_closes_compound(content, compound.close_keyword)
&& !raw_line_is_compound_mid_keyword(content)
})
}
pub(super) fn child_indent_if_underindented<'a>(
&'a self,
indent: &str,
content: &str,
options: &ResolvedShellFormatOptions,
) -> Option<&'a str> {
let compound = self.comments.last()?;
(self.in_body(content)
&& raw_indent_units(indent, options)
< raw_indent_units(&compound.child_indent, options))
.then_some(compound.child_indent.as_str())
}
pub(super) fn closes_last(&self, content: &str) -> bool {
self.comments
.last()
.is_some_and(|compound| raw_line_closes_compound(content, compound.close_keyword))
}
pub(super) fn closes_pipeline_stage(&self, content: &str) -> bool {
self.comments.last().is_some_and(|compound| {
compound.pipeline_continuation
&& raw_line_closes_compound(content, compound.close_keyword)
})
}
pub(super) fn update_line(
&mut self,
content: &str,
source_indent: &str,
rendered_indent: &str,
shifted_indent: &str,
pipeline_continuation: bool,
options: &ResolvedShellFormatOptions,
) {
if let Some(close_keyword) = raw_compound_close_keyword(content) {
self.comments.push(RawCompoundCommentIndent {
child_indent: source_indent_plus_one_unit(rendered_indent, options),
close_keyword,
pipeline_continuation,
});
let before_units = raw_indent_units(source_indent, options);
let after_units = raw_indent_units(shifted_indent, options);
if after_units > before_units {
self.shifts.push(RawCompoundIndentShift {
source_indent: source_indent.to_string(),
extra_units: after_units - before_units,
close_keyword,
});
}
}
if self
.shifts
.last()
.is_some_and(|shift| raw_line_closes_compound(content, shift.close_keyword))
{
self.shifts.pop();
}
if self.closes_last(content) {
self.comments.pop();
}
}
}
pub(super) fn raw_line_indent_matches_shift(line: &str, shift: &RawCompoundIndentShift) -> bool {
let (indent, content) = raw_line_parts(line);
!content.trim().is_empty() && raw_indent_starts_with(indent, &shift.source_indent)
}
pub(super) fn raw_line_parts(line: &str) -> (&str, &str) {
let indent = line_leading_shell_indent(line);
(indent, &line[indent.len()..])
}
pub(super) fn raw_indent_starts_with(indent: &str, prefix: &str) -> bool {
indent == prefix || indent.starts_with(prefix)
}
pub(super) fn add_raw_indent_units(
line: &str,
extra_units: usize,
options: &ResolvedShellFormatOptions,
) -> String {
let (indent, content) = raw_line_parts(line);
let mut shifted = indent.to_string();
for _ in 0..extra_units {
shifted = source_indent_plus_one_unit(&shifted, options);
}
format!("{shifted}{content}")
}
pub(super) fn push_raw_shell_line_with_normalized_source_indent(
target: &mut String,
line: &str,
options: &ResolvedShellFormatOptions,
body_indent: Option<&str>,
) {
let (mut indent, content) = raw_line_parts(line);
if content.starts_with('#')
&& let Some(body_indent) = body_indent
&& indent.len() > body_indent.len()
{
indent = body_indent;
}
let trimmed_content = content.trim_matches([' ', '\t', '\r']);
let mut rendered_indent = String::new();
if body_indent == Some("")
&& !trimmed_content.is_empty()
&& !raw_line_closes_substitution_wrapper(trimmed_content)
{
options.push_indent_units(&mut rendered_indent, 1);
} else {
rendered_indent.push_str(&normalized_raw_shell_indent(indent, options));
}
target.push_str(&rendered_indent);
let normalized_content;
let content = {
normalized_content = body_indent
.is_some()
.then(|| strip_semicolon_before_trailing_comment(content))
.flatten()
.or_else(|| normalize_padding_before_trailing_comment(content));
normalized_content.as_deref().unwrap_or(content)
};
push_raw_shell_line_content_with_normalized_spacing(target, content, options, &rendered_indent);
}
pub(super) fn push_raw_shell_line_with_rendered_indent(
target: &mut String,
line: &str,
options: &ResolvedShellFormatOptions,
rendered_indent: &str,
) {
let (_, content) = raw_line_parts(line);
target.push_str(rendered_indent);
let normalized_content = normalize_padding_before_trailing_comment(content);
let content = normalized_content.as_deref().unwrap_or(content);
push_raw_shell_line_content_with_normalized_spacing(target, content, options, rendered_indent);
}
pub(super) fn rendered_raw_shell_indent_for_line(
indent: &str,
content: &str,
body_indent: Option<&str>,
options: &ResolvedShellFormatOptions,
) -> String {
let trimmed_content = content.trim_matches([' ', '\t', '\r']);
if body_indent == Some("")
&& !trimmed_content.is_empty()
&& !raw_line_closes_substitution_wrapper(trimmed_content)
{
let mut rendered = String::new();
options.push_indent_units(&mut rendered, 1);
rendered
} else {
normalized_raw_shell_indent(indent, options)
}
}
pub(super) fn strip_semicolon_before_trailing_comment(line: &str) -> Option<String> {
let comment_start = trailing_comment_start(line)?;
let before_comment = line[..comment_start].trim_end_matches([' ', '\t', '\r']);
let before_semicolon = before_comment.strip_suffix(';')?;
if before_semicolon.ends_with(';') {
return None;
}
let mut rendered = String::with_capacity(line.len().saturating_sub(1));
rendered.push_str(before_semicolon.trim_end_matches([' ', '\t', '\r']));
rendered.push(' ');
rendered.push_str(&line[comment_start..]);
Some(rendered)
}
pub(super) fn normalize_padding_before_trailing_comment(line: &str) -> Option<String> {
let comment_start = trailing_comment_start(line)?;
let before_comment = &line[..comment_start];
let code = before_comment.trim_end_matches([' ', '\t', '\r']);
if code.is_empty()
|| code.len() == before_comment.len()
|| before_comment[code.len()..].chars().count() == 1
{
return None;
}
let mut rendered = String::with_capacity(line.len());
rendered.push_str(code);
rendered.push(' ');
rendered.push_str(&line[comment_start..]);
Some(rendered)
}
pub(super) fn trailing_comment_start(line: &str) -> Option<usize> {
RawShellScanner::new(line).find_comment(0, line.len())
}
pub(super) fn raw_line_closes_substitution_wrapper(content: &str) -> bool {
let Some(rest) = content.trim_matches([' ', '\t', '\r']).strip_prefix(')') else {
return false;
};
let rest = rest.trim_matches([' ', '\t', '\r']);
rest.is_empty()
|| rest == "\\"
|| rest == "|"
|| rest == "|&"
|| rest.starts_with("#")
|| rest.starts_with("\\ ")
|| rest.starts_with("| ")
|| rest.starts_with("|& ")
}
pub(super) fn push_raw_shell_text_with_normalized_redirect_spacing(
target: &mut String,
text: &str,
) {
let normalized_pipeline = normalize_raw_pipeline_continuations(text);
let text = normalized_pipeline.as_deref().unwrap_or(text);
push_raw_shell_text_with_normalized_redirect_spacing_preserving_continuations(target, text);
}
pub(super) fn push_raw_shell_text_with_normalized_redirect_spacing_preserving_continuations(
target: &mut String,
text: &str,
) {
let mut lines = text.split('\n');
if let Some(first) = lines.next() {
push_raw_shell_line_with_normalized_redirect_spacing(target, first);
}
for line in lines {
target.push('\n');
push_raw_shell_line_with_normalized_redirect_spacing(target, line);
}
}
pub(super) fn push_raw_shell_line_content_with_normalized_spacing(
target: &mut String,
line: &str,
options: &ResolvedShellFormatOptions,
line_indent: &str,
) {
let mut rendered = String::new();
if expand_inline_raw_command_substitutions_in_line(&mut rendered, line, options) {
let mut lines = rendered.split('\n');
if let Some(first) = lines.next() {
target.push_str(first);
}
for line in lines {
target.push('\n');
target.push_str(line_indent);
target.push_str(line);
}
} else {
push_raw_shell_line_with_normalized_redirect_spacing(target, line);
}
}
pub(super) fn expand_inline_raw_command_substitutions_in_line(
target: &mut String,
line: &str,
options: &ResolvedShellFormatOptions,
) -> bool {
if !line.contains("$(") {
return false;
}
let mut changed = false;
let mut last = 0usize;
let mut index = 0usize;
let mut quote = QuoteState::default();
let scanner = RawShellScanner::new(line);
let bytes = line.as_bytes();
while index < line.len() {
let Some(ch) = line[index..].chars().next() else {
break;
};
let next_index = index + ch.len_utf8();
if quote.consume_raw_char(ch, false) {
index = next_index;
continue;
}
if ch == '#' && scanner.find_comment(index, next_index).is_some() {
break;
}
if ch == '$'
&& bytes.get(index + 1) == Some(&b'(')
&& bytes.get(index + 2) != Some(&b'(')
&& let Some(close_offset) = matching_raw_command_substitution_close(line, index + 2)
{
let raw = &line[index..=close_offset];
if let Some(block) = render_inline_raw_command_substitution_as_block(raw, options) {
push_raw_shell_line_with_normalized_redirect_spacing(target, &line[last..index]);
target.push_str(&block);
last = close_offset + 1;
changed = true;
}
index = close_offset + 1;
continue;
}
index = next_index;
}
if changed {
push_raw_shell_line_with_normalized_redirect_spacing(target, &line[last..]);
}
changed
}
pub(super) fn render_inline_raw_command_substitution_as_block(
raw: &str,
options: &ResolvedShellFormatOptions,
) -> Option<String> {
if raw.contains('\n') {
return None;
}
let body = raw_dollar_command_substitution_body(raw)?.trim_matches([' ', '\t', '\r']);
if body.is_empty() {
return None;
}
if !raw_shell_body_may_render_as_block(body) {
return None;
}
let fragment = FragmentFormatter::parse(body, options)?;
let inline_multiline = fragment
.facts()
.sequence_contains_multistatement_pipeline_brace_group(fragment.body())
|| raw_body_contains_pipeline_multistatement_brace_group(body);
if fragment.body_len() <= 1 && !inline_multiline {
return None;
}
let mut nested = String::new();
fragment.format_body_to_buf(None, &mut nested).ok()?;
let trimmed = trim_trailing_line_endings(&nested);
if trimmed.is_empty() {
return Some("$()".to_string());
}
let mut rendered = String::new();
if inline_multiline && fragment.body_len() == 1 {
rendered.push_str("$(");
push_command_substitution_inline_body(
&mut rendered,
trim_inline_command_substitution_padding(trimmed),
options,
1,
);
rendered.push(')');
} else {
rendered.push_str("$(\n");
push_indented_rendered_block(&mut rendered, trimmed, options, 1);
rendered.push_str("\n)");
}
Some(rendered)
}
fn raw_shell_body_may_render_as_block(body: &str) -> bool {
raw_shell_body_needs_structural_spacing(body) || raw_shell_body_has_async_separator(body)
}
pub(super) fn raw_body_contains_pipeline_multistatement_brace_group(body: &str) -> bool {
let bytes = body.as_bytes();
let mut index = 0usize;
let mut in_single_quotes = false;
let mut in_double_quotes = false;
let mut escaped = false;
while index < bytes.len() {
let byte = bytes[index];
if escaped {
escaped = false;
index += 1;
continue;
}
match byte {
b'\\' if !in_single_quotes => {
escaped = true;
index += 1;
continue;
}
b'\'' if !in_double_quotes => {
in_single_quotes = !in_single_quotes;
index += 1;
continue;
}
b'"' if !in_single_quotes => {
in_double_quotes = !in_double_quotes;
index += 1;
continue;
}
b'|' if !in_single_quotes
&& !in_double_quotes
&& bytes.get(index + 1) != Some(&b'|') =>
{
let mut group_start = index + 1;
if bytes.get(group_start) == Some(&b'&') {
group_start += 1;
}
while bytes
.get(group_start)
.is_some_and(|byte| matches!(byte, b' ' | b'\t' | b'\r'))
{
group_start += 1;
}
if bytes.get(group_start) == Some(&b'{')
&& raw_brace_group_has_multiple_commands(&body[group_start + 1..])
{
return true;
}
}
_ => {}
}
index += 1;
}
false
}
pub(super) fn raw_brace_group_has_multiple_commands(body_after_open: &str) -> bool {
let bytes = body_after_open.as_bytes();
let mut index = 0usize;
let mut in_single_quotes = false;
let mut in_double_quotes = false;
let mut escaped = false;
let mut saw_separator = false;
while index < bytes.len() {
let byte = bytes[index];
if escaped {
escaped = false;
index += 1;
continue;
}
match byte {
b'\\' if !in_single_quotes => {
escaped = true;
}
b'\'' if !in_double_quotes => {
in_single_quotes = !in_single_quotes;
}
b'"' if !in_single_quotes => {
in_double_quotes = !in_double_quotes;
}
b'}' if !in_single_quotes && !in_double_quotes => return false,
b';' | b'\n' if !in_single_quotes && !in_double_quotes => {
saw_separator = true;
}
_ if saw_separator
&& !in_single_quotes
&& !in_double_quotes
&& !matches!(byte, b' ' | b'\t' | b'\r') =>
{
return true;
}
_ => {}
}
index += 1;
}
false
}
fn raw_shell_body_has_async_separator(body: &str) -> bool {
let mut quote = QuoteState::default();
let mut index = 0usize;
while index < body.len() {
let rest = &body[index..];
let Some(ch) = rest.chars().next() else {
break;
};
let next_index = index + ch.len_utf8();
if quote.consume_raw_char(ch, true) {
index = next_index;
continue;
}
if rest.starts_with("$(")
&& !rest.starts_with("$((")
&& let Some(close_offset) = matching_raw_command_substitution_close(body, index + 2)
{
index = close_offset + 1;
continue;
}
if ch == '&' && !matches!(rest.as_bytes().get(1), Some(b'>')) {
return true;
}
index = next_index;
}
false
}
pub(super) fn raw_command_redirect_spacing_would_change(raw: &str) -> bool {
if !(raw.contains('<') || raw.contains('>')) {
return false;
}
let mut normalized = String::with_capacity(raw.len());
push_raw_shell_text_with_normalized_redirect_spacing(&mut normalized, raw);
normalized != raw
}
pub(super) fn push_preserved_raw_word_source(
rendered: &mut String,
word: &Word,
raw: &str,
source: &str,
options: &ResolvedShellFormatOptions,
) {
if raw.contains('<') || raw.contains('>') || raw.contains('`') {
push_raw_word_with_normalized_command_redirect_spacing(
rendered, word, raw, source, options,
);
} else {
rendered.push_str(raw);
}
}
pub(super) fn raw_parameter_command_spacing_would_change(raw: &str) -> bool {
raw_command_redirect_spacing_would_change(raw)
|| raw_command_substitution_needs_structural_spacing(raw)
}
pub(super) fn raw_command_substitution_needs_structural_spacing(raw: &str) -> bool {
let mut index = 0usize;
while let Some((open_offset, close_offset)) = next_raw_command_substitution(raw, index) {
if raw_shell_body_needs_structural_spacing(&raw[open_offset + 2..close_offset]) {
return true;
}
index = close_offset + 1;
}
false
}
pub(super) fn raw_shell_body_needs_structural_spacing(body: &str) -> bool {
let body = body.trim_matches([' ', '\t']);
if raw_body_contains_pipeline_multistatement_brace_group(body) {
return true;
}
let mut quote = QuoteState::default();
let mut horizontal_run = 0usize;
let mut index = 0usize;
while index < body.len() {
let rest = &body[index..];
let Some(ch) = rest.chars().next() else {
break;
};
let next_index = index + ch.len_utf8();
if quote.consume_raw_char(ch, true) {
horizontal_run = 0;
index = next_index;
continue;
}
if rest.starts_with("$(")
&& !rest.starts_with("$((")
&& let Some(close_offset) = matching_raw_command_substitution_close(body, index + 2)
{
if raw_shell_body_needs_structural_spacing(&body[index + 2..close_offset]) {
return true;
}
horizontal_run = 0;
index = close_offset + 1;
continue;
}
match ch {
' ' | '\t' | '\r' => {
if ch != ' ' {
return true;
}
horizontal_run += 1;
if horizontal_run > 1 {
return true;
}
}
'|' if !rest.starts_with("||") => {
let op_len = if rest.starts_with("|&") { 2 } else { 1 };
let previous_is_space = body[..index]
.chars()
.next_back()
.is_some_and(|previous| matches!(previous, ' ' | '\t' | '\r'));
let next_is_space = body[index + op_len..]
.chars()
.next()
.is_some_and(|next| matches!(next, ' ' | '\t' | '\r'));
if !previous_is_space || !next_is_space {
return true;
}
horizontal_run = 0;
}
';' if !rest.starts_with(";;") => return true,
_ => horizontal_run = 0,
}
index = next_index;
}
false
}
pub(super) fn normalize_raw_command_substitution_padding(raw: &str) -> Option<String> {
let mut rendered = None;
let mut cursor = 0usize;
let mut index = 0usize;
while let Some((open_offset, close_offset)) = next_raw_command_substitution(raw, index) {
let body = &raw[open_offset + 2..close_offset];
if !body.contains('\n') {
let trimmed = trim_raw_command_substitution_horizontal_padding(body);
let normalized_body = normalize_raw_command_substitution_padding(trimmed);
let normalized_body = normalized_body.as_deref().unwrap_or(trimmed);
if trimmed.len() != body.len() || normalized_body != trimmed {
let rendered = rendered.get_or_insert_with(|| String::with_capacity(raw.len()));
rendered.push_str(&raw[cursor..open_offset]);
rendered.push_str("$(");
if normalized_body.starts_with('(') {
rendered.push(' ');
}
rendered.push_str(normalized_body);
rendered.push(')');
cursor = close_offset + 1;
}
}
index = close_offset + 1;
}
rendered.map(|mut rendered| {
rendered.push_str(&raw[cursor..]);
rendered
})
}
pub(super) fn trim_raw_command_substitution_horizontal_padding(body: &str) -> &str {
trim_unescaped_trailing_whitespace(body.trim_start_matches([' ', '\t']))
}
pub(crate) fn normalize_raw_empty_parameter_replacement_delimiters(raw: &str) -> Option<String> {
if !raw.contains("${") {
return None;
}
let bytes = raw.as_bytes();
let mut rendered = String::with_capacity(raw.len());
let mut cursor = 0usize;
let mut index = 0usize;
let mut changed = false;
let mut in_single_quotes = false;
let mut escaped = false;
while index + 1 < bytes.len() {
let ch = raw[index..].chars().next()?;
let next_index = index + ch.len_utf8();
if in_single_quotes {
if ch == '\'' {
in_single_quotes = false;
}
index = next_index;
continue;
}
if escaped {
escaped = false;
index = next_index;
continue;
}
if ch == '\\' {
escaped = true;
index = next_index;
continue;
}
if ch == '\'' {
in_single_quotes = true;
index = next_index;
continue;
}
if bytes[index] == b'$'
&& bytes[index + 1] == b'{'
&& let Some(close_offset) = matching_raw_parameter_expansion_close(raw, index + 2)
{
let body = &raw[index + 2..close_offset];
if raw_parameter_replacement_needs_empty_delimiter(body) {
rendered.push_str(&raw[cursor..close_offset]);
rendered.push('/');
cursor = close_offset;
changed = true;
}
index = close_offset + 1;
continue;
}
index = next_index;
}
finish_raw_rewrite(rendered, raw, cursor, changed)
}
pub(super) fn matching_raw_parameter_expansion_close(
raw: &str,
body_start: usize,
) -> Option<usize> {
let bytes = raw.as_bytes();
let mut depth = 1usize;
let mut escaped = false;
let mut index = body_start;
while index < bytes.len() {
if escaped {
escaped = false;
index += 1;
continue;
}
match bytes[index] {
b'\\' => escaped = true,
b'$' if bytes.get(index + 1) == Some(&b'{') => {
depth += 1;
index += 1;
}
b'}' => {
depth = depth.saturating_sub(1);
if depth == 0 {
return Some(index);
}
}
_ => {}
}
index += 1;
}
None
}
pub(super) fn raw_parameter_replacement_needs_empty_delimiter(body: &str) -> bool {
let Some(after_operator) = raw_parameter_replacement_body_after_operator(body) else {
return false;
};
let (_, replacement) = split_raw_parameter_replacement(after_operator);
if replacement.is_empty() {
return !raw_has_final_replacement_delimiter(after_operator);
}
replacement_ends_with_ambiguous_quote(replacement)
}
pub(super) fn raw_parameter_replacement_body_after_operator(body: &str) -> Option<&str> {
let mut index = body.strip_prefix('!').map_or(0, |_| 1);
let bytes = body.as_bytes();
if index >= bytes.len() {
return None;
}
if bytes[index].is_ascii_alphabetic() || bytes[index] == b'_' {
index += 1;
while index < bytes.len() && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_')
{
index += 1;
}
} else if bytes[index].is_ascii_digit()
|| matches!(
bytes[index],
b'@' | b'*' | b'#' | b'?' | b'-' | b'$' | b'!' | b'0'
)
{
index += 1;
} else {
return None;
}
if bytes.get(index) == Some(&b'[') {
index = raw_parameter_subscript_end(body, index)?;
}
body.get(index..)
.and_then(|rest| rest.strip_prefix("//").or_else(|| rest.strip_prefix('/')))
}
pub(super) fn raw_parameter_subscript_end(body: &str, open: usize) -> Option<usize> {
let bytes = body.as_bytes();
let mut depth = 1usize;
let mut escaped = false;
let mut index = open + 1;
while index < bytes.len() {
if escaped {
escaped = false;
index += 1;
continue;
}
match bytes[index] {
b'\\' => escaped = true,
b'[' => depth += 1,
b']' => {
depth = depth.saturating_sub(1);
if depth == 0 {
return Some(index + 1);
}
}
_ => {}
}
index += 1;
}
None
}
pub(super) fn raw_has_final_replacement_delimiter(after_operator: &str) -> bool {
let Some((last_index, _)) = after_operator.char_indices().next_back() else {
return false;
};
after_operator[last_index..].starts_with('/')
&& !RawShellScanner::new(after_operator).is_escaped(last_index)
}
pub(super) fn replacement_ends_with_ambiguous_quote(replacement: &str) -> bool {
if replacement.ends_with('\'') {
return replacement[..replacement.len() - '\''.len_utf8()].ends_with('\\');
}
if replacement.ends_with('"') {
let quote_index = replacement.len() - '"'.len_utf8();
let backslashes = replacement[..quote_index]
.chars()
.rev()
.take_while(|ch| *ch == '\\')
.count();
return backslashes > 0 && backslashes % 2 == 0;
}
false
}
pub(super) fn normalize_raw_arithmetic_command_substitution_padding(raw: &str) -> Option<String> {
let (open, close) = if raw.starts_with("$((") && raw.ends_with("))") {
("$((", "))")
} else if raw.starts_with("$[") && raw.ends_with(']') {
("$[", "]")
} else {
return None;
};
let body_start = open.len();
let body_end = raw.len().saturating_sub(close.len());
let body = raw.get(body_start..body_end)?;
if !(body.contains("$(") || body.contains('`')) {
return None;
}
let trimmed = body.trim_matches([' ', '\t', '\r']);
if trimmed.len() == body.len() {
return None;
}
let mut rendered = String::with_capacity(raw.len());
rendered.push_str(open);
rendered.push_str(trimmed);
rendered.push_str(close);
Some(rendered)
}
pub(super) fn normalize_raw_arithmetic_expansion_padding(raw: &str) -> Option<String> {
let mut rendered = String::with_capacity(raw.len());
let mut cursor = 0usize;
let mut index = 0usize;
let mut changed = false;
while index + 2 < raw.len() {
let rest = &raw[index..];
if rest.starts_with("$((")
&& index
.checked_sub(1)
.and_then(|previous| raw.as_bytes().get(previous))
.is_none_or(|byte| *byte != b'\\')
&& let Some(close_start) = matching_raw_arithmetic_expansion_close(raw, index + 3)
{
let body = &raw[index + 3..close_start];
let trimmed = body.trim_matches([' ', '\t', '\r']);
if trimmed.len() != body.len() {
rendered.push_str(&raw[cursor..index]);
rendered.push_str("$((");
rendered.push_str(trimmed);
rendered.push_str("))");
cursor = close_start + 2;
changed = true;
}
index = close_start + 2;
continue;
}
let Some(ch) = rest.chars().next() else {
break;
};
index += ch.len_utf8();
}
finish_raw_rewrite(rendered, raw, cursor, changed)
}
pub(super) fn matching_raw_arithmetic_expansion_close(
raw: &str,
body_start: usize,
) -> Option<usize> {
let mut quote = QuoteState::default();
let mut paren_depth = 0usize;
let mut index = body_start;
while index < raw.len() {
let rest = &raw[index..];
let ch = rest.chars().next()?;
let next_index = index + ch.len_utf8();
if quote.consume_raw_char(ch, true) {
index = next_index;
continue;
}
if rest.starts_with("$(")
&& !rest.starts_with("$((")
&& let Some(close_offset) = matching_raw_command_substitution_close(raw, index + 2)
{
index = close_offset + 1;
continue;
}
match ch {
'(' => paren_depth += 1,
')' if rest.starts_with("))") && paren_depth == 0 => return Some(index),
')' if paren_depth > 0 => paren_depth -= 1,
_ => {}
}
index = next_index;
}
None
}
pub(super) fn push_raw_shell_line_with_normalized_redirect_spacing(
target: &mut String,
line: &str,
) {
let mut last = 0;
let mut index = 0;
let mut quote = QuoteState::default();
let scanner = RawShellScanner::new(line);
let bytes = line.as_bytes();
while index < line.len() {
let Some(ch) = line[index..].chars().next() else {
break;
};
let next_index = index + ch.len_utf8();
if quote.consume_shell_word_char(ch) {
index = next_index;
continue;
}
if !quote.in_quotes() && ch == '#' && scanner.find_comment(index, next_index).is_some() {
break;
}
if !quote.in_single_quotes()
&& ch == '$'
&& bytes.get(index + 1) == Some(&b'(')
&& bytes.get(index + 2) != Some(&b'(')
&& let Some(close_offset) = matching_raw_command_substitution_close(line, index + 2)
{
target.push_str(&line[last..index]);
target.push_str("$(");
push_raw_shell_text_with_normalized_redirect_spacing(
target,
&line[index + 2..close_offset],
);
target.push(')');
last = close_offset + 1;
index = close_offset + 1;
continue;
}
if !quote.in_quotes() && matches!(bytes[index], b' ' | b'\t' | b'\r') {
let whitespace_start = index;
let mut semicolon_start = index + 1;
while semicolon_start < bytes.len()
&& matches!(bytes[semicolon_start], b' ' | b'\t' | b'\r')
{
semicolon_start += 1;
}
if bytes.get(semicolon_start) == Some(&b';')
&& raw_semicolon_can_attach_to_previous_word(bytes, whitespace_start)
&& raw_semicolon_is_single_terminator(bytes, semicolon_start)
{
target.push_str(&line[last..whitespace_start]);
last = semicolon_start;
index = semicolon_start;
continue;
}
}
if !quote.in_quotes() && bytes[index].is_ascii_digit() {
let fd_start = index;
let mut operator_start = index + 1;
while operator_start < bytes.len() && bytes[operator_start].is_ascii_digit() {
operator_start += 1;
}
if let Some(operator_end) = redirect_operator_end(bytes, operator_start) {
let mut target_start = operator_end;
while target_start < bytes.len()
&& matches!(bytes[target_start], b' ' | b'\t' | b'\r')
{
target_start += 1;
}
if target_start > operator_end && target_start < bytes.len() {
target.push_str(&line[last..operator_end]);
last = target_start;
index = target_start;
continue;
}
}
index = fd_start;
}
if !quote.in_quotes()
&& matches!(bytes[index], b'<' | b'>')
&& let Some(operator_end) = redirect_operator_end(bytes, index)
{
let mut target_start = operator_end;
while target_start < bytes.len() && matches!(bytes[target_start], b' ' | b'\t' | b'\r')
{
target_start += 1;
}
if target_start > operator_end
&& target_start < bytes.len()
&& raw_redirect_target_spacing_can_be_stripped(bytes, index, target_start)
{
target.push_str(&line[last..operator_end]);
last = target_start;
index = target_start;
continue;
}
}
if !quote.in_quotes() && bytes.get(index..index + 3) == Some(b"<<<") {
let operator_end = index + 3;
let mut target_start = operator_end;
while target_start < bytes.len() && matches!(bytes[target_start], b' ' | b'\t' | b'\r')
{
target_start += 1;
}
if target_start > operator_end && target_start < bytes.len() {
target.push_str(&line[last..operator_end]);
last = target_start;
index = target_start;
continue;
}
}
index = next_index;
}
target.push_str(&line[last..]);
}
pub(super) fn raw_semicolon_can_attach_to_previous_word(
bytes: &[u8],
whitespace_start: usize,
) -> bool {
bytes
.get(..whitespace_start)
.and_then(|prefix| {
prefix
.iter()
.rev()
.find(|byte| !matches!(byte, b' ' | b'\t' | b'\r'))
.copied()
})
.is_some_and(|byte| !matches!(byte, b';' | b'('))
}
pub(super) fn raw_semicolon_is_single_terminator(bytes: &[u8], semicolon_start: usize) -> bool {
!matches!(
bytes.get(semicolon_start + 1).copied(),
Some(b';' | b'&' | b'|')
)
}
pub(super) fn raw_compound_close_keyword(content: &str) -> Option<&'static str> {
let trimmed = content.trim_end_matches([' ', '\t', '\r']);
if trimmed == "{" || trimmed.ends_with(" {") || trimmed.ends_with("; {") {
return Some("}");
}
if raw_line_starts_with_keyword(trimmed, "for")
|| raw_line_starts_with_keyword(trimmed, "select")
|| raw_line_starts_with_keyword(trimmed, "while")
|| raw_line_starts_with_keyword(trimmed, "until")
{
return raw_line_ends_with_keyword(trimmed, "do").then_some("done");
}
if raw_line_starts_with_keyword(trimmed, "if") {
return raw_line_ends_with_keyword(trimmed, "then").then_some("fi");
}
if raw_line_starts_with_keyword(trimmed, "case") {
return raw_line_ends_with_keyword(trimmed, "in").then_some("esac");
}
None
}
pub(super) fn raw_line_closes_compound(content: &str, close_keyword: &str) -> bool {
raw_line_starts_with_keyword(content.trim_start_matches([' ', '\t', '\r']), close_keyword)
}
pub(super) fn raw_line_is_compound_mid_keyword(content: &str) -> bool {
let content = content.trim_start_matches([' ', '\t', '\r']);
raw_line_starts_with_keyword(content, "else")
|| raw_line_starts_with_keyword(content, "elif")
|| raw_line_starts_with_keyword(content, "then")
|| raw_line_starts_with_keyword(content, "do")
}
pub(super) fn raw_line_starts_with_keyword(line: &str, keyword: &str) -> bool {
let Some(rest) = line.strip_prefix(keyword) else {
return false;
};
rest.is_empty()
|| rest
.as_bytes()
.first()
.is_some_and(|byte| matches!(byte, b' ' | b'\t' | b'\r' | b';' | b'|' | b'&'))
}
pub(super) fn raw_line_ends_with_keyword(line: &str, keyword: &str) -> bool {
let Some(prefix) = line.strip_suffix(keyword) else {
return false;
};
prefix.is_empty()
|| prefix
.as_bytes()
.last()
.is_some_and(|byte| matches!(byte, b' ' | b'\t' | b'\r' | b';' | b'|'))
}
pub(super) fn raw_line_closes_inline_brace_group_before_pipeline(content: &str) -> bool {
let trimmed = content.trim_end_matches([' ', '\t', '\r']);
let before_operator = if let Some(prefix) = trimmed.strip_suffix("|&") {
prefix
} else if let Some(prefix) = trimmed.strip_suffix('|') {
prefix
} else {
return false;
};
before_operator
.trim_end_matches([' ', '\t', '\r'])
.ends_with('}')
}
pub(super) fn raw_redirect_target_spacing_can_be_stripped(
bytes: &[u8],
operator_start: usize,
target_start: usize,
) -> bool {
if !matches!(bytes.get(operator_start), Some(b'<' | b'>')) {
return true;
}
if bytes.get(operator_start) == bytes.get(target_start)
&& bytes.get(target_start + 1) == Some(&b'(')
{
return false;
}
!bytes
.get(target_start)
.is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
}
pub(super) fn source_indent_units_before_offset(
source: &str,
offset: usize,
options: &ResolvedShellFormatOptions,
) -> usize {
let Some(indent) = SourceView::new(source).line_indent_before_offset(offset) else {
return 0;
};
raw_indent_units(indent, options)
}
pub(super) fn raw_indent_units(indent: &str, options: &ResolvedShellFormatOptions) -> usize {
let normalized = normalized_raw_shell_indent(indent, options);
let width = usize::from(options.indent_width()).max(1);
match options.indent_style() {
IndentStyle::Tab => {
normalized.chars().filter(|ch| *ch == '\t').count()
+ normalized.chars().filter(|ch| *ch == ' ').count() / width
}
IndentStyle::Space => normalized.len() / width,
}
}
pub(super) fn strip_one_indent_unit<'a>(
line: &'a str,
options: &ResolvedShellFormatOptions,
) -> &'a str {
match options.indent_style() {
IndentStyle::Tab => line.strip_prefix('\t').unwrap_or_else(|| {
line.strip_prefix(&" ".repeat(usize::from(options.indent_width())))
.unwrap_or(line)
}),
IndentStyle::Space => line
.strip_prefix(&" ".repeat(usize::from(options.indent_width())))
.unwrap_or(line),
}
}
pub(super) fn strip_outer_indent_or_one_unit<'a>(
line: &'a str,
outer_indent: &str,
options: &ResolvedShellFormatOptions,
) -> &'a str {
if outer_indent.is_empty() {
return strip_one_indent_unit(line, options);
}
line.strip_prefix(outer_indent)
.unwrap_or_else(|| strip_one_indent_unit(line, options))
}
pub(super) fn source_indent_minus_one_unit(
indent: &str,
options: &ResolvedShellFormatOptions,
) -> String {
match options.indent_style() {
IndentStyle::Tab if indent.ends_with('\t') => {
let mut shortened = indent.to_string();
shortened.pop();
shortened
}
_ => {
let width = usize::from(options.indent_width()).max(1);
if indent.ends_with(&" ".repeat(width)) {
indent[..indent.len().saturating_sub(width)].to_string()
} else if indent.ends_with('\t') {
let mut shortened = indent.to_string();
shortened.pop();
shortened
} else {
indent.to_string()
}
}
}
}
pub(super) fn source_indent_plus_one_unit(
indent: &str,
options: &ResolvedShellFormatOptions,
) -> String {
if indent.is_empty() {
return options.indent_prefix(1);
}
if indent.chars().all(|ch| ch == '\t') {
let mut extended = indent.to_string();
extended.push('\t');
extended
} else {
let width = match options.indent_style() {
IndentStyle::Tab => usize::from(options.indent_width()).clamp(1, 4),
IndentStyle::Space => usize::from(options.indent_width()),
};
let mut extended = indent.to_string();
extended.push_str(&" ".repeat(width));
extended
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn raw_word_source_slice<'a>(word: &Word, source: &'a str) -> Option<&'a str> {
raw_source_slice(word.span, source)
}
pub(super) fn word_is_single_quoted_only(word: &Word) -> bool {
matches!(
word.parts.as_slice(),
[shuck_ast::WordPartNode {
kind: WordPart::SingleQuoted { .. },
..
}]
)
}
pub(super) fn raw_pattern_source_slice<'a>(pattern: &Pattern, source: &'a str) -> Option<&'a str> {
raw_source_slice(pattern.span, source)
}
pub(super) fn raw_source_slice(span: shuck_ast::Span, source: &str) -> Option<&str> {
if span.start.offset >= span.end.offset || span.end.offset > source.len() {
return None;
}
let slice = span.slice(source);
if slice.contains('\n') {
Some(slice)
} else {
Some(trim_unescaped_trailing_whitespace(slice))
}
}
pub(super) fn should_preserve_raw_syntax(raw: &str, rendered: &str) -> bool {
raw != rendered && could_need_preserve_raw_syntax(raw)
}
pub(super) fn should_preserve_special_rendered_raw_syntax(raw: &str, rendered: &str) -> bool {
raw != rendered
&& !raw.contains('\n')
&& !raw_command_substitution_needs_structural_spacing(raw)
&& could_need_preserve_raw_syntax_beyond_line_continuations(raw)
}
pub(super) fn could_need_preserve_raw_syntax(raw: &str) -> bool {
raw.starts_with('\\')
|| raw.starts_with('&')
|| raw.starts_with("$'")
|| raw_contains_escaped_horizontal_whitespace(raw)
|| raw.contains("\\\n")
|| raw.contains("\\\"")
|| raw.contains("\\`")
|| raw_contains_double_backslash_outside_single_quotes(raw)
|| raw.contains("[^ ]")
}
pub(super) fn could_need_preserve_raw_syntax_beyond_line_continuations(raw: &str) -> bool {
raw.starts_with('\\')
|| raw.starts_with('&')
|| raw.starts_with("$'")
|| raw_contains_escaped_horizontal_whitespace(raw)
|| raw.contains("\\\"")
|| raw.contains("\\`")
|| raw.contains("[^ ]")
}
pub(super) fn raw_contains_escaped_horizontal_whitespace(raw: &str) -> bool {
raw.contains("\\ ") || raw.contains("\\\t")
}
pub(super) fn raw_contains_double_backslash_outside_single_quotes(raw: &str) -> bool {
let mut in_single_quotes = false;
let mut previous_was_backslash = false;
let mut chars = raw.char_indices().peekable();
while let Some((index, ch)) = chars.next() {
if ch == '\'' && !previous_was_backslash {
in_single_quotes = !in_single_quotes;
}
if !in_single_quotes && ch == '\\' && chars.peek().is_some_and(|(_, next)| *next == '\\') {
return true;
}
previous_was_backslash = ch == '\\'
&& raw
.get(index + ch.len_utf8()..)
.is_some_and(|rest| !rest.starts_with('\\'));
}
false
}