use super::*;

pub use super::core::CommandFactRef;

#[derive(Debug, Clone)]
pub struct CommandFact<'a> {
    pub(crate) id: CommandId,
    pub(crate) visit: CommandVisit<'a>,
    pub(crate) nested_word_command: bool,
    pub(crate) scope: ScopeId,
    pub(crate) enclosing_function_scope: Option<ScopeId>,
    pub(crate) normalized: NormalizedCommand<'a>,
    pub(crate) shell_behavior: ShellBehaviorAt<'a>,
    pub(crate) redirect_facts: IdRange<RedirectFact<'a>>,
    pub(crate) substitution_facts: IdRange<SubstitutionFact>,
    pub(crate) options: Option<Box<CommandOptionFacts<'a>>>,
    pub(crate) scope_read_source_words: IdRange<PathWordFact<'a>>,
    pub(crate) scope_name_read_uses: IdRange<ComparableNameUse>,
    pub(crate) scope_heredoc_name_read_uses: IdRange<ComparableNameUse>,
    pub(crate) scope_name_write_uses: IdRange<ComparableNameUse>,
    pub(crate) declaration_assignment_probes: IdRange<DeclarationAssignmentProbe>,
    pub(crate) glued_closing_bracket_operand_span: Option<Span>,
    pub(crate) glued_closing_bracket_insert_offset: Option<usize>,
    pub(crate) linebreak_in_test_anchor_span: Option<Span>,
    pub(crate) linebreak_in_test_insert_offset: Option<usize>,
    pub(crate) simple_test: Option<SimpleTestFact<'a>>,
    pub(crate) conditional: Option<ConditionalFact<'a>>,
}

#[derive(Debug, Clone)]
pub struct RedundantEchoSpaceFact {
    diagnostic_span: Span,
    space_spans: Vec<Span>,
}

impl RedundantEchoSpaceFact {
    pub fn diagnostic_span(&self) -> Span {
        self.diagnostic_span
    }

    pub fn space_spans(&self) -> &[Span] {
        &self.space_spans
    }
}

impl<'a> CommandFact<'a> {
    pub fn id(&self) -> CommandId {
        self.id
    }

    pub fn is_nested_word_command(&self) -> bool {
        self.nested_word_command
    }

    pub fn scope(&self) -> ScopeId {
        self.scope
    }

    pub fn enclosing_function_scope(&self) -> Option<ScopeId> {
        self.enclosing_function_scope
    }

    pub fn stmt(&self) -> &'a Stmt {
        self.visit.stmt
    }

    pub fn command(&self) -> &'a Command {
        self.visit.command
    }

    pub fn span(&self) -> Span {
        command_span(self.visit.command)
    }

    pub fn span_in_source(&self, source: &str) -> Span {
        trim_trailing_whitespace_span(self.span(), source)
    }

    pub fn redirects(&self) -> &'a [Redirect] {
        self.visit.redirects
    }

    pub(crate) fn shell_behavior(&self) -> &ShellBehaviorAt<'a> {
        &self.shell_behavior
    }

    pub fn normalized(&self) -> &NormalizedCommand<'a> {
        &self.normalized
    }

    pub fn options(&self) -> CommandOptionFactsRef<'_, 'a> {
        CommandOptionFactsRef::new(self.options.as_deref())
    }

    pub fn glued_closing_bracket_operand_span(&self) -> Option<Span> {
        self.glued_closing_bracket_operand_span
    }

    pub fn glued_closing_bracket_insert_offset(&self) -> Option<usize> {
        self.glued_closing_bracket_insert_offset
    }

    pub fn linebreak_in_test_anchor_span(&self) -> Option<Span> {
        self.linebreak_in_test_anchor_span
    }

    pub fn linebreak_in_test_insert_offset(&self) -> Option<usize> {
        self.linebreak_in_test_insert_offset
    }

    pub fn simple_test(&self) -> Option<&SimpleTestFact<'a>> {
        self.simple_test.as_ref()
    }

    pub fn conditional(&self) -> Option<&ConditionalFact<'a>> {
        self.conditional.as_ref()
    }

    pub fn literal_name(&self) -> Option<&str> {
        self.normalized.literal_name.as_deref()
    }

    pub fn effective_name(&self) -> Option<&str> {
        self.normalized.effective_name.as_deref()
    }

    pub fn effective_or_literal_name(&self) -> Option<&str> {
        self.normalized.effective_or_literal_name()
    }

    pub fn effective_name_is(&self, name: &str) -> bool {
        self.normalized.effective_name_is(name)
    }

    pub fn static_utility_name(&self) -> Option<&str> {
        self.effective_or_literal_name()
    }

    pub fn static_utility_name_is(&self, name: &str) -> bool {
        self.static_utility_name() == Some(name)
    }

    pub fn wrappers(&self) -> &[WrapperKind] {
        &self.normalized.wrappers
    }

    pub fn has_wrapper(&self, wrapper: WrapperKind) -> bool {
        self.normalized.has_wrapper(wrapper)
    }

    pub fn body_span(&self) -> Span {
        self.normalized.body_span
    }

    pub fn body_name_word(&self) -> Option<&'a Word> {
        self.normalized.body_name_word()
    }

    pub fn body_word_span(&self) -> Option<Span> {
        self.normalized.body_word_span()
    }

    pub fn body_word_contains_template_placeholder(&self, source: &str) -> bool {
        let Some(span) = self.body_word_span() else {
            return false;
        };
        contains_template_placeholder_text(span.slice(source))
    }

    pub fn body_word_has_suspicious_quoted_command_trailer(
        &self,
        source: &str,
        trailing_literal_char: Option<char>,
    ) -> bool {
        let Some(span) = self.body_word_span() else {
            return false;
        };
        quoted_command_name_has_suspicious_ending(span.slice(source), trailing_literal_char)
    }

    pub fn body_word_has_hash_suffix(&self, source: &str) -> bool {
        let Some(span) = self.body_word_span() else {
            return false;
        };
        let text = span.slice(source);
        text != "#" && text.ends_with('#')
    }

    pub fn bracket_command_name_needs_separator(&self, source: &str) -> bool {
        if self.literal_name() != Some("[") {
            return false;
        }

        let Some(span) = self.body_word_span() else {
            return false;
        };
        let raw = span.slice(source);
        raw != "[" || !command_assignments(self.command()).is_empty()
    }

    pub fn body_args(&self) -> &[&'a Word] {
        self.normalized.body_args()
    }
}

impl<'facts, 'a> CommandFactRef<'facts, 'a> {
    pub fn id(self) -> CommandId {
        self.fact.id()
    }

    pub fn is_nested_word_command(self) -> bool {
        self.fact.is_nested_word_command()
    }

    pub fn scope(self) -> ScopeId {
        self.fact.scope()
    }

    pub fn enclosing_function_scope(self) -> Option<ScopeId> {
        self.fact.enclosing_function_scope()
    }

    pub fn stmt(self) -> &'a Stmt {
        self.fact.stmt()
    }

    pub fn command(self) -> &'a Command {
        self.fact.command()
    }

    pub fn span(self) -> Span {
        self.fact.span()
    }

    pub fn span_in_source(self, source: &str) -> Span {
        self.fact.span_in_source(source)
    }

    pub fn redirects(self) -> &'a [Redirect] {
        self.fact.redirects()
    }

    pub(crate) fn shell_behavior(self) -> &'facts ShellBehaviorAt<'a> {
        &self.fact.shell_behavior
    }

    pub fn zsh_options(self) -> Option<&'facts shuck_semantic::ZshOptionState> {
        self.fact.shell_behavior.zsh_options()
    }

    pub fn redirect_facts(self) -> &'facts [RedirectFact<'a>] {
        self.store.redirect_facts(self.fact.redirect_facts)
    }

    pub fn substitution_facts(self) -> &'facts [SubstitutionFact] {
        self.store.substitution_facts(self.fact.substitution_facts)
    }

    pub fn scope_read_source_words(self) -> &'facts [PathWordFact<'a>] {
        self.store
            .scope_read_source_words(self.fact.scope_read_source_words)
    }

    pub(crate) fn scope_name_read_uses(self) -> &'facts [ComparableNameUse] {
        self.store
            .scope_name_read_uses(self.fact.scope_name_read_uses)
    }

    pub(crate) fn scope_heredoc_name_read_uses(self) -> &'facts [ComparableNameUse] {
        self.store
            .scope_heredoc_name_read_uses(self.fact.scope_heredoc_name_read_uses)
    }

    pub(crate) fn scope_name_write_uses(self) -> &'facts [ComparableNameUse] {
        self.store
            .scope_name_write_uses(self.fact.scope_name_write_uses)
    }

    pub fn declaration_assignment_probes(self) -> &'facts [DeclarationAssignmentProbe] {
        self.store
            .declaration_assignment_probes(self.fact.declaration_assignment_probes)
    }

    pub fn options(self) -> CommandOptionFactsRef<'facts, 'a> {
        self.fact.options()
    }

    pub fn glued_closing_bracket_operand_span(self) -> Option<Span> {
        self.fact.glued_closing_bracket_operand_span()
    }

    pub fn glued_closing_bracket_insert_offset(self) -> Option<usize> {
        self.fact.glued_closing_bracket_insert_offset()
    }

    pub fn linebreak_in_test_anchor_span(self) -> Option<Span> {
        self.fact.linebreak_in_test_anchor_span()
    }

    pub fn linebreak_in_test_insert_offset(self) -> Option<usize> {
        self.fact.linebreak_in_test_insert_offset()
    }

    pub fn simple_test(self) -> Option<&'facts SimpleTestFact<'a>> {
        self.fact.simple_test.as_ref()
    }

    pub fn conditional(self) -> Option<&'facts ConditionalFact<'a>> {
        self.fact.conditional.as_ref()
    }

    pub fn literal_name(self) -> Option<&'facts str> {
        self.fact.normalized.literal_name.as_deref()
    }

    pub fn effective_name(self) -> Option<&'facts str> {
        self.fact.normalized.effective_name.as_deref()
    }

    pub fn effective_or_literal_name(self) -> Option<&'facts str> {
        self.fact.normalized.effective_or_literal_name()
    }

    pub fn effective_name_is(self, name: &str) -> bool {
        self.fact.effective_name_is(name)
    }

    pub fn static_utility_name(self) -> Option<&'facts str> {
        self.effective_or_literal_name()
    }

    pub fn static_utility_name_is(self, name: &str) -> bool {
        self.static_utility_name() == Some(name)
    }

    pub fn wrappers(self) -> &'facts [WrapperKind] {
        &self.fact.normalized.wrappers
    }

    pub fn has_wrapper(self, wrapper: WrapperKind) -> bool {
        self.fact.has_wrapper(wrapper)
    }

    pub fn declaration(self) -> Option<&'facts NormalizedDeclaration<'a>> {
        self.fact.normalized.declaration.as_deref()
    }

    pub fn body_span(self) -> Span {
        self.fact.body_span()
    }

    pub fn body_name_word(self) -> Option<&'a Word> {
        self.fact.body_name_word()
    }

    pub fn command_name_word(self) -> Option<&'a Word> {
        match self.command() {
            Command::Simple(command) => Some(&command.name),
            _ => None,
        }
    }

    pub fn command_name_starts_with_literal_dash(self, source: &str) -> bool {
        let Some(word) = self.command_name_word() else {
            return false;
        };
        let Some(first) = word.parts.first() else {
            return false;
        };

        match &first.kind {
            WordPart::Literal(text) => {
                text.as_str(source, first.span).starts_with('-')
                    || static_word_text(word, source).is_some_and(|text| text.starts_with('-'))
            }
            _ => {
                !word.has_quoted_parts()
                    && self
                        .literal_name()
                        .is_some_and(|name| name.starts_with('-'))
            }
        }
    }

    pub fn command_name_follows_escaped_semicolon(self, source: &str) -> bool {
        let Some(word) = self.command_name_word() else {
            return false;
        };
        let Some(before) = source.get(..word.span.start.offset()) else {
            return false;
        };

        before.trim_end_matches([' ', '\t']).ends_with("\\;")
    }

    pub fn command_name_word_active_glob_spans_outside_brace_expansion(
        self,
        source: &str,
    ) -> Vec<Span> {
        if self.has_wrapper(WrapperKind::Noglob) {
            return Vec::new();
        }

        let Some(word) = self.command_name_word() else {
            return Vec::new();
        };

        let behavior = self.shell_behavior();
        word_spans::word_active_glob_pattern_spans_outside_brace_expansion(
            word,
            source,
            behavior.pathname_expansion(),
            behavior.glob_pattern(),
        )
    }

    pub fn command_name_word_single_double_quoted_replacement(
        self,
        source: &str,
    ) -> Option<Box<str>> {
        self.command_name_word()
            .map(|word| rewrite_word_as_single_double_quoted_string(word, source, None))
    }

    pub fn suspicious_body_name_bracket_glob_spans(self, source: &str) -> Vec<Span> {
        let Some(word) = self.body_name_word() else {
            return Vec::new();
        };

        let mut spans = word_spans::word_suspicious_bracket_glob_spans(word, source);
        if self
            .zsh_options()
            .is_some_and(|options| !options.brace_ccl.is_definitely_off())
        {
            spans.extend(word_spans::word_suspicious_brace_character_class_spans(
                word, source,
            ));
        }
        spans
    }

    pub fn body_word_span(self) -> Option<Span> {
        self.fact.body_word_span()
    }

    pub fn body_word_contains_template_placeholder(self, source: &str) -> bool {
        self.fact.body_word_contains_template_placeholder(source)
    }

    pub fn body_word_has_suspicious_quoted_command_trailer(
        self,
        source: &str,
        trailing_literal_char: Option<char>,
    ) -> bool {
        self.fact
            .body_word_has_suspicious_quoted_command_trailer(source, trailing_literal_char)
    }

    pub fn body_word_has_hash_suffix(self, source: &str) -> bool {
        self.fact.body_word_has_hash_suffix(source)
    }

    pub fn bracket_command_name_needs_separator(self, source: &str) -> bool {
        self.fact.bracket_command_name_needs_separator(source)
    }

    pub fn body_args(self) -> &'facts [&'a Word] {
        self.fact.normalized.body_args()
    }

    pub fn file_operand_words(self) -> &'facts [&'a Word] {
        self.options().file_operand_words()
    }

    pub fn shellcheck_command_span(self, source: &str) -> Option<Span> {
        command_span_with_redirects_and_shellcheck_tail(self, source)
            .map(|span| trim_trailing_whitespace_span(span, source))
    }
}

pub(crate) fn pipeline_span_with_shellcheck_tail(
    commands: CommandFacts<'_, '_>,
    pipeline: &PipelineFact<'_>,
    source: &str,
) -> Span {
    let Some(first_segment) = pipeline.first_segment() else {
        unreachable!("pipeline has segments");
    };
    let Some(last_segment) = pipeline.last_segment() else {
        unreachable!("pipeline has segments");
    };
    let first = command_fact_ref(commands, first_segment.command_id());
    let last = command_fact_ref(commands, last_segment.command_id());
    let last_end = last.span_in_source(source).end;
    let end = extend_over_shellcheck_trailing_inline_space(last_end, source);

    let Some(body_name_word) = first.body_name_word() else {
        unreachable!("plain echo command should have a body name");
    };
    Span::from_positions(body_name_word.span.start, end)
}

pub(crate) fn command_span_with_redirects_and_shellcheck_tail(
    command: CommandFactRef<'_, '_>,
    source: &str,
) -> Option<Span> {
    let body_name = command.body_name_word()?;
    let mut end = body_name.span.end;

    for word in command.body_args() {
        if word.span.end.offset() > end.offset() {
            end = word.span.end;
        }
    }

    for redirect in command.redirect_facts() {
        let redirect_end = redirect.redirect().span.end;
        if redirect_end.offset() > end.offset() {
            end = redirect_end;
        }
    }

    Some(Span::from_positions(
        body_name.span.start,
        extend_over_shellcheck_trailing_inline_space(end, source),
    ))
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn effective_command_shell_behavior<'a>(
    semantic: &'a SemanticModel,
    offset: usize,
    normalized: &NormalizedCommand<'_>,
) -> ShellBehaviorAt<'a> {
    let behavior = semantic.shell_behavior_at(offset);
    if normalized.has_wrapper(WrapperKind::Noglob) {
        return behavior.with_zsh_option_overlay(|options| {
            options.glob = shuck_semantic::OptionValue::Off;
        });
    }
    behavior
}

pub(crate) fn contains_template_placeholder_text(text: &str) -> bool {
    let Some(start) = text.find("{{") else {
        return false;
    };
    text[start + 2..].contains("}}")
}

pub(crate) fn quoted_command_name_has_suspicious_ending(
    text: &str,
    trailing_literal_char: Option<char>,
) -> bool {
    let Some(inner) = strip_matching_quotes(text) else {
        return false;
    };

    let Some(ch) = trailing_literal_char.or_else(|| inner.chars().next_back()) else {
        return false;
    };
    if !is_suspicious_command_trailer(ch) {
        return false;
    }
    if trailing_literal_char.is_some() {
        return true;
    }

    match ch {
        '}' => !inner_ends_with_parameter_expansion(inner),
        ')' => !inner_ends_with_command_substitution(inner),
        _ => true,
    }
}

pub(crate) fn strip_matching_quotes(text: &str) -> Option<&str> {
    if text.len() < 2 {
        return None;
    }

    match (
        text.as_bytes().first().copied(),
        text.as_bytes().last().copied(),
    ) {
        (Some(b'"'), Some(b'"')) | (Some(b'\''), Some(b'\'')) => Some(&text[1..text.len() - 1]),
        _ => None,
    }
}

pub(crate) fn is_suspicious_command_trailer(ch: char) -> bool {
    matches!(
        ch,
        '.' | ',' | '#' | '[' | ']' | '(' | ')' | '{' | '}' | '\''
    )
}

pub(crate) fn inner_ends_with_parameter_expansion(inner: &str) -> bool {
    matching_shell_delimiter_start(inner, b'{', b'}')
        .is_some_and(|index| index > 0 && inner.as_bytes()[index - 1] == b'$')
}

pub(crate) fn inner_ends_with_command_substitution(inner: &str) -> bool {
    matching_shell_delimiter_start(inner, b'(', b')')
        .is_some_and(|index| index > 0 && inner.as_bytes()[index - 1] == b'$')
}

pub(crate) fn matching_shell_delimiter_start(inner: &str, open: u8, close: u8) -> Option<usize> {
    let bytes = inner.as_bytes();
    if bytes.last().copied() != Some(close) {
        return None;
    }

    let mut depth = 1usize;
    let mut quote_state = None;
    let mut index = bytes.len() - 1;

    while index > 0 {
        index -= 1;
        match quote_state {
            Some(QuoteState::Single) => {
                if bytes[index] == b'\'' {
                    quote_state = None;
                }
            }
            Some(QuoteState::Double) => {
                if bytes[index] == b'"' && !byte_is_shell_escaped(bytes, index) {
                    quote_state = None;
                }
            }
            Some(QuoteState::Backtick) => {
                if bytes[index] == b'`' && !byte_is_shell_escaped(bytes, index) {
                    quote_state = None;
                }
            }
            None => match bytes[index] {
                b'\'' if !byte_is_shell_escaped(bytes, index) => {
                    quote_state = Some(QuoteState::Single);
                }
                b'"' if !byte_is_shell_escaped(bytes, index) => {
                    quote_state = Some(QuoteState::Double);
                }
                b'`' if !byte_is_shell_escaped(bytes, index) => {
                    quote_state = Some(QuoteState::Backtick);
                }
                byte if byte == close && !byte_is_shell_escaped(bytes, index) => depth += 1,
                byte if byte == open && !byte_is_shell_escaped(bytes, index) => {
                    depth -= 1;
                    if depth == 0 {
                        return Some(index);
                    }
                }
                _ => {}
            },
        }
    }

    None
}

pub(crate) fn byte_is_shell_escaped(bytes: &[u8], index: usize) -> bool {
    let mut slash_count = 0usize;
    let mut cursor = index;

    while cursor > 0 && bytes[cursor - 1] == b'\\' {
        slash_count += 1;
        cursor -= 1;
    }

    slash_count % 2 == 1
}

#[derive(Clone, Copy)]
pub(crate) enum QuoteState {
    Single,
    Double,
    Backtick,
}

pub(crate) fn extend_over_shellcheck_trailing_inline_space(
    end: Position,
    source: &str,
) -> Position {
    let tail = &source[end.offset()..];
    let spaces_len = tail
        .char_indices()
        .take_while(|(_, ch)| matches!(ch, ' ' | '\t'))
        .last()
        .map_or(0, |(index, ch)| index + ch.len_utf8());

    if spaces_len == 0 {
        return end;
    }

    let rest = &tail[spaces_len..];
    if rest.is_empty()
        || rest.starts_with('\n')
        || rest.starts_with('\r')
        || rest.starts_with(')')
        || rest.starts_with(']')
        || rest.starts_with('}')
    {
        end.advanced_by(&tail[..spaces_len])
    } else {
        end
    }
}

pub(crate) fn build_background_semicolon_spans(
    commands: &[CommandFact<'_>],
    case_items: &[CaseItemFact<'_>],
    locator: Locator<'_>,
) -> Vec<Span> {
    let case_terminator_starts = case_items
        .iter()
        .filter_map(CaseItemFact::terminator_span)
        .map(|span| span.start.offset())
        .collect::<FxHashSet<_>>();
    let mut spans = commands
        .iter()
        .filter_map(|command| background_semicolon_span(command, &case_terminator_starts, locator))
        .collect::<Vec<_>>();
    sort_and_dedup_spans(&mut spans);
    spans
}

pub(crate) fn background_semicolon_span(
    command: &CommandFact<'_>,
    case_terminator_starts: &FxHashSet<usize>,
    locator: Locator<'_>,
) -> Option<Span> {
    if command.stmt().terminator != Some(StmtTerminator::Background(BackgroundOperator::Plain)) {
        return None;
    }

    let source = locator.source();
    let terminator_span = command.stmt().terminator_span?;
    if terminator_span.slice(source) != "&" {
        return None;
    }

    let semicolon_offset = source[terminator_span.end.offset()..]
        .char_indices()
        .find_map(|(relative, ch)| match ch {
            ' ' | '\t' | '\r' => None,
            '\n' | '#' => Some(None),
            ';' => Some(Some(terminator_span.end.offset() + relative)),
            _ => Some(None),
        })??;

    if case_terminator_starts.contains(&semicolon_offset) {
        return None;
    }

    let start = locator.position_at_offset(semicolon_offset)?;
    let end = locator.position_at_offset(semicolon_offset + 1)?;
    Some(Span::from_positions(start, end))
}

pub(crate) fn build_redundant_echo_space_facts(
    facts: &LinterFacts<'_>,
) -> Vec<RedundantEchoSpaceFact> {
    facts
        .command_facts()
        .structural_commands()
        .filter_map(|command| redundant_echo_space_fact(command, facts.source_facts.source))
        .collect()
}

pub(crate) fn redundant_echo_space_fact(
    command: CommandFactRef<'_, '_>,
    source: &str,
) -> Option<RedundantEchoSpaceFact> {
    if !command.effective_name_is("echo") || !command.wrappers().is_empty() {
        return None;
    }

    let args = command.body_args();
    let space_spans = args
        .windows(2)
        .filter_map(|pair| repeated_echo_argument_space_span(pair[0].span, pair[1].span, source))
        .collect::<Vec<_>>();
    if space_spans.is_empty() {
        return None;
    }

    let name = command.body_name_word()?;
    let last = args.last()?;
    Some(RedundantEchoSpaceFact {
        diagnostic_span: Span::from_positions(name.span.start, last.span.end),
        space_spans,
    })
}

pub(crate) fn repeated_echo_argument_space_span(
    left: Span,
    right: Span,
    source: &str,
) -> Option<Span> {
    if left.end.line() != right.start.line() {
        return None;
    }

    // Some spans can collapse a backslash-newline continuation onto one logical
    // line. S037 only cares about repeated spaces on the same physical line.
    let context_start = source[..left.end.offset()]
        .char_indices()
        .next_back()
        .map_or(left.end.offset(), |(idx, _)| idx);
    let context = source.get(context_start..right.start.offset())?;
    if context.chars().any(|ch| matches!(ch, '\n' | '\r')) {
        return None;
    }

    let gap = source.get(left.end.offset()..right.start.offset())?;
    if gap.len() < 4 || !gap.chars().all(|ch| ch == ' ') {
        return None;
    }

    Some(Span::from_positions(left.end, right.start))
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn populate_scope_fact_ranges<'a>(
    commands: &mut [CommandFact<'a>],
    fact_store: &mut FactStore<'a>,
    command_fact_indices_by_id: &[Option<usize>],
    pipelines: &[PipelineFact<'a>],
    if_condition_command_ids: &DenseCommandIdSet,
    source: &'a str,
) {
    let (pipeline_summaries, pipeline_summary_ids_by_writer) = {
        let command_facts = CommandFacts::new(commands, fact_store, command_fact_indices_by_id);
        build_pipeline_scope_summaries(command_facts, pipelines, if_condition_command_ids, source)
    };
    let inputs = ScopeFactInputs {
        pipeline_summaries: &pipeline_summaries,
        pipeline_summary_ids_by_writer: &pipeline_summary_ids_by_writer,
        if_condition_command_ids,
        source,
    };
    let mut scratch = ScopeFactScratch::default();

    for index in 0..commands.len() {
        populate_scope_fact_ranges_for_command(
            index,
            commands,
            fact_store,
            command_fact_indices_by_id,
            inputs,
            &mut scratch,
        );
    }
}

#[derive(Clone, Copy)]
pub(crate) struct ScopeFactInputs<'facts, 'a> {
    pipeline_summaries: &'facts [PipelineScopeSummary<'a>],
    pipeline_summary_ids_by_writer: &'facts [SmallVec<[usize; 1]>],
    if_condition_command_ids: &'facts DenseCommandIdSet,
    source: &'a str,
}

#[derive(Default)]
pub(crate) struct ScopeFactScratch<'a> {
    source_words: Vec<PathWordFact<'a>>,
    name_reads: Vec<ComparableNameUse>,
    heredoc_name_reads: Vec<ComparableNameUse>,
    name_writes: Vec<ComparableNameUse>,
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn populate_scope_fact_ranges_for_command<'a>(
    index: usize,
    commands: &mut [CommandFact<'a>],
    fact_store: &mut FactStore<'a>,
    command_fact_indices_by_id: &[Option<usize>],
    inputs: ScopeFactInputs<'_, 'a>,
    scratch: &mut ScopeFactScratch<'a>,
) {
    {
        let command_facts = CommandFacts::new(commands, fact_store, command_fact_indices_by_id);
        let command = command_facts
            .get(index)
            .expect("command index should resolve while populating scope facts");
        collect_scope_read_source_words_for_command(
            command_facts,
            command,
            inputs.pipeline_summaries,
            &inputs.pipeline_summary_ids_by_writer[index],
            inputs.if_condition_command_ids,
            inputs.source,
            &mut scratch.source_words,
        );
        collect_scope_name_read_uses_for_command(
            command_facts,
            command,
            inputs.pipeline_summaries,
            &inputs.pipeline_summary_ids_by_writer[index],
            inputs.source,
            &mut scratch.name_reads,
        );
        collect_scope_heredoc_name_read_uses_for_command(
            command_facts,
            command,
            inputs.pipeline_summaries,
            &inputs.pipeline_summary_ids_by_writer[index],
            inputs.source,
            &mut scratch.heredoc_name_reads,
        );
        collect_scope_name_write_uses_for_command(
            command_facts,
            command,
            inputs.source,
            &mut scratch.name_writes,
        );
    }

    commands[index].scope_read_source_words = fact_store
        .scope_read_source_words
        .push_many(scratch.source_words.drain(..));
    commands[index].scope_name_read_uses = fact_store
        .scope_name_read_uses
        .push_many(scratch.name_reads.drain(..));
    commands[index].scope_heredoc_name_read_uses = fact_store
        .scope_heredoc_name_read_uses
        .push_many(scratch.heredoc_name_reads.drain(..));
    commands[index].scope_name_write_uses = fact_store
        .scope_name_write_uses
        .push_many(scratch.name_writes.drain(..));
}

pub(crate) struct PipelineScopeSummary<'a> {
    source_words: Vec<PathWordFact<'a>>,
    name_reads: Vec<ComparableNameUse>,
    heredoc_name_reads: Vec<ComparableNameUse>,
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn build_pipeline_scope_summaries<'a>(
    commands: CommandFacts<'_, 'a>,
    pipelines: &[PipelineFact<'a>],
    if_condition_command_ids: &DenseCommandIdSet,
    source: &str,
) -> (Vec<PipelineScopeSummary<'a>>, Vec<SmallVec<[usize; 1]>>) {
    let mut summaries = Vec::new();
    let mut summary_ids_by_writer = vec![SmallVec::<[usize; 1]>::new(); commands.count()];

    for pipeline in pipelines {
        let writer_indexes = pipeline
            .segments()
            .iter()
            .map(|segment| segment.command_id())
            .filter_map(|id| {
                let index = commands.indices_by_id.get(id.index()).copied().flatten()?;
                let command = commands.get(index)?;
                command_has_file_output_redirect(command).then_some(index)
            })
            .collect::<SmallVec<[_; 4]>>();
        if writer_indexes.is_empty() {
            continue;
        }

        let mut source_words = Vec::new();
        let mut name_reads = Vec::new();
        let mut heredoc_name_reads = Vec::new();
        for command in commands.contained_in(pipeline.span()) {
            collect_own_scope_read_source_words(
                command,
                if_condition_command_ids,
                source,
                &mut source_words,
            );
            collect_own_scope_name_read_uses(command, source, &mut name_reads);
            collect_own_scope_heredoc_name_read_uses(command, source, &mut heredoc_name_reads);
        }
        dedup_path_words(&mut source_words);
        dedup_name_uses(&mut name_reads);
        dedup_name_uses(&mut heredoc_name_reads);

        let summary_id = summaries.len();
        summaries.push(PipelineScopeSummary {
            source_words,
            name_reads,
            heredoc_name_reads,
        });
        for writer_index in writer_indexes {
            summary_ids_by_writer[writer_index].push(summary_id);
        }
    }

    (summaries, summary_ids_by_writer)
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn collect_scope_read_source_words_for_command<'a>(
    commands: CommandFacts<'_, 'a>,
    command: CommandFactRef<'_, 'a>,
    pipeline_summaries: &[PipelineScopeSummary<'a>],
    pipeline_summary_ids: &[usize],
    if_condition_command_ids: &DenseCommandIdSet,
    source: &str,
    words: &mut Vec<PathWordFact<'a>>,
) {
    collect_own_scope_read_source_words(command, if_condition_command_ids, source, words);
    if command_has_file_output_redirect(command) {
        collect_nested_scope_read_source_words(
            commands,
            command,
            if_condition_command_ids,
            source,
            words,
        );
        for summary_id in pipeline_summary_ids {
            words.extend(pipeline_summaries[*summary_id].source_words.iter().cloned());
        }
    }
    dedup_path_words(words);
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn collect_scope_name_read_uses_for_command(
    commands: CommandFacts<'_, '_>,
    command: CommandFactRef<'_, '_>,
    pipeline_summaries: &[PipelineScopeSummary<'_>],
    pipeline_summary_ids: &[usize],
    source: &str,
    uses: &mut Vec<ComparableNameUse>,
) {
    collect_own_scope_name_read_uses(command, source, uses);
    if command_has_file_output_redirect(command) {
        collect_nested_scope_name_read_uses(commands, command, source, uses);
        for summary_id in pipeline_summary_ids {
            uses.extend(pipeline_summaries[*summary_id].name_reads.iter().cloned());
        }
    }
    dedup_name_uses(uses);
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn collect_scope_heredoc_name_read_uses_for_command(
    commands: CommandFacts<'_, '_>,
    command: CommandFactRef<'_, '_>,
    pipeline_summaries: &[PipelineScopeSummary<'_>],
    pipeline_summary_ids: &[usize],
    source: &str,
    uses: &mut Vec<ComparableNameUse>,
) {
    collect_own_scope_heredoc_name_read_uses(command, source, uses);
    if command_has_file_output_redirect(command) || command_has_file_input_redirect(command) {
        collect_nested_scope_heredoc_name_read_uses(commands, command, source, uses);
    }
    if command_has_file_output_redirect(command) {
        for summary_id in pipeline_summary_ids {
            uses.extend(
                pipeline_summaries[*summary_id]
                    .heredoc_name_reads
                    .iter()
                    .cloned(),
            );
        }
    }
    dedup_name_uses(uses);
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn collect_scope_name_write_uses_for_command(
    commands: CommandFacts<'_, '_>,
    command: CommandFactRef<'_, '_>,
    source: &str,
    uses: &mut Vec<ComparableNameUse>,
) {
    collect_own_scope_name_write_uses(command, source, uses);
    if command_has_file_input_redirect(command) {
        collect_nested_scope_name_write_uses(commands, command, source, uses);
    }
    dedup_name_uses(uses);
}

pub(crate) fn collect_own_scope_read_source_words<'a>(
    command: CommandFactRef<'_, 'a>,
    if_condition_command_ids: &DenseCommandIdSet,
    source: &str,
    words: &mut Vec<PathWordFact<'a>>,
) {
    words.extend(command.file_operand_words().iter().copied().map(|word| {
        PathWordFact::new(
            word,
            ExpansionContext::CommandArgument,
            source,
            command.shell_behavior(),
        )
    }));
    collect_command_redirect_read_source_words(command, source, words);
    collect_command_simple_test_path_words(command, source, words);
    if !if_condition_command_ids.contains(command.id()) {
        collect_command_conditional_path_words(command, source, words);
    }
}

pub(crate) fn collect_own_scope_name_read_uses(
    command: CommandFactRef<'_, '_>,
    _source: &str,
    uses: &mut Vec<ComparableNameUse>,
) {
    for redirect in command.redirect_facts() {
        match redirect.redirect().kind {
            RedirectKind::Input => {
                uses.extend(redirect.comparable_name_uses().iter().cloned());
            }
            RedirectKind::ReadWrite => {}
            RedirectKind::HereDoc | RedirectKind::HereDocStrip => {}
            RedirectKind::Output
            | RedirectKind::Clobber
            | RedirectKind::Append
            | RedirectKind::HereString
            | RedirectKind::DupOutput
            | RedirectKind::DupInput
            | RedirectKind::OutputBoth => {}
        }
    }
}

pub(crate) fn collect_own_scope_heredoc_name_read_uses(
    command: CommandFactRef<'_, '_>,
    _source: &str,
    uses: &mut Vec<ComparableNameUse>,
) {
    for redirect in command.redirect_facts() {
        if !matches!(
            redirect.redirect().kind,
            RedirectKind::HereDoc | RedirectKind::HereDocStrip
        ) {
            continue;
        }
        uses.extend(redirect.comparable_name_uses().iter().cloned());
    }
}

pub(crate) fn collect_own_scope_name_write_uses(
    command: CommandFactRef<'_, '_>,
    _source: &str,
    uses: &mut Vec<ComparableNameUse>,
) {
    if let Some(read) = command.options().read() {
        uses.extend(read.target_name_uses().iter().cloned());
    }
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn collect_nested_scope_read_source_words<'a>(
    commands: CommandFacts<'_, 'a>,
    command: CommandFactRef<'_, 'a>,
    if_condition_command_ids: &DenseCommandIdSet,
    source: &str,
    words: &mut Vec<PathWordFact<'a>>,
) {
    for_each_nested_command(commands, command, |other| {
        collect_own_scope_read_source_words(other, if_condition_command_ids, source, words);
    });
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn collect_nested_scope_name_read_uses(
    commands: CommandFacts<'_, '_>,
    command: CommandFactRef<'_, '_>,
    source: &str,
    uses: &mut Vec<ComparableNameUse>,
) {
    for_each_nested_command(commands, command, |other| {
        collect_own_scope_name_read_uses(other, source, uses);
    });
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn collect_nested_scope_heredoc_name_read_uses(
    commands: CommandFacts<'_, '_>,
    command: CommandFactRef<'_, '_>,
    source: &str,
    uses: &mut Vec<ComparableNameUse>,
) {
    for_each_nested_command(commands, command, |other| {
        collect_own_scope_heredoc_name_read_uses(other, source, uses);
    });
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn collect_nested_scope_name_write_uses(
    commands: CommandFacts<'_, '_>,
    command: CommandFactRef<'_, '_>,
    source: &str,
    uses: &mut Vec<ComparableNameUse>,
) {
    for_each_nested_command(commands, command, |other| {
        collect_own_scope_name_write_uses(other, source, uses);
    });
}

/// Visits commands nested inside `outer`'s span.
pub(crate) fn for_each_nested_command<'facts, 'a>(
    commands: CommandFacts<'facts, 'a>,
    outer: CommandFactRef<'_, 'a>,
    mut visit: impl FnMut(CommandFactRef<'facts, 'a>),
) {
    let outer_span = outer.span();
    let start = match commands.index_of(outer.id()) {
        Some(index) => index + 1,
        None => return,
    };
    for other in commands.iter_from(start) {
        if other.span().start.offset() > outer_span.end.offset() {
            break;
        }
        if contains_span(outer_span, other.span()) {
            visit(other);
        }
    }
}

pub(crate) fn dedup_path_words(words: &mut Vec<PathWordFact<'_>>) {
    if words.len() < 2 {
        return;
    }
    let mut seen = FxHashSet::<(FactSpan, ExpansionContext)>::default();
    words.retain(|fact| seen.insert((FactSpan::new(fact.word().span), fact.context())));
}

pub(crate) fn dedup_name_uses(uses: &mut Vec<ComparableNameUse>) {
    if uses.len() < 2 {
        return;
    }
    let mut seen = FxHashSet::<(ComparableNameKey, FactSpan)>::default();
    uses.retain(|name_use| seen.insert((name_use.key().clone(), FactSpan::new(name_use.span()))));
}

pub(crate) fn command_has_file_output_redirect(command: CommandFactRef<'_, '_>) -> bool {
    command.redirect_facts().iter().any(|redirect| {
        matches!(
            redirect.redirect().kind,
            RedirectKind::Output
                | RedirectKind::Clobber
                | RedirectKind::Append
                | RedirectKind::OutputBoth
        ) && redirect
            .analysis()
            .is_some_and(|analysis| analysis.is_file_target())
    })
}

pub(crate) fn command_has_file_input_redirect(command: CommandFactRef<'_, '_>) -> bool {
    command.redirect_facts().iter().any(|redirect| {
        matches!(
            redirect.redirect().kind,
            RedirectKind::Input | RedirectKind::ReadWrite
        ) && redirect
            .analysis()
            .is_some_and(|analysis| analysis.is_file_target())
    })
}

pub(crate) fn collect_command_redirect_read_source_words<'a>(
    command: CommandFactRef<'_, 'a>,
    source: &str,
    words: &mut Vec<PathWordFact<'a>>,
) {
    for redirect in command.redirect_facts() {
        if !matches!(
            redirect.redirect().kind,
            RedirectKind::Input | RedirectKind::ReadWrite | RedirectKind::HereString
        ) {
            continue;
        }

        let Some(word) = redirect.redirect().word_target() else {
            continue;
        };
        let context = match ExpansionContext::from_redirect_kind(redirect.redirect().kind) {
            Some(context) => context,
            None => unreachable!("input redirects should carry a word target context"),
        };
        words.push(PathWordFact::new(
            word,
            context,
            source,
            command.shell_behavior(),
        ));
    }
}

pub(crate) fn collect_command_simple_test_path_words<'a>(
    command: CommandFactRef<'_, 'a>,
    source: &str,
    words: &mut Vec<PathWordFact<'a>>,
) {
    let Some(simple_test) = command.simple_test() else {
        return;
    };

    words.extend(
        simple_test
            .operator_expression_operand_words(source)
            .into_iter()
            .map(|word| {
                PathWordFact::new(
                    word,
                    ExpansionContext::StringTestOperand,
                    source,
                    command.shell_behavior(),
                )
            }),
    );
}

pub(crate) fn collect_command_conditional_path_words<'a>(
    command: CommandFactRef<'_, 'a>,
    source: &str,
    words: &mut Vec<PathWordFact<'a>>,
) {
    if let Some(conditional) = command.conditional() {
        for node in conditional.nodes() {
            match node {
                ConditionalNodeFact::Binary(binary)
                    if binary.operator_family() == ConditionalOperatorFamily::StringBinary =>
                {
                    if let Some(word) = binary.left().word() {
                        words.push(PathWordFact::new(
                            word,
                            ExpansionContext::StringTestOperand,
                            source,
                            command.shell_behavior(),
                        ));
                    }
                    if let Some(word) = binary.right().word() {
                        words.push(PathWordFact::new(
                            word,
                            ExpansionContext::StringTestOperand,
                            source,
                            command.shell_behavior(),
                        ));
                    }
                }
                ConditionalNodeFact::Binary(_) => {}
                ConditionalNodeFact::BareWord(_) | ConditionalNodeFact::Other(_) => {}
                ConditionalNodeFact::Unary(_) => {}
            }
        }
    }
}

pub(crate) fn contains_span(outer: Span, inner: Span) -> bool {
    outer.start.offset() <= inner.start.offset() && inner.end.offset() <= outer.end.offset()
}

pub(crate) fn contains_span_strictly(outer: Span, inner: Span) -> bool {
    contains_span(outer, inner)
        && (outer.start.offset() < inner.start.offset() || inner.end.offset() < outer.end.offset())
}

pub(crate) fn build_backtick_command_name_spans(commands: &[CommandFact<'_>]) -> Vec<Span> {
    let mut spans = commands
        .iter()
        .filter_map(|fact| match fact.command() {
            Command::Simple(command) if command.args.is_empty() => {
                plain_backtick_command_name_span(&command.name)
            }
            _ => None,
        })
        .collect::<Vec<_>>();

    let mut seen = FxHashSet::default();
    spans.retain(|span| seen.insert(FactSpan::new(*span)));
    spans.sort_by_key(|span| (span.start.offset(), span.end.offset()));
    spans
}

pub(crate) fn plain_backtick_command_name_span(word: &Word) -> Option<Span> {
    let [part] = word.parts.as_slice() else {
        return None;
    };

    match &part.kind {
        WordPart::CommandSubstitution {
            syntax: CommandSubstitutionSyntax::Backtick,
            ..
        } => Some(part.span),
        _ => None,
    }
}

pub(crate) fn command_span(command: &Command) -> Span {
    match command {
        Command::Simple(command) => command.span,
        Command::Builtin(command) => builtin_span(command),
        Command::Decl(command) => command.span,
        Command::Binary(command) => command.span,
        Command::Compound(command) => compound_span(command),
        Command::Function(command) => command.span,
        Command::AnonymousFunction(command) => command.span,
    }
}

pub(crate) fn command_lookup_kind(command: &Command) -> CommandLookupKind {
    match command {
        Command::Simple(_) => CommandLookupKind::Simple,
        Command::Builtin(command) => CommandLookupKind::Builtin(match command {
            BuiltinCommand::Break(_) => BuiltinLookupKind::Break,
            BuiltinCommand::Continue(_) => BuiltinLookupKind::Continue,
            BuiltinCommand::Return(_) => BuiltinLookupKind::Return,
            BuiltinCommand::Exit(_) => BuiltinLookupKind::Exit,
        }),
        Command::Decl(_) => CommandLookupKind::Decl,
        Command::Binary(_) => CommandLookupKind::Binary,
        Command::Compound(command) => CommandLookupKind::Compound(match command {
            CompoundCommand::If(_) => CompoundLookupKind::If,
            CompoundCommand::For(_) => CompoundLookupKind::For,
            CompoundCommand::Repeat(_) => CompoundLookupKind::Repeat,
            CompoundCommand::Foreach(_) => CompoundLookupKind::Foreach,
            CompoundCommand::ArithmeticFor(_) => CompoundLookupKind::ArithmeticFor,
            CompoundCommand::While(_) => CompoundLookupKind::While,
            CompoundCommand::Until(_) => CompoundLookupKind::Until,
            CompoundCommand::Case(_) => CompoundLookupKind::Case,
            CompoundCommand::Select(_) => CompoundLookupKind::Select,
            CompoundCommand::Subshell(_) => CompoundLookupKind::Subshell,
            CompoundCommand::BraceGroup(_) => CompoundLookupKind::BraceGroup,
            CompoundCommand::Arithmetic(_) => CompoundLookupKind::Arithmetic,
            CompoundCommand::Time(_) => CompoundLookupKind::Time,
            CompoundCommand::Conditional(_) => CompoundLookupKind::Conditional,
            CompoundCommand::Coproc(_) => CompoundLookupKind::Coproc,
            CompoundCommand::Always(_) => CompoundLookupKind::Always,
        }),
        Command::Function(_) => CommandLookupKind::Function,
        Command::AnonymousFunction(_) => CommandLookupKind::AnonymousFunction,
    }
}

pub(crate) fn command_id_for_command(
    command: &Command,
    command_ids_by_span: &CommandLookupIndex,
) -> Option<CommandId> {
    command_ids_by_span
        .get(&FactSpan::new(command_span(command)))
        .and_then(|entries| {
            let kind = command_lookup_kind(command);
            entries
                .iter()
                .find(|entry| entry.kind == kind)
                .map(|entry| entry.id)
        })
}

pub(crate) fn command_fact<'facts, 'a>(
    commands: &'facts [CommandFact<'a>],
    indices_by_id: &[Option<usize>],
    id: CommandId,
) -> &'facts CommandFact<'a> {
    indices_by_id
        .get(id.index())
        .copied()
        .flatten()
        .and_then(|index| commands.get(index))
        .unwrap_or_else(|| panic!("command id {} must exist", id.index()))
}

/// Command positions keyed by their statement span, in source order.
pub(crate) type StmtSpanIndex = FxHashMap<FactSpan, SmallVec<[u32; 2]>>;

pub(crate) fn build_stmt_span_index(commands: &[CommandFact<'_>]) -> StmtSpanIndex {
    let mut index = StmtSpanIndex::default();
    for (position, command) in commands.iter().enumerate() {
        index
            .entry(FactSpan::new(command.stmt().span))
            .or_default()
            .push(position as u32);
    }
    index
}

pub(crate) fn command_fact_for_semantic_span_matching<'facts, 'a>(
    commands: &'facts [CommandFact<'a>],
    indices_by_id: &[Option<usize>],
    command_ids_by_span: &CommandLookupIndex,
    stmt_span_index: &StmtSpanIndex,
    span: Span,
    predicate: impl Fn(&CommandFact<'a>) -> bool,
) -> Option<&'facts CommandFact<'a>> {
    command_ids_by_span
        .get(&FactSpan::new(span))
        .and_then(|entries| {
            entries
                .iter()
                .map(|entry| command_fact(commands, indices_by_id, entry.id))
                .find(|command| predicate(command))
        })
        .or_else(|| {
            stmt_span_index
                .get(&FactSpan::new(span))
                .and_then(|positions| {
                    positions
                        .iter()
                        .map(|position| &commands[*position as usize])
                        .find(|command| predicate(command))
                })
        })
        .or_else(|| {
            commands
                .iter()
                .filter(|command| {
                    let command_span = command.span();
                    predicate(command)
                        && span.start.offset() <= command_span.start.offset()
                        && command_span.end.offset() <= span.end.offset()
                })
                .max_by_key(|command| {
                    let span = command.span();
                    (span.end.offset(), std::cmp::Reverse(span.start.offset()))
                })
        })
}

pub(crate) fn command_fact_ref<'facts, 'a>(
    commands: CommandFacts<'facts, 'a>,
    id: CommandId,
) -> CommandFactRef<'facts, 'a> {
    commands
        .find(id)
        .unwrap_or_else(|| panic!("command id {} must exist", id.index()))
}

#[derive(Clone, Copy)]
pub(crate) struct CommandRelationshipContext<'facts, 'a> {
    pub(crate) commands: &'facts [CommandFact<'a>],
    pub(crate) command_fact_indices_by_id: &'facts [Option<usize>],
    pub(crate) command_ids_by_span: &'facts CommandLookupIndex,
    pub(crate) command_child_index: &'facts CommandChildIndex,
}

impl<'facts, 'a> CommandRelationshipContext<'facts, 'a> {
    pub(crate) fn new(
        commands: &'facts [CommandFact<'a>],
        command_fact_indices_by_id: &'facts [Option<usize>],
        command_ids_by_span: &'facts CommandLookupIndex,
        command_child_index: &'facts CommandChildIndex,
    ) -> Self {
        Self {
            commands,
            command_fact_indices_by_id,
            command_ids_by_span,
            command_child_index,
        }
    }

    fn fact(self, id: CommandId) -> &'facts CommandFact<'a> {
        command_fact(self.commands, self.command_fact_indices_by_id, id)
    }

    pub(crate) fn id_for_command(self, command: &Command) -> Option<CommandId> {
        command_id_for_command(command, self.command_ids_by_span)
    }

    fn fact_for_command(self, command: &Command) -> Option<&'facts CommandFact<'a>> {
        self.id_for_command(command).map(|id| self.fact(id))
    }

    pub(crate) fn fact_for_stmt(self, stmt: &Stmt) -> Option<&'facts CommandFact<'a>> {
        self.fact_for_command(&stmt.command)
    }

    pub(crate) fn child_id_for_command(
        self,
        parent_id: CommandId,
        command: &Command,
    ) -> Option<CommandId> {
        child_command_id_for_command(
            parent_id,
            command,
            self.commands,
            self.command_fact_indices_by_id,
            self.command_child_index,
        )
    }

    fn child_fact_for_stmt(
        self,
        parent_id: CommandId,
        stmt: &Stmt,
    ) -> Option<&'facts CommandFact<'a>> {
        self.child_id_for_command(parent_id, &stmt.command)
            .map(|id| self.fact(id))
    }

    pub(crate) fn child_or_lookup_fact(
        self,
        parent_id: CommandId,
        stmt: &Stmt,
    ) -> Option<&'facts CommandFact<'a>> {
        self.child_fact_for_stmt(parent_id, stmt)
            .or_else(|| self.fact_for_stmt(stmt))
    }
}

pub(crate) fn build_command_fact_indices_by_id(commands: &[CommandFact<'_>]) -> Vec<Option<usize>> {
    let len = commands
        .iter()
        .map(|command| command.id().index())
        .max()
        .map_or(0, |index| index + 1);
    let mut indices = vec![None; len];
    for (index, command) in commands.iter().enumerate() {
        indices[command.id().index()] = Some(index);
    }
    indices
}

pub(crate) fn compare_command_facts_by_offset(
    left: &CommandFact<'_>,
    right: &CommandFact<'_>,
) -> std::cmp::Ordering {
    compare_command_parent_entries((left.span(), left.id()), (right.span(), right.id()))
}

pub(crate) fn compare_command_parent_entries(
    (left_span, left_id): (Span, CommandId),
    (right_span, right_id): (Span, CommandId),
) -> std::cmp::Ordering {
    left_span
        .start
        .offset()
        .cmp(&right_span.start.offset())
        .then_with(|| right_span.end.offset().cmp(&left_span.end.offset()))
        .then_with(|| right_id.index().cmp(&left_id.index()))
}

#[cfg_attr(shuck_profiling, inline(never))]
pub(crate) fn build_command_dominance_barrier_flags(commands: &[CommandFact<'_>]) -> Vec<bool> {
    let mut flags = vec![false; command_slot_count(commands)];
    for fact in commands {
        flags[fact.id().index()] = match fact.command() {
            Command::Binary(_) => true,
            Command::Compound(compound) => !matches!(
                compound,
                CompoundCommand::BraceGroup(_)
                    | CompoundCommand::Arithmetic(_)
                    | CompoundCommand::Time(_)
            ),
            Command::Simple(_)
            | Command::Builtin(_)
            | Command::Decl(_)
            | Command::Function(_)
            | Command::AnonymousFunction(_) => false,
        };
    }
    flags
}

pub(crate) fn command_slot_count(commands: &[CommandFact<'_>]) -> usize {
    commands
        .iter()
        .map(|command| command.id().index())
        .max()
        .map_or(0, |index| index + 1)
}

pub(crate) fn sort_and_dedup_spans(spans: &mut Vec<Span>) {
    let mut seen = FxHashSet::default();
    spans.retain(|span| seen.insert(FactSpan::new(*span)));
    spans.sort_by_key(|span| (span.start.offset(), span.end.offset()));
}

pub(crate) fn trim_trailing_whitespace_span(span: Span, source: &str) -> Span {
    let text = span.slice(source);
    let trimmed = text.trim_end_matches(char::is_whitespace);
    Span::from_positions(span.start, span.start.advanced_by(trimmed))
}

pub(crate) fn command_fact_for_command<'a>(
    command: &Command,
    commands: &'a [CommandFact<'a>],
    indices_by_id: &[Option<usize>],
    command_ids_by_span: &CommandLookupIndex,
) -> Option<&'a CommandFact<'a>> {
    command_id_for_command(command, command_ids_by_span)
        .map(|id| command_fact(commands, indices_by_id, id))
}

pub(crate) fn command_fact_for_stmt<'a>(
    stmt: &Stmt,
    commands: &'a [CommandFact<'a>],
    indices_by_id: &[Option<usize>],
    command_ids_by_span: &CommandLookupIndex,
) -> Option<&'a CommandFact<'a>> {
    command_fact_for_command(&stmt.command, commands, indices_by_id, command_ids_by_span)
}

pub(crate) fn child_command_id_for_command(
    parent_id: CommandId,
    command: &Command,
    commands: &[CommandFact<'_>],
    indices_by_id: &[Option<usize>],
    command_child_index: &CommandChildIndex,
) -> Option<CommandId> {
    command_child_index
        .child_ids(parent_id)
        .iter()
        .copied()
        .find(|id| {
            indices_by_id
                .get(id.index())
                .copied()
                .flatten()
                .and_then(|index| commands.get(index))
                .is_some_and(|fact| std::ptr::eq(fact.command(), command))
        })
}

pub(crate) fn command_fact_ref_for_stmt<'facts, 'a>(
    stmt: &Stmt,
    commands: CommandFacts<'facts, 'a>,
    command_ids_by_span: &CommandLookupIndex,
) -> Option<CommandFactRef<'facts, 'a>> {
    command_id_for_command(&stmt.command, command_ids_by_span)
        .map(|id| command_fact_ref(commands, id))
}

pub(crate) fn builtin_span(command: &BuiltinCommand) -> Span {
    match command {
        BuiltinCommand::Break(command) => command.span,
        BuiltinCommand::Continue(command) => command.span,
        BuiltinCommand::Return(command) => command.span,
        BuiltinCommand::Exit(command) => command.span,
    }
}

pub(crate) fn compound_span(command: &CompoundCommand) -> Span {
    match command {
        CompoundCommand::If(command) => command.span,
        CompoundCommand::For(command) => command.span,
        CompoundCommand::Repeat(command) => command.span,
        CompoundCommand::Foreach(command) => command.span,
        CompoundCommand::ArithmeticFor(command) => command.span,
        CompoundCommand::While(command) => command.span,
        CompoundCommand::Until(command) => command.span,
        CompoundCommand::Case(command) => command.span,
        CompoundCommand::Select(command) => command.span,
        CompoundCommand::Subshell(commands) | CompoundCommand::BraceGroup(commands) => {
            commands.span
        }
        CompoundCommand::Arithmetic(command) => command.span,
        CompoundCommand::Time(command) => command.span,
        CompoundCommand::Conditional(command) => command.span,
        CompoundCommand::Coproc(command) => command.span,
        CompoundCommand::Always(command) => command.span,
    }
}
