rustc_resolve/
rustdoc.rs

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