mod branch;
mod breaks;
mod case;
mod comments;
mod compound;
mod layout;
mod sequence;
use rustc_hash::FxHashMap as HashMap;
use shuck_ast::{
AlwaysCommand, AnonymousFunctionCommand, ArithmeticCommand, ArithmeticExpr, ArithmeticExprNode,
ArithmeticForCommand, ArithmeticLvalue, Assignment, AssignmentValue, BinaryCommand, BinaryOp,
BourneParameterExpansion, CaseCommand, CaseItem, Command, CommandSubstitutionSyntax,
CompoundCommand, ConditionalCommand, ConditionalExpr, CoprocCommand, DeclOperand, File,
ForCommand, ForeachCommand, FunctionDef, Heredoc, HeredocBody, HeredocBodyPart,
HeredocBodyPartNode, IfCommand, ParameterExpansion, ParameterExpansionSyntax, ParameterOp,
Pattern, PatternPart, Redirect, RedirectKind, RedirectTarget, RepeatCommand, SelectCommand,
Span, Stmt, StmtSeq, StmtTerminator, Subscript, TimeCommand, UntilCommand, VarRef,
WhileCommand, Word, WordPart, WordPartNode, ZshExpansionOperation, ZshExpansionTarget,
ZshGlobSegment,
};
use shuck_ast::{TextRange, TextSize};
use shuck_indexer::{
CloseDelimiterKind, CommentIndex, IndexedComment, Indexer, IndexerOptions, LineIndex,
};
use crate::command::{
array_elem_parts, builtin_like_parts, case_item_body_upper_bound,
case_item_was_inline_in_source, case_terminator,
collect_binary_list_first as collect_binary_list_first_with, collect_pipeline_parts,
command_format_span, command_group_commands,
compound_close_span as command_compound_close_span, group_attachment_span_with_heredoc,
group_open_suffix, group_was_inline_in_source, if_close_span,
if_next_branch_region_with_body_end, matching_group_close, rendered_stmt_end_line_with_heredoc,
should_render_verbatim_with_heredoc, stmt_attachment_span_with_heredoc_and_compound_close,
stmt_format_span, stmt_group_attachment_or_verbatim_span_with_heredoc,
stmt_has_trailing_comment, stmt_render_start_line_with_heredoc, stmt_span,
stmt_start_after_operator, stmt_verbatim_span_with_source_map,
trim_unescaped_trailing_whitespace,
};
use crate::comments::{BranchPrefixComment, CommentAttachmentModel, SourceComment, SourceMap};
use crate::options::{LineEnding, ResolvedShellFormatOptions};
use crate::render_plan::CompoundBodySite;
use crate::source::{SourceView, command_substitution_source_starts_with_body_line};
use crate::visit::{self, AstVisitor};
use self::branch::BranchFacts;
use self::breaks::BreakFacts;
use self::case::CaseFacts;
use self::comments::CommentFacts;
use self::compound::CompoundFacts;
use self::layout::LayoutFacts;
use self::sequence::{SequenceFactsStore, SequenceSite, SequenceSiteKey};
pub(crate) use self::case::{CaseCommandFacts, CaseItemFacts};
pub(crate) use self::comments::{BranchPrefixFacts, InlineCommentPlan};
pub(crate) use self::layout::{StmtFacts, WordFacts};
pub(crate) use self::sequence::SequenceFacts;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct FactSpan {
start: usize,
end: usize,
}
impl FactSpan {
fn new(span: Span) -> Self {
Self {
start: span.start.offset,
end: span.end.offset,
}
}
fn from_offsets(start: usize, end: usize) -> Self {
Self { start, end }
}
}
impl From<Span> for FactSpan {
fn from(span: Span) -> Self {
Self::new(span)
}
}
#[derive(Debug, Clone, Copy)]
struct StmtSite<'a> {
stmt: &'a Stmt,
key: FactSpan,
}
impl<'a> StmtSite<'a> {
fn new(stmt: &'a Stmt) -> Self {
Self {
stmt,
key: FactSpan::from(stmt_span(stmt)),
}
}
}
#[derive(Debug, Clone, Copy, Default)]
struct LayoutSummary {
contains_comments: bool,
contains_heredoc: bool,
contains_multiline_literal_source: bool,
contains_multistatement_pipeline_brace_group: bool,
}
impl LayoutSummary {
fn merge(&mut self, other: Self) {
self.contains_comments |= other.contains_comments;
self.contains_heredoc |= other.contains_heredoc;
self.contains_multiline_literal_source |= other.contains_multiline_literal_source;
self.contains_multistatement_pipeline_brace_group |=
other.contains_multistatement_pipeline_brace_group;
}
fn with_comments(mut self, contains_comments: bool) -> Self {
self.contains_comments |= contains_comments;
self
}
}
struct LayoutClassifier;
enum LayoutCommand<'a> {
Simple {
assignments: &'a [Assignment],
name: &'a Word,
args: &'a [Word],
},
Builtin {
assignments: &'a [Assignment],
primary: Option<&'a Word>,
extra_args: &'a [Word],
},
Decl {
assignments: &'a [Assignment],
operands: &'a [DeclOperand],
},
Binary(&'a BinaryCommand),
Compound(&'a CompoundCommand),
Function(&'a FunctionDef),
AnonymousFunction(&'a AnonymousFunctionCommand),
}
enum LayoutCompoundCommand<'a> {
If(&'a IfCommand),
For(&'a ForCommand),
Repeat(&'a RepeatCommand),
Foreach(&'a ForeachCommand),
ArithmeticFor(&'a ArithmeticForCommand),
While(&'a WhileCommand),
Until(&'a UntilCommand),
Case(&'a CaseCommand),
Select(&'a SelectCommand),
Subshell(&'a StmtSeq),
BraceGroup(&'a StmtSeq),
Arithmetic(&'a ArithmeticCommand),
Time(&'a TimeCommand),
Conditional(&'a ConditionalCommand),
Coproc(&'a CoprocCommand),
Always(&'a AlwaysCommand),
}
impl LayoutClassifier {
fn command(command: &Command) -> LayoutCommand<'_> {
match command {
Command::Simple(command) => LayoutCommand::Simple {
assignments: command.assignments.as_ref(),
name: &command.name,
args: command.args.as_slice(),
},
Command::Builtin(command) => {
let (_, _, assignments, primary, extra_args) = builtin_like_parts(command);
LayoutCommand::Builtin {
assignments,
primary,
extra_args,
}
}
Command::Decl(command) => LayoutCommand::Decl {
assignments: command.assignments.as_ref(),
operands: command.operands.as_slice(),
},
Command::Binary(command) => LayoutCommand::Binary(command),
Command::Compound(command) => LayoutCommand::Compound(command),
Command::Function(command) => LayoutCommand::Function(command),
Command::AnonymousFunction(command) => LayoutCommand::AnonymousFunction(command),
}
}
fn compound_command(command: &CompoundCommand) -> LayoutCompoundCommand<'_> {
match command {
CompoundCommand::If(command) => LayoutCompoundCommand::If(command),
CompoundCommand::For(command) => LayoutCompoundCommand::For(command),
CompoundCommand::Repeat(command) => LayoutCompoundCommand::Repeat(command),
CompoundCommand::Foreach(command) => LayoutCompoundCommand::Foreach(command),
CompoundCommand::ArithmeticFor(command) => {
LayoutCompoundCommand::ArithmeticFor(command)
}
CompoundCommand::While(command) => LayoutCompoundCommand::While(command),
CompoundCommand::Until(command) => LayoutCompoundCommand::Until(command),
CompoundCommand::Case(command) => LayoutCompoundCommand::Case(command),
CompoundCommand::Select(command) => LayoutCompoundCommand::Select(command),
CompoundCommand::Subshell(body) => LayoutCompoundCommand::Subshell(body),
CompoundCommand::BraceGroup(body) => LayoutCompoundCommand::BraceGroup(body),
CompoundCommand::Arithmetic(command) => LayoutCompoundCommand::Arithmetic(command),
CompoundCommand::Time(command) => LayoutCompoundCommand::Time(command),
CompoundCommand::Conditional(command) => LayoutCompoundCommand::Conditional(command),
CompoundCommand::Coproc(command) => LayoutCompoundCommand::Coproc(command),
CompoundCommand::Always(command) => LayoutCompoundCommand::Always(command),
}
}
}
#[derive(Debug, Default)]
struct LayoutAnnotations {
sequences: HashMap<FactSpan, LayoutSummary>,
statements: HashMap<FactSpan, LayoutSummary>,
words: HashMap<FactSpan, LayoutSummary>,
}
impl LayoutAnnotations {
fn build_for_stmt(source: &str, stmt: &Stmt) -> Self {
let mut annotations = Self::default();
{
let mut pass = LayoutAnnotationPass::new(source, &mut annotations);
pass.visit_stmt(stmt);
}
annotations
}
fn build_for_word(source: &str, word: &Word) -> Self {
let mut annotations = Self::default();
{
let mut pass = LayoutAnnotationPass::new(source, &mut annotations);
pass.visit_word(word);
}
annotations
}
fn sequence(&self, sequence: &StmtSeq) -> LayoutSummary {
self.sequences
.get(&FactSpan::from(sequence.span))
.copied()
.unwrap_or_default()
}
fn stmt(&self, stmt: &Stmt) -> LayoutSummary {
self.statements
.get(&FactSpan::from(stmt_span(stmt)))
.copied()
.unwrap_or_default()
}
fn word_summary(&self, word: &Word) -> LayoutSummary {
self.words
.get(&FactSpan::from(word.span))
.copied()
.unwrap_or_default()
}
fn word_facts(&self, word: &Word) -> WordFacts {
WordFacts {
has_multiline_literal_source: self.word_summary(word).contains_multiline_literal_source,
}
}
}
struct LayoutAnnotationPass<'source, 'annotations> {
source: &'source str,
annotations: &'annotations mut LayoutAnnotations,
}
impl<'source, 'annotations> LayoutAnnotationPass<'source, 'annotations> {
fn new(source: &'source str, annotations: &'annotations mut LayoutAnnotations) -> Self {
Self {
source,
annotations,
}
}
fn command_layout(&self, command: &Command) -> LayoutSummary {
match LayoutClassifier::command(command) {
LayoutCommand::Simple {
assignments,
name,
args,
} => {
let mut summary = self.assignments_layout(assignments);
summary.merge(self.annotations.word_summary(name));
for word in args {
summary.merge(self.annotations.word_summary(word));
}
summary
}
LayoutCommand::Builtin {
assignments,
primary,
extra_args,
} => {
let mut summary = self.assignments_layout(assignments);
if let Some(primary) = primary {
summary.merge(self.annotations.word_summary(primary));
}
for word in extra_args {
summary.merge(self.annotations.word_summary(word));
}
summary
}
LayoutCommand::Decl {
assignments,
operands,
} => {
let mut summary = self.assignments_layout(assignments);
for operand in operands {
summary.merge(self.decl_operand_layout(operand));
}
summary
}
LayoutCommand::Binary(command) => {
let mut summary = self.annotations.stmt(&command.left);
summary.merge(self.annotations.stmt(&command.right));
summary.contains_multistatement_pipeline_brace_group =
self.command_contains_multistatement_pipeline_brace_group(command, false);
summary
}
LayoutCommand::Compound(command) => self.compound_command_layout(command),
LayoutCommand::Function(function) => {
let mut summary = self.annotations.stmt(function.body.as_ref());
for entry in &function.header.entries {
summary.merge(self.annotations.word_summary(&entry.word));
}
summary
}
LayoutCommand::AnonymousFunction(function) => {
let mut summary = self.annotations.stmt(function.body.as_ref());
for argument in &function.args {
summary.merge(self.annotations.word_summary(argument));
}
summary
}
}
}
fn compound_command_layout(&self, command: &CompoundCommand) -> LayoutSummary {
match LayoutClassifier::compound_command(command) {
LayoutCompoundCommand::If(command) => {
let mut summary = self.annotations.sequence(&command.condition);
summary.merge(self.annotations.sequence(&command.then_branch));
for (condition, body) in &command.elif_branches {
summary.merge(self.annotations.sequence(condition));
summary.merge(self.annotations.sequence(body));
}
if let Some(body) = &command.else_branch {
summary.merge(self.annotations.sequence(body));
}
summary
}
LayoutCompoundCommand::For(command) => {
let mut summary = LayoutSummary::default();
for target in &command.targets {
summary.merge(self.annotations.word_summary(&target.word));
}
if let Some(words) = &command.words {
for word in words {
summary.merge(self.annotations.word_summary(word));
}
}
summary.merge(self.annotations.sequence(&command.body));
summary
}
LayoutCompoundCommand::Repeat(command) => {
let mut summary = self.annotations.word_summary(&command.count);
summary.merge(self.annotations.sequence(&command.body));
summary
}
LayoutCompoundCommand::Foreach(command) => {
let mut summary = LayoutSummary::default();
for word in &command.words {
summary.merge(self.annotations.word_summary(word));
}
summary.merge(self.annotations.sequence(&command.body));
summary
}
LayoutCompoundCommand::ArithmeticFor(command) => {
let mut summary = LayoutSummary::default();
if let Some(expr) = &command.init_ast {
summary.merge(self.arithmetic_expr_layout(expr));
}
if let Some(expr) = &command.condition_ast {
summary.merge(self.arithmetic_expr_layout(expr));
}
if let Some(expr) = &command.step_ast {
summary.merge(self.arithmetic_expr_layout(expr));
}
summary.merge(self.annotations.sequence(&command.body));
summary
}
LayoutCompoundCommand::While(command) => {
let mut summary = self.annotations.sequence(&command.condition);
summary.merge(self.annotations.sequence(&command.body));
summary
}
LayoutCompoundCommand::Until(command) => {
let mut summary = self.annotations.sequence(&command.condition);
summary.merge(self.annotations.sequence(&command.body));
summary
}
LayoutCompoundCommand::Case(command) => {
let mut summary = self.annotations.word_summary(&command.word);
for item in &command.cases {
for pattern in &item.patterns {
summary.merge(self.pattern_layout(pattern));
}
summary.merge(self.annotations.sequence(&item.body));
}
summary
}
LayoutCompoundCommand::Select(command) => {
let mut summary = LayoutSummary::default();
for word in &command.words {
summary.merge(self.annotations.word_summary(word));
}
summary.merge(self.annotations.sequence(&command.body));
summary
}
LayoutCompoundCommand::Subshell(body) | LayoutCompoundCommand::BraceGroup(body) => {
self.annotations.sequence(body)
}
LayoutCompoundCommand::Arithmetic(command) => command
.expr_ast
.as_ref()
.map_or_else(LayoutSummary::default, |expr| {
self.arithmetic_expr_layout(expr)
}),
LayoutCompoundCommand::Time(command) => command
.command
.as_ref()
.map_or_else(LayoutSummary::default, |command| {
self.annotations.stmt(command)
}),
LayoutCompoundCommand::Conditional(command) => {
self.conditional_expr_layout(&command.expression)
}
LayoutCompoundCommand::Coproc(command) => self.annotations.stmt(&command.body),
LayoutCompoundCommand::Always(command) => {
let mut summary = self.annotations.sequence(&command.body);
summary.merge(self.annotations.sequence(&command.always_body));
summary
}
}
}
fn decl_operand_layout(&self, operand: &DeclOperand) -> LayoutSummary {
match operand {
DeclOperand::Flag(word) | DeclOperand::Dynamic(word) => {
self.annotations.word_summary(word)
}
DeclOperand::Name(reference) => self.var_ref_layout(reference),
DeclOperand::Assignment(assignment) => self.assignment_layout(assignment),
}
}
fn assignments_layout(&self, assignments: &[Assignment]) -> LayoutSummary {
let mut summary = LayoutSummary::default();
for assignment in assignments {
summary.merge(self.assignment_layout(assignment));
}
summary
}
fn assignment_layout(&self, assignment: &Assignment) -> LayoutSummary {
let mut summary = self.var_ref_layout(&assignment.target);
match &assignment.value {
AssignmentValue::Scalar(word) => summary.merge(self.annotations.word_summary(word)),
AssignmentValue::Compound(array) => {
for element in &array.elements {
if let Some(key) = array_elem_parts(element).0 {
summary.merge(self.subscript_layout(key));
}
summary.merge(self.annotations.word_summary(array_elem_parts(element).1));
}
}
}
summary.contains_multiline_literal_source =
self.assignment_has_multiline_literal_source(assignment);
summary
}
fn redirect_layout(&self, redirect: &Redirect) -> LayoutSummary {
let mut summary = match &redirect.target {
RedirectTarget::Word(word) => self.annotations.word_summary(word),
RedirectTarget::Heredoc(heredoc) => {
let mut summary = self.annotations.word_summary(&heredoc.delimiter.raw);
summary.merge(self.heredoc_body_layout(&heredoc.body));
summary
}
};
summary.contains_heredoc |= matches!(
redirect.kind,
RedirectKind::HereDoc | RedirectKind::HereDocStrip
);
summary.contains_multiline_literal_source =
self.redirect_has_multiline_literal_source(redirect);
summary
}
fn heredoc_body_layout(&self, body: &HeredocBody) -> LayoutSummary {
let mut summary = LayoutSummary::default();
for part in &body.parts {
summary.merge(self.heredoc_body_part_layout(&part.kind));
}
summary
}
fn heredoc_body_part_layout(&self, part: &HeredocBodyPart) -> LayoutSummary {
match part {
HeredocBodyPart::CommandSubstitution { body, .. } => self.annotations.sequence(body),
HeredocBodyPart::ArithmeticExpansion {
expression_ast: Some(expr),
..
} => self.arithmetic_expr_layout(expr),
HeredocBodyPart::ArithmeticExpansion {
expression_ast: None,
expression_word_ast,
..
} => self.annotations.word_summary(expression_word_ast),
HeredocBodyPart::Parameter(parameter) => self.parameter_expansion_layout(parameter),
HeredocBodyPart::Literal(_) | HeredocBodyPart::Variable(_) => LayoutSummary::default(),
}
}
fn word_layout(&self, word: &Word) -> LayoutSummary {
let mut summary = LayoutSummary::default();
for part in &word.parts {
summary.merge(self.word_part_layout(&part.kind));
}
summary.contains_multiline_literal_source = self.word_has_multiline_literal_source(word);
summary.contains_heredoc = false;
summary.contains_multistatement_pipeline_brace_group = false;
summary
}
fn word_part_layout(&self, part: &WordPart) -> LayoutSummary {
match part {
WordPart::Literal(_) | WordPart::Variable(_) | WordPart::PrefixMatch { .. } => {
LayoutSummary::default()
}
WordPart::ZshQualifiedGlob(glob) => {
let mut summary = LayoutSummary::default();
for segment in &glob.segments {
if let ZshGlobSegment::Pattern(pattern) = segment {
summary.merge(self.pattern_layout(pattern));
}
}
summary
}
WordPart::SingleQuoted { .. } => LayoutSummary::default(),
WordPart::DoubleQuoted { parts, .. } => {
let mut summary = LayoutSummary::default();
for part in parts {
summary.merge(self.word_part_layout(&part.kind));
}
summary
}
WordPart::CommandSubstitution { body, .. }
| WordPart::ProcessSubstitution { body, .. } => self.annotations.sequence(body),
WordPart::ArithmeticExpansion {
expression_ast: Some(expr),
..
} => self.arithmetic_expr_layout(expr),
WordPart::ArithmeticExpansion {
expression_ast: None,
expression_word_ast,
..
} => self.annotations.word_summary(expression_word_ast),
WordPart::Parameter(parameter) => self.parameter_expansion_layout(parameter),
WordPart::ParameterExpansion {
reference,
operator,
operand_word_ast,
..
} => {
let mut summary = self.var_ref_layout(reference);
summary.merge(self.parameter_op_layout(operator));
if let Some(operand) = operand_word_ast {
summary.merge(self.annotations.word_summary(operand));
}
summary
}
WordPart::Length(reference)
| WordPart::ArrayAccess(reference)
| WordPart::ArrayLength(reference)
| WordPart::ArrayIndices(reference)
| WordPart::Transformation { reference, .. } => self.var_ref_layout(reference),
WordPart::Substring {
reference,
offset_ast,
offset_word_ast,
length_ast,
length_word_ast,
..
}
| WordPart::ArraySlice {
reference,
offset_ast,
offset_word_ast,
length_ast,
length_word_ast,
..
} => {
let mut summary = self.var_ref_layout(reference);
if let Some(expression) = offset_ast {
summary.merge(self.arithmetic_expr_layout(expression));
} else {
summary.merge(self.annotations.word_summary(offset_word_ast));
}
if let Some(expression) = length_ast {
summary.merge(self.arithmetic_expr_layout(expression));
} else if let Some(word) = length_word_ast {
summary.merge(self.annotations.word_summary(word));
}
summary
}
WordPart::IndirectExpansion {
reference,
operator,
operand_word_ast,
..
} => {
let mut summary = self.var_ref_layout(reference);
if let Some(operator) = operator {
summary.merge(self.parameter_op_layout(operator));
}
if let Some(operand) = operand_word_ast {
summary.merge(self.annotations.word_summary(operand));
}
summary
}
}
}
fn parameter_expansion_layout(&self, parameter: &ParameterExpansion) -> LayoutSummary {
match ¶meter.syntax {
ParameterExpansionSyntax::Bourne(syntax) => {
self.bourne_parameter_expansion_layout(syntax)
}
ParameterExpansionSyntax::Zsh(syntax) => {
let mut summary = match &syntax.target {
ZshExpansionTarget::Reference(reference) => self.var_ref_layout(reference),
ZshExpansionTarget::Nested(parameter) => {
self.parameter_expansion_layout(parameter)
}
ZshExpansionTarget::Word(word) => self.annotations.word_summary(word),
ZshExpansionTarget::Empty => LayoutSummary::default(),
};
for modifier in &syntax.modifiers {
if let Some(word) = modifier.argument_word_ast() {
summary.merge(self.annotations.word_summary(word));
}
}
if let Some(operation) = &syntax.operation {
summary.merge(self.zsh_expansion_operation_layout(operation));
}
summary
}
}
}
fn bourne_parameter_expansion_layout(
&self,
syntax: &BourneParameterExpansion,
) -> LayoutSummary {
match syntax {
BourneParameterExpansion::Access { reference }
| BourneParameterExpansion::Length { reference }
| BourneParameterExpansion::Indices { reference }
| BourneParameterExpansion::Transformation { reference, .. } => {
self.var_ref_layout(reference)
}
BourneParameterExpansion::Indirect {
reference,
operator,
operand_word_ast,
..
} => {
let mut summary = self.var_ref_layout(reference);
if let Some(operator) = operator {
summary.merge(self.parameter_op_layout(operator));
}
if let Some(operand) = operand_word_ast {
summary.merge(self.annotations.word_summary(operand));
}
summary
}
BourneParameterExpansion::PrefixMatch { .. } => LayoutSummary::default(),
BourneParameterExpansion::Slice {
reference,
offset_ast,
offset_word_ast,
length_ast,
length_word_ast,
..
} => {
let mut summary = self.var_ref_layout(reference);
if let Some(expression) = offset_ast {
summary.merge(self.arithmetic_expr_layout(expression));
} else {
summary.merge(self.annotations.word_summary(offset_word_ast));
}
if let Some(expression) = length_ast {
summary.merge(self.arithmetic_expr_layout(expression));
} else if let Some(word) = length_word_ast {
summary.merge(self.annotations.word_summary(word));
}
summary
}
BourneParameterExpansion::Operation {
reference,
operator,
operand_word_ast,
..
} => {
let mut summary = self.var_ref_layout(reference);
summary.merge(self.parameter_op_layout(operator));
if let Some(operand) = operand_word_ast {
summary.merge(self.annotations.word_summary(operand));
}
summary
}
}
}
fn parameter_op_layout(&self, operator: &ParameterOp) -> LayoutSummary {
match operator {
ParameterOp::RemovePrefixShort { pattern }
| ParameterOp::RemovePrefixLong { pattern }
| ParameterOp::RemoveSuffixShort { pattern }
| ParameterOp::RemoveSuffixLong { pattern } => self.pattern_layout(pattern),
ParameterOp::ReplaceFirst {
pattern,
replacement_word_ast,
..
}
| ParameterOp::ReplaceAll {
pattern,
replacement_word_ast,
..
} => {
let mut summary = self.pattern_layout(pattern);
summary.merge(self.annotations.word_summary(replacement_word_ast));
summary
}
ParameterOp::UseDefault
| ParameterOp::AssignDefault
| ParameterOp::UseReplacement
| ParameterOp::Error
| ParameterOp::UpperFirst
| ParameterOp::UpperAll
| ParameterOp::LowerFirst
| ParameterOp::LowerAll => LayoutSummary::default(),
}
}
fn zsh_expansion_operation_layout(&self, operation: &ZshExpansionOperation) -> LayoutSummary {
match operation {
ZshExpansionOperation::PatternOperation {
operand_word_ast, ..
}
| ZshExpansionOperation::Defaulting {
operand_word_ast, ..
}
| ZshExpansionOperation::TrimOperation {
operand_word_ast, ..
} => self.annotations.word_summary(operand_word_ast),
ZshExpansionOperation::ReplacementOperation {
pattern_word_ast,
replacement_word_ast,
..
} => {
let mut summary = self.annotations.word_summary(pattern_word_ast);
if let Some(replacement) = replacement_word_ast {
summary.merge(self.annotations.word_summary(replacement));
}
summary
}
ZshExpansionOperation::Slice {
offset_word_ast,
length_word_ast,
..
} => {
let mut summary = self.annotations.word_summary(offset_word_ast);
if let Some(length) = length_word_ast {
summary.merge(self.annotations.word_summary(length));
}
summary
}
ZshExpansionOperation::Unknown { word_ast, .. } => {
self.annotations.word_summary(word_ast)
}
}
}
fn conditional_expr_layout(&self, expression: &ConditionalExpr) -> LayoutSummary {
match expression {
ConditionalExpr::Binary(expression) => {
let mut summary = self.conditional_expr_layout(&expression.left);
summary.merge(self.conditional_expr_layout(&expression.right));
summary
}
ConditionalExpr::Unary(expression) => self.conditional_expr_layout(&expression.expr),
ConditionalExpr::Parenthesized(expression) => {
self.conditional_expr_layout(&expression.expr)
}
ConditionalExpr::Word(word) | ConditionalExpr::Regex(word) => {
self.annotations.word_summary(word)
}
ConditionalExpr::Pattern(pattern) => self.pattern_layout(pattern),
ConditionalExpr::VarRef(reference) => self.var_ref_layout(reference),
}
}
fn pattern_layout(&self, pattern: &Pattern) -> LayoutSummary {
let mut summary = LayoutSummary::default();
for part in &pattern.parts {
summary.merge(self.pattern_part_layout(&part.kind));
}
summary
}
fn pattern_part_layout(&self, part: &PatternPart) -> LayoutSummary {
match part {
PatternPart::Group { patterns, .. } => {
let mut summary = LayoutSummary::default();
for pattern in patterns {
summary.merge(self.pattern_layout(pattern));
}
summary
}
PatternPart::Word(word) => self.annotations.word_summary(word),
PatternPart::Literal(_)
| PatternPart::AnyString
| PatternPart::AnyChar
| PatternPart::CharClass(_) => LayoutSummary::default(),
}
}
fn arithmetic_expr_layout(&self, expression: &ArithmeticExprNode) -> LayoutSummary {
match &expression.kind {
ArithmeticExpr::Number(_) | ArithmeticExpr::Variable(_) => LayoutSummary::default(),
ArithmeticExpr::Indexed { index, .. } => self.arithmetic_expr_layout(index),
ArithmeticExpr::ShellWord(word) => self.annotations.word_summary(word),
ArithmeticExpr::Parenthesized { expression } => self.arithmetic_expr_layout(expression),
ArithmeticExpr::Unary { expr, .. } | ArithmeticExpr::Postfix { expr, .. } => {
self.arithmetic_expr_layout(expr)
}
ArithmeticExpr::Binary { left, right, .. } => {
let mut summary = self.arithmetic_expr_layout(left);
summary.merge(self.arithmetic_expr_layout(right));
summary
}
ArithmeticExpr::Conditional {
condition,
then_expr,
else_expr,
} => {
let mut summary = self.arithmetic_expr_layout(condition);
summary.merge(self.arithmetic_expr_layout(then_expr));
summary.merge(self.arithmetic_expr_layout(else_expr));
summary
}
ArithmeticExpr::Assignment { target, value, .. } => {
let mut summary = self.arithmetic_lvalue_layout(target);
summary.merge(self.arithmetic_expr_layout(value));
summary
}
}
}
fn arithmetic_lvalue_layout(&self, target: &ArithmeticLvalue) -> LayoutSummary {
match target {
ArithmeticLvalue::Variable(_) => LayoutSummary::default(),
ArithmeticLvalue::Indexed { index, .. } => self.arithmetic_expr_layout(index),
}
}
fn var_ref_layout(&self, reference: &VarRef) -> LayoutSummary {
reference
.subscript
.as_deref()
.map_or_else(LayoutSummary::default, |subscript| {
self.subscript_layout(subscript)
})
}
fn subscript_layout(&self, subscript: &Subscript) -> LayoutSummary {
let mut summary = subscript
.word_ast
.as_ref()
.map_or_else(LayoutSummary::default, |word| {
self.annotations.word_summary(word)
});
if let Some(expression) = &subscript.arithmetic_ast {
summary.merge(self.arithmetic_expr_layout(expression));
}
summary
}
fn word_has_multiline_literal_source(&self, word: &Word) -> bool {
word_has_multiline_literal_source_with_sequence_layout(word, self.source, |body| {
self.annotations.sequence(body)
})
}
fn redirect_has_multiline_literal_source(&self, redirect: &Redirect) -> bool {
redirect.word_target().is_some_and(|word| {
self.annotations
.word_facts(word)
.has_multiline_literal_source()
}) || redirect.heredoc().is_some_and(|heredoc| {
self.annotations
.word_facts(&heredoc.delimiter.raw)
.has_multiline_literal_source()
})
}
fn assignment_has_multiline_literal_source(&self, assignment: &Assignment) -> bool {
self.assignment_value_has_multiline_literal_source(assignment)
|| matches!(&assignment.value, AssignmentValue::Scalar(_))
&& assignment_has_raw_backslash_continuation_literal(assignment, self.source)
}
fn assignment_value_has_multiline_literal_source(&self, assignment: &Assignment) -> bool {
match &assignment.value {
AssignmentValue::Scalar(word) => self
.annotations
.word_facts(word)
.has_multiline_literal_source(),
AssignmentValue::Compound(array) => array.elements.iter().any(|element| {
self.annotations
.word_facts(array_elem_parts(element).1)
.has_multiline_literal_source()
}),
}
}
fn command_contains_multistatement_pipeline_brace_group(
&self,
command: &BinaryCommand,
in_pipeline: bool,
) -> bool {
let in_pipeline = in_pipeline || matches!(command.op, BinaryOp::Pipe | BinaryOp::PipeAll);
self.stmt_contains_multistatement_pipeline_brace_group(&command.left, in_pipeline)
|| self.stmt_contains_multistatement_pipeline_brace_group(&command.right, in_pipeline)
}
fn stmt_contains_multistatement_pipeline_brace_group(
&self,
stmt: &Stmt,
in_pipeline: bool,
) -> bool {
match &stmt.command {
Command::Binary(command)
if matches!(command.op, BinaryOp::Pipe | BinaryOp::PipeAll) =>
{
self.command_contains_multistatement_pipeline_brace_group(command, in_pipeline)
}
Command::Compound(CompoundCommand::BraceGroup(body)) if in_pipeline => body.len() > 1,
_ => false,
}
}
}
impl AstVisitor for LayoutAnnotationPass<'_, '_> {
fn visit_stmt_seq(&mut self, sequence: &StmtSeq) {
let key = FactSpan::from(sequence.span);
if self.annotations.sequences.contains_key(&key) {
return;
}
visit::walk_stmt_seq(self, sequence);
let mut summary = LayoutSummary::default().with_comments(
!sequence.leading_comments.is_empty() || !sequence.trailing_comments.is_empty(),
);
for stmt in sequence.iter() {
summary.merge(self.annotations.stmt(stmt));
}
self.annotations.sequences.insert(key, summary);
}
fn visit_stmt(&mut self, stmt: &Stmt) {
let key = FactSpan::from(stmt_span(stmt));
if self.annotations.statements.contains_key(&key) {
return;
}
visit::walk_stmt(self, stmt);
let mut summary = self
.command_layout(&stmt.command)
.with_comments(!stmt.leading_comments.is_empty() || stmt.inline_comment.is_some());
for redirect in &stmt.redirects {
summary.merge(self.redirect_layout(redirect));
}
self.annotations.statements.insert(key, summary);
}
fn visit_word(&mut self, word: &Word) {
let key = FactSpan::from(word.span);
if self.annotations.words.contains_key(&key) {
return;
}
visit::walk_word(self, word);
let summary = self.word_layout(word);
self.annotations.words.insert(key, summary);
}
}
fn word_has_multiline_literal_source_with_sequence_layout(
word: &Word,
source: &str,
mut sequence_layout: impl FnMut(&StmtSeq) -> LayoutSummary,
) -> bool {
if raw_word_source_slice(word, source).is_some_and(|raw| {
raw.contains("\\\n")
&& word_has_multiline_double_quoted_source(word, source)
&& !word_is_quoted_command_substitution_only(word)
}) {
return true;
}
word_part_nodes_any(&word.parts, &mut |part| {
word_part_has_multiline_literal_source_with_sequence_layout(
&part.kind,
part.span,
source,
&mut sequence_layout,
)
})
}
fn word_part_has_multiline_literal_source_with_sequence_layout(
part: &WordPart,
span: Span,
source: &str,
sequence_layout: &mut impl FnMut(&StmtSeq) -> LayoutSummary,
) -> bool {
match part {
WordPart::Literal(text) => text.as_str(source, span).contains('\n'),
WordPart::SingleQuoted { value, dollar } => {
if *dollar {
raw_source_slice(span, source).is_some_and(|raw| raw.contains('\n'))
} else {
value.slice(source).contains('\n')
}
}
WordPart::CommandSubstitution { body, .. } => {
let layout = sequence_layout(body);
layout.contains_multiline_literal_source
|| (layout.contains_comments
&& raw_source_slice(span, source).is_some_and(|raw| {
raw.contains('\n')
&& !command_substitution_source_starts_with_body_line(raw)
}))
}
WordPart::ProcessSubstitution { body, .. } => {
let layout = sequence_layout(body);
layout.contains_multiline_literal_source
|| (layout.contains_comments
&& raw_source_slice(span, source).is_some_and(|raw| raw.contains('\n')))
}
_ => false,
}
}
#[derive(Debug, Clone)]
pub(crate) struct FormatterFacts<'source> {
source_map: SourceMap<'source>,
layout_facts: LayoutFacts,
sequences: SequenceFactsStore<'source>,
breaks: BreakFacts,
branches: BranchFacts,
comments: CommentFacts<'source>,
cases: CaseFacts<'source>,
compound: CompoundFacts,
indexer: Indexer,
}
impl<'source> FormatterFacts<'source> {
pub(crate) fn build(
source: &'source str,
file: &File,
options: &ResolvedShellFormatOptions,
) -> Self {
let indexer = Indexer::for_file_with_options(
source,
file,
IndexerOptions::new().with_source_layout_indexes(true),
);
FormatterFactsBuilder::new(source, options, indexer).build(file)
}
pub(crate) fn source_map(&self) -> &SourceMap<'source> {
&self.source_map
}
pub(crate) fn stmt(&self, stmt: &Stmt) -> &StmtFacts {
let Some(facts) = self
.layout_facts
.statements
.get(&FactSpan::from(stmt_span(stmt)))
else {
unreachable!("missing statement facts");
};
facts
}
pub(crate) fn sequence(
&self,
sequence: &StmtSeq,
upper_bound: Option<usize>,
) -> &SequenceFacts<'source> {
let key = SequenceSiteKey::new(sequence, upper_bound);
self.sequences
.by_site
.get(&key)
.unwrap_or_else(|| self.sequence_by_span(key.span))
}
fn sequence_by_span(&self, span: FactSpan) -> &SequenceFacts<'source> {
let Some(key) = self.sequences.by_span.get(&span) else {
unreachable!("missing sequence facts");
};
let Some(facts) = self.sequences.by_site.get(key) else {
unreachable!("missing sequence facts");
};
facts
}
pub(crate) fn word_has_multiline_literal_source(&self, word: &Word) -> bool {
self.layout_facts
.words
.get(&FactSpan::from(word.span))
.map_or_else(
|| classify_word_has_multiline_literal_source(word, self.source_map.source()),
WordFacts::has_multiline_literal_source,
)
}
pub(crate) fn assignment_value_has_multiline_literal_source(
&self,
assignment: &Assignment,
) -> bool {
match &assignment.value {
AssignmentValue::Scalar(word) => self.word_has_multiline_literal_source(word),
AssignmentValue::Compound(array) => array
.elements
.iter()
.any(|element| self.word_has_multiline_literal_source(array_elem_parts(element).1)),
}
}
pub(crate) fn assignment_has_multiline_literal_source(
&self,
assignment: &Assignment,
source: &str,
) -> bool {
self.assignment_value_has_multiline_literal_source(assignment)
|| matches!(&assignment.value, AssignmentValue::Scalar(_))
&& assignment_has_raw_backslash_continuation_literal(assignment, source)
}
pub(crate) fn sequence_contains_comments(&self, sequence: &StmtSeq) -> bool {
self.sequence_by_span(FactSpan::from(sequence.span))
.contains_comments()
}
pub(crate) fn sequence_contains_heredoc(&self, sequence: &StmtSeq) -> bool {
self.sequence_by_span(FactSpan::from(sequence.span))
.contains_heredoc()
}
pub(crate) fn sequence_contains_multiline_literal_source(&self, sequence: &StmtSeq) -> bool {
self.sequence_by_span(FactSpan::from(sequence.span))
.contains_multiline_literal_source()
}
pub(crate) fn sequence_contains_multistatement_pipeline_brace_group(
&self,
sequence: &StmtSeq,
) -> bool {
self.sequence_by_span(FactSpan::from(sequence.span))
.contains_multistatement_pipeline_brace_group()
}
pub(crate) fn pipeline_has_explicit_line_break(&self, pipeline: &BinaryCommand) -> bool {
self.breaks
.pipeline
.contains(&FactSpan::from(pipeline.span))
}
pub(crate) fn list_item_has_explicit_line_break(&self, operator_span: Span) -> bool {
self.breaks
.list_item
.contains(&FactSpan::from(operator_span))
}
pub(crate) fn background_has_explicit_line_break(&self, stmt: &Stmt) -> bool {
stmt.terminator_span
.map(FactSpan::from)
.or_else(|| {
matches!(stmt.terminator, Some(StmtTerminator::Background(_)))
.then_some(FactSpan::from(stmt_span(stmt)))
})
.is_some_and(|key| self.breaks.background.contains(&key))
}
pub(crate) fn stmt_contains_heredoc(&self, stmt: &Stmt) -> bool {
self.stmt(stmt).contains_heredoc()
}
pub(crate) fn group_was_inline_in_source(&self, commands: &StmtSeq) -> bool {
self.branches
.inline_group_sequences
.contains(&FactSpan::from(commands.span))
}
pub(crate) fn compound_close_span(&self, command: &CompoundCommand) -> Option<Span> {
self.compound.close_span(command)
}
pub(crate) fn compound_close_span_for_span(&self, span: Span) -> Option<Span> {
self.compound.close_span_for_span(span)
}
pub(crate) fn stmt_compound_close_span(&self, stmt: &Stmt) -> Option<Span> {
let Command::Compound(command) = &stmt.command else {
return None;
};
self.compound_close_span(command)
}
pub(crate) fn if_close_span(&self, command: &IfCommand) -> Span {
self.compound
.close_span_for_span(command.span)
.unwrap_or_else(|| if_close_span(command, self.source_map.source(), &self.source_map))
}
pub(crate) fn case_item_was_inline_in_source(&self, item: &CaseItem) -> bool {
self.branches
.inline_case_item_bodies
.contains(&FactSpan::from(item.body.span))
}
pub(crate) fn close_suffix_comment_plan_after_span(
&self,
span: Span,
) -> Option<InlineCommentPlan<'source>> {
self.comments
.close_suffix_comment_after_span(&self.source_map, span)
}
pub(crate) fn suffix_comment_plan_for_span(
&self,
span: Span,
) -> Option<InlineCommentPlan<'source>> {
self.comments
.suffix_comment_for_span(&self.source_map, span)
}
pub(crate) fn trailing_comment_plan(
&self,
comment: SourceComment<'source>,
) -> InlineCommentPlan<'source> {
self.comments.trailing_comment(&self.source_map, comment)
}
pub(crate) fn branch_prefix_facts(&self, start: usize, end: usize) -> BranchPrefixFacts {
self.comments
.branch_prefix_facts(start, end)
.cloned()
.unwrap_or_else(|| {
BranchPrefixFacts::new(
self.source_map.source(),
start,
end,
self.branch_prefix_comments_from_source(start, end),
)
})
}
pub(crate) fn if_next_branch_region(
&self,
command: &IfCommand,
branch_index: usize,
) -> Option<(usize, usize)> {
if_next_branch_region_with_body_end(
command,
branch_index,
self.source_map.source(),
|body| self.sequence(body, None).body_content_end(),
)
}
pub(crate) fn if_branch_upper_bound(&self, command: &IfCommand, branch_index: usize) -> usize {
if let Some((start, end)) = self.if_next_branch_region(command, branch_index) {
self.branch_prefix_facts(start, end)
.first_comment_offset()
.unwrap_or(end)
} else {
self.if_close_span(command).start.offset
}
}
pub(crate) fn case_command(&self, command: &CaseCommand) -> &CaseCommandFacts {
self.cases
.case_command(command)
.unwrap_or_else(|| unreachable!("missing case command facts"))
}
pub(crate) fn case_item(&self, item: &CaseItem) -> &CaseItemFacts<'source> {
self.cases
.case_item(item)
.unwrap_or_else(|| unreachable!("missing case item facts"))
}
pub(crate) fn offset_is_in_heredoc_body(&self, offset: usize) -> bool {
self.indexer
.region_index()
.is_heredoc(TextSize::new(offset as u32))
}
pub(crate) fn line_ending(&self) -> LineEnding {
match self.indexer.line_index().line_ending() {
shuck_indexer::LineEndingStyle::Lf => LineEnding::Lf,
shuck_indexer::LineEndingStyle::CrLf => LineEnding::CrLf,
}
}
pub(crate) fn contains_newline_between(&self, start: usize, end: usize) -> bool {
self.source_map.contains_newline_between(start, end)
}
pub(crate) fn has_continuation_line_start_between(&self, start: usize, end: usize) -> bool {
if start >= end {
return false;
}
let start = TextSize::new(start as u32);
let end = TextSize::new(end as u32);
self.indexer
.continuation_line_starts()
.iter()
.copied()
.any(|line_start| start < line_start && line_start <= end)
}
pub(crate) fn has_raw_continuation_backslash_between(&self, start: usize, end: usize) -> bool {
if start >= end {
return false;
}
let start = TextSize::new(start as u32);
let end = TextSize::new(end as u32);
self.indexer
.line_index()
.raw_continuation_backslashes()
.iter()
.copied()
.any(|backslash| start <= backslash && backslash < end)
}
pub(crate) fn branch_prefix_first_comment_offset(
&self,
start: usize,
end: usize,
) -> Option<usize> {
self.branch_prefix_facts(start, end).first_comment_offset()
}
fn branch_prefix_comments_from_source(
&self,
start: usize,
end: usize,
) -> Vec<BranchPrefixComment> {
branch_prefix_comments_from_index(
self.source_map.source(),
self.indexer.line_index(),
self.indexer.comment_index(),
start,
end,
)
.into_iter()
.filter(|comment| !self.offset_is_in_heredoc_body(comment.offset))
.collect()
}
pub(crate) fn own_line_comments_in_region(
&self,
start: usize,
end: usize,
) -> Vec<BranchPrefixComment> {
own_line_comments_in_region_from_index(
self.source_map.source(),
self.indexer.line_index(),
self.indexer.comment_index(),
start,
end,
)
.into_iter()
.filter(|comment| !self.offset_is_in_heredoc_body(comment.offset))
.collect()
}
pub(crate) fn heredoc_closing_marker_bounds(
&self,
heredoc: &Heredoc,
) -> Option<(usize, usize)> {
self.indexer
.region_index()
.heredoc_closing_marker_range(heredoc.body.span.to_range())
.map(|range| (usize::from(range.start()), usize::from(range.end())))
}
#[cfg(feature = "benchmarking")]
pub(crate) fn len(&self) -> usize {
self.layout_facts.len()
+ self.sequences.len()
+ self.breaks.len()
+ self.branches.len()
+ self.comments.len()
+ self.cases.len()
+ self.compound.len()
+ self.indexer.region_index().heredoc_ranges().len()
}
}
pub(crate) fn classify_word_has_multiline_literal_source(word: &Word, source: &str) -> bool {
LayoutAnnotations::build_for_word(source, word)
.word_facts(word)
.has_multiline_literal_source()
}
pub(crate) fn classify_stmt_contains_heredoc(stmt: &Stmt) -> bool {
LayoutAnnotations::build_for_stmt("", stmt)
.stmt(stmt)
.contains_heredoc
}
fn word_part_nodes_any(
parts: &[WordPartNode],
predicate: &mut impl FnMut(&WordPartNode) -> bool,
) -> bool {
parts.iter().any(|part| {
predicate(part)
|| matches!(
&part.kind,
WordPart::DoubleQuoted { parts, .. }
if word_part_nodes_any(parts.as_slice(), predicate)
)
})
}
fn assignment_has_raw_backslash_continuation_literal(
assignment: &Assignment,
source: &str,
) -> bool {
let raw = assignment.span.slice(source);
raw.contains("\\\n")
&& !raw.contains("$(")
&& !raw.contains('`')
&& !raw.contains("<(")
&& !raw.contains(">(")
}
fn word_has_multiline_double_quoted_source(word: &Word, source: &str) -> bool {
word_part_nodes_any(&word.parts, &mut |part| {
matches!(&part.kind, WordPart::DoubleQuoted { .. })
&& raw_source_slice(part.span, source).is_some_and(|raw| raw.contains('\n'))
})
}
fn word_is_quoted_command_substitution_only(word: &Word) -> bool {
quoted_command_substitution_only_body(word).is_some()
}
fn quoted_command_substitution_only_body(word: &Word) -> Option<&StmtSeq> {
let [
shuck_ast::WordPartNode {
kind:
WordPart::DoubleQuoted {
parts,
dollar: false,
},
..
},
] = word.parts.as_slice()
else {
return None;
};
let mut substitution_body = None;
for part in parts {
match &part.kind {
WordPart::CommandSubstitution { body, .. } if substitution_body.is_none() => {
substitution_body = Some(body);
}
WordPart::Literal(text) if text.is_empty() => {}
_ => return None,
}
}
substitution_body
}
fn raw_word_source_slice<'a>(word: &Word, source: &'a str) -> Option<&'a str> {
raw_source_slice(word.span, source)
}
fn raw_source_slice(span: Span, source: &str) -> Option<&str> {
let slice = SourceView::new(source).span_slice(span)?;
if slice.contains('\n') {
Some(slice)
} else {
Some(trim_unescaped_trailing_whitespace(slice))
}
}
fn line_indent_before_offset<'source>(
source: &'source str,
line_index: &LineIndex,
offset: usize,
) -> Option<&'source str> {
let offset = offset.min(source.len());
let line = line_index.line_number(TextSize::new(offset as u32));
let line_start = usize::from(line_index.line_start(line)?);
let prefix = source.get(line_start..offset)?;
let indent_end = prefix
.char_indices()
.find(|(_, ch)| !matches!(ch, ' ' | '\t'))
.map_or(prefix.len(), |(index, _)| index);
prefix.get(..indent_end)
}
fn branch_prefix_comments_from_index(
source: &str,
line_index: &LineIndex,
comment_index: &CommentIndex,
start: usize,
end: usize,
) -> Vec<BranchPrefixComment> {
let start = start.min(end).min(source.len());
let end = end.min(source.len());
if start >= end {
return Vec::new();
}
let keyword_indent = line_indent_before_offset(source, line_index, end).unwrap_or("");
let mut comments = Vec::new();
let mut in_branch_prefix_run = false;
let first_line = line_index.line_number(TextSize::new(start as u32));
let last_line = line_index.line_number(TextSize::new(end.saturating_sub(1) as u32));
for line in first_line..=last_line {
let Some((line_start, line_end, text)) =
clamped_line_text(source, line_index, line, start, end)
else {
continue;
};
let trimmed = text.trim_start_matches([' ', '\t']);
let indent = text.len().saturating_sub(trimmed.len());
let own_line_comment =
own_line_comment_in_bounds(comment_index, line, line_start, line_end).is_some();
if own_line_comment
&& trimmed.starts_with('#')
&& (in_branch_prefix_run || text.get(..indent) == Some(keyword_indent))
{
comments.push(BranchPrefixComment {
offset: line_start + indent,
text: trimmed.trim_end_matches([' ', '\t', '\r']).to_string(),
source_indent: indent,
});
in_branch_prefix_run = true;
} else if !trimmed.is_empty() {
in_branch_prefix_run = false;
}
}
comments
}
fn own_line_comments_in_region_from_index(
source: &str,
line_index: &LineIndex,
comment_index: &CommentIndex,
start: usize,
end: usize,
) -> Vec<BranchPrefixComment> {
let start = start.min(end).min(source.len());
let end = end.min(source.len());
let start_line = line_index.line_number(TextSize::new(start as u32));
let Some(next_line_start) = line_index.line_start(start_line + 1).map(usize::from) else {
return Vec::new();
};
if next_line_start >= end {
return Vec::new();
}
let mut comments = Vec::new();
let first_line = start_line + 1;
let last_line = line_index.line_number(TextSize::new(end.saturating_sub(1) as u32));
for line in first_line..=last_line {
let Some((line_start, line_end, text)) =
clamped_line_text(source, line_index, line, next_line_start, end)
else {
continue;
};
if own_line_comment_in_bounds(comment_index, line, line_start, line_end).is_none() {
continue;
}
let trimmed = text.trim_start_matches([' ', '\t']);
if !trimmed.starts_with('#') {
continue;
}
let indent = text.len().saturating_sub(trimmed.len());
comments.push(BranchPrefixComment {
offset: line_start + indent,
text: trimmed.trim_end_matches([' ', '\t', '\r']).to_string(),
source_indent: indent,
});
}
comments
}
fn clamped_line_text<'source>(
source: &'source str,
line_index: &LineIndex,
line: usize,
start: usize,
end: usize,
) -> Option<(usize, usize, &'source str)> {
let range: TextRange = line_index.line_range(line, source)?;
let line_start = usize::from(range.start()).max(start);
let line_end = usize::from(range.end()).min(end);
(line_start <= line_end)
.then(|| {
source
.get(line_start..line_end)
.map(|text| (line_start, line_end, text))
})
.flatten()
}
fn own_line_comment_in_bounds(
comment_index: &CommentIndex,
line: usize,
line_start: usize,
line_end: usize,
) -> Option<&IndexedComment> {
comment_index.comments_on_line(line).iter().find(|comment| {
comment.is_own_line && {
let start = usize::from(comment.range.start());
line_start <= start && start < line_end
}
})
}
fn line_end_for_offset(source: &str, offset: usize) -> Option<usize> {
let offset = offset.min(source.len());
source.get(offset..).map(|suffix| {
suffix
.find('\n')
.map_or(source.len(), |index| offset + index)
})
}
struct FormatterFactsBuilder<'source, 'options> {
source: &'source str,
options: &'options ResolvedShellFormatOptions,
facts: FormatterFacts<'source>,
layout: LayoutAnnotations,
comment_attachments: CommentAttachmentModel<'source>,
}
impl<'source, 'options> FormatterFactsBuilder<'source, 'options> {
fn new(
source: &'source str,
options: &'options ResolvedShellFormatOptions,
indexer: Indexer,
) -> Self {
let source_map = SourceMap::from_indexer(source, &indexer, options.keep_padding());
let comment_attachments = CommentAttachmentModel::from_indexer(&source_map, &indexer);
let comments = CommentFacts::new(source, &source_map, &comment_attachments);
Self {
source,
options,
facts: FormatterFacts {
source_map,
layout_facts: LayoutFacts::default(),
sequences: SequenceFactsStore::default(),
breaks: BreakFacts::default(),
branches: BranchFacts::default(),
comments,
cases: CaseFacts::default(),
compound: CompoundFacts::default(),
indexer,
},
layout: LayoutAnnotations::default(),
comment_attachments,
}
}
fn build(mut self, file: &File) -> FormatterFacts<'source> {
let _ = self.visit_sequence(&file.body, None, None);
self.facts
}
fn cache_compound_close_span(&mut self, command: &CompoundCommand) -> Option<Span> {
let key = CompoundFacts::key(command)?;
if let Some(span) = self.facts.compound.close_span_for_key(key) {
return Some(span);
}
let span = command_compound_close_span(command, self.source, self.source_map());
if let Some(span) = span {
self.facts.compound.insert_close_span(key, span);
}
span
}
fn cached_close(&self, span: Span) -> Option<Span> {
self.facts.compound.close_span_for_span(span)
}
fn visit_sequence(
&mut self,
sequence: &StmtSeq,
upper_bound: Option<usize>,
group_open_char: Option<char>,
) -> LayoutSummary {
self.visit_sequence_with_suffix(sequence, upper_bound, group_open_char, None, None)
}
fn visit_compound_body_site(&mut self, site: CompoundBodySite<'_>) -> LayoutSummary {
if let Some(open) = site.group_open_char() {
self.record_inline_group_sequence(site.body(), open, matching_group_close(open));
}
let summary = self.visit_sequence_with_suffix(
site.body(),
site.bounds().facts_limit(),
site.group_open_char(),
site.open_suffix_span(self.source_map()),
site.open_end_offset(self.source),
);
self.record_close_suffix(site.close_span());
summary
}
fn record_inline_group_sequence(&mut self, body: &StmtSeq, open: char, close: char) {
if group_was_inline_in_source(body.as_slice(), self.source_map(), open, close) {
self.facts
.branches
.inline_group_sequences
.insert(FactSpan::from(body.span));
}
}
fn visit_sequence_with_suffix(
&mut self,
sequence: &StmtSeq,
upper_bound: Option<usize>,
group_open_char: Option<char>,
open_suffix_span: Option<Span>,
open_end_offset: Option<usize>,
) -> LayoutSummary {
let site = SequenceSite::new(
sequence,
upper_bound,
group_open_char,
open_suffix_span,
open_end_offset,
);
let key = site.key();
if self.facts.sequences.by_site.contains_key(&key) {
return self.layout.sequence(sequence);
}
let layout_key = FactSpan::from(sequence.span);
let cached_layout = self.layout.sequences.get(&layout_key).copied();
let child_layouts = sequence
.iter()
.map(|stmt| self.visit_stmt(stmt))
.collect::<Vec<_>>();
let layout = cached_layout.unwrap_or_else(|| {
let mut summary = LayoutSummary::default().with_comments(
!sequence.leading_comments.is_empty() || !sequence.trailing_comments.is_empty(),
);
for child in child_layouts {
summary.merge(child);
}
summary
});
self.layout.sequences.entry(layout_key).or_insert(layout);
let mut facts = SequenceFacts::new(sequence.len());
facts.group_open_suffix_span = site.open_suffix_span.or_else(|| {
site.group_open_char.and_then(|open| {
group_open_suffix(sequence.as_slice(), self.source_map(), open)
.map(|(span, _)| span)
})
});
if let Some(span) = facts.group_open_suffix_span {
self.record_suffix_attachment(span);
}
facts.contains_comments = layout.contains_comments;
facts.contains_heredoc = layout.contains_heredoc;
facts.contains_multiline_literal_source = layout.contains_multiline_literal_source;
facts.contains_multistatement_pipeline_brace_group =
layout.contains_multistatement_pipeline_brace_group;
let group_attachment_span = site.group_open_char.and_then(|open| {
let close = match open {
'{' => '}',
'(' => ')',
other => other,
};
group_attachment_span_with_heredoc(
sequence.as_slice(),
self.source_map(),
open,
close,
|stmt| self.layout.stmt(stmt).contains_heredoc,
)
});
facts.group_attachment_span = group_attachment_span;
facts.body_content_end =
sequence_body_content_end(sequence, self.source, &self.facts.indexer);
facts.close_gap_start = sequence
.trailing_comments
.iter()
.map(|comment| usize::from(comment.range.end()))
.max()
.unwrap_or(facts.body_content_end);
facts.open_end_offset = if let Some(open) = site.group_open_char {
facts
.group_open_suffix_span
.map(|span| span.end.offset)
.or_else(|| {
facts
.group_attachment_span
.map(|span| span.start.offset.saturating_add(open.len_utf8()))
})
} else {
site.open_end_offset
};
facts.has_blank_line_after_open = facts.open_end_offset.is_some_and(|offset| {
body_has_blank_line_after_open(
self.source,
self.source_map(),
offset,
sequence,
&self.layout,
)
});
facts.has_blank_line_before_close = if let (Some(open), Some(span)) =
(site.group_open_char, facts.group_attachment_span)
{
let close = matching_group_close(open);
let close_offset =
group_close_offset(self.source, span, site.upper_bound, close, close.len_utf8());
self.source_map()
.has_blank_line_immediately_before_offset(close_offset)
} else {
site.upper_bound.is_some_and(|offset| {
self.source_map()
.has_blank_line_immediately_before_offset(offset)
})
};
let sequence_limit = group_attachment_span
.map(|span| span.end.offset)
.or(site.upper_bound);
let comment_lower_bound = sequence_comment_lower_bound(sequence, self.source_map());
let lower_bound = group_attachment_span
.map(|span| span.start.offset.min(comment_lower_bound))
.unwrap_or(comment_lower_bound);
if sequence.is_empty() {
facts.comments = self.comment_attachments.attach_sequence(
lower_bound,
sequence_limit,
facts.group_open_suffix_span,
&[],
);
} else {
let child_spans = sequence
.iter()
.map(|stmt| self.facts.stmt(stmt).attachment_span())
.collect::<Vec<_>>();
facts.comments = self.comment_attachments.attach_sequence(
lower_bound,
sequence_limit,
facts.group_open_suffix_span,
&child_spans,
);
for (index, stmt) in sequence.iter().enumerate() {
facts.first_rendered_lines[index] = facts
.comments
.leading_for(index)
.first()
.map(SourceComment::line)
.unwrap_or_else(|| self.facts.stmt(stmt).rendered_start_line());
}
}
for window in sequence.as_slice().windows(2) {
let [current, next] = window else {
continue;
};
if !matches!(current.terminator, Some(StmtTerminator::Background(_))) {
continue;
}
let break_key = current
.terminator_span
.map(FactSpan::from)
.unwrap_or_else(|| FactSpan::from(stmt_span(current)));
let break_start = current
.terminator_span
.map(|span| span.end.offset)
.unwrap_or_else(|| stmt_span(current).end.offset);
let next_start = self.facts.stmt(next).attachment_span().start.offset;
if self.facts.contains_newline_between(break_start, next_start) {
self.facts.breaks.background.insert(break_key);
}
}
self.facts.sequences.by_span.entry(key.span).or_insert(key);
self.facts.sequences.by_site.insert(key, facts);
layout
}
fn visit_stmt(&mut self, stmt: &Stmt) -> LayoutSummary {
let site = StmtSite::new(stmt);
let stmt = site.stmt;
if let Some(layout) = self.layout.statements.get(&site.key).copied()
&& self.facts.layout_facts.statements.contains_key(&site.key)
{
return layout;
}
let already_recorded = self.facts.layout_facts.statements.contains_key(&site.key);
let mut redirect_layout = LayoutSummary::default();
for redirect in &stmt.redirects {
redirect_layout.merge(self.visit_redirect(redirect));
}
if let Some((commands, open)) = command_group_commands(&stmt.command) {
if group_was_inline_in_source(
commands.as_slice(),
self.source_map(),
open,
matching_group_close(open),
) {
self.facts
.branches
.inline_group_sequences
.insert(FactSpan::from(commands.span));
}
let _ = self.visit_sequence(commands, Some(stmt_span(stmt).end.offset), Some(open));
}
let mut layout = self
.visit_command(&stmt.command)
.with_comments(!stmt.leading_comments.is_empty() || stmt.inline_comment.is_some());
layout.merge(redirect_layout);
self.layout.statements.insert(site.key, layout);
if already_recorded {
return layout;
}
let contains_heredoc = layout.contains_heredoc;
let preserve_verbatim = should_render_verbatim_with_heredoc(
stmt,
self.source_map(),
self.options,
contains_heredoc,
);
let render_span = if preserve_verbatim {
stmt_verbatim_span_with_source_map(stmt, self.source_map())
} else {
stmt_format_span(stmt)
};
let stmt_contains_heredoc = |stmt: &Stmt| self.layout.stmt(stmt).contains_heredoc;
let attachment_span = stmt_attachment_span_with_heredoc_and_compound_close(
stmt,
self.source,
self.source_map(),
self.options,
stmt_contains_heredoc,
self.facts.stmt_compound_close_span(stmt),
);
let rendered_end_line = rendered_stmt_end_line_with_heredoc(
stmt,
self.source,
self.source_map(),
stmt_contains_heredoc,
);
let rendered_start_line = stmt_render_start_line_with_heredoc(
stmt,
self.source,
self.source_map(),
self.options,
stmt_contains_heredoc,
);
self.facts.layout_facts.statements.insert(
site.key,
StmtFacts {
attachment_span,
render_span,
rendered_start_line,
rendered_end_line,
has_trailing_comment: stmt_has_trailing_comment(stmt, self.source_map()),
preserve_verbatim,
contains_heredoc,
},
);
layout
}
fn visit_command(&mut self, command: &Command) -> LayoutSummary {
match LayoutClassifier::command(command) {
LayoutCommand::Binary(command) => self.visit_binary_command(command),
command => self.visit_non_binary_command(command),
}
}
fn visit_non_binary_command(&mut self, command: LayoutCommand<'_>) -> LayoutSummary {
match command {
LayoutCommand::Simple {
assignments,
name,
args,
} => {
let mut summary = LayoutSummary::default();
for assignment in assignments {
summary.merge(self.visit_assignment(assignment));
}
summary.merge(self.visit_word(name));
for word in args {
summary.merge(self.visit_word(word));
}
summary
}
LayoutCommand::Builtin {
assignments,
primary,
extra_args,
} => {
let mut summary = LayoutSummary::default();
for assignment in assignments {
summary.merge(self.visit_assignment(assignment));
}
if let Some(primary) = primary {
summary.merge(self.visit_word(primary));
}
for word in extra_args {
summary.merge(self.visit_word(word));
}
summary
}
LayoutCommand::Decl {
assignments,
operands,
} => {
let mut summary = LayoutSummary::default();
for assignment in assignments {
summary.merge(self.visit_assignment(assignment));
}
for operand in operands {
summary.merge(self.visit_decl_operand(operand));
}
summary
}
LayoutCommand::Compound(command) => self.visit_compound_command(command),
LayoutCommand::Function(function) => self.visit_function(function),
LayoutCommand::AnonymousFunction(function) => self.visit_anonymous_function(function),
LayoutCommand::Binary(_) => unreachable!("binary commands are handled separately"),
}
}
fn visit_decl_operand(&mut self, operand: &DeclOperand) -> LayoutSummary {
match operand {
DeclOperand::Flag(word) | DeclOperand::Dynamic(word) => self.visit_word(word),
DeclOperand::Name(reference) => self.visit_var_ref(reference),
DeclOperand::Assignment(assignment) => self.visit_assignment(assignment),
}
}
fn visit_binary_command(&mut self, command: &BinaryCommand) -> LayoutSummary {
let mut summary = self.visit_stmt(command.left.as_ref());
summary.merge(self.visit_stmt(command.right.as_ref()));
summary.contains_multistatement_pipeline_brace_group =
self.command_contains_multistatement_pipeline_brace_group(command, false);
if matches!(command.op, BinaryOp::Pipe | BinaryOp::PipeAll)
&& pipeline_has_explicit_line_break(command, self.source, self.source_map())
{
self.facts
.breaks
.pipeline
.insert(FactSpan::from(command.span));
}
if matches!(command.op, BinaryOp::And | BinaryOp::Or) {
let mut rest = Vec::new();
let mut previous = collect_command_list_first(command, &mut rest);
for item in rest {
let next_start = stmt_start_after_operator(
item.stmt,
item.operator_span.end.offset,
self.source,
self.source_map(),
);
let next_start_line = self.source_map().line_number_for_offset(next_start);
let previous_span = stmt_span(previous);
if self
.source_map()
.operator_starts_or_ends_line(item.operator_span)
|| self
.facts
.contains_newline_between(item.operator_span.end.offset, next_start)
|| (stmt_is_multiline_conditional(previous)
&& previous_span.start.line < item.operator_span.start.line
&& item.operator_span.end.line == next_start_line
&& !stmt_can_follow_multiline_conditional_inline(item.stmt))
{
self.facts
.breaks
.list_item
.insert(FactSpan::from(item.operator_span));
}
previous = item.stmt;
}
}
summary
}
fn visit_compound_command(&mut self, command: &CompoundCommand) -> LayoutSummary {
let cached_close_span = self.cache_compound_close_span(command);
match LayoutClassifier::compound_command(command) {
LayoutCompoundCommand::If(command) => self.visit_if(command),
LayoutCompoundCommand::For(command) => self.visit_for(command),
LayoutCompoundCommand::Repeat(command) => self.visit_repeat(command),
LayoutCompoundCommand::Foreach(command) => {
let mut summary = LayoutSummary::default();
for word in &command.words {
summary.merge(self.visit_word(word));
}
let site = CompoundBodySite::foreach_command(
command,
self.source_map(),
cached_close_span,
);
summary.merge(self.visit_compound_body_site(site));
summary
}
LayoutCompoundCommand::ArithmeticFor(command) => {
let mut summary = LayoutSummary::default();
if let Some(expr) = &command.init_ast {
summary.merge(self.visit_arithmetic_expr(expr));
}
if let Some(expr) = &command.condition_ast {
summary.merge(self.visit_arithmetic_expr(expr));
}
if let Some(expr) = &command.step_ast {
summary.merge(self.visit_arithmetic_expr(expr));
}
let site = CompoundBodySite::arithmetic_for_command(
command,
self.source_map(),
cached_close_span,
);
summary.merge(self.visit_compound_body_site(site));
summary
}
LayoutCompoundCommand::While(command) => self.visit_while(command),
LayoutCompoundCommand::Until(command) => self.visit_until(command),
LayoutCompoundCommand::Case(command) => self.visit_case(command),
LayoutCompoundCommand::Select(command) => self.visit_select(command),
LayoutCompoundCommand::Subshell(body) => {
self.record_inline_group_sequence(body, '(', ')');
self.visit_sequence(body, None, Some('('))
}
LayoutCompoundCommand::BraceGroup(body) => {
self.record_inline_group_sequence(body, '{', '}');
self.visit_sequence(body, None, Some('{'))
}
LayoutCompoundCommand::Arithmetic(command) => {
if let Some(expr) = &command.expr_ast {
self.visit_arithmetic_expr(expr)
} else {
LayoutSummary::default()
}
}
LayoutCompoundCommand::Time(command) => self.visit_time(command),
LayoutCompoundCommand::Conditional(command) => self.visit_conditional(command),
LayoutCompoundCommand::Coproc(command) => self.visit_stmt(command.body.as_ref()),
LayoutCompoundCommand::Always(command) => {
let mut summary =
self.visit_sequence(&command.body, Some(command.span.end.offset), Some('{'));
summary.merge(self.visit_sequence(
&command.always_body,
Some(command.span.end.offset),
Some('{'),
));
self.record_inline_group_sequence(&command.body, '{', '}');
self.record_inline_group_sequence(&command.always_body, '{', '}');
summary
}
}
}
fn visit_if(&mut self, command: &IfCommand) -> LayoutSummary {
let condition_upper_bound = match command.syntax {
shuck_ast::IfSyntax::ThenFi { then_span, .. } => Some(then_span.start.offset),
shuck_ast::IfSyntax::Brace {
left_brace_span, ..
} => Some(left_brace_span.start.offset),
};
let mut summary = self.visit_sequence(&command.condition, condition_upper_bound, None);
let brace_syntax = matches!(command.syntax, shuck_ast::IfSyntax::Brace { .. });
if let shuck_ast::IfSyntax::ThenFi { then_span, .. } = command.syntax
&& let Some(comment) = condition_separator_suffix_comment(
&command.condition,
then_span,
self.source,
self.source_map(),
)
{
self.facts
.comments
.insert_suffix_comment(then_span, comment);
}
let then_upper_bound =
if_branch_upper_bound(command, 0, self.source, self.source_map(), &self.facts);
let then_site =
CompoundBodySite::if_then_branch(command, &command.then_branch, then_upper_bound);
summary.merge(self.visit_compound_body_site(then_site));
for (index, (condition, body)) in command.elif_branches.iter().enumerate() {
let body_upper_bound = if_branch_upper_bound(
command,
index + 1,
self.source,
self.source_map(),
&self.facts,
);
let body_site = CompoundBodySite::if_then_branch(command, body, body_upper_bound);
let mut body_summary = None;
if brace_syntax {
body_summary = Some(self.visit_compound_body_site(body_site));
}
let condition_upper_bound = if brace_syntax {
group_attachment_span_with_heredoc(
body.as_slice(),
self.source_map(),
'{',
'}',
|stmt| self.layout.stmt(stmt).contains_heredoc,
)
.map(|span| span.start.offset)
} else {
body_site.open_keyword_start(self.source)
};
summary.merge(self.visit_sequence(condition, condition_upper_bound, None));
if !brace_syntax {
body_summary = Some(self.visit_compound_body_site(body_site));
}
if let Some(body_summary) = body_summary {
summary.merge(body_summary);
}
}
if let Some(else_branch) = &command.else_branch {
let upper_bound = self.facts.if_close_span(command).start.offset;
let site = CompoundBodySite::if_else_branch(command, else_branch, upper_bound);
summary.merge(self.visit_compound_body_site(site));
}
self.record_if_branch_prefix_facts(command);
self.record_close_suffix(Some(self.facts.if_close_span(command)));
summary
}
fn visit_for(&mut self, command: &ForCommand) -> LayoutSummary {
let mut summary = LayoutSummary::default();
for target in &command.targets {
summary.merge(self.visit_word(&target.word));
}
if let Some(words) = &command.words {
for word in words {
summary.merge(self.visit_word(word));
}
}
let site = CompoundBodySite::for_command(
command,
self.source_map(),
self.cached_close(command.span),
);
summary.merge(self.visit_compound_body_site(site));
summary
}
fn visit_repeat(&mut self, command: &RepeatCommand) -> LayoutSummary {
let mut summary = self.visit_word(&command.count);
let site = CompoundBodySite::repeat_command(
command,
self.source_map(),
self.cached_close(command.span),
);
summary.merge(self.visit_compound_body_site(site));
summary
}
fn visit_while(&mut self, command: &WhileCommand) -> LayoutSummary {
let site = CompoundBodySite::while_command(
command,
self.source_map(),
self.cached_close(command.span),
);
let condition_upper_bound = site.open_keyword_start(self.source);
let mut summary = self.visit_sequence(&command.condition, condition_upper_bound, None);
summary.merge(self.visit_compound_body_site(site));
summary
}
fn visit_until(&mut self, command: &UntilCommand) -> LayoutSummary {
let site = CompoundBodySite::until_command(
command,
self.source_map(),
self.cached_close(command.span),
);
let condition_upper_bound = site.open_keyword_start(self.source);
let mut summary = self.visit_sequence(&command.condition, condition_upper_bound, None);
summary.merge(self.visit_compound_body_site(site));
summary
}
fn visit_case(&mut self, command: &CaseCommand) -> LayoutSummary {
let mut summary = self.visit_word(&command.word);
let case_command_facts = self.build_case_command_facts(command);
self.record_close_suffix(case_command_facts.esac_span());
let mut previous_item: Option<&CaseItem> = None;
for item in &command.cases {
for pattern in &item.patterns {
summary.merge(self.visit_pattern(pattern));
}
if case_item_was_inline_in_source(item) {
self.facts
.branches
.inline_case_item_bodies
.insert(FactSpan::from(item.body.span));
}
let upper_bound =
case_item_body_upper_bound(item, case_command_facts.body_fallback_upper_bound());
summary.merge(self.visit_sequence(&item.body, upper_bound, None));
let item_facts = self.build_case_item_facts(item, previous_item, upper_bound);
self.facts.cases.insert_case_item(item, item_facts);
previous_item = Some(item);
}
self.facts
.cases
.insert_case_command(command, case_command_facts);
summary
}
fn visit_select(&mut self, command: &SelectCommand) -> LayoutSummary {
let mut summary = LayoutSummary::default();
for word in &command.words {
summary.merge(self.visit_word(word));
}
let site = CompoundBodySite::select_command(
command,
self.source_map(),
self.cached_close(command.span),
);
summary.merge(self.visit_compound_body_site(site));
summary
}
fn visit_time(&mut self, command: &TimeCommand) -> LayoutSummary {
if let Some(inner) = &command.command {
let summary = self.visit_stmt(inner.as_ref());
self.record_close_suffix(Some(stmt_format_span(inner.as_ref())));
summary
} else {
LayoutSummary::default()
}
}
fn visit_conditional(&mut self, command: &ConditionalCommand) -> LayoutSummary {
self.visit_conditional_expr(&command.expression)
}
fn visit_function(&mut self, function: &FunctionDef) -> LayoutSummary {
let mut summary = LayoutSummary::default();
for entry in &function.header.entries {
summary.merge(self.visit_word(&entry.word));
}
summary.merge(self.visit_function_body(function.body.as_ref(), function.span.end.offset));
summary
}
fn visit_anonymous_function(&mut self, function: &AnonymousFunctionCommand) -> LayoutSummary {
let mut summary = LayoutSummary::default();
for argument in &function.args {
summary.merge(self.visit_word(argument));
}
summary.merge(self.visit_function_body(function.body.as_ref(), function.span.end.offset));
summary
}
fn visit_function_body(&mut self, body: &Stmt, function_end_offset: usize) -> LayoutSummary {
if let Some(site) = CompoundBodySite::function_group_body(body, function_end_offset) {
let summary = self
.visit_compound_body_site(site)
.with_comments(!body.leading_comments.is_empty() || body.inline_comment.is_some());
self.layout
.statements
.insert(FactSpan::from(stmt_span(body)), summary);
summary
} else {
self.visit_stmt(body)
}
}
fn visit_redirect(&mut self, redirect: &Redirect) -> LayoutSummary {
if let Some(word) = redirect.word_target() {
let mut summary = self.visit_word(word);
summary.contains_heredoc |= matches!(
redirect.kind,
RedirectKind::HereDoc | RedirectKind::HereDocStrip
);
return summary;
}
if let Some(heredoc) = redirect.heredoc() {
let delimiter = self.visit_word(&heredoc.delimiter.raw);
let mut summary = delimiter;
summary.merge(self.visit_heredoc_body(&heredoc.body));
summary.contains_heredoc |= matches!(
redirect.kind,
RedirectKind::HereDoc | RedirectKind::HereDocStrip
);
summary.contains_multiline_literal_source = delimiter.contains_multiline_literal_source;
return summary;
}
LayoutSummary::default()
}
fn visit_assignment(&mut self, assignment: &Assignment) -> LayoutSummary {
let mut summary = self.visit_var_ref(&assignment.target);
let value_has_multiline_literal_source = match &assignment.value {
AssignmentValue::Scalar(word) => {
let word = self.visit_word(word);
summary.merge(word);
word.contains_multiline_literal_source
}
AssignmentValue::Compound(array) => {
let mut contains_multiline_literal_source = false;
for element in &array.elements {
if let Some(key) = array_elem_parts(element).0 {
summary.merge(self.visit_subscript(key));
}
let word = self.visit_word(array_elem_parts(element).1);
contains_multiline_literal_source |= word.contains_multiline_literal_source;
summary.merge(word);
}
contains_multiline_literal_source
}
};
summary.contains_multiline_literal_source = value_has_multiline_literal_source
|| matches!(&assignment.value, AssignmentValue::Scalar(_))
&& assignment_has_raw_backslash_continuation_literal(assignment, self.source);
summary
}
fn visit_conditional_expr(&mut self, expression: &ConditionalExpr) -> LayoutSummary {
match expression {
ConditionalExpr::Binary(expression) => {
let mut summary = self.visit_conditional_expr(&expression.left);
summary.merge(self.visit_conditional_expr(&expression.right));
summary
}
ConditionalExpr::Unary(expression) => self.visit_conditional_expr(&expression.expr),
ConditionalExpr::Parenthesized(expression) => {
self.visit_conditional_expr(&expression.expr)
}
ConditionalExpr::Word(word) | ConditionalExpr::Regex(word) => self.visit_word(word),
ConditionalExpr::Pattern(pattern) => self.visit_pattern(pattern),
ConditionalExpr::VarRef(reference) => self.visit_var_ref(reference),
}
}
fn visit_pattern(&mut self, pattern: &Pattern) -> LayoutSummary {
let mut summary = LayoutSummary::default();
for part in &pattern.parts {
summary.merge(self.visit_pattern_part(&part.kind));
}
summary
}
fn visit_pattern_part(&mut self, part: &PatternPart) -> LayoutSummary {
match part {
PatternPart::Group { patterns, .. } => {
let mut summary = LayoutSummary::default();
for pattern in patterns {
summary.merge(self.visit_pattern(pattern));
}
summary
}
PatternPart::Word(word) => self.visit_word(word),
PatternPart::Literal(_)
| PatternPart::AnyString
| PatternPart::AnyChar
| PatternPart::CharClass(_) => LayoutSummary::default(),
}
}
fn visit_word(&mut self, word: &Word) -> LayoutSummary {
let word_key = FactSpan::from(word.span);
if let Some(layout) = self.layout.words.get(&word_key).copied()
&& self.facts.layout_facts.words.contains_key(&word_key)
{
return layout;
}
let mut layout = LayoutSummary::default();
for part in &word.parts {
layout.merge(self.visit_word_part(&part.kind, part.span));
}
layout.contains_multiline_literal_source =
word_has_multiline_literal_source_with_sequence_layout(word, self.source, |body| {
self.layout.sequence(body)
});
layout.contains_heredoc = false;
layout.contains_multistatement_pipeline_brace_group = false;
self.layout.words.insert(word_key, layout);
self.facts.layout_facts.words.insert(
word_key,
WordFacts {
has_multiline_literal_source: layout.contains_multiline_literal_source,
},
);
layout
}
fn visit_word_part(&mut self, part: &WordPart, span: Span) -> LayoutSummary {
match part {
WordPart::Literal(text) => LayoutSummary {
contains_multiline_literal_source: text.as_str(self.source, span).contains('\n'),
..LayoutSummary::default()
},
WordPart::SingleQuoted { value, dollar } => LayoutSummary {
contains_multiline_literal_source: if *dollar {
raw_source_slice(span, self.source).is_some_and(|raw| raw.contains('\n'))
} else {
value.slice(self.source).contains('\n')
},
..LayoutSummary::default()
},
WordPart::CommandSubstitution { body, syntax }
if matches!(
*syntax,
CommandSubstitutionSyntax::DollarParen | CommandSubstitutionSyntax::Backtick
) =>
{
let mut summary = self.visit_sequence(body, Some(span.end.offset), None);
summary.contains_multiline_literal_source |= summary.contains_comments
&& raw_source_slice(span, self.source).is_some_and(|raw| {
raw.contains('\n')
&& !command_substitution_source_starts_with_body_line(raw)
});
summary
}
WordPart::ProcessSubstitution { body, .. } => {
let mut summary = self.visit_sequence(body, span.end.offset.checked_sub(1), None);
summary.contains_multiline_literal_source |= summary.contains_comments
&& raw_source_slice(span, self.source).is_some_and(|raw| raw.contains('\n'));
summary
}
WordPart::ZshQualifiedGlob(glob) => {
let mut summary = LayoutSummary::default();
for segment in &glob.segments {
if let ZshGlobSegment::Pattern(pattern) = segment {
summary.merge(self.visit_pattern(pattern));
}
}
summary
}
WordPart::ArithmeticExpansion {
expression_ast: Some(expr),
..
} => self.visit_arithmetic_expr(expr),
WordPart::ArithmeticExpansion {
expression_ast: None,
expression_word_ast,
..
} => self.visit_word(expression_word_ast),
WordPart::Parameter(parameter) => self.visit_parameter_expansion(parameter),
WordPart::ParameterExpansion {
reference,
operator,
operand_word_ast,
..
} => {
let mut summary = self.visit_var_ref(reference);
summary.merge(self.visit_parameter_op(operator));
if let Some(operand) = operand_word_ast {
summary.merge(self.visit_word(operand));
}
summary
}
WordPart::Length(reference)
| WordPart::ArrayAccess(reference)
| WordPart::ArrayLength(reference)
| WordPart::ArrayIndices(reference)
| WordPart::Transformation { reference, .. } => self.visit_var_ref(reference),
WordPart::Substring {
reference,
offset_ast,
offset_word_ast,
length_ast,
length_word_ast,
..
}
| WordPart::ArraySlice {
reference,
offset_ast,
offset_word_ast,
length_ast,
length_word_ast,
..
} => {
let mut summary = self.visit_var_ref(reference);
if let Some(expr) = offset_ast {
summary.merge(self.visit_arithmetic_expr(expr));
} else {
summary.merge(self.visit_word(offset_word_ast));
}
if let Some(expr) = length_ast {
summary.merge(self.visit_arithmetic_expr(expr));
} else if let Some(word) = length_word_ast {
summary.merge(self.visit_word(word));
}
summary
}
WordPart::IndirectExpansion {
reference,
operator,
operand_word_ast,
..
} => {
let mut summary = self.visit_var_ref(reference);
if let Some(operator) = operator {
summary.merge(self.visit_parameter_op(operator));
}
if let Some(operand) = operand_word_ast {
summary.merge(self.visit_word(operand));
}
summary
}
WordPart::CommandSubstitution { .. }
| WordPart::Variable(_)
| WordPart::PrefixMatch { .. } => LayoutSummary::default(),
WordPart::DoubleQuoted { parts, .. } => {
let mut summary = LayoutSummary::default();
for part in parts {
summary.merge(self.visit_word_part(&part.kind, part.span));
}
summary
}
}
}
fn visit_heredoc_body(&mut self, body: &HeredocBody) -> LayoutSummary {
let mut summary = LayoutSummary::default();
for part in &body.parts {
summary.merge(self.visit_heredoc_body_part(&part.kind, part.span));
}
summary
}
fn visit_heredoc_body_part(&mut self, part: &HeredocBodyPart, span: Span) -> LayoutSummary {
match part {
HeredocBodyPart::CommandSubstitution { body, syntax }
if matches!(
*syntax,
CommandSubstitutionSyntax::DollarParen | CommandSubstitutionSyntax::Backtick
) =>
{
self.visit_sequence(body, Some(span.end.offset), None)
}
HeredocBodyPart::ArithmeticExpansion {
expression_ast: Some(expr),
..
} => self.visit_arithmetic_expr(expr),
HeredocBodyPart::ArithmeticExpansion {
expression_ast: None,
expression_word_ast,
..
} => self.visit_word(expression_word_ast),
HeredocBodyPart::Parameter(parameter) => self.visit_parameter_expansion(parameter),
HeredocBodyPart::Literal(_)
| HeredocBodyPart::Variable(_)
| HeredocBodyPart::CommandSubstitution { .. } => LayoutSummary::default(),
}
}
fn visit_arithmetic_expr(&mut self, expr: &ArithmeticExprNode) -> LayoutSummary {
match &expr.kind {
ArithmeticExpr::Number(_) | ArithmeticExpr::Variable(_) => LayoutSummary::default(),
ArithmeticExpr::Indexed { index, .. } => self.visit_arithmetic_expr(index),
ArithmeticExpr::ShellWord(word) => self.visit_word(word),
ArithmeticExpr::Parenthesized { expression } => self.visit_arithmetic_expr(expression),
ArithmeticExpr::Unary { expr, .. } | ArithmeticExpr::Postfix { expr, .. } => {
self.visit_arithmetic_expr(expr)
}
ArithmeticExpr::Binary { left, right, .. } => {
let mut summary = self.visit_arithmetic_expr(left);
summary.merge(self.visit_arithmetic_expr(right));
summary
}
ArithmeticExpr::Conditional {
condition,
then_expr,
else_expr,
} => {
let mut summary = self.visit_arithmetic_expr(condition);
summary.merge(self.visit_arithmetic_expr(then_expr));
summary.merge(self.visit_arithmetic_expr(else_expr));
summary
}
ArithmeticExpr::Assignment { target, value, .. } => {
let mut summary = self.visit_arithmetic_lvalue(target);
summary.merge(self.visit_arithmetic_expr(value));
summary
}
}
}
fn visit_arithmetic_lvalue(&mut self, target: &ArithmeticLvalue) -> LayoutSummary {
match target {
ArithmeticLvalue::Variable(_) => LayoutSummary::default(),
ArithmeticLvalue::Indexed { index, .. } => self.visit_arithmetic_expr(index),
}
}
fn visit_var_ref(&mut self, reference: &VarRef) -> LayoutSummary {
reference
.subscript
.as_deref()
.map_or_else(LayoutSummary::default, |subscript| {
self.visit_subscript(subscript)
})
}
fn visit_subscript(&mut self, subscript: &Subscript) -> LayoutSummary {
let mut summary = subscript
.word_ast
.as_ref()
.map_or_else(LayoutSummary::default, |word| self.visit_word(word));
if let Some(expression) = &subscript.arithmetic_ast {
summary.merge(self.visit_arithmetic_expr(expression));
}
summary
}
fn visit_parameter_expansion(&mut self, parameter: &ParameterExpansion) -> LayoutSummary {
match ¶meter.syntax {
ParameterExpansionSyntax::Bourne(syntax) => {
self.visit_bourne_parameter_expansion(syntax)
}
ParameterExpansionSyntax::Zsh(syntax) => {
let mut summary = match &syntax.target {
ZshExpansionTarget::Reference(reference) => self.visit_var_ref(reference),
ZshExpansionTarget::Nested(parameter) => {
self.visit_parameter_expansion(parameter)
}
ZshExpansionTarget::Word(word) => self.visit_word(word),
ZshExpansionTarget::Empty => LayoutSummary::default(),
};
for modifier in &syntax.modifiers {
if let Some(word) = modifier.argument_word_ast() {
summary.merge(self.visit_word(word));
}
}
if let Some(operation) = &syntax.operation {
summary.merge(self.visit_zsh_expansion_operation(operation));
}
summary
}
}
}
fn visit_bourne_parameter_expansion(
&mut self,
syntax: &BourneParameterExpansion,
) -> LayoutSummary {
match syntax {
BourneParameterExpansion::Access { reference }
| BourneParameterExpansion::Length { reference }
| BourneParameterExpansion::Indices { reference }
| BourneParameterExpansion::Transformation { reference, .. } => {
self.visit_var_ref(reference)
}
BourneParameterExpansion::Indirect {
reference,
operator,
operand_word_ast,
..
} => {
let mut summary = self.visit_var_ref(reference);
if let Some(operator) = operator {
summary.merge(self.visit_parameter_op(operator));
}
if let Some(operand) = operand_word_ast {
summary.merge(self.visit_word(operand));
}
summary
}
BourneParameterExpansion::PrefixMatch { .. } => LayoutSummary::default(),
BourneParameterExpansion::Slice {
reference,
offset_ast,
offset_word_ast,
length_ast,
length_word_ast,
..
} => {
let mut summary = self.visit_var_ref(reference);
if let Some(expression) = offset_ast {
summary.merge(self.visit_arithmetic_expr(expression));
} else {
summary.merge(self.visit_word(offset_word_ast));
}
if let Some(expression) = length_ast {
summary.merge(self.visit_arithmetic_expr(expression));
} else if let Some(word) = length_word_ast {
summary.merge(self.visit_word(word));
}
summary
}
BourneParameterExpansion::Operation {
reference,
operator,
operand_word_ast,
..
} => {
let mut summary = self.visit_var_ref(reference);
summary.merge(self.visit_parameter_op(operator));
if let Some(operand) = operand_word_ast {
summary.merge(self.visit_word(operand));
}
summary
}
}
}
fn visit_parameter_op(&mut self, operator: &ParameterOp) -> LayoutSummary {
match operator {
ParameterOp::RemovePrefixShort { pattern }
| ParameterOp::RemovePrefixLong { pattern }
| ParameterOp::RemoveSuffixShort { pattern }
| ParameterOp::RemoveSuffixLong { pattern } => self.visit_pattern(pattern),
ParameterOp::ReplaceFirst {
pattern,
replacement_word_ast,
..
}
| ParameterOp::ReplaceAll {
pattern,
replacement_word_ast,
..
} => {
let mut summary = self.visit_pattern(pattern);
summary.merge(self.visit_word(replacement_word_ast));
summary
}
ParameterOp::UseDefault
| ParameterOp::AssignDefault
| ParameterOp::UseReplacement
| ParameterOp::Error
| ParameterOp::UpperFirst
| ParameterOp::UpperAll
| ParameterOp::LowerFirst
| ParameterOp::LowerAll => LayoutSummary::default(),
}
}
fn visit_zsh_expansion_operation(
&mut self,
operation: &ZshExpansionOperation,
) -> LayoutSummary {
match operation {
ZshExpansionOperation::PatternOperation {
operand_word_ast, ..
}
| ZshExpansionOperation::Defaulting {
operand_word_ast, ..
}
| ZshExpansionOperation::TrimOperation {
operand_word_ast, ..
} => self.visit_word(operand_word_ast),
ZshExpansionOperation::ReplacementOperation {
pattern_word_ast,
replacement_word_ast,
..
} => {
let mut summary = self.visit_word(pattern_word_ast);
if let Some(replacement) = replacement_word_ast {
summary.merge(self.visit_word(replacement));
}
summary
}
ZshExpansionOperation::Slice {
offset_word_ast,
length_word_ast,
..
} => {
let mut summary = self.visit_word(offset_word_ast);
if let Some(length) = length_word_ast {
summary.merge(self.visit_word(length));
}
summary
}
ZshExpansionOperation::Unknown { word_ast, .. } => self.visit_word(word_ast),
}
}
fn command_contains_multistatement_pipeline_brace_group(
&self,
command: &BinaryCommand,
in_pipeline: bool,
) -> bool {
let in_pipeline = in_pipeline || matches!(command.op, BinaryOp::Pipe | BinaryOp::PipeAll);
self.stmt_contains_multistatement_pipeline_brace_group(&command.left, in_pipeline)
|| self.stmt_contains_multistatement_pipeline_brace_group(&command.right, in_pipeline)
}
fn stmt_contains_multistatement_pipeline_brace_group(
&self,
stmt: &Stmt,
in_pipeline: bool,
) -> bool {
match &stmt.command {
Command::Binary(command)
if matches!(command.op, BinaryOp::Pipe | BinaryOp::PipeAll) =>
{
self.command_contains_multistatement_pipeline_brace_group(command, in_pipeline)
}
Command::Compound(CompoundCommand::BraceGroup(body)) if in_pipeline => body.len() > 1,
_ => false,
}
}
fn record_close_suffix(&mut self, span: Option<Span>) {
let Some(span) = span else {
return;
};
if let Some(comment) = self.source_map().suffix_comment_after_span(span) {
self.facts
.comments
.insert_close_suffix_comment(span, comment);
}
}
fn record_suffix_attachment(&mut self, span: Span) {
if let Some(comment) = suffix_comment_from_span(self.source, self.source_map(), span) {
self.facts.comments.insert_suffix_comment(span, comment);
}
}
fn record_if_branch_prefix_facts(&mut self, command: &IfCommand) {
for branch_index in 0..=command.elif_branches.len() {
let Some((start, end)) =
if_next_branch_region_with_body_end(command, branch_index, self.source, |body| {
self.facts.sequence(body, None).body_content_end()
})
else {
continue;
};
self.record_branch_prefix_facts(start, end);
}
}
fn record_branch_prefix_facts(&mut self, start: usize, end: usize) {
let comments = branch_prefix_comments_from_index(
self.source,
self.facts.indexer.line_index(),
self.facts.indexer.comment_index(),
start,
end,
)
.into_iter()
.filter(|comment| !self.facts.offset_is_in_heredoc_body(comment.offset))
.collect();
self.facts.comments.insert_branch_prefix_facts(
start,
end,
BranchPrefixFacts::new(self.source, start, end, comments),
);
}
fn build_case_command_facts(&self, command: &CaseCommand) -> CaseCommandFacts {
let esac_span = self
.cached_close(command.span)
.or_else(|| case_close_span(command, self.source_map()));
let body_fallback_upper_bound = esac_span
.map(|span| span.start.offset)
.unwrap_or(command.span.end.offset);
let suffix_comments_before_esac = command
.cases
.last()
.and_then(|last_item| case_item_source_end_offset(last_item, self.source))
.map(|start| {
own_line_comments_in_region_from_index(
self.source,
self.facts.indexer.line_index(),
self.facts.indexer.comment_index(),
start,
body_fallback_upper_bound,
)
.into_iter()
.filter(|comment| !self.facts.offset_is_in_heredoc_body(comment.offset))
.collect()
})
.unwrap_or_default();
CaseCommandFacts {
esac_span,
body_fallback_upper_bound,
has_blank_line_after_in: case_has_blank_line_after_in(command, self.source),
has_blank_line_before_esac: case_has_blank_line_before_esac(
command,
self.source,
esac_span,
),
suffix_comments_before_esac,
}
}
fn build_case_item_facts(
&self,
item: &CaseItem,
previous_item: Option<&CaseItem>,
upper_bound: Option<usize>,
) -> CaseItemFacts<'source> {
let sequence = self.facts.sequence(&item.body, upper_bound);
let first_body_line = sequence.first_rendered_line_for(0);
let first_body_stmt_line = item
.body
.first()
.map(|stmt| self.facts.stmt(stmt).rendered_start_line())
.unwrap_or(first_body_line);
let first_pattern_start = item
.patterns
.first()
.map(|pattern| pattern.span.start.offset);
let mut prefix_comments = first_pattern_start
.map(|start| {
let mut comments = sequence
.leading_for(0)
.iter()
.copied()
.filter(|comment| comment.span().start.offset < start)
.collect::<Vec<_>>();
for comment in
case_item_source_prefix_comments(self.source, self.source_map(), start)
{
if !comments
.iter()
.any(|existing| existing.span().start.offset == comment.span().start.offset)
{
comments.push(comment);
}
}
comments
})
.unwrap_or_default();
prefix_comments.sort_by_key(|comment| comment.span().start.offset);
CaseItemFacts {
suffix_comment_start_line: case_suffix_comment_start_line(item),
has_blank_line_before: previous_item.is_some_and(|previous| {
case_item_has_blank_line_before(previous, item, self.source)
}),
has_blank_line_after_pattern: case_item_has_blank_line_after_pattern(
item,
self.source,
first_body_line,
first_body_stmt_line,
),
has_blank_line_before_terminator: case_item_has_blank_line_before_terminator(
item,
self.source,
sequence.close_gap_start(),
),
prefix_comments,
pattern_suffix_comment: case_item_pattern_suffix_comment(
item,
upper_bound,
self.source,
self.source_map(),
),
terminator_suffix_comment: case_item_terminator_suffix_comment(item, self.source_map()),
}
}
fn source_map(&self) -> &SourceMap<'source> {
&self.facts.source_map
}
}
impl<'source, 'options> AstVisitor for FormatterFactsBuilder<'source, 'options> {
fn visit_stmt_seq(&mut self, sequence: &StmtSeq) {
self.visit_sequence(sequence, None, None);
}
fn visit_stmt(&mut self, stmt: &Stmt) {
FormatterFactsBuilder::visit_stmt(self, stmt);
}
fn visit_command(&mut self, command: &Command) {
FormatterFactsBuilder::visit_command(self, command);
}
fn visit_compound_command(&mut self, command: &CompoundCommand) {
FormatterFactsBuilder::visit_compound_command(self, command);
}
fn visit_function(&mut self, function: &FunctionDef) {
FormatterFactsBuilder::visit_function(self, function);
}
fn visit_anonymous_function(&mut self, function: &AnonymousFunctionCommand) {
FormatterFactsBuilder::visit_anonymous_function(self, function);
}
fn visit_redirect(&mut self, redirect: &Redirect) {
FormatterFactsBuilder::visit_redirect(self, redirect);
}
fn visit_assignment(&mut self, assignment: &Assignment) {
FormatterFactsBuilder::visit_assignment(self, assignment);
}
fn visit_word(&mut self, word: &Word) {
FormatterFactsBuilder::visit_word(self, word);
}
fn visit_word_part(&mut self, part: &WordPartNode) {
FormatterFactsBuilder::visit_word_part(self, &part.kind, part.span);
}
fn visit_heredoc_body_part(&mut self, part: &HeredocBodyPartNode) {
FormatterFactsBuilder::visit_heredoc_body_part(self, &part.kind, part.span);
}
}
#[derive(Debug, Clone, Copy)]
struct BinaryListItemFact<'a> {
operator_span: Span,
stmt: &'a Stmt,
}
fn collect_command_list_first<'a>(
command: &'a BinaryCommand,
rest: &mut Vec<BinaryListItemFact<'a>>,
) -> &'a Stmt {
collect_binary_list_first_with(command, rest, &|command| BinaryListItemFact {
operator_span: command.op_span,
stmt: command.right.as_ref(),
})
}
fn stmt_is_multiline_conditional(stmt: &Stmt) -> bool {
matches!(
stmt.command,
Command::Compound(CompoundCommand::Conditional(_))
)
}
fn stmt_can_follow_multiline_conditional_inline(stmt: &Stmt) -> bool {
matches!(
stmt.command,
Command::Simple(_)
| Command::Builtin(_)
| Command::Compound(CompoundCommand::BraceGroup(_) | CompoundCommand::Subshell(_))
)
}
fn pipeline_has_explicit_line_break(
pipeline: &BinaryCommand,
source: &str,
source_map: &SourceMap<'_>,
) -> bool {
let mut statements = Vec::new();
let mut operators = Vec::new();
collect_pipeline_parts(pipeline, &mut statements, &mut operators, &|command| {
command.op_span
});
for (statement, operator_span) in statements.iter().skip(1).zip(operators.iter()) {
let next_start =
stmt_start_after_operator(statement, operator_span.end.offset, source, source_map);
if source_map.operator_starts_or_ends_line(*operator_span)
|| source_map.contains_newline_between(operator_span.end.offset, next_start)
{
return true;
}
}
false
}
fn sequence_comment_lower_bound(sequence: &StmtSeq, source_map: &SourceMap<'_>) -> usize {
let mut lower_bound = sequence.span.start.offset;
for comment in &sequence.leading_comments {
if source_map
.source_comment(*comment)
.is_some_and(|comment| !comment.inline())
{
lower_bound = lower_bound.min(usize::from(comment.range.start()));
}
}
for stmt in sequence.iter() {
for comment in &stmt.leading_comments {
if source_map
.source_comment(*comment)
.is_some_and(|comment| !comment.inline())
{
lower_bound = lower_bound.min(usize::from(comment.range.start()));
}
}
}
lower_bound
}
fn case_close_span(command: &CaseCommand, source_map: &SourceMap<'_>) -> Option<Span> {
source_map
.close_delimiter_span(command.span, CloseDelimiterKind::Esac)
.or(Some(command.esac_span))
}
fn group_close_offset(
source: &str,
span: Span,
upper_bound: Option<usize>,
close_char: char,
close_len: usize,
) -> usize {
let fallback = span.end.offset.saturating_sub(close_len);
let search_end = upper_bound
.map(|offset| offset.saturating_add(close_len))
.unwrap_or(span.end.offset)
.min(source.len())
.max(span.start.offset);
source
.get(span.start.offset..search_end)
.and_then(|text| text.rfind(close_char))
.map_or(fallback, |offset| span.start.offset + offset)
}
fn sequence_body_content_end(body: &StmtSeq, source: &str, indexer: &Indexer) -> usize {
let mut end = body
.last()
.map(|stmt| stmt_span(stmt).end.offset)
.unwrap_or(body.span.end.offset);
if let Some(stmt) = body.last() {
for redirect in &stmt.redirects {
let Some(heredoc) = redirect.heredoc() else {
continue;
};
let heredoc_end = indexer
.region_index()
.heredoc_closing_marker_range(heredoc.body.span.to_range())
.map(|range| usize::from(range.end()))
.unwrap_or(heredoc.body.span.end.offset);
end = end.max(heredoc_end);
}
}
trim_trailing_gap_before_offset(source, end.min(source.len()))
}
fn trim_trailing_gap_before_offset(source: &str, mut offset: usize) -> usize {
let bytes = source.as_bytes();
while offset > 0 && matches!(bytes[offset - 1], b' ' | b'\t' | b'\r' | b'\n') {
offset -= 1;
}
offset
}
fn body_has_blank_line_after_open(
source: &str,
source_map: &SourceMap<'_>,
open_end_offset: usize,
commands: &StmtSeq,
layout: &LayoutAnnotations,
) -> bool {
let Some(mut first_start) = sequence_first_content_offset(commands, source_map, layout) else {
return false;
};
if first_start <= open_end_offset
&& let Some(stmt) = commands.first()
{
first_start = stmt_first_content_offset(stmt, source_map, layout);
}
if source_map.line_number_for_offset(first_start)
== source_map.line_number_for_offset(open_end_offset)
&& let Some(stmt) = commands.first()
{
first_start = stmt_first_content_offset(stmt, source_map, layout);
}
let open_line = source_map.line_number_for_offset(open_end_offset);
let mut comment_search = open_end_offset;
while let Some(comment_start) = source_map.first_comment_between(comment_search, first_start) {
if source_map.line_number_for_offset(comment_start) != open_line {
first_start = comment_start;
break;
}
comment_search = comment_start.saturating_add(1);
}
gap_has_blank_line(source, open_end_offset, first_start)
|| (source
.get(..open_end_offset.min(source.len()))
.is_some_and(|prefix| prefix.ends_with('\n'))
&& gap_starts_with_empty_physical_line(source, open_end_offset, first_start))
}
fn sequence_first_content_offset(
commands: &StmtSeq,
source_map: &SourceMap<'_>,
layout: &LayoutAnnotations,
) -> Option<usize> {
let mut first = commands
.leading_comments
.iter()
.map(|comment| usize::from(comment.range.start()))
.min();
if let Some(stmt) = commands.first() {
first = first
.into_iter()
.chain(
stmt.leading_comments
.iter()
.map(|comment| usize::from(comment.range.start())),
)
.chain(std::iter::once(stmt_first_content_offset(
stmt, source_map, layout,
)))
.min();
}
first
}
fn stmt_first_content_offset(
stmt: &Stmt,
source_map: &SourceMap<'_>,
layout: &LayoutAnnotations,
) -> usize {
match &stmt.command {
Command::Binary(command) => stmt_first_content_offset(&command.left, source_map, layout),
_ => {
stmt_group_attachment_or_verbatim_span_with_heredoc(stmt, source_map, |stmt| {
layout.stmt(stmt).contains_heredoc
})
.unwrap_or_else(|| stmt_verbatim_span_with_source_map(stmt, source_map))
.start
.offset
}
}
}
fn gap_has_blank_line(source: &str, start: usize, end: usize) -> bool {
SourceView::new(source)
.slice_between(start, end)
.is_some_and(|gap| gap.bytes().filter(|byte| *byte == b'\n').count() >= 2)
}
fn gap_has_empty_physical_line(source: &str, start: usize, end: usize) -> bool {
let Some(gap) = SourceView::new(source).slice_between(start, end) else {
return false;
};
let bytes = gap.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'\n' {
let mut next = index + 1;
while next < bytes.len() && matches!(bytes[next], b' ' | b'\t' | b'\r') {
next += 1;
}
if next < bytes.len() && bytes[next] == b'\n' {
return true;
}
}
index += 1;
}
false
}
fn gap_starts_with_empty_physical_line(source: &str, start: usize, end: usize) -> bool {
let Some(gap) = SourceView::new(source).slice_between(start, end) else {
return false;
};
for byte in gap.bytes() {
match byte {
b' ' | b'\t' | b'\r' => {}
b'\n' => return true,
_ => return false,
}
}
false
}
fn case_has_blank_line_after_in(command: &CaseCommand, source: &str) -> bool {
let Some(first_pattern_start) = command
.cases
.first()
.and_then(|item| item.patterns.first())
.map(|pattern| pattern.span.start.offset)
else {
return false;
};
let start = command.word.span.end.offset.min(source.len());
let end = first_pattern_start.min(source.len());
let Some(prefix) = source.get(start..end) else {
return false;
};
let Some(in_end) = SourceView::new(prefix)
.last_shell_keyword_start_between(0, prefix.len(), "in")
.map(|start| start + "in".len())
else {
return false;
};
gap_has_empty_physical_line(source, start + in_end, end)
}
fn case_item_has_blank_line_before(previous: &CaseItem, item: &CaseItem, source: &str) -> bool {
let Some(start) = case_item_source_end_offset(previous, source) else {
return false;
};
let Some(end) = item
.patterns
.first()
.map(|pattern| pattern.span.start.offset)
else {
return false;
};
gap_has_empty_physical_line(source, start, end)
}
fn case_item_source_end_offset(item: &CaseItem, source: &str) -> Option<usize> {
let content_end = item
.body
.last()
.map(|stmt| stmt_format_span(stmt).end.offset)
.or_else(|| item.patterns.last().map(|pattern| pattern.span.end.offset))?;
if let Some(terminator_span) = item.terminator_span
&& terminator_span.end.offset >= content_end
&& terminator_span.end.offset <= source.len()
{
return Some(terminator_span.end.offset);
}
let stmt_end = content_end.min(source.len());
let line_end = source[stmt_end..]
.find(['\n', '\r'])
.map_or(source.len(), |offset| stmt_end + offset);
let terminator = case_terminator(item.terminator);
let end = source
.get(stmt_end..line_end)
.and_then(|tail| {
tail.find(terminator)
.map(|offset| stmt_end + offset + terminator.len())
})
.unwrap_or(stmt_end);
Some(end)
}
fn case_suffix_comment_start_line(item: &CaseItem) -> Option<usize> {
item.terminator_span
.map(|span| span.end.line)
.or_else(|| item.body.last().map(|stmt| stmt_span(stmt).end.line))
.or_else(|| item.patterns.last().map(|pattern| pattern.span.end.line))
}
fn case_has_blank_line_before_esac(
command: &CaseCommand,
source: &str,
esac_span: Option<Span>,
) -> bool {
let Some(last_item) = command.cases.last() else {
return false;
};
let Some(start) = case_item_source_end_offset(last_item, source) else {
return false;
};
let Some(esac_start) = esac_span.map(|span| span.start.offset) else {
return false;
};
gap_has_blank_line(source, start, esac_start)
}
fn case_item_has_blank_line_after_pattern(
item: &CaseItem,
source: &str,
first_body_line: usize,
first_body_stmt_line: usize,
) -> bool {
let Some(pattern_line) = item.patterns.last().map(|pattern| pattern.span.end.line) else {
return false;
};
let stmt_line = if first_body_line <= pattern_line {
first_body_stmt_line
} else {
first_body_line
};
if stmt_line == 0 || stmt_line <= pattern_line.saturating_add(1) {
return false;
}
let lines = source.lines().collect::<Vec<_>>();
((pattern_line + 1)..stmt_line).any(|line| {
line.checked_sub(1)
.and_then(|index| lines.get(index))
.is_some_and(|text| text.trim_matches([' ', '\t', '\r']).is_empty())
})
}
fn case_item_has_blank_line_before_terminator(
item: &CaseItem,
source: &str,
content_end: usize,
) -> bool {
let Some(terminator_start) = item.terminator_span.map(|span| span.start.offset) else {
return false;
};
!item.body.is_empty() && gap_has_empty_physical_line(source, content_end, terminator_start)
}
fn case_item_pattern_suffix_comment<'source>(
item: &CaseItem,
upper_bound: Option<usize>,
source: &'source str,
source_map: &SourceMap<'source>,
) -> Option<SourceComment<'source>> {
let start = item.patterns.last()?.span.end.offset.min(source.len());
let end = item
.body
.first()
.map(|stmt| stmt_span(stmt).start.offset)
.or_else(|| item.terminator_span.map(|span| span.start.offset))
.or(upper_bound)
.unwrap_or(source.len())
.min(source.len());
if start >= end {
return None;
}
let (_, source_line_end) = source_map.line_bounds_for_offset(start)?;
let line_end = source_line_end.min(end);
let comment = source_map.first_source_comment_between(start, line_end)?;
let before = source_map.slice_between(start, comment.span().start.offset)?;
if !before.contains(')') {
return None;
}
Some(comment)
}
fn case_item_terminator_suffix_comment<'source>(
item: &CaseItem,
source_map: &SourceMap<'source>,
) -> Option<SourceComment<'source>> {
let span = item.terminator_span?;
if span.start.line != span.end.line {
return None;
}
source_map.suffix_comment_after_span(span)
}
fn case_item_source_prefix_comments<'source>(
source: &'source str,
source_map: &SourceMap<'source>,
first_pattern_start: usize,
) -> Vec<SourceComment<'source>> {
let Some((pattern_line_start, _)) = source_map.line_bounds_for_offset(first_pattern_start)
else {
return Vec::new();
};
if source
.get(pattern_line_start..first_pattern_start)
.is_some_and(|prefix| !prefix.trim_matches([' ', '\t', '\r']).is_empty())
{
return Vec::new();
}
let mut comments = Vec::new();
let mut next_start = pattern_line_start;
while let Some((start, end)) = source_map.previous_line_bounds(next_start) {
let Some(line) = source.get(start..end) else {
break;
};
let trimmed = line.trim_matches([' ', '\t', '\r']);
if trimmed.is_empty() {
next_start = start;
continue;
}
let leading_padding = line.len() - line.trim_start_matches([' ', '\t']).len();
let comment = &line[leading_padding..];
if !comment.starts_with('#') {
break;
}
let absolute_start = start + leading_padding;
let absolute_end = absolute_start + comment.trim_end_matches([' ', '\t', '\r']).len();
if let Some(comment) = source_map.source_comment_for_offsets(absolute_start, absolute_end) {
comments.push(comment);
}
next_start = start;
}
comments.reverse();
comments
}
fn suffix_comment_from_span<'source>(
source: &'source str,
source_map: &SourceMap<'source>,
span: Span,
) -> Option<SourceComment<'source>> {
let raw = span.slice(source);
let leading_padding = raw.len() - raw.trim_start_matches([' ', '\t']).len();
let comment = raw[leading_padding..].trim_end_matches([' ', '\t', '\r']);
if !comment.starts_with('#') {
return None;
}
let absolute_start = span.start.offset + leading_padding;
let absolute_end = absolute_start + comment.len();
source_map.source_comment_for_offsets(absolute_start, absolute_end)
}
fn condition_separator_suffix_comment<'source>(
condition: &StmtSeq,
then_span: Span,
source: &'source str,
source_map: &SourceMap<'source>,
) -> Option<SourceComment<'source>> {
let start = condition.last().map(condition_stmt_command_end)?;
let end = then_span.start.offset.min(source.len());
if start >= end {
return None;
}
let region = source.get(start..end)?;
let comment_rel = region.find('#')?;
let before_comment = region.get(..comment_rel)?;
if !before_comment
.chars()
.all(|ch| matches!(ch, ' ' | '\t' | '\r' | '\n' | ';'))
{
return None;
}
let comment_start = start + comment_rel;
let line_end = source
.get(comment_start..end)?
.find('\n')
.map_or(end, |offset| comment_start + offset);
let comment = source
.get(comment_start..line_end)?
.trim_end_matches([' ', '\t', '\r']);
source_map.source_comment_for_offsets(comment_start, comment_start + comment.len())
}
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
}
fn if_branch_upper_bound(
command: &IfCommand,
branch_index: usize,
source: &str,
_source_map: &SourceMap<'_>,
facts: &FormatterFacts<'_>,
) -> usize {
if let Some((start, end)) = if_next_branch_region(command, branch_index, source) {
facts
.branch_prefix_first_comment_offset(start, end)
.unwrap_or(end)
} else {
facts.if_close_span(command).start.offset
}
}
fn if_next_branch_region(
command: &IfCommand,
branch_index: usize,
source: &str,
) -> Option<(usize, usize)> {
if_next_branch_region_with_body_end(command, branch_index, source, branch_body_content_end)
}
fn branch_body_content_end(body: &StmtSeq) -> usize {
body.last()
.map(|stmt| stmt_span(stmt).end.offset)
.unwrap_or(body.span.end.offset)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use shuck_parser::parser::Parser;
use super::*;
use crate::command::group_attachment_span_with_heredoc;
use crate::{ShellDialect, ShellFormatOptions};
fn parse(source: &str) -> shuck_ast::File {
Parser::new(source).parse().unwrap().file
}
fn build_facts<'source>(source: &'source str) -> (shuck_ast::File, FormatterFacts<'source>) {
build_facts_with_options(source, ShellFormatOptions::default(), "test.sh")
}
fn build_facts_with_options<'source>(
source: &'source str,
options: ShellFormatOptions,
path: &str,
) -> (shuck_ast::File, FormatterFacts<'source>) {
let file = parse(source);
let resolved = options.resolve(source, Some(Path::new(path)));
let facts = FormatterFacts::build(source, &file, &resolved);
(file, facts)
}
fn first_brace_group(file: &shuck_ast::File) -> &StmtSeq {
match &file.body[0].command {
Command::Compound(CompoundCommand::BraceGroup(commands)) => commands,
_ => panic!("expected brace group"),
}
}
fn group_attachment_source<'source>(
source: &'source str,
facts: &FormatterFacts<'source>,
commands: &StmtSeq,
open: char,
close: char,
) -> &'source str {
group_attachment_span_with_heredoc(
commands.as_slice(),
facts.source_map(),
open,
close,
|stmt| facts.stmt(stmt).contains_heredoc(),
)
.expect("expected group attachment span")
.slice(source)
}
#[test]
fn builds_branch_comment_sequence_facts() {
let source =
"if foo; then\n one\nelif bar; then\n # note\n two\nelse\n # alt\n three\nfi\n";
let (file, facts) = build_facts_with_options(
source,
ShellFormatOptions::default().with_dialect(ShellDialect::Bash),
"test.bash",
);
let (_, elif_body) = &match &file.body[0].command {
Command::Compound(CompoundCommand::If(command)) => &command.elif_branches[0],
_ => panic!("expected if command"),
};
let elif_facts = facts.sequence(
elif_body,
Some(if_branch_upper_bound(
match &file.body[0].command {
Command::Compound(CompoundCommand::If(command)) => command,
_ => unreachable!(),
},
1,
source,
facts.source_map(),
&facts,
)),
);
assert_eq!(elif_facts.leading_for(0).len(), 1);
assert!(!elif_facts.is_ambiguous());
}
#[test]
fn captures_group_open_suffix_comments() {
let source = "foo() {\n # outer\n { # note\n echo hi\n }\n}\n";
let (file, facts) = build_facts(source);
let body = match &file.body[0].command {
Command::Function(function) => match function.body.as_ref() {
Stmt {
command: Command::Compound(CompoundCommand::BraceGroup(commands)),
..
} => commands,
_ => panic!("expected brace group"),
},
_ => panic!("expected function"),
};
let inner = match &body[0].command {
Command::Compound(CompoundCommand::BraceGroup(commands)) => commands,
_ => panic!("expected inner brace group"),
};
let sequence = facts.sequence(inner, Some(body[0].span.end.offset));
let span = sequence
.group_open_suffix_span()
.expect("expected group open suffix span");
let comment = facts
.suffix_comment_plan_for_span(span)
.map(InlineCommentPlan::comment)
.expect("expected suffix comment attachment");
assert_eq!(comment.text(), "# note");
assert!(sequence.leading_for(0).is_empty());
}
#[test]
fn precomputes_trailing_comment_alignment_targets() {
let source = "one # a\ntwo_long # b\n";
let (file, facts) = build_facts(source);
let sequence = facts.sequence(&file.body, None);
let comment = sequence
.trailing_for(0)
.first()
.expect("expected first trailing comment");
let plan = facts.trailing_comment_plan(*comment);
assert!(plan.has_alignment(facts.source_map(), 0));
assert_eq!(plan.padding(facts.source_map(), "one".len(), 0), 6);
}
#[test]
fn captures_group_attachment_and_blank_line_layout() {
let source = "{\n\n echo hi\n\n}\n";
let (file, facts) = build_facts(source);
let body = first_brace_group(&file);
let sequence = facts.sequence(body, None);
assert_eq!(
sequence
.group_attachment_span()
.expect("expected group attachment")
.slice(source),
source.trim_end()
);
assert!(sequence.open_end_offset().is_some());
assert!(sequence.has_blank_line_after_open());
assert!(sequence.has_blank_line_before_close());
assert_eq!(&source[..sequence.close_gap_start()], "{\n\n echo hi");
}
#[test]
fn captures_then_branch_open_suffix_comments() {
let source = "if foo; then # note\n bar\nfi\n";
let (file, facts) = build_facts(source);
let then_branch = match &file.body[0].command {
Command::Compound(CompoundCommand::If(command)) => &command.then_branch,
_ => panic!("expected if command"),
};
let sequence = facts.sequence(
then_branch,
Some(if_branch_upper_bound(
match &file.body[0].command {
Command::Compound(CompoundCommand::If(command)) => command,
_ => unreachable!(),
},
0,
source,
facts.source_map(),
&facts,
)),
);
assert!(sequence.group_open_suffix_span().is_some());
assert!(!sequence.is_ambiguous());
assert!(sequence.leading_for(0).is_empty());
}
#[test]
fn captures_branch_prefix_region_layout() {
let source = "if foo; then\n one\n\n# keep with branch\n\nelif bar; then\n two\nfi\n";
let (file, facts) = build_facts(source);
let command = match &file.body[0].command {
Command::Compound(CompoundCommand::If(command)) => command,
_ => panic!("expected if command"),
};
let (start, end) = facts
.if_next_branch_region(command, 0)
.expect("expected elif branch region");
let branch = facts.branch_prefix_facts(start, end);
assert_eq!(branch.comments().len(), 1);
assert_eq!(branch.comments()[0].text, "# keep with branch");
assert!(branch.has_blank_line_before_keyword());
assert!(branch.has_blank_line_after_comments());
assert_eq!(
facts.if_branch_upper_bound(command, 0),
branch
.first_comment_offset()
.expect("expected prefix comment")
);
}
#[test]
fn records_explicit_break_layout_facts() {
let list_source = "foo &&\n bar\n";
let (list_file, list_facts) = build_facts(list_source);
let Command::Binary(list) = &list_file.body[0].command else {
panic!("expected command list");
};
assert!(list_facts.list_item_has_explicit_line_break(list.op_span));
let background_source = "background &\necho next\n";
let (background_file, background_facts) = build_facts(background_source);
assert!(background_facts.background_has_explicit_line_break(&background_file.body[0]));
}
#[test]
fn records_padding_and_heredoc_verbatim_facts() {
let source = "a=1 b=2\ncat <<EOF # note\nhi\nEOF\n";
let (file, facts) = build_facts_with_options(
source,
ShellFormatOptions::default().with_keep_padding(true),
"test.sh",
);
assert!(facts.stmt(&file.body[0]).preserve_verbatim());
assert!(facts.stmt(&file.body[1]).preserve_verbatim());
}
#[test]
fn captures_case_prefix_suffix_and_blank_line_regions() {
let source = "case value in\n\n # before pattern\n one) # pattern note\n echo one\n\n ;; # done note\n\n # before close\nesac # close note\n";
let (file, facts) = build_facts(source);
let command = match &file.body[0].command {
Command::Compound(CompoundCommand::Case(command)) => command,
_ => panic!("expected case command"),
};
let case_facts = facts.case_command(command);
let item_facts = facts.case_item(&command.cases[0]);
assert!(case_facts.has_blank_line_after_in());
assert_eq!(case_facts.suffix_comments_before_esac().len(), 1);
assert_eq!(
case_facts.suffix_comments_before_esac()[0].text,
"# before close"
);
assert!(case_facts.has_blank_line_before_esac());
assert_eq!(item_facts.prefix_comments().len(), 1);
assert_eq!(item_facts.prefix_comments()[0].text(), "# before pattern");
assert_eq!(
item_facts
.pattern_suffix_comment()
.expect("expected pattern suffix")
.text(),
"# pattern note"
);
assert_eq!(
item_facts
.terminator_suffix_comment()
.expect("expected terminator suffix")
.text(),
"# done note"
);
assert!(
facts
.close_suffix_comment_plan_after_span(case_facts.esac_span().unwrap())
.is_some()
);
}
#[test]
fn brace_form_loop_close_suffix_comments_extend_attachment_span() {
let source =
"for key value in a b c d; { print -r -- \"$key:$value\"; } # close\nprint done\n";
let file = Parser::with_dialect(source, shuck_parser::ShellDialect::Zsh)
.parse()
.unwrap()
.file;
let resolved = ShellFormatOptions::default()
.with_dialect(ShellDialect::Zsh)
.resolve(source, Some(Path::new("test.zsh")));
let facts = FormatterFacts::build(source, &file, &resolved);
let stmt = &file.body[0];
let attachment = facts.stmt(stmt).attachment_span().slice(source);
assert!(facts.stmt_compound_close_span(stmt).is_some());
assert!(attachment.ends_with("# close"));
assert!(!attachment.contains("print done"));
}
#[test]
fn records_non_layout_classification_facts() {
let source = "value=\"one\ntwo\"\ncat <<EOF\nhi\nEOF\nfoo | { a; b; }\n";
let (file, facts) = build_facts(source);
let Command::Simple(command) = &file.body[0].command else {
panic!("expected simple assignment");
};
let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
panic!("expected scalar assignment");
};
assert!(facts.word_has_multiline_literal_source(word));
assert!(facts.stmt(&file.body[1]).contains_heredoc());
assert!(facts.sequence_contains_heredoc(&file.body));
assert!(facts.sequence_contains_multistatement_pipeline_brace_group(&file.body));
}
#[test]
fn records_nested_sequence_comment_classification_facts() {
let source = "value=$(echo hi\n # note\n)\n";
let (file, facts) = build_facts(source);
let Command::Simple(command) = &file.body[0].command else {
panic!("expected simple assignment");
};
let AssignmentValue::Scalar(word) = &command.assignments[0].value else {
panic!("expected scalar assignment");
};
let [
WordPartNode {
kind: WordPart::CommandSubstitution { body, .. },
..
},
] = word.parts.as_slice()
else {
panic!("expected command substitution word");
};
assert!(facts.sequence_contains_comments(body));
assert!(facts.word_has_multiline_literal_source(word));
}
#[test]
fn grouped_condition_sequences_do_not_capture_later_file_comments() {
let source = "download() {\n local url\n url=https://github.com/junegunn/fzf/releases/download/v$version/${1}\n set -o pipefail\n if ! (try_curl $url || try_wget $url); then\n set +o pipefail\n binary_error=\"Failed to download with curl and wget\"\n return\n fi\n set +o pipefail\n}\n\n# Try to download binary executable\narchi=$(uname -smo 2> /dev/null || uname -sm)\n";
let (file, facts) = build_facts(source);
let function = match &file.body[0].command {
Command::Function(function) => function,
_ => panic!("expected function"),
};
let function_body = match &function.body.command {
Command::Compound(CompoundCommand::BraceGroup(commands)) => commands,
_ => panic!("expected brace group function body"),
};
let if_command = match &function_body[3].command {
Command::Compound(CompoundCommand::If(command)) => command,
_ => panic!("expected if command"),
};
let condition_stmt = &if_command.condition[0];
let subshell = match &condition_stmt.command {
Command::Compound(CompoundCommand::Subshell(commands)) => commands,
_ => panic!("expected subshell condition"),
};
let sequence = facts.sequence(subshell, Some(stmt_span(condition_stmt).end.offset));
let attachment_span = group_attachment_span_with_heredoc(
subshell.as_slice(),
facts.source_map(),
'(',
')',
|stmt| facts.stmt(stmt).contains_heredoc(),
)
.expect("expected subshell attachment span");
assert!(!sequence.has_comments());
assert!(facts.group_was_inline_in_source(subshell));
assert_eq!(
attachment_span.slice(source),
"(try_curl $url || try_wget $url)"
);
}
#[test]
fn brace_group_attachment_span_reaches_wrapper_close_after_parameter_expansion() {
let source = "{\n echo ${value}\n}\n# outside\nprintf '%s\\n' done\n";
let (file, facts) = build_facts(source);
let attachment =
group_attachment_source(source, &facts, first_brace_group(&file), '{', '}');
assert_eq!(attachment, "{\n echo ${value}\n}");
}
#[test]
fn function_body_comments_with_parameter_syntax_attach_to_first_stmt() {
let source = "function f() {\n # parse all defined shortcuts ${BASH_IT_DIRS_BKS}\n if [[ -s x ]]; then\n echo ok\n fi\n}\n";
let (file, facts) = build_facts_with_options(
source,
ShellFormatOptions::default().with_dialect(ShellDialect::Bash),
"test.bash",
);
let Command::Function(function) = &file.body[0].command else {
panic!("expected function");
};
let Command::Compound(CompoundCommand::BraceGroup(body)) = &function.body.command else {
panic!("expected brace group body");
};
let sequence = facts.sequence(body, Some(function.span.end.offset));
let leading = sequence.leading_for(0);
assert_eq!(leading.len(), 1);
assert_eq!(
leading[0].text(),
"# parse all defined shortcuts ${BASH_IT_DIRS_BKS}"
);
}
#[test]
fn subshell_attachment_span_reaches_wrapper_close_after_command_substitution() {
let source = "(\n echo $(printf '%s' value)\n)\n# outside\nprintf '%s\\n' done\n";
let (file, facts) = build_facts(source);
let subshell = match &file.body[0].command {
Command::Compound(CompoundCommand::Subshell(commands)) => commands,
_ => panic!("expected subshell"),
};
let attachment_span = group_attachment_span_with_heredoc(
subshell.as_slice(),
facts.source_map(),
'(',
')',
|stmt| facts.stmt(stmt).contains_heredoc(),
)
.expect("expected subshell attachment span");
assert_eq!(
attachment_span.slice(source),
"(\n echo $(printf '%s' value)\n)"
);
}
#[test]
fn brace_group_attachment_span_keeps_semicolon_terminated_trailing_comments() {
let source = "{\n echo ok; # inside\n}\n# outside\nprintf '%s\\n' done\n";
let (file, facts) = build_facts(source);
let attachment =
group_attachment_source(source, &facts, first_brace_group(&file), '{', '}');
assert_eq!(attachment, "{\n echo ok; # inside\n}");
}
#[test]
fn brace_group_attachment_span_reaches_wrapper_close_after_heredoc_body() {
let source = "{\n cat <<EOF\npayload\nEOF\n}\n# outside\nprintf '%s\\n' done\n";
let (file, facts) = build_facts(source);
let attachment =
group_attachment_source(source, &facts, first_brace_group(&file), '{', '}');
assert_eq!(attachment, "{\n cat <<EOF\npayload\nEOF\n}");
}
#[test]
fn brace_group_attachment_span_reaches_wrapper_close_after_line_continuation() {
let source = "{ echo ok; \\\n}\n# outside\nprintf '%s\\n' done\n";
let (file, facts) = build_facts(source);
let brace_group = first_brace_group(&file);
let attachment = group_attachment_source(source, &facts, brace_group, '{', '}');
assert!(!facts.group_was_inline_in_source(brace_group));
assert_eq!(attachment, "{ echo ok; \\\n}");
}
}