//! Emit diagnostics using the `annotate-snippets` library
//!
//! This is the equivalent of `./emitter.rs` but making use of the
//! [`annotate-snippets`][annotate_snippets] library instead of building the output ourselves.
//!
//! [annotate_snippets]: https://docs.rs/crate/annotate-snippets/

use std::borrow::Cow;
use std::fmt::Debug;
use std::io;
use std::io::Write;
use std::sync::Arc;

use annotate_snippets::renderer::DEFAULT_TERM_WIDTH;
use annotate_snippets::{AnnotationKind, Group, Origin, Padding, Patch, Renderer, Snippet};
use anstream::ColorChoice;
use derive_setters::Setters;
use rustc_data_structures::sync::IntoDynSyncSend;
use rustc_error_messages::{DiagArgMap, SpanLabel};
use rustc_lint_defs::pluralize;
use rustc_span::source_map::SourceMap;
use rustc_span::{BytePos, FileName, Pos, SourceFile, Span};
use tracing::debug;

use crate::emitter::{
    ConfusionType, Destination, MAX_SUGGESTIONS, OutputTheme, detect_confusion_type, is_different,
    normalize_whitespace, should_show_source_code,
};
use crate::formatting::{format_diag_message, format_diag_messages};
use crate::{
    CodeSuggestion, DiagInner, DiagMessage, Emitter, ErrCode, Level, MultiSpan, Style, Subdiag,
    SuggestionStyle, TerminalUrl,
};

/// Generates diagnostics using annotate-snippet
#[derive(Setters)]
pub struct AnnotateSnippetEmitter {
    #[setters(skip)]
    dst: IntoDynSyncSend<Destination>,
    sm: Option<Arc<SourceMap>>,
    short_message: bool,
    ui_testing: bool,
    ignored_directories_in_source_blocks: Vec<String>,
    diagnostic_width: Option<usize>,

    macro_backtrace: bool,
    track_diagnostics: bool,
    terminal_url: TerminalUrl,
    theme: OutputTheme,
}

impl Debug for AnnotateSnippetEmitter {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("AnnotateSnippetEmitter")
            .field("short_message", &self.short_message)
            .field("ui_testing", &self.ui_testing)
            .field(
                "ignored_directories_in_source_blocks",
                &self.ignored_directories_in_source_blocks,
            )
            .field("diagnostic_width", &self.diagnostic_width)
            .field("macro_backtrace", &self.macro_backtrace)
            .field("track_diagnostics", &self.track_diagnostics)
            .field("terminal_url", &self.terminal_url)
            .field("theme", &self.theme)
            .finish()
    }
}

impl Emitter for AnnotateSnippetEmitter {
    /// The entry point for the diagnostics generation
    fn emit_diagnostic(&mut self, mut diag: DiagInner) {
        if self.track_diagnostics && diag.span.has_primary_spans() && !diag.span.is_dummy() {
            diag.children.insert(0, diag.emitted_at_sub_diag());
        }

        let mut suggestions = diag.suggestions.unwrap_tag();
        self.primary_span_formatted(&mut diag.span, &mut suggestions, &diag.args);

        self.fix_multispans_in_extern_macros_and_render_macro_backtrace(
            &mut diag.span,
            &mut diag.children,
            &diag.level,
            self.macro_backtrace,
        );

        self.emit_messages_default(
            &diag.level,
            &diag.messages,
            &diag.args,
            &diag.code,
            &diag.span,
            &diag.children,
            suggestions,
        );
    }

    fn source_map(&self) -> Option<&SourceMap> {
        self.sm.as_deref()
    }

    fn should_show_explain(&self) -> bool {
        !self.short_message
    }

    fn supports_color(&self) -> bool {
        false
    }
}

fn annotation_level_for_level(level: Level) -> annotate_snippets::level::Level<'static> {
    match level {
        Level::Bug | Level::DelayedBug => {
            annotate_snippets::Level::ERROR.with_name("error: internal compiler error")
        }
        Level::Fatal | Level::Error => annotate_snippets::level::ERROR,
        Level::ForceWarning | Level::Warning => annotate_snippets::Level::WARNING,
        Level::Note | Level::OnceNote => annotate_snippets::Level::NOTE,
        Level::Help | Level::OnceHelp => annotate_snippets::Level::HELP,
        Level::FailureNote => annotate_snippets::Level::NOTE.no_name(),
        Level::Allow => panic!("Should not call with Allow"),
        Level::Expect => panic!("Should not call with Expect"),
    }
}

impl AnnotateSnippetEmitter {
    pub fn new(dst: Destination) -> Self {
        Self {
            dst: IntoDynSyncSend(dst),
            sm: None,
            short_message: false,
            ui_testing: false,
            ignored_directories_in_source_blocks: Vec::new(),
            diagnostic_width: None,
            macro_backtrace: false,
            track_diagnostics: false,
            terminal_url: TerminalUrl::No,
            theme: OutputTheme::Ascii,
        }
    }

    fn emit_messages_default(
        &mut self,
        level: &Level,
        msgs: &[(DiagMessage, Style)],
        args: &DiagArgMap,
        code: &Option<ErrCode>,
        msp: &MultiSpan,
        children: &[Subdiag],
        suggestions: Vec<CodeSuggestion>,
    ) {
        let renderer = self.renderer();
        let annotation_level = annotation_level_for_level(*level);

        // If at least one portion of the message is styled, we need to
        // "pre-style" the message
        let mut title = if msgs.iter().any(|(_, style)| style != &crate::Style::NoStyle) {
            annotation_level
                .clone()
                .secondary_title(Cow::Owned(self.pre_style_msgs(msgs, *level, args)))
        } else {
            annotation_level.clone().primary_title(format_diag_messages(msgs, args))
        };

        if let Some(c) = code {
            title = title.id(c.to_string());
            if let TerminalUrl::Yes = self.terminal_url {
                title = title.id_url(format!("https://doc.rust-lang.org/error_codes/{c}.html"));
            }
        }

        let mut report = vec![];
        let mut group = Group::with_title(title);

        // If we don't have span information, emit and exit
        let Some(sm) = self.sm.as_ref() else {
            group = group.elements(children.iter().map(|c| {
                let msg = format_diag_messages(&c.messages, args).to_string();
                let level = annotation_level_for_level(c.level);
                level.message(msg)
            }));

            report.push(group);
            if let Err(e) = emit_to_destination(
                renderer.render(&report),
                level,
                &mut self.dst,
                self.short_message,
            ) {
                panic!("failed to emit error: {e}");
            }
            return;
        };

        let mut file_ann = collect_annotations(args, msp, sm);

        // Make sure our primary file comes first
        let primary_span = msp.primary_span().unwrap_or_default();
        if !primary_span.is_dummy() {
            let primary_lo = sm.lookup_char_pos(primary_span.lo());
            if let Ok(pos) = file_ann.binary_search_by(|(f, _)| f.name.cmp(&primary_lo.file.name)) {
                file_ann.swap(0, pos);
            }

            let file_ann_len = file_ann.len();
            for (file_idx, (file, annotations)) in file_ann.into_iter().enumerate() {
                if should_show_source_code(&self.ignored_directories_in_source_blocks, sm, &file) {
                    if let Some(snippet) = self.annotated_snippet(annotations, &file.name, sm) {
                        group = group.element(snippet);
                    }
                // we can't annotate anything if the source is unavailable.
                } else if !self.short_message {
                    // We'll just print unannotated messages
                    group = self.unannotated_messages(
                        annotations,
                        &file.name,
                        sm,
                        file_idx,
                        &mut report,
                        group,
                        &annotation_level,
                    );
                    // If this is the last annotation for a file, and
                    // this is the last file, and the first child is a
                    // "secondary" message, we need to add padding
                    // ╭▸ /rustc/FAKE_PREFIX/library/core/src/clone.rs:236:13
                    // │
                    // ├ note: the late bound lifetime parameter
                    // │ (<- It adds *this*)
                    // ╰ warning: this was previously accepted
                    if let Some(c) = children.first()
                        && (!c.span.has_primary_spans() && !c.span.has_span_labels())
                        && file_idx == file_ann_len - 1
                    {
                        group = group.element(Padding);
                    }
                }
            }
        }

        for c in children {
            let level = annotation_level_for_level(c.level);

            // If at least one portion of the message is styled, we need to
            // "pre-style" the message
            let msg = if c.messages.iter().any(|(_, style)| style != &crate::Style::NoStyle) {
                Cow::Owned(self.pre_style_msgs(&c.messages, c.level, args))
            } else {
                format_diag_messages(&c.messages, args)
            };

            // This is a secondary message with no span info
            if !c.span.has_primary_spans() && !c.span.has_span_labels() {
                group = group.element(level.clone().message(msg));
                continue;
            }

            report.push(std::mem::replace(
                &mut group,
                Group::with_title(level.clone().secondary_title(msg)),
            ));

            let mut file_ann = collect_annotations(args, &c.span, sm);
            let primary_span = c.span.primary_span().unwrap_or_default();
            if !primary_span.is_dummy() {
                let primary_lo = sm.lookup_char_pos(primary_span.lo());
                if let Ok(pos) =
                    file_ann.binary_search_by(|(f, _)| f.name.cmp(&primary_lo.file.name))
                {
                    file_ann.swap(0, pos);
                }
            }

            for (file_idx, (file, annotations)) in file_ann.into_iter().enumerate() {
                if should_show_source_code(&self.ignored_directories_in_source_blocks, sm, &file) {
                    if let Some(snippet) = self.annotated_snippet(annotations, &file.name, sm) {
                        group = group.element(snippet);
                    }
                // we can't annotate anything if the source is unavailable.
                } else if !self.short_message {
                    // We'll just print unannotated messages
                    group = self.unannotated_messages(
                        annotations,
                        &file.name,
                        sm,
                        file_idx,
                        &mut report,
                        group,
                        &level,
                    );
                }
            }
        }

        for suggestion in suggestions {
            match suggestion.style {
                SuggestionStyle::CompletelyHidden => {
                    // do not display this suggestion, it is meant only for tools
                }
                SuggestionStyle::HideCodeAlways => {
                    let msg = format_diag_messages(
                        &[(suggestion.msg.to_owned(), Style::HeaderMsg)],
                        args,
                    );
                    group = group.element(annotate_snippets::Level::HELP.message(msg));
                }
                SuggestionStyle::HideCodeInline
                | SuggestionStyle::ShowCode
                | SuggestionStyle::ShowAlways => {
                    let substitutions = suggestion
                        .substitutions
                        .into_iter()
                        .filter(|subst| {
                            // Suggestions coming from macros can have malformed spans. This is a heavy
                            // handed approach to avoid ICEs by ignoring the suggestion outright.
                            let invalid =
                                subst.parts.iter().any(|item| sm.is_valid_span(item.span).is_err());
                            if invalid {
                                debug!("suggestion contains an invalid span: {:?}", subst);
                            }
                            !invalid
                        })
                        .filter_map(|mut subst| {
                            // Assumption: all spans are in the same file, and all spans
                            // are disjoint. Sort in ascending order.
                            subst.parts.sort_by_key(|part| part.span.lo());
                            // Verify the assumption that all spans are disjoint
                            debug_assert_eq!(
                                subst.parts.array_windows().find(|[a, b]| a.span.overlaps(b.span)),
                                None,
                                "all spans must be disjoint",
                            );

                            let lo = subst.parts.iter().map(|part| part.span.lo()).min()?;
                            let lo_file = sm.lookup_source_file(lo);
                            let hi = subst.parts.iter().map(|part| part.span.hi()).max()?;
                            let hi_file = sm.lookup_source_file(hi);

                            // The different spans might belong to different contexts, if so ignore suggestion.
                            if lo_file.stable_id != hi_file.stable_id {
                                return None;
                            }

                            // We can't splice anything if the source is unavailable.
                            if !sm.ensure_source_file_source_present(&lo_file) {
                                return None;
                            }

                            // Account for cases where we are suggesting the same code that's already
                            // there. This shouldn't happen often, but in some cases for multipart
                            // suggestions it's much easier to handle it here than in the origin.
                            subst.parts.retain(|p| is_different(sm, &p.snippet, p.span));

                            if subst.parts.is_empty() { None } else { Some(subst) }
                        })
                        .collect::<Vec<_>>();

                    if substitutions.is_empty() {
                        continue;
                    }
                    let mut msg = format_diag_message(&suggestion.msg, args).to_string();

                    let lo = substitutions
                        .iter()
                        .find_map(|sub| sub.parts.first().map(|p| p.span.lo()))
                        .unwrap();
                    let file = sm.lookup_source_file(lo);

                    let filename =
                        sm.filename_for_diagnostics(&file.name).to_string_lossy().to_string();

                    let other_suggestions = substitutions.len().saturating_sub(MAX_SUGGESTIONS);

                    let subs = substitutions
                        .into_iter()
                        .take(MAX_SUGGESTIONS)
                        .filter_map(|sub| {
                            let mut confusion_type = ConfusionType::None;
                            for part in &sub.parts {
                                let part_confusion =
                                    detect_confusion_type(sm, &part.snippet, part.span);
                                confusion_type = confusion_type.combine(part_confusion);
                            }

                            if !matches!(confusion_type, ConfusionType::None) {
                                msg.push_str(confusion_type.label_text());
                            }

                            let mut parts = sub
                                .parts
                                .into_iter()
                                .filter_map(|p| {
                                    if is_different(sm, &p.snippet, p.span) {
                                        Some((p.span, p.snippet))
                                    } else {
                                        None
                                    }
                                })
                                .collect::<Vec<_>>();

                            if parts.is_empty() {
                                None
                            } else {
                                let spans = parts.iter().map(|(span, _)| *span).collect::<Vec<_>>();
                                // The suggestion adds an entire line of code, ending on a newline, so we'll also
                                // print the *following* line, to provide context of what we're advising people to
                                // do. Otherwise you would only see contextless code that can be confused for
                                // already existing code, despite the colors and UI elements.
                                // We special case `#[derive(_)]\n` and other attribute suggestions, because those
                                // are the ones where context is most useful.
                                let fold = if let [(p, snippet)] = &mut parts[..]
                                    && snippet.trim().starts_with("#[")
                                    // This allows for spaces to come between the attribute and the newline
                                    && snippet.trim().ends_with("]")
                                    && snippet.ends_with('\n')
                                    && p.hi() == p.lo()
                                    && let Ok(b) = sm.span_to_prev_source(*p)
                                    && let b = b.rsplit_once('\n').unwrap_or_else(|| ("", &b)).1
                                    && b.trim().is_empty()
                                {
                                    // FIXME: This is a hack:
                                    // The span for attribute suggestions often times points to the
                                    // beginning of an item, disregarding leading whitespace. This
                                    // causes the attribute to be properly indented, but leaves original
                                    // item without indentation when rendered.
                                    // This fixes that problem by adjusting the span to point to the start
                                    // of the whitespace, and adds the whitespace to the replacement.
                                    //
                                    // Source: "    extern "custom" fn negate(a: i64) -> i64 {\n"
                                    // Span: 4..4
                                    // Replacement: "#[unsafe(naked)]\n"
                                    //
                                    // Before:
                                    // help: convert this to an `#[unsafe(naked)]` function
                                    //    |
                                    // LL +     #[unsafe(naked)]
                                    // LL | extern "custom" fn negate(a: i64) -> i64 {
                                    //    |
                                    //
                                    // After
                                    // help: convert this to an `#[unsafe(naked)]` function
                                    //    |
                                    // LL +     #[unsafe(naked)]
                                    // LL |     extern "custom" fn negate(a: i64) -> i64 {
                                    //    |
                                    if !b.is_empty() && !snippet.ends_with(b) {
                                        snippet.insert_str(0, b);
                                        let offset = BytePos(b.len() as u32);
                                        *p = p.with_lo(p.lo() - offset).shrink_to_lo();
                                    }
                                    false
                                } else {
                                    true
                                };

                                if let Some((bounding_span, source, line_offset)) =
                                    shrink_file(spans.as_slice(), &file.name, sm)
                                {
                                    let adj_lo = bounding_span.lo().to_usize();
                                    Some(
                                        Snippet::source(source)
                                            .line_start(line_offset)
                                            .path(filename.clone())
                                            .fold(fold)
                                            .patches(parts.into_iter().map(
                                                |(span, replacement)| {
                                                    let lo =
                                                        span.lo().to_usize().saturating_sub(adj_lo);
                                                    let hi =
                                                        span.hi().to_usize().saturating_sub(adj_lo);

                                                    Patch::new(lo..hi, replacement)
                                                },
                                            )),
                                    )
                                } else {
                                    None
                                }
                            }
                        })
                        .collect::<Vec<_>>();
                    if !subs.is_empty() {
                        report.push(std::mem::replace(
                            &mut group,
                            Group::with_title(annotate_snippets::Level::HELP.secondary_title(msg)),
                        ));

                        group = group.elements(subs);
                        if other_suggestions > 0 {
                            group = group.element(
                                annotate_snippets::Level::NOTE.no_name().message(format!(
                                    "and {} other candidate{}",
                                    other_suggestions,
                                    pluralize!(other_suggestions)
                                )),
                            );
                        }
                    }
                }
            }
        }

        if !group.is_empty() {
            report.push(group);
        }
        if let Err(e) =
            emit_to_destination(renderer.render(&report), level, &mut self.dst, self.short_message)
        {
            panic!("failed to emit error: {e}");
        }
    }

    fn renderer(&self) -> Renderer {
        let width = if let Some(width) = self.diagnostic_width {
            width
        } else if self.ui_testing || cfg!(miri) {
            DEFAULT_TERM_WIDTH
        } else {
            termize::dimensions().map(|(w, _)| w).unwrap_or(DEFAULT_TERM_WIDTH)
        };
        let decor_style = match self.theme {
            OutputTheme::Ascii => annotate_snippets::renderer::DecorStyle::Ascii,
            OutputTheme::Unicode => annotate_snippets::renderer::DecorStyle::Unicode,
        };

        match self.dst.current_choice() {
            ColorChoice::AlwaysAnsi | ColorChoice::Always | ColorChoice::Auto => Renderer::styled(),
            ColorChoice::Never => Renderer::plain(),
        }
        .term_width(width)
        .anonymized_line_numbers(self.ui_testing)
        .decor_style(decor_style)
        .short_message(self.short_message)
    }

    fn pre_style_msgs(
        &self,
        msgs: &[(DiagMessage, Style)],
        level: Level,
        args: &DiagArgMap,
    ) -> String {
        msgs.iter()
            .filter_map(|(m, style)| {
                let text = format_diag_message(m, args);
                let style = style.anstyle(level);
                if text.is_empty() { None } else { Some(format!("{style}{text}{style:#}")) }
            })
            .collect()
    }

    fn annotated_snippet<'a>(
        &self,
        annotations: Vec<Annotation>,
        file_name: &FileName,
        sm: &Arc<SourceMap>,
    ) -> Option<Snippet<'a, annotate_snippets::Annotation<'a>>> {
        let spans = annotations.iter().map(|a| a.span).collect::<Vec<_>>();
        if let Some((bounding_span, source, offset_line)) = shrink_file(&spans, file_name, sm) {
            let adj_lo = bounding_span.lo().to_usize();
            let filename = sm.filename_for_diagnostics(file_name).to_string_lossy().to_string();
            Some(Snippet::source(source).line_start(offset_line).path(filename).annotations(
                annotations.into_iter().map(move |a| {
                    let lo = a.span.lo().to_usize().saturating_sub(adj_lo);
                    let hi = a.span.hi().to_usize().saturating_sub(adj_lo);
                    let ann = a.kind.span(lo..hi);
                    if let Some(label) = a.label { ann.label(label) } else { ann }
                }),
            ))
        } else {
            None
        }
    }

    fn unannotated_messages<'a>(
        &self,
        annotations: Vec<Annotation>,
        file_name: &FileName,
        sm: &Arc<SourceMap>,
        file_idx: usize,
        report: &mut Vec<Group<'a>>,
        mut group: Group<'a>,
        level: &annotate_snippets::level::Level<'static>,
    ) -> Group<'a> {
        let filename = sm.filename_for_diagnostics(file_name).to_string_lossy().to_string();
        let mut line_tracker = vec![];
        for (i, a) in annotations.into_iter().enumerate() {
            let lo = sm.lookup_char_pos(a.span.lo());
            let hi = sm.lookup_char_pos(a.span.hi());
            if i == 0 || (a.label.is_some()) {
                // Render each new file after the first in its own Group
                //    ╭▸ $DIR/deriving-meta-unknown-trait.rs:1:10
                //    │
                // LL │ #[derive(Eqr)]
                //    │          ━━━
                //    ╰╴ (<- It makes it so *this* will get printed)
                //    ╭▸ $SRC_DIR/core/src/option.rs:594:0
                //    ⸬  $SRC_DIR/core/src/option.rs:602:4
                //    │
                //    ╰ note: not covered
                if i == 0 && file_idx != 0 {
                    report.push(std::mem::replace(&mut group, Group::with_level(level.clone())));
                }

                if !line_tracker.contains(&lo.line) && (i == 0 || hi.line <= lo.line) {
                    line_tracker.push(lo.line);
                    // ╭▸ $SRC_DIR/core/src/option.rs:594:0 (<- It adds *this*)
                    // ⸬  $SRC_DIR/core/src/option.rs:602:4
                    // │
                    // ╰ note: not covered
                    group = group.element(
                        Origin::path(filename.clone())
                            .line(sm.doctest_offset_line(file_name, lo.line))
                            .char_column(lo.col_display),
                    );
                }

                if hi.line > lo.line
                    && a.label.as_ref().is_some_and(|l| !l.is_empty())
                    && !line_tracker.contains(&hi.line)
                {
                    line_tracker.push(hi.line);
                    // ╭▸ $SRC_DIR/core/src/option.rs:594:0
                    // ⸬  $SRC_DIR/core/src/option.rs:602:4 (<- It adds *this*)
                    // │
                    // ╰ note: not covered
                    group = group.element(
                        Origin::path(filename.clone())
                            .line(sm.doctest_offset_line(file_name, hi.line))
                            .char_column(hi.col_display),
                    );
                }

                if let Some(label) = a.label
                    && !label.is_empty()
                {
                    // ╭▸ $SRC_DIR/core/src/option.rs:594:0
                    // ⸬  $SRC_DIR/core/src/option.rs:602:4
                    // │ (<- It adds *this*)
                    // ╰ note: not covered (<- and *this*)
                    group = group
                        .element(Padding)
                        .element(annotate_snippets::Level::NOTE.message(label));
                }
            }
        }
        group
    }
}

fn emit_to_destination(
    rendered: String,
    lvl: &Level,
    dst: &mut Destination,
    short_message: bool,
) -> io::Result<()> {
    use crate::lock;
    let _buffer_lock = lock::acquire_global_lock("rustc_errors");
    writeln!(dst, "{rendered}")?;
    if !short_message && !lvl.is_failure_note() {
        writeln!(dst)?;
    }
    dst.flush()?;
    Ok(())
}

#[derive(Debug)]
struct Annotation {
    kind: AnnotationKind,
    span: Span,
    label: Option<String>,
}

fn collect_annotations(
    args: &DiagArgMap,
    msp: &MultiSpan,
    sm: &Arc<SourceMap>,
) -> Vec<(Arc<SourceFile>, Vec<Annotation>)> {
    let mut output: Vec<(Arc<SourceFile>, Vec<Annotation>)> = vec![];

    for SpanLabel { span, is_primary, label } in msp.span_labels() {
        // If we don't have a useful span, pick the primary span if that exists.
        // Worst case we'll just print an error at the top of the main file.
        let span = match (span.is_dummy(), msp.primary_span()) {
            (_, None) | (false, _) => span,
            (true, Some(span)) => span,
        };
        let file = sm.lookup_source_file(span.lo());

        let kind = if is_primary { AnnotationKind::Primary } else { AnnotationKind::Context };

        let label = label.as_ref().map(|m| normalize_whitespace(&format_diag_message(m, args)));

        let ann = Annotation { kind, span, label };
        if sm.is_valid_span(ann.span).is_ok() {
            // Look through each of our files for the one we're adding to. We
            // use each files `stable_id` to avoid issues with file name
            // collisions when multiple versions of the same crate are present
            // in the dependency graph
            if let Some((_, annotations)) =
                output.iter_mut().find(|(f, _)| f.stable_id == file.stable_id)
            {
                annotations.push(ann);
            } else {
                output.push((file, vec![ann]));
            }
        }
    }

    // Sort annotations within each file by line number
    for (_, ann) in output.iter_mut() {
        ann.sort_by_key(|a| {
            let lo = sm.lookup_char_pos(a.span.lo());
            lo.line
        });
    }
    output
}

fn shrink_file(
    spans: &[Span],
    file_name: &FileName,
    sm: &Arc<SourceMap>,
) -> Option<(Span, String, usize)> {
    let lo_byte = spans.iter().map(|s| s.lo()).min()?;
    let lo_loc = sm.lookup_char_pos(lo_byte);

    let hi_byte = spans.iter().map(|s| s.hi()).max()?;
    let hi_loc = sm.lookup_char_pos(hi_byte);

    if lo_loc.file.stable_id != hi_loc.file.stable_id {
        // this may happen when spans cross file boundaries due to macro expansion.
        return None;
    }

    let lo = lo_loc.file.line_bounds(lo_loc.line.saturating_sub(1)).start;
    let hi = hi_loc.file.line_bounds(hi_loc.line.saturating_sub(1)).end;

    let bounding_span = Span::with_root_ctxt(lo, hi);
    let source = sm.span_to_snippet(bounding_span).ok()?;
    let offset_line = sm.doctest_offset_line(file_name, lo_loc.line);

    Some((bounding_span, source, offset_line))
}
