Skip to main content

rustdoc/passes/lint/
html_tags.rs

1//! Detects invalid HTML (like an unclosed `<span>`) in doc comments.
2
3use std::borrow::Cow;
4use std::iter::Peekable;
5use std::ops::Range;
6use std::str::CharIndices;
7
8use itertools::Itertools as _;
9use rustc_ast::attr::AttributeExt;
10use rustc_ast::token::{CommentKind, DocFragmentKind};
11use rustc_hir::HirId;
12use rustc_resolve::rustdoc::pulldown_cmark::{BrokenLink, Event, LinkType, Parser, Tag, TagEnd};
13use rustc_resolve::rustdoc::source_span_for_markdown_range;
14
15use crate::clean::*;
16use crate::core::DocContext;
17use crate::html::markdown::main_body_opts;
18
19pub(crate) fn visit_item(cx: &DocContext<'_>, item: &Item, hir_id: HirId, dox: &str) {
20    let tcx = cx.tcx;
21    let report_diag = |msg: String, range: &Range<usize>, mode: HtmlDiagMode| {
22        let sp = match source_span_for_markdown_range(tcx, dox, range, &item.attrs.doc_strings) {
23            Some((sp, _)) => sp,
24            None => item.attr_span(tcx),
25        };
26        tcx.emit_node_span_lint(
27            crate::lint::INVALID_HTML_TAGS,
28            hir_id,
29            sp,
30            rustc_errors::DiagDecorator(|lint| {
31                use rustc_lint::Applicability;
32
33                lint.primary_message(msg);
34
35                // If a tag looks like `<this>`, it might actually be a generic.
36                // We don't try to detect stuff `<like, this>` because that's not valid HTML,
37                // and we don't try to detect stuff `<like this>` because that's not valid Rust.
38                let mut generics_end = range.end;
39                if mode == HtmlDiagMode::Unclosed
40                    && dox[..generics_end].ends_with('>')
41                    && let Some(mut generics_start) = extract_path_backwards(dox, range.start)
42                {
43                    while generics_start != 0
44                        && generics_end < dox.len()
45                        && dox.as_bytes()[generics_start - 1] == b'<'
46                        && dox.as_bytes()[generics_end] == b'>'
47                    {
48                        generics_end += 1;
49                        generics_start -= 1;
50                        if let Some(new_start) = extract_path_backwards(dox, generics_start) {
51                            generics_start = new_start;
52                        }
53                        if let Some(new_end) = extract_path_forward(dox, generics_end) {
54                            generics_end = new_end;
55                        }
56                    }
57                    if let Some(new_end) = extract_path_forward(dox, generics_end) {
58                        generics_end = new_end;
59                    }
60                    let generics_sp = match source_span_for_markdown_range(
61                        tcx,
62                        dox,
63                        &(generics_start..generics_end),
64                        &item.attrs.doc_strings,
65                    ) {
66                        Some((sp, _)) => sp,
67                        None => item.attr_span(tcx),
68                    };
69                    // Sometimes, we only extract part of a path. For example, consider this:
70                    //
71                    //     <[u32] as IntoIter<u32>>::Item
72                    //                       ^^^^^ unclosed HTML tag `u32`
73                    //
74                    // We don't have any code for parsing fully-qualified trait paths.
75                    // In theory, we could add it, but doing it correctly would require
76                    // parsing the entire path grammar, which is problematic because of
77                    // overlap between the path grammar and Markdown.
78                    //
79                    // The example above shows that ambiguity. Is `[u32]` intended to be an
80                    // intra-doc link to the u32 primitive, or is it intended to be a slice?
81                    //
82                    // If the below conditional were removed, we would suggest this, which is
83                    // not what the user probably wants.
84                    //
85                    //     <[u32] as `IntoIter<u32>`>::Item
86                    //
87                    // We know that the user actually wants to wrap the whole thing in a code
88                    // block, but the only reason we know that is because `u32` does not, in
89                    // fact, implement IntoIter. If the example looks like this:
90                    //
91                    //     <[Vec<i32>] as IntoIter<i32>::Item
92                    //
93                    // The ideal fix would be significantly different.
94                    if (generics_start > 0 && dox.as_bytes()[generics_start - 1] == b'<')
95                        || (generics_end < dox.len() && dox.as_bytes()[generics_end] == b'>')
96                    {
97                        return;
98                    }
99                    // multipart form is chosen here because ``Vec<i32>`` would be confusing.
100                    lint.multipart_suggestion(
101                        "try marking as source code",
102                        vec![
103                            (generics_sp.shrink_to_lo(), String::from("`")),
104                            (generics_sp.shrink_to_hi(), String::from("`")),
105                        ],
106                        Applicability::MaybeIncorrect,
107                    );
108                } else if let HtmlDiagMode::Unopened { possible_pair: Some(possible_pair) } = mode {
109                    let (reason_display_text, reason_range) = match possible_pair.reason {
110                        HtmlOrMarkdownTag::Markdown(tag @ TagEnd::Paragraph, range)
111                            if dox.as_bytes().get(range.end) == Some(&b'>') =>
112                        {
113                            (
114                                format!(
115                                    "because the Markdown {} is interrupted by this block quote",
116                                    markdown_tag_name(tag)
117                                ),
118                                range.end..range.end,
119                            )
120                        }
121                        HtmlOrMarkdownTag::Markdown(
122                            tag @ (TagEnd::Paragraph | TagEnd::TableCell),
123                            range,
124                        ) => (
125                            format!("because the Markdown {} ends here", markdown_tag_name(tag)),
126                            range.end..range.end,
127                        ),
128                        HtmlOrMarkdownTag::Markdown(tag, range) => {
129                            (format!("because of this Markdown {}", markdown_tag_name(tag)), range)
130                        }
131                        HtmlOrMarkdownTag::Html(name, range) => {
132                            (format!("because of this HTML `{name}`"), range)
133                        }
134                    };
135                    if let HtmlOrMarkdownTag::Html(_, unclosed_tag_range) =
136                        possible_pair.unclosed_tag
137                        && let Some((unclosed_tag_span, _)) = source_span_for_markdown_range(
138                            tcx,
139                            dox,
140                            &unclosed_tag_range,
141                            &item.attrs.doc_strings,
142                        )
143                    {
144                        lint.span_label(sp, "this unopened tag");
145                        lint.span_label(unclosed_tag_span, "does not match this unclosed tag");
146                    }
147                    if let Some((reason_span, _)) = source_span_for_markdown_range(
148                        tcx,
149                        dox,
150                        &reason_range,
151                        &item.attrs.doc_strings,
152                    ) {
153                        lint.span_label(reason_span, reason_display_text);
154                    }
155                } else if let HtmlDiagMode::MarkdownNestedInRawText(html_tag_range, html_tag) = mode {
156                    lint.span_label(sp, format!("Markdown translates this into HTML, but the browser parses it as {language}", language = html_tag.language()));
157                    if
158                        // get the span for this diagnostic, if possible
159                        let Some((html_tag_span, _)) = source_span_for_markdown_range(
160                            tcx,
161                            dox,
162                            &html_tag_range,
163                            &item.attrs.doc_strings,
164                        ) &&
165                        // this suggestion is only implemented for line doc comments
166                        item.attrs.doc_strings.iter().all(|f| f.kind == DocFragmentKind::Sugared(CommentKind::Line)) &&
167                        // this suggestion is only implemented if every line doc comment has the same position (either outer or inner)
168                        let Some(def_id) = item.def_id() &&
169                        let mut style_iter = inline::load_attrs(cx.tcx, def_id).iter().filter_map(|attr| attr.doc_resolution_scope()) &&
170                        let Some(doc_attr_style) = style_iter.next() &&
171                        style_iter.all(|style| style == doc_attr_style)
172                    {
173                        lint.span_suggestion(
174                            html_tag_span,
175                            "to turn off Markdown parsing, put the tag at the start of the line",
176                            format!("\n{mark} {doc}", mark=doc_attr_style.line_doc_comment_prefix(), doc=&dox[html_tag_range.clone()]),
177                            Applicability::MachineApplicable,
178                        );
179                    }
180                }
181            }),
182        );
183    };
184
185    let mut tagp = TagParser::new();
186    let mut is_in_comment = None;
187
188    let link_names = item.link_names(&cx.cache);
189
190    let mut replacer = |broken_link: BrokenLink<'_>| {
191        if let Some(link) =
192            link_names.iter().find(|link| *link.original_text == *broken_link.reference)
193        {
194            Some((link.href.as_str().into(), link.new_text.to_string().into()))
195        } else if matches!(&broken_link.link_type, LinkType::Reference | LinkType::ReferenceUnknown)
196        {
197            // If the link is shaped [like][this], suppress any broken HTML in the [this] part.
198            // The `broken_intra_doc_links` will report typos in there anyway.
199            Some((
200                broken_link.reference.to_string().into(),
201                broken_link.reference.to_string().into(),
202            ))
203        } else {
204            None
205        }
206    };
207
208    let p = Parser::new_with_broken_link_callback(dox, main_body_opts(), Some(&mut replacer))
209        .into_offset_iter()
210        .coalesce(|a, b| {
211            // for some reason, pulldown-cmark splits html blocks into separate events for each line.
212            // we undo this, in order to handle multi-line tags.
213            match (a, b) {
214                ((Event::Html(_), ra), (Event::Html(_), rb)) if ra.end == rb.start => {
215                    let merged = ra.start..rb.end;
216                    Ok((Event::Html(Cow::Borrowed(&dox[merged.clone()]).into()), merged))
217                }
218                x => Err(x),
219            }
220        });
221
222    for (event, range) in p {
223        match event {
224            Event::Html(text) | Event::InlineHtml(text) => {
225                tagp.extract_tags(&text, range, &mut is_in_comment, &report_diag)
226            }
227            Event::Start(Tag::HtmlBlock) | Event::End(TagEnd::HtmlBlock) => {}
228            Event::Start(tag) => {
229                tagp.push_markdown_tag(tag.into(), range, &report_diag);
230            }
231            Event::End(tag) => {
232                tagp.pop_markdown_tag(tag, range, &report_diag);
233            }
234            _ => {}
235        }
236    }
237
238    if let Some(range) = is_in_comment {
239        report_diag("Unclosed HTML comment".to_string(), &range, HtmlDiagMode::Incomplete);
240    } else if let &Some(quote_pos) = &tagp.quote_pos {
241        let qr = Range { start: quote_pos, end: quote_pos };
242        report_diag(
243            format!("unclosed quoted HTML attribute on tag `{}`", &tagp.tag_name),
244            &qr,
245            HtmlDiagMode::Incomplete,
246        );
247    } else {
248        if !tagp.tag_name.is_empty() {
249            report_diag(
250                format!("incomplete HTML tag `{}`", &tagp.tag_name),
251                &(tagp.tag_start_pos..dox.len()),
252                HtmlDiagMode::Incomplete,
253            );
254        }
255        for tag in tagp.tags.iter().chain(
256            tagp.unclosed_tag_buf
257                .iter()
258                .map(|buffered_unclosed_tag| &buffered_unclosed_tag.unclosed_tag),
259        ) {
260            match tag {
261                HtmlOrMarkdownTag::Html(tag, range) => {
262                    if !is_implicitly_self_closing(&tag.to_ascii_lowercase()) {
263                        report_diag(
264                            format!("unclosed HTML tag `{tag}`"),
265                            range,
266                            HtmlDiagMode::Unclosed,
267                        );
268                    }
269                }
270                HtmlOrMarkdownTag::Markdown(tag, range) => {
271                    report_diag(
272                        format!(
273                            "invalid tree with Markdown delimiter `{tag_name}`",
274                            tag_name = markdown_tag_name(*tag)
275                        ),
276                        range,
277                        HtmlDiagMode::Unclosed,
278                    );
279                }
280            }
281        }
282    }
283}
284
285/// These tags are interpreted as self-closing if they lack an explicit closing tag.
286const ALLOWED_UNCLOSED: &[&str] = &[
287    "area", "base", "br", "col", "embed", "hr", "img", "input", "keygen", "link", "meta", "param",
288    "source", "track", "wbr",
289];
290
291/// Allows constructs like `<img>`, but not `<img`.
292fn is_implicitly_self_closing(tag_name: &str) -> bool {
293    ALLOWED_UNCLOSED.contains(&tag_name)
294}
295
296fn extract_path_backwards(text: &str, end_pos: usize) -> Option<usize> {
297    use rustc_lexer::{is_id_continue, is_id_start};
298    let mut current_pos = end_pos;
299    loop {
300        if current_pos >= 2 && text[..current_pos].ends_with("::") {
301            current_pos -= 2;
302        }
303        let new_pos = text[..current_pos]
304            .char_indices()
305            .rev()
306            .take_while(|(_, c)| is_id_start(*c) || is_id_continue(*c))
307            .reduce(|_accum, item| item)
308            .and_then(|(new_pos, c)| is_id_start(c).then_some(new_pos));
309        if let Some(new_pos) = new_pos
310            && current_pos != new_pos
311        {
312            current_pos = new_pos;
313            continue;
314        }
315        break;
316    }
317    if current_pos == end_pos { None } else { Some(current_pos) }
318}
319
320fn extract_path_forward(text: &str, start_pos: usize) -> Option<usize> {
321    use rustc_lexer::{is_id_continue, is_id_start};
322    let mut current_pos = start_pos;
323    loop {
324        if current_pos < text.len() && text[current_pos..].starts_with("::") {
325            current_pos += 2;
326        } else {
327            break;
328        }
329        let mut chars = text[current_pos..].chars();
330        if let Some(c) = chars.next() {
331            if is_id_start(c) {
332                current_pos += c.len_utf8();
333            } else {
334                break;
335            }
336        }
337        for c in chars {
338            if is_id_continue(c) {
339                current_pos += c.len_utf8();
340            } else {
341                break;
342            }
343        }
344    }
345    if current_pos == start_pos { None } else { Some(current_pos) }
346}
347
348fn is_valid_for_html_tag_name(c: char, is_empty: bool) -> bool {
349    // https://spec.commonmark.org/0.30/#raw-html
350    //
351    // > A tag name consists of an ASCII letter followed by zero or more ASCII letters, digits, or
352    // > hyphens (-).
353    c.is_ascii_alphabetic() || !is_empty && (c == '-' || c.is_ascii_digit())
354}
355
356#[derive(Eq, PartialEq, Debug, Clone)]
357enum HtmlOrMarkdownTag {
358    Html(String, Range<usize>),
359    Markdown(TagEnd, Range<usize>),
360}
361
362impl HtmlOrMarkdownTag {
363    fn range(&self) -> Range<usize> {
364        match self {
365            HtmlOrMarkdownTag::Html(_, range) => range.clone(),
366            HtmlOrMarkdownTag::Markdown(_, range) => range.clone(),
367        }
368    }
369}
370
371#[derive(Eq, PartialEq, Debug, Clone)]
372struct BufferedUnclosedTag {
373    unclosed_tag: HtmlOrMarkdownTag,
374    reason: HtmlOrMarkdownTag,
375}
376
377#[derive(Eq, PartialEq, Debug, Clone, Copy)]
378enum HtmlRawTextTag {
379    Script,
380    Style,
381}
382
383impl HtmlRawTextTag {
384    fn name(self) -> &'static str {
385        match self {
386            HtmlRawTextTag::Script => "script",
387            HtmlRawTextTag::Style => "style",
388        }
389    }
390    fn language(self) -> &'static str {
391        match self {
392            HtmlRawTextTag::Script => "JavaScript",
393            HtmlRawTextTag::Style => "CSS",
394        }
395    }
396    fn from_tag(tag: &str) -> Option<HtmlRawTextTag> {
397        match &tag.to_ascii_lowercase() {
398            "script" => Some(HtmlRawTextTag::Script),
399            "style" => Some(HtmlRawTextTag::Style),
400            _ => None,
401        }
402    }
403}
404
405#[derive(Eq, PartialEq, Debug, Clone)]
406enum HtmlDiagMode {
407    Unclosed,
408    Unopened { possible_pair: Option<BufferedUnclosedTag> },
409    Incomplete,
410    MarkdownNestedInRawText(Range<usize>, HtmlRawTextTag),
411}
412
413/// Parse html tags to ensure they are well-formed
414#[derive(Debug, Clone)]
415struct TagParser {
416    tags: Vec<HtmlOrMarkdownTag>,
417    unclosed_tag_buf: Vec<BufferedUnclosedTag>,
418    /// Name of the tag that is being parsed, if we are within a tag.
419    ///
420    /// Since the `<` and name of a tag must appear on the same line with no whitespace,
421    /// if this is the empty string, we are not in a tag.
422    tag_name: String,
423    tag_start_pos: usize,
424    is_closing: bool,
425    /// `true` if we are within a tag, but not within its name.
426    in_attrs: bool,
427    /// If we are in a quoted attribute, what quote char does it use?
428    ///
429    /// This needs to be stored in the struct since HTML5 allows newlines in quoted attrs.
430    quote: Option<char>,
431    quote_pos: Option<usize>,
432    after_eq: bool,
433}
434
435impl TagParser {
436    fn new() -> Self {
437        Self {
438            tags: Vec::new(),
439            unclosed_tag_buf: Vec::new(),
440            tag_name: String::with_capacity(8),
441            tag_start_pos: 0,
442            is_closing: false,
443            in_attrs: false,
444            quote: None,
445            quote_pos: None,
446            after_eq: false,
447        }
448    }
449
450    fn drop_tag(&mut self, range: Range<usize>, f: &impl Fn(String, &Range<usize>, HtmlDiagMode)) {
451        let tag_name_low = self.tag_name.to_ascii_lowercase();
452        let tag_name_is_match = |tag: &HtmlOrMarkdownTag| match tag {
453            HtmlOrMarkdownTag::Html(name, _span) => name.to_ascii_lowercase() == tag_name_low,
454            HtmlOrMarkdownTag::Markdown(..) => false,
455        };
456        if let Some(pos) = self.tags.iter().rposition(tag_name_is_match) {
457            // If the tag is nested inside a "<script>" or a "<style>" tag, no warning should
458            // be emitted.
459            let should_not_warn = self.tags.iter().take(pos + 1).any(|tag| match tag {
460                HtmlOrMarkdownTag::Html(at, _span) => HtmlRawTextTag::from_tag(at).is_some(),
461                HtmlOrMarkdownTag::Markdown(..) => false,
462            });
463            if should_not_warn {
464                // HTML tags nested within <script> should just be ignored.
465                //
466                // Markdown tags already produce a warning when added as children of
467                // raw text HTML elements, so we want to avoid producing a redundant
468                // warning for improper nesting.
469                self.tags
470                    .extract_if(pos.., |tag| matches!(tag, HtmlOrMarkdownTag::Html(..)))
471                    .for_each(|_| ());
472            } else {
473                let (HtmlOrMarkdownTag::Html(_, start_range)
474                | HtmlOrMarkdownTag::Markdown(_, start_range)) = &self.tags[pos];
475                let range = start_range.start..range.end;
476                // `tags` is used as a queue, meaning that everything after `pos` is included inside it.
477                // So `<h2><h3></h2>` will look like `["h2", "h3"]`. So when closing `h2`, we will still
478                // have `h3`, meaning the tag wasn't closed as it should have.
479                self.unclosed_tag_buf.extend(self.tags.drain(pos + 1..).map(|unclosed_tag| {
480                    BufferedUnclosedTag {
481                        unclosed_tag,
482                        reason: HtmlOrMarkdownTag::Html(self.tag_name.clone(), range.clone()),
483                    }
484                }));
485                // Remove the `tag_name` that was originally closed
486                self.tags.pop();
487            }
488        } else if !self.tags.iter().any(|tag| match tag {
489            HtmlOrMarkdownTag::Html(at, _span) => HtmlRawTextTag::from_tag(at).is_some(),
490            HtmlOrMarkdownTag::Markdown(..) => false,
491        }) {
492            // It can happen for example in this case: `<h2></script></h2>` (the `h2` tag isn't required
493            // but it helps for the visualization).
494            let mode = HtmlDiagMode::Unopened {
495                possible_pair: self
496                    .unclosed_tag_buf
497                    .iter()
498                    .rposition(|buf| tag_name_is_match(&buf.unclosed_tag))
499                    .map(|pos| self.unclosed_tag_buf.remove(pos)),
500            };
501            f(format!("unopened HTML tag `{}`", &self.tag_name), &range, mode);
502        }
503    }
504
505    /// Handle a `<` that appeared while parsing a tag.
506    fn handle_lt_in_tag(
507        &mut self,
508        range: Range<usize>,
509        lt_pos: usize,
510        f: &impl Fn(String, &Range<usize>, HtmlDiagMode),
511    ) {
512        let global_pos = range.start + lt_pos;
513        // is this check needed?
514        if global_pos == self.tag_start_pos {
515            // `<` is in the tag because it is the start.
516            return;
517        }
518        // tried to start a new tag while in a tag
519        f(
520            format!("incomplete HTML tag `{}`", &self.tag_name),
521            &(self.tag_start_pos..global_pos),
522            HtmlDiagMode::Incomplete,
523        );
524        self.tag_parsed();
525    }
526
527    fn extract_html_tag(
528        &mut self,
529        text: &str,
530        range: &Range<usize>,
531        start_pos: usize,
532        iter: &mut Peekable<CharIndices<'_>>,
533        f: &impl Fn(String, &Range<usize>, HtmlDiagMode),
534    ) {
535        let mut prev_pos = start_pos;
536
537        'outer_loop: loop {
538            let (pos, c) = match iter.peek() {
539                Some((pos, c)) => (*pos, *c),
540                // In case we reached the of the doc comment, we want to check that it's an
541                // unclosed HTML tag. For example "/// <h3".
542                None if self.tag_name.is_empty() => (prev_pos, '\0'),
543                None => break,
544            };
545            prev_pos = pos;
546            if c == '/' && self.tag_name.is_empty() {
547                // Checking if this is a closing tag (like `</a>` for `<a>`).
548                self.is_closing = true;
549            } else if !self.in_attrs && is_valid_for_html_tag_name(c, self.tag_name.is_empty()) {
550                self.tag_name.push(c);
551            } else {
552                if !self.tag_name.is_empty() {
553                    self.in_attrs = true;
554                    // range of the entire tag within dox
555                    let mut r = Range { start: range.start + start_pos, end: range.start + pos };
556                    if c == '>' {
557                        // In case we have a tag without attribute, we can consider the span to
558                        // refer to it fully.
559                        r.end += 1;
560                    }
561                    if self.is_closing {
562                        // In case we have "</div >" or even "</div         >".
563                        if c != '>' {
564                            if !c.is_whitespace() {
565                                // It seems like it's not a valid HTML tag.
566                                break;
567                            }
568                            let mut found = false;
569                            for (new_pos, c) in text[pos..].char_indices() {
570                                if !c.is_whitespace() {
571                                    if c == '>' {
572                                        r.end = range.start + pos + new_pos + 1;
573                                        found = true;
574                                    } else if c == '<' {
575                                        self.handle_lt_in_tag(range.clone(), pos + new_pos, f);
576                                    }
577                                    break;
578                                }
579                            }
580                            if !found {
581                                break 'outer_loop;
582                            }
583                        }
584                        self.drop_tag(r, f);
585                        self.tag_parsed();
586                    } else {
587                        self.extract_opening_tag(text, range, r, pos, c, iter, f)
588                    }
589                }
590                break;
591            }
592            iter.next();
593        }
594    }
595
596    fn extract_opening_tag(
597        &mut self,
598        text: &str,
599        range: &Range<usize>,
600        r: Range<usize>,
601        pos: usize,
602        c: char,
603        iter: &mut Peekable<CharIndices<'_>>,
604        f: &impl Fn(String, &Range<usize>, HtmlDiagMode),
605    ) {
606        // we can store this as a local, since html5 does require the `/` and `>`
607        // to not be separated by whitespace.
608        let mut is_self_closing = false;
609        if c != '>' {
610            'parse_til_gt: {
611                for (i, c) in text[pos..].char_indices() {
612                    if !c.is_whitespace() {
613                        debug_assert_eq!(self.quote_pos.is_some(), self.quote.is_some());
614                        if let Some(q) = self.quote {
615                            if c == q {
616                                self.quote = None;
617                                self.quote_pos = None;
618                                self.after_eq = false;
619                            }
620                        } else if c == '>' {
621                            break 'parse_til_gt;
622                        } else if c == '<' {
623                            self.handle_lt_in_tag(range.clone(), pos + i, f);
624                        } else if c == '/' && !self.after_eq {
625                            is_self_closing = true;
626                        } else {
627                            if is_self_closing {
628                                is_self_closing = false;
629                            }
630                            if (c == '"' || c == '\'') && self.after_eq {
631                                self.quote = Some(c);
632                                self.quote_pos = Some(pos + i);
633                            } else if c == '=' {
634                                self.after_eq = true;
635                            }
636                        }
637                    } else if self.quote.is_none() {
638                        self.after_eq = false;
639                    }
640                    if !is_self_closing && !self.tag_name.is_empty() {
641                        iter.next();
642                    }
643                }
644                // if we've run out of text but still haven't found a `>`,
645                // return early without calling `tag_parsed` or emitting lints.
646                // this allows us to either find the `>` in a later event
647                // or emit a lint about it being missing.
648                return;
649            }
650        }
651        if is_self_closing {
652            // https://html.spec.whatwg.org/#parse-error-non-void-html-element-start-tag-with-trailing-solidus
653            let valid = ALLOWED_UNCLOSED.contains(&&self.tag_name[..])
654                || self.tags.iter().take(pos + 1).any(|tag| match tag {
655                    HtmlOrMarkdownTag::Html(at, _) => {
656                        let at = at.to_ascii_lowercase();
657                        at == "svg" || at == "math"
658                    }
659                    HtmlOrMarkdownTag::Markdown(..) => false,
660                });
661            if !valid {
662                f(
663                    format!("invalid self-closing HTML tag `{}`", self.tag_name),
664                    &r,
665                    HtmlDiagMode::Incomplete,
666                );
667            }
668        } else if !self.tag_name.is_empty() {
669            self.tags.push(HtmlOrMarkdownTag::Html(std::mem::take(&mut self.tag_name), r));
670        }
671        self.tag_parsed();
672    }
673    /// Finished parsing a tag, reset related data.
674    fn tag_parsed(&mut self) {
675        self.tag_name.clear();
676        self.is_closing = false;
677        self.in_attrs = false;
678    }
679
680    fn extract_tags(
681        &mut self,
682        text: &str,
683        range: Range<usize>,
684        is_in_comment: &mut Option<Range<usize>>,
685        f: &impl Fn(String, &Range<usize>, HtmlDiagMode),
686    ) {
687        let mut iter = text.char_indices().peekable();
688        let mut prev_pos = 0;
689        loop {
690            if self.quote.is_some() {
691                debug_assert!(self.in_attrs && self.quote_pos.is_some());
692            }
693            if self.in_attrs
694                && let Some(&(start_pos, _)) = iter.peek()
695            {
696                self.extract_html_tag(text, &range, start_pos, &mut iter, f);
697                // if no progress is being made, move forward forcefully.
698                if prev_pos == start_pos {
699                    iter.next();
700                }
701                prev_pos = start_pos;
702                continue;
703            }
704            let Some((start_pos, c)) = iter.next() else { break };
705            if is_in_comment.is_some() {
706                if text[start_pos..].starts_with("-->") {
707                    *is_in_comment = None;
708                }
709            } else if c == '<' {
710                // "<!--" is a valid attribute name under html5, so don't treat it as a comment if we're in a tag.
711                if self.tag_name.is_empty() && text[start_pos..].starts_with("<!--") {
712                    // We skip the "!--" part. (Once `advance_by` is stable, might be nice to use it!)
713                    iter.next();
714                    iter.next();
715                    iter.next();
716                    *is_in_comment = Some(Range {
717                        start: range.start + start_pos,
718                        end: range.start + start_pos + 4,
719                    });
720                } else {
721                    if self.tag_name.is_empty() {
722                        self.tag_start_pos = range.start + start_pos;
723                    }
724                    self.extract_html_tag(text, &range, start_pos, &mut iter, f);
725                }
726            } else if !self.tag_name.is_empty() {
727                // partially inside html tag that spans across events
728                self.extract_html_tag(text, &range, start_pos, &mut iter, f);
729            }
730        }
731    }
732
733    fn push_markdown_tag(
734        &mut self,
735        tag: TagEnd,
736        range: Range<usize>,
737        f: &impl Fn(String, &Range<usize>, HtmlDiagMode),
738    ) {
739        // If the tag is nested inside a "<script>" or a "<style>" tag, unconditionally warn.
740        let script_or_style_tag = self.tags.iter().find_map(|tag| match tag {
741            HtmlOrMarkdownTag::Html(at, _span) => {
742                Some((HtmlRawTextTag::from_tag(at)?, tag.range()))
743            }
744            HtmlOrMarkdownTag::Markdown(..) => None,
745        });
746        if let Some((html_tag, tag_range)) = script_or_style_tag {
747            f(
748                format!(
749                    "nested Markdown {} in HTML `{}` tag",
750                    markdown_tag_name(tag),
751                    html_tag.name()
752                ),
753                &range,
754                HtmlDiagMode::MarkdownNestedInRawText(tag_range.clone(), html_tag),
755            );
756        }
757        self.tags.push(HtmlOrMarkdownTag::Markdown(tag, range));
758    }
759
760    fn pop_markdown_tag(
761        &mut self,
762        tag_end: TagEnd,
763        range: Range<usize>,
764        f: &impl Fn(String, &Range<usize>, HtmlDiagMode),
765    ) {
766        let tag_is_match = |tag: &HtmlOrMarkdownTag| match tag {
767            HtmlOrMarkdownTag::Html(..) => false,
768            HtmlOrMarkdownTag::Markdown(last_tag, _span) => *last_tag == tag_end,
769        };
770        if let Some(pos) = self.tags.iter().rposition(tag_is_match) {
771            // If the tag is interleaved with a "<script>" or a "<style>" tag,
772            // give a different warning.
773            //
774            // Notice the `skip(pos + 1)` is here to catch `*a <script> b*`:
775            // the case where an MD is *properly* nested within the tag is already
776            // covered by `push_markdown_tag`.
777            let script_or_style_tag = self.tags.iter().skip(pos + 1).find_map(|tag| match tag {
778                HtmlOrMarkdownTag::Html(at, _span) => {
779                    Some((HtmlRawTextTag::from_tag(at)?, tag.range()))
780                }
781                HtmlOrMarkdownTag::Markdown(..) => None,
782            });
783            if let Some((tag, tag_range)) = script_or_style_tag {
784                f(
785                    format!(
786                        "improperly nested Markdown {} in HTML `{}` tag",
787                        markdown_tag_name(tag_end),
788                        tag.name()
789                    ),
790                    &range,
791                    HtmlDiagMode::MarkdownNestedInRawText(tag_range.clone(), tag),
792                );
793                self.tags.truncate(pos);
794                // Do not implicitly close a raw text tag when its nesting Markdown closes it.
795                // This silences the "unopened script tag" warning that you would get from:
796                //
797                //     <script>a *b c</script> d*
798                self.tags.push(HtmlOrMarkdownTag::Html(tag.name().to_owned(), tag_range));
799            } else {
800                // `tags` is used as a queue, meaning that everything after `pos` is included inside it.
801                // So `*<span>*` will look like `["*", "span"]`. So when closing `*`, we will still
802                // have `span`, meaning the tag wasn't closed as it should have.
803                self.unclosed_tag_buf.extend(self.tags.drain(pos + 1..).map(|unclosed_tag| {
804                    BufferedUnclosedTag {
805                        unclosed_tag,
806                        reason: HtmlOrMarkdownTag::Markdown(tag_end, range.clone()),
807                    }
808                }));
809                // Remove the tag that was originally closed
810                self.tags.pop();
811            }
812        } else {
813            // It can happen for example in this case: `<h2></script></h2>` (the `h2` tag isn't required
814            // but it helps for the visualization).
815            let mode = HtmlDiagMode::Unopened {
816                possible_pair: self
817                    .unclosed_tag_buf
818                    .iter()
819                    .rposition(|buf| tag_is_match(&buf.unclosed_tag))
820                    .map(|pos| self.unclosed_tag_buf.remove(pos)),
821            };
822            f(format!("improperly nested Markdown {}", markdown_tag_name(tag_end)), &range, mode);
823        }
824    }
825}
826
827fn markdown_tag_name(tag: TagEnd) -> &'static str {
828    match tag {
829        TagEnd::Paragraph => "paragraph",
830        TagEnd::Heading(..) => "heading",
831        TagEnd::BlockQuote => "block quote `>`",
832        TagEnd::CodeBlock => "code block",
833        TagEnd::HtmlBlock => "HTML",
834        TagEnd::List(true) => "numbered list",
835        TagEnd::List(false) => "bulleted list",
836        TagEnd::Item => "list item",
837        TagEnd::FootnoteDefinition => "footnote definition",
838        TagEnd::Table => "table",
839        TagEnd::TableHead => "table head",
840        TagEnd::TableRow => "table row",
841        TagEnd::TableCell => "table cell",
842        TagEnd::Emphasis => "emphasis",
843        TagEnd::Strong => "strong emphasis",
844        TagEnd::Strikethrough => "strikethrough",
845        TagEnd::Link => "link",
846        TagEnd::Image => "image",
847        TagEnd::MetadataBlock(..) => "front matter",
848    }
849}
850
851#[cfg(test)]
852mod tests;