rustc_resolve/
rustdoc.rs

1use std::mem;
2use std::ops::Range;
3
4use itertools::Itertools;
5use pulldown_cmark::{
6    BrokenLink, BrokenLinkCallback, CowStr, Event, LinkType, Options, Parser, Tag,
7};
8use rustc_ast as ast;
9use rustc_ast::attr::AttributeExt;
10use rustc_ast::util::comments::beautify_doc_string;
11use rustc_data_structures::fx::FxIndexMap;
12use rustc_data_structures::unord::UnordSet;
13use rustc_middle::ty::TyCtxt;
14use rustc_span::def_id::DefId;
15use rustc_span::source_map::SourceMap;
16use rustc_span::{DUMMY_SP, InnerSpan, Span, Symbol, sym};
17use thin_vec::ThinVec;
18use tracing::{debug, trace};
19
20#[cfg(test)]
21mod tests;
22
23#[derive(Clone, Copy, PartialEq, Eq, Debug)]
24pub enum DocFragmentKind {
25    /// A doc fragment created from a `///` or `//!` doc comment.
26    SugaredDoc,
27    /// A doc fragment created from a "raw" `#[doc=""]` attribute.
28    RawDoc,
29}
30
31/// A portion of documentation, extracted from a `#[doc]` attribute.
32///
33/// Each variant contains the line number within the complete doc-comment where the fragment
34/// starts, as well as the Span where the corresponding doc comment or attribute is located.
35///
36/// Included files are kept separate from inline doc comments so that proper line-number
37/// information can be given when a doctest fails. Sugared doc comments and "raw" doc comments are
38/// kept separate because of issue #42760.
39#[derive(Clone, PartialEq, Eq, Debug)]
40pub struct DocFragment {
41    pub span: Span,
42    /// The item this doc-comment came from.
43    /// Used to determine the scope in which doc links in this fragment are resolved.
44    /// Typically filled for reexport docs when they are merged into the docs of the
45    /// original reexported item.
46    /// If the id is not filled, which happens for the original reexported item, then
47    /// it has to be taken from somewhere else during doc link resolution.
48    pub item_id: Option<DefId>,
49    pub doc: Symbol,
50    pub kind: DocFragmentKind,
51    pub indent: usize,
52    /// Because we tamper with the spans context, this information cannot be correctly retrieved
53    /// later on. So instead, we compute it and store it here.
54    pub from_expansion: bool,
55}
56
57#[derive(Clone, Copy, Debug)]
58pub enum MalformedGenerics {
59    /// This link has unbalanced angle brackets.
60    ///
61    /// For example, `Vec<T` should trigger this, as should `Vec<T>>`.
62    UnbalancedAngleBrackets,
63    /// The generics are not attached to a type.
64    ///
65    /// For example, `<T>` should trigger this.
66    ///
67    /// This is detected by checking if the path is empty after the generics are stripped.
68    MissingType,
69    /// The link uses fully-qualified syntax, which is currently unsupported.
70    ///
71    /// For example, `<Vec as IntoIterator>::into_iter` should trigger this.
72    ///
73    /// This is detected by checking if ` as ` (the keyword `as` with spaces around it) is inside
74    /// angle brackets.
75    HasFullyQualifiedSyntax,
76    /// The link has an invalid path separator.
77    ///
78    /// For example, `Vec:<T>:new()` should trigger this. Note that `Vec:new()` will **not**
79    /// trigger this because it has no generics and thus [`strip_generics_from_path`] will not be
80    /// called.
81    ///
82    /// Note that this will also **not** be triggered if the invalid path separator is inside angle
83    /// brackets because rustdoc mostly ignores what's inside angle brackets (except for
84    /// [`HasFullyQualifiedSyntax`](MalformedGenerics::HasFullyQualifiedSyntax)).
85    ///
86    /// This is detected by checking if there is a colon followed by a non-colon in the link.
87    InvalidPathSeparator,
88    /// The link has too many angle brackets.
89    ///
90    /// For example, `Vec<<T>>` should trigger this.
91    TooManyAngleBrackets,
92    /// The link has empty angle brackets.
93    ///
94    /// For example, `Vec<>` should trigger this.
95    EmptyAngleBrackets,
96}
97
98/// Removes excess indentation on comments in order for the Markdown
99/// to be parsed correctly. This is necessary because the convention for
100/// writing documentation is to provide a space between the /// or //! marker
101/// and the doc text, but Markdown is whitespace-sensitive. For example,
102/// a block of text with four-space indentation is parsed as a code block,
103/// so if we didn't unindent comments, these list items
104///
105/// /// A list:
106/// ///
107/// ///    - Foo
108/// ///    - Bar
109///
110/// would be parsed as if they were in a code block, which is likely not what the user intended.
111pub fn unindent_doc_fragments(docs: &mut [DocFragment]) {
112    // `add` is used in case the most common sugared doc syntax is used ("/// "). The other
113    // fragments kind's lines are never starting with a whitespace unless they are using some
114    // markdown formatting requiring it. Therefore, if the doc block have a mix between the two,
115    // we need to take into account the fact that the minimum indent minus one (to take this
116    // whitespace into account).
117    //
118    // For example:
119    //
120    // /// hello!
121    // #[doc = "another"]
122    //
123    // In this case, you want "hello! another" and not "hello!  another".
124    let add = if docs.windows(2).any(|arr| arr[0].kind != arr[1].kind)
125        && docs.iter().any(|d| d.kind == DocFragmentKind::SugaredDoc)
126    {
127        // In case we have a mix of sugared doc comments and "raw" ones, we want the sugared one to
128        // "decide" how much the minimum indent will be.
129        1
130    } else {
131        0
132    };
133
134    // `min_indent` is used to know how much whitespaces from the start of each lines must be
135    // removed. Example:
136    //
137    // ///     hello!
138    // #[doc = "another"]
139    //
140    // In here, the `min_indent` is 1 (because non-sugared fragment are always counted with minimum
141    // 1 whitespace), meaning that "hello!" will be considered a codeblock because it starts with 4
142    // (5 - 1) whitespaces.
143    let Some(min_indent) = docs
144        .iter()
145        .map(|fragment| {
146            fragment
147                .doc
148                .as_str()
149                .lines()
150                .filter(|line| line.chars().any(|c| !c.is_whitespace()))
151                .map(|line| {
152                    // Compare against either space or tab, ignoring whether they are
153                    // mixed or not.
154                    let whitespace = line.chars().take_while(|c| *c == ' ' || *c == '\t').count();
155                    whitespace
156                        + (if fragment.kind == DocFragmentKind::SugaredDoc { 0 } else { add })
157                })
158                .min()
159                .unwrap_or(usize::MAX)
160        })
161        .min()
162    else {
163        return;
164    };
165
166    for fragment in docs {
167        if fragment.doc == sym::empty {
168            continue;
169        }
170
171        let indent = if fragment.kind != DocFragmentKind::SugaredDoc && min_indent > 0 {
172            min_indent - add
173        } else {
174            min_indent
175        };
176
177        fragment.indent = indent;
178    }
179}
180
181/// The goal of this function is to apply the `DocFragment` transformation that is required when
182/// transforming into the final Markdown, which is applying the computed indent to each line in
183/// each doc fragment (a `DocFragment` can contain multiple lines in case of `#[doc = ""]`).
184///
185/// Note: remove the trailing newline where appropriate
186pub fn add_doc_fragment(out: &mut String, frag: &DocFragment) {
187    if frag.doc == sym::empty {
188        out.push('\n');
189        return;
190    }
191    let s = frag.doc.as_str();
192    let mut iter = s.lines();
193
194    while let Some(line) = iter.next() {
195        if line.chars().any(|c| !c.is_whitespace()) {
196            assert!(line.len() >= frag.indent);
197            out.push_str(&line[frag.indent..]);
198        } else {
199            out.push_str(line);
200        }
201        out.push('\n');
202    }
203}
204
205pub fn attrs_to_doc_fragments<'a, A: AttributeExt + Clone + 'a>(
206    attrs: impl Iterator<Item = (&'a A, Option<DefId>)>,
207    doc_only: bool,
208) -> (Vec<DocFragment>, ThinVec<A>) {
209    let mut doc_fragments = Vec::new();
210    let mut other_attrs = ThinVec::<A>::new();
211    for (attr, item_id) in attrs {
212        if let Some((doc_str, comment_kind)) = attr.doc_str_and_comment_kind() {
213            let doc = beautify_doc_string(doc_str, comment_kind);
214            let (span, kind, from_expansion) = if attr.is_doc_comment() {
215                let span = attr.span();
216                (span, DocFragmentKind::SugaredDoc, span.from_expansion())
217            } else {
218                let attr_span = attr.span();
219                let (span, from_expansion) = match attr.value_span() {
220                    Some(sp) => (sp.with_ctxt(attr_span.ctxt()), sp.from_expansion()),
221                    None => (attr_span, attr_span.from_expansion()),
222                };
223                (span, DocFragmentKind::RawDoc, from_expansion)
224            };
225            let fragment = DocFragment { span, doc, kind, item_id, indent: 0, from_expansion };
226            doc_fragments.push(fragment);
227        } else if !doc_only {
228            other_attrs.push(attr.clone());
229        }
230    }
231
232    unindent_doc_fragments(&mut doc_fragments);
233
234    (doc_fragments, other_attrs)
235}
236
237/// Return the doc-comments on this item, grouped by the module they came from.
238/// The module can be different if this is a re-export with added documentation.
239///
240/// The last newline is not trimmed so the produced strings are reusable between
241/// early and late doc link resolution regardless of their position.
242pub fn prepare_to_doc_link_resolution(
243    doc_fragments: &[DocFragment],
244) -> FxIndexMap<Option<DefId>, String> {
245    let mut res = FxIndexMap::default();
246    for fragment in doc_fragments {
247        let out_str = res.entry(fragment.item_id).or_default();
248        add_doc_fragment(out_str, fragment);
249    }
250    res
251}
252
253/// Options for rendering Markdown in the main body of documentation.
254pub fn main_body_opts() -> Options {
255    Options::ENABLE_TABLES
256        | Options::ENABLE_FOOTNOTES
257        | Options::ENABLE_STRIKETHROUGH
258        | Options::ENABLE_TASKLISTS
259        | Options::ENABLE_SMART_PUNCTUATION
260}
261
262fn strip_generics_from_path_segment(segment: Vec<char>) -> Result<String, MalformedGenerics> {
263    let mut stripped_segment = String::new();
264    let mut param_depth = 0;
265
266    let mut latest_generics_chunk = String::new();
267
268    for c in segment {
269        if c == '<' {
270            param_depth += 1;
271            latest_generics_chunk.clear();
272        } else if c == '>' {
273            param_depth -= 1;
274            if latest_generics_chunk.contains(" as ") {
275                // The segment tries to use fully-qualified syntax, which is currently unsupported.
276                // Give a helpful error message instead of completely ignoring the angle brackets.
277                return Err(MalformedGenerics::HasFullyQualifiedSyntax);
278            }
279        } else if param_depth == 0 {
280            stripped_segment.push(c);
281        } else {
282            latest_generics_chunk.push(c);
283        }
284    }
285
286    if param_depth == 0 {
287        Ok(stripped_segment)
288    } else {
289        // The segment has unbalanced angle brackets, e.g. `Vec<T` or `Vec<T>>`
290        Err(MalformedGenerics::UnbalancedAngleBrackets)
291    }
292}
293
294pub fn strip_generics_from_path(path_str: &str) -> Result<Box<str>, MalformedGenerics> {
295    if !path_str.contains(['<', '>']) {
296        return Ok(path_str.into());
297    }
298    let mut stripped_segments = vec![];
299    let mut path = path_str.chars().peekable();
300    let mut segment = Vec::new();
301
302    while let Some(chr) = path.next() {
303        match chr {
304            ':' => {
305                if path.next_if_eq(&':').is_some() {
306                    let stripped_segment =
307                        strip_generics_from_path_segment(mem::take(&mut segment))?;
308                    if !stripped_segment.is_empty() {
309                        stripped_segments.push(stripped_segment);
310                    }
311                } else {
312                    return Err(MalformedGenerics::InvalidPathSeparator);
313                }
314            }
315            '<' => {
316                segment.push(chr);
317
318                match path.next() {
319                    Some('<') => {
320                        return Err(MalformedGenerics::TooManyAngleBrackets);
321                    }
322                    Some('>') => {
323                        return Err(MalformedGenerics::EmptyAngleBrackets);
324                    }
325                    Some(chr) => {
326                        segment.push(chr);
327
328                        while let Some(chr) = path.next_if(|c| *c != '>') {
329                            segment.push(chr);
330                        }
331                    }
332                    None => break,
333                }
334            }
335            _ => segment.push(chr),
336        }
337        trace!("raw segment: {:?}", segment);
338    }
339
340    if !segment.is_empty() {
341        let stripped_segment = strip_generics_from_path_segment(segment)?;
342        if !stripped_segment.is_empty() {
343            stripped_segments.push(stripped_segment);
344        }
345    }
346
347    debug!("path_str: {path_str:?}\nstripped segments: {stripped_segments:?}");
348
349    let stripped_path = stripped_segments.join("::");
350
351    if !stripped_path.is_empty() {
352        Ok(stripped_path.into())
353    } else {
354        Err(MalformedGenerics::MissingType)
355    }
356}
357
358/// Returns whether the first doc-comment is an inner attribute.
359///
360/// If there are no doc-comments, return true.
361/// FIXME(#78591): Support both inner and outer attributes on the same item.
362pub fn inner_docs(attrs: &[impl AttributeExt]) -> bool {
363    for attr in attrs {
364        if let Some(attr_style) = attr.doc_resolution_scope() {
365            return attr_style == ast::AttrStyle::Inner;
366        }
367    }
368    true
369}
370
371/// Has `#[rustc_doc_primitive]` or `#[doc(keyword)]`.
372pub fn has_primitive_or_keyword_docs(attrs: &[impl AttributeExt]) -> bool {
373    for attr in attrs {
374        if attr.has_name(sym::rustc_doc_primitive) {
375            return true;
376        } else if attr.has_name(sym::doc)
377            && let Some(items) = attr.meta_item_list()
378        {
379            for item in items {
380                if item.has_name(sym::keyword) {
381                    return true;
382                }
383            }
384        }
385    }
386    false
387}
388
389/// Simplified version of the corresponding function in rustdoc.
390/// If the rustdoc version returns a successful result, this function must return the same result.
391/// Otherwise this function may return anything.
392fn preprocess_link(link: &str) -> Box<str> {
393    let link = link.replace('`', "");
394    let link = link.split('#').next().unwrap();
395    let link = link.trim();
396    let link = link.rsplit('@').next().unwrap();
397    let link = link.strip_suffix("()").unwrap_or(link);
398    let link = link.strip_suffix("{}").unwrap_or(link);
399    let link = link.strip_suffix("[]").unwrap_or(link);
400    let link = if link != "!" { link.strip_suffix('!').unwrap_or(link) } else { link };
401    let link = link.trim();
402    strip_generics_from_path(link).unwrap_or_else(|_| link.into())
403}
404
405/// Keep inline and reference links `[]`,
406/// but skip autolinks `<>` which we never consider to be intra-doc links.
407pub fn may_be_doc_link(link_type: LinkType) -> bool {
408    match link_type {
409        LinkType::Inline
410        | LinkType::Reference
411        | LinkType::ReferenceUnknown
412        | LinkType::Collapsed
413        | LinkType::CollapsedUnknown
414        | LinkType::Shortcut
415        | LinkType::ShortcutUnknown => true,
416        LinkType::Autolink | LinkType::Email => false,
417    }
418}
419
420/// Simplified version of `preprocessed_markdown_links` from rustdoc.
421/// Must return at least the same links as it, but may add some more links on top of that.
422pub(crate) fn attrs_to_preprocessed_links<A: AttributeExt + Clone>(attrs: &[A]) -> Vec<Box<str>> {
423    let (doc_fragments, _) = attrs_to_doc_fragments(attrs.iter().map(|attr| (attr, None)), true);
424    let doc = prepare_to_doc_link_resolution(&doc_fragments).into_values().next().unwrap();
425
426    parse_links(&doc)
427}
428
429/// Similar version of `markdown_links` from rustdoc.
430/// This will collect destination links and display text if exists.
431fn parse_links<'md>(doc: &'md str) -> Vec<Box<str>> {
432    let mut broken_link_callback = |link: BrokenLink<'md>| Some((link.reference, "".into()));
433    let mut event_iter = Parser::new_with_broken_link_callback(
434        doc,
435        main_body_opts(),
436        Some(&mut broken_link_callback),
437    );
438    let mut links = Vec::new();
439
440    let mut refids = UnordSet::default();
441
442    while let Some(event) = event_iter.next() {
443        match event {
444            Event::Start(Tag::Link { link_type, dest_url, title: _, id })
445                if may_be_doc_link(link_type) =>
446            {
447                if matches!(
448                    link_type,
449                    LinkType::Inline
450                        | LinkType::ReferenceUnknown
451                        | LinkType::Reference
452                        | LinkType::Shortcut
453                        | LinkType::ShortcutUnknown
454                ) {
455                    if let Some(display_text) = collect_link_data(&mut event_iter) {
456                        links.push(display_text);
457                    }
458                }
459                if matches!(
460                    link_type,
461                    LinkType::Reference | LinkType::Shortcut | LinkType::Collapsed
462                ) {
463                    refids.insert(id);
464                }
465
466                links.push(preprocess_link(&dest_url));
467            }
468            _ => {}
469        }
470    }
471
472    for (label, refdef) in event_iter.reference_definitions().iter().sorted_by_key(|x| x.0) {
473        if !refids.contains(label) {
474            links.push(preprocess_link(&refdef.dest));
475        }
476    }
477
478    links
479}
480
481/// Collects additional data of link.
482fn collect_link_data<'input, F: BrokenLinkCallback<'input>>(
483    event_iter: &mut Parser<'input, F>,
484) -> Option<Box<str>> {
485    let mut display_text: Option<String> = None;
486    let mut append_text = |text: CowStr<'_>| {
487        if let Some(display_text) = &mut display_text {
488            display_text.push_str(&text);
489        } else {
490            display_text = Some(text.to_string());
491        }
492    };
493
494    while let Some(event) = event_iter.next() {
495        match event {
496            Event::Text(text) => {
497                append_text(text);
498            }
499            Event::Code(code) => {
500                append_text(code);
501            }
502            Event::End(_) => {
503                break;
504            }
505            _ => {}
506        }
507    }
508
509    display_text.map(String::into_boxed_str)
510}
511
512/// Returns a tuple containing a span encompassing all the document fragments and a boolean that is
513/// `true` if any of the fragments are from a macro expansion.
514pub fn span_of_fragments_with_expansion(fragments: &[DocFragment]) -> Option<(Span, bool)> {
515    let (first_fragment, last_fragment) = match fragments {
516        [] => return None,
517        [first, .., last] => (first, last),
518        [first] => (first, first),
519    };
520    if first_fragment.span == DUMMY_SP {
521        return None;
522    }
523    Some((
524        first_fragment.span.to(last_fragment.span),
525        fragments.iter().any(|frag| frag.from_expansion),
526    ))
527}
528
529/// Returns a span encompassing all the document fragments.
530pub fn span_of_fragments(fragments: &[DocFragment]) -> Option<Span> {
531    span_of_fragments_with_expansion(fragments).map(|(sp, _)| sp)
532}
533
534/// Attempts to match a range of bytes from parsed markdown to a `Span` in the source code.
535///
536/// This method does not always work, because markdown bytes don't necessarily match source bytes,
537/// like if escapes are used in the string. In this case, it returns `None`.
538///
539/// `markdown` is typically the entire documentation for an item,
540/// after combining fragments.
541///
542/// This method will return `Some` only if one of the following is true:
543///
544/// - The doc is made entirely from sugared doc comments, which cannot contain escapes
545/// - The doc is entirely from a single doc fragment with a string literal exactly equal to
546///   `markdown`.
547/// - The doc comes from `include_str!`
548/// - The doc includes exactly one substring matching `markdown[md_range]` which is contained in a
549///   single doc fragment.
550///
551/// This function is defined in the compiler so it can be used by both `rustdoc` and `clippy`.
552///
553/// It returns a tuple containing a span encompassing all the document fragments and a boolean that
554/// is `true` if any of the *matched* fragments are from a macro expansion.
555pub fn source_span_for_markdown_range(
556    tcx: TyCtxt<'_>,
557    markdown: &str,
558    md_range: &Range<usize>,
559    fragments: &[DocFragment],
560) -> Option<(Span, bool)> {
561    let map = tcx.sess.source_map();
562    source_span_for_markdown_range_inner(map, markdown, md_range, fragments)
563}
564
565// inner function used for unit testing
566pub fn source_span_for_markdown_range_inner(
567    map: &SourceMap,
568    markdown: &str,
569    md_range: &Range<usize>,
570    fragments: &[DocFragment],
571) -> Option<(Span, bool)> {
572    use rustc_span::BytePos;
573
574    if let &[fragment] = &fragments
575        && fragment.kind == DocFragmentKind::RawDoc
576        && let Ok(snippet) = map.span_to_snippet(fragment.span)
577        && snippet.trim_end() == markdown.trim_end()
578        && let Ok(md_range_lo) = u32::try_from(md_range.start)
579        && let Ok(md_range_hi) = u32::try_from(md_range.end)
580    {
581        // Single fragment with string that contains same bytes as doc.
582        return Some((
583            Span::new(
584                fragment.span.lo() + rustc_span::BytePos(md_range_lo),
585                fragment.span.lo() + rustc_span::BytePos(md_range_hi),
586                fragment.span.ctxt(),
587                fragment.span.parent(),
588            ),
589            fragment.from_expansion,
590        ));
591    }
592
593    let is_all_sugared_doc = fragments.iter().all(|frag| frag.kind == DocFragmentKind::SugaredDoc);
594
595    if !is_all_sugared_doc {
596        // This case ignores the markdown outside of the range so that it can
597        // work in cases where the markdown is made from several different
598        // doc fragments, but the target range does not span across multiple
599        // fragments.
600        let mut match_data = None;
601        let pat = &markdown[md_range.clone()];
602        // This heirustic doesn't make sense with a zero-sized range.
603        if pat.is_empty() {
604            return None;
605        }
606        for (i, fragment) in fragments.iter().enumerate() {
607            if let Ok(snippet) = map.span_to_snippet(fragment.span)
608                && let Some(match_start) = snippet.find(pat)
609            {
610                // If there is either a match in a previous fragment, or
611                // multiple matches in this fragment, there is ambiguity.
612                // the snippet cannot be zero-sized, because it matches
613                // the pattern, which is checked to not be zero sized.
614                if match_data.is_none()
615                    && !snippet.as_bytes()[match_start + 1..]
616                        .windows(pat.len())
617                        .any(|s| s == pat.as_bytes())
618                {
619                    match_data = Some((i, match_start));
620                } else {
621                    // Heuristic produced ambiguity, return nothing.
622                    return None;
623                }
624            }
625        }
626        if let Some((i, match_start)) = match_data {
627            let fragment = &fragments[i];
628            let sp = fragment.span;
629            // we need to calculate the span start,
630            // then use that in our calulations for the span end
631            let lo = sp.lo() + BytePos(match_start as u32);
632            return Some((
633                sp.with_lo(lo).with_hi(lo + BytePos((md_range.end - md_range.start) as u32)),
634                fragment.from_expansion,
635            ));
636        }
637        return None;
638    }
639
640    let snippet = map.span_to_snippet(span_of_fragments(fragments)?).ok()?;
641
642    let starting_line = markdown[..md_range.start].matches('\n').count();
643    let ending_line = starting_line + markdown[md_range.start..md_range.end].matches('\n').count();
644
645    // We use `split_terminator('\n')` instead of `lines()` when counting bytes so that we treat
646    // CRLF and LF line endings the same way.
647    let mut src_lines = snippet.split_terminator('\n');
648    let md_lines = markdown.split_terminator('\n');
649
650    // The number of bytes from the source span to the markdown span that are not part
651    // of the markdown, like comment markers.
652    let mut start_bytes = 0;
653    let mut end_bytes = 0;
654
655    'outer: for (line_no, md_line) in md_lines.enumerate() {
656        loop {
657            let source_line = src_lines.next()?;
658            match source_line.find(md_line) {
659                Some(offset) => {
660                    if line_no == starting_line {
661                        start_bytes += offset;
662
663                        if starting_line == ending_line {
664                            break 'outer;
665                        }
666                    } else if line_no == ending_line {
667                        end_bytes += offset;
668                        break 'outer;
669                    } else if line_no < starting_line {
670                        start_bytes += source_line.len() - md_line.len();
671                    } else {
672                        end_bytes += source_line.len() - md_line.len();
673                    }
674                    break;
675                }
676                None => {
677                    // Since this is a source line that doesn't include a markdown line,
678                    // we have to count the newline that we split from earlier.
679                    if line_no <= starting_line {
680                        start_bytes += source_line.len() + 1;
681                    } else {
682                        end_bytes += source_line.len() + 1;
683                    }
684                }
685            }
686        }
687    }
688
689    let (span, _) = span_of_fragments_with_expansion(fragments)?;
690    let src_span = span.from_inner(InnerSpan::new(
691        md_range.start + start_bytes,
692        md_range.end + start_bytes + end_bytes,
693    ));
694    Some((
695        src_span,
696        fragments.iter().any(|frag| frag.span.overlaps(src_span) && frag.from_expansion),
697    ))
698}