Skip to content

Commit e6027a4

Browse files
Add unclosed_html_tags lint
1 parent 7820135 commit e6027a4

File tree

8 files changed

+213
-9
lines changed

8 files changed

+213
-9
lines changed

compiler/rustc_lint/src/lib.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ use rustc_middle::ty::query::Providers;
6363
use rustc_middle::ty::TyCtxt;
6464
use rustc_session::lint::builtin::{
6565
BARE_TRAIT_OBJECTS, BROKEN_INTRA_DOC_LINKS, ELIDED_LIFETIMES_IN_PATHS,
66-
EXPLICIT_OUTLIVES_REQUIREMENTS, INVALID_CODEBLOCK_ATTRIBUTES, MISSING_DOC_CODE_EXAMPLES,
67-
PRIVATE_DOC_TESTS,
66+
EXPLICIT_OUTLIVES_REQUIREMENTS, INVALID_CODEBLOCK_ATTRIBUTES, INVALID_HTML_TAGS,
67+
MISSING_DOC_CODE_EXAMPLES, PRIVATE_DOC_TESTS,
6868
};
6969
use rustc_span::symbol::{Ident, Symbol};
7070
use rustc_span::Span;
@@ -308,7 +308,8 @@ fn register_builtins(store: &mut LintStore, no_interleave_lints: bool) {
308308
PRIVATE_INTRA_DOC_LINKS,
309309
INVALID_CODEBLOCK_ATTRIBUTES,
310310
MISSING_DOC_CODE_EXAMPLES,
311-
PRIVATE_DOC_TESTS
311+
PRIVATE_DOC_TESTS,
312+
INVALID_HTML_TAGS
312313
);
313314

314315
// Register renamed and removed lints.

compiler/rustc_session/src/lint/builtin.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1881,6 +1881,16 @@ declare_lint! {
18811881
"detects code samples in docs of private items not documented by rustdoc"
18821882
}
18831883

1884+
declare_lint! {
1885+
/// The `invalid_html_tags` lint detects invalid HTML tags. This is a
1886+
/// `rustdoc` only lint, see the documentation in the [rustdoc book].
1887+
///
1888+
/// [rustdoc book]: ../../../rustdoc/lints.html#invalid_html_tags
1889+
pub INVALID_HTML_TAGS,
1890+
Warn,
1891+
"detects invalid HTML tags in doc comments"
1892+
}
1893+
18841894
declare_lint! {
18851895
/// The `where_clauses_object_safety` lint detects for [object safety] of
18861896
/// [where clauses].
@@ -2699,6 +2709,7 @@ declare_lint_pass! {
26992709
INVALID_CODEBLOCK_ATTRIBUTES,
27002710
MISSING_CRATE_LEVEL_DOCS,
27012711
MISSING_DOC_CODE_EXAMPLES,
2712+
INVALID_HTML_TAGS,
27022713
PRIVATE_DOC_TESTS,
27032714
WHERE_CLAUSES_OBJECT_SAFETY,
27042715
PROC_MACRO_DERIVE_RESOLUTION_FALLBACK,

src/librustdoc/core.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ pub fn run_core(
328328
let private_doc_tests = rustc_lint::builtin::PRIVATE_DOC_TESTS.name;
329329
let no_crate_level_docs = rustc_lint::builtin::MISSING_CRATE_LEVEL_DOCS.name;
330330
let invalid_codeblock_attributes_name = rustc_lint::builtin::INVALID_CODEBLOCK_ATTRIBUTES.name;
331+
let invalid_html_tags = rustc_lint::builtin::INVALID_HTML_TAGS.name;
331332
let renamed_and_removed_lints = rustc_lint::builtin::RENAMED_AND_REMOVED_LINTS.name;
332333
let unknown_lints = rustc_lint::builtin::UNKNOWN_LINTS.name;
333334

@@ -340,6 +341,7 @@ pub fn run_core(
340341
private_doc_tests.to_owned(),
341342
no_crate_level_docs.to_owned(),
342343
invalid_codeblock_attributes_name.to_owned(),
344+
invalid_html_tags.to_owned(),
343345
renamed_and_removed_lints.to_owned(),
344346
unknown_lints.to_owned(),
345347
];

src/librustdoc/html/markdown.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ use pulldown_cmark::{html, BrokenLink, CodeBlockKind, CowStr, Event, Options, Pa
4343
#[cfg(test)]
4444
mod tests;
4545

46-
fn opts() -> Options {
46+
pub(crate) fn opts() -> Options {
4747
Options::ENABLE_TABLES | Options::ENABLE_FOOTNOTES | Options::ENABLE_STRIKETHROUGH
4848
}
4949

src/librustdoc/passes/html_tags.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
use super::{span_of_attrs, Pass};
2+
use crate::clean::*;
3+
use crate::core::DocContext;
4+
use crate::fold::DocFolder;
5+
use crate::html::markdown::opts;
6+
use pulldown_cmark::{Event, Parser};
7+
use rustc_hir::hir_id::HirId;
8+
use rustc_session::lint;
9+
use rustc_span::Span;
10+
11+
pub const CHECK_INVALID_HTML_TAGS: Pass = Pass {
12+
name: "check-invalid-html-tags",
13+
run: check_invalid_html_tags,
14+
description: "detects invalid HTML tags in doc comments",
15+
};
16+
17+
struct InvalidHtmlTagsLinter<'a, 'tcx> {
18+
cx: &'a DocContext<'tcx>,
19+
}
20+
21+
impl<'a, 'tcx> InvalidHtmlTagsLinter<'a, 'tcx> {
22+
fn new(cx: &'a DocContext<'tcx>) -> Self {
23+
InvalidHtmlTagsLinter { cx }
24+
}
25+
}
26+
27+
pub fn check_invalid_html_tags(krate: Crate, cx: &DocContext<'_>) -> Crate {
28+
let mut coll = InvalidHtmlTagsLinter::new(cx);
29+
30+
coll.fold_crate(krate)
31+
}
32+
33+
const ALLOWED_UNCLOSED: &[&str] = &[
34+
"area", "base", "br", "col", "embed", "hr", "img", "input", "keygen", "link", "meta", "param",
35+
"source", "track", "wbr",
36+
];
37+
38+
fn drop_tag(
39+
cx: &DocContext<'_>,
40+
tags: &mut Vec<String>,
41+
tag_name: String,
42+
hir_id: HirId,
43+
sp: Span,
44+
) {
45+
if let Some(pos) = tags.iter().position(|t| *t == tag_name) {
46+
for _ in pos + 1..tags.len() {
47+
if ALLOWED_UNCLOSED.iter().find(|&at| at == &tags[pos + 1]).is_some() {
48+
continue;
49+
}
50+
// `tags` is used as a queue, meaning that everything after `pos` is included inside it.
51+
// So `<h2><h3></h2>` will look like `["h2", "h3"]`. So when closing `h2`, we will still
52+
// have `h3`, meaning the tag wasn't closed as it should have.
53+
cx.tcx.struct_span_lint_hir(lint::builtin::INVALID_HTML_TAGS, hir_id, sp, |lint| {
54+
lint.build(&format!("unclosed HTML tag `{}`", tags[pos + 1])).emit()
55+
});
56+
tags.remove(pos + 1);
57+
}
58+
tags.remove(pos);
59+
} else {
60+
// It can happen for example in this case: `<h2></script></h2>` (the `h2` tag isn't required
61+
// but it helps for the visualization).
62+
cx.tcx.struct_span_lint_hir(lint::builtin::INVALID_HTML_TAGS, hir_id, sp, |lint| {
63+
lint.build(&format!("unopened HTML tag `{}`", tag_name)).emit()
64+
});
65+
}
66+
}
67+
68+
fn extract_tag(cx: &DocContext<'_>, tags: &mut Vec<String>, text: &str, hir_id: HirId, sp: Span) {
69+
let mut iter = text.chars().peekable();
70+
71+
while let Some(c) = iter.next() {
72+
if c == '<' {
73+
let mut tag_name = String::new();
74+
let mut is_closing = false;
75+
while let Some(&c) = iter.peek() {
76+
// </tag>
77+
if c == '/' && tag_name.is_empty() {
78+
is_closing = true;
79+
} else if c.is_ascii_alphanumeric() && !c.is_ascii_uppercase() {
80+
tag_name.push(c);
81+
} else {
82+
break;
83+
}
84+
iter.next();
85+
}
86+
if tag_name.is_empty() {
87+
// Not an HTML tag presumably...
88+
continue;
89+
}
90+
if is_closing {
91+
drop_tag(cx, tags, tag_name, hir_id, sp);
92+
} else {
93+
tags.push(tag_name);
94+
}
95+
}
96+
}
97+
}
98+
99+
impl<'a, 'tcx> DocFolder for InvalidHtmlTagsLinter<'a, 'tcx> {
100+
fn fold_item(&mut self, item: Item) -> Option<Item> {
101+
let hir_id = match self.cx.as_local_hir_id(item.def_id) {
102+
Some(hir_id) => hir_id,
103+
None => {
104+
// If non-local, no need to check anything.
105+
return None;
106+
}
107+
};
108+
let dox = item.attrs.collapsed_doc_value().unwrap_or_default();
109+
if !dox.is_empty() {
110+
let sp = span_of_attrs(&item.attrs).unwrap_or(item.source.span());
111+
let mut tags = Vec::new();
112+
113+
let p = Parser::new_ext(&dox, opts());
114+
115+
for event in p {
116+
match event {
117+
Event::Html(text) => extract_tag(self.cx, &mut tags, &text, hir_id, sp),
118+
_ => {}
119+
}
120+
}
121+
122+
for tag in tags.iter().filter(|t| ALLOWED_UNCLOSED.iter().find(|at| at == t).is_none())
123+
{
124+
self.cx.tcx.struct_span_lint_hir(
125+
lint::builtin::INVALID_HTML_TAGS,
126+
hir_id,
127+
sp,
128+
|lint| lint.build(&format!("unclosed HTML tag `{}`", tag)).emit(),
129+
);
130+
}
131+
}
132+
133+
self.fold_item_recur(item)
134+
}
135+
}

src/librustdoc/passes/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ pub use self::check_code_block_syntax::CHECK_CODE_BLOCK_SYNTAX;
4545
mod calculate_doc_coverage;
4646
pub use self::calculate_doc_coverage::CALCULATE_DOC_COVERAGE;
4747

48+
mod html_tags;
49+
pub use self::html_tags::CHECK_INVALID_HTML_TAGS;
50+
4851
/// A single pass over the cleaned documentation.
4952
///
5053
/// Runs in the compiler context, so it has access to types and traits and the like.
@@ -87,6 +90,7 @@ pub const PASSES: &[Pass] = &[
8790
CHECK_CODE_BLOCK_SYNTAX,
8891
COLLECT_TRAIT_IMPLS,
8992
CALCULATE_DOC_COVERAGE,
93+
CHECK_INVALID_HTML_TAGS,
9094
];
9195

9296
/// The list of passes run by default.
@@ -101,6 +105,7 @@ pub const DEFAULT_PASSES: &[ConditionalPass] = &[
101105
ConditionalPass::always(COLLECT_INTRA_DOC_LINKS),
102106
ConditionalPass::always(CHECK_CODE_BLOCK_SYNTAX),
103107
ConditionalPass::always(PROPAGATE_DOC_CFG),
108+
ConditionalPass::always(CHECK_INVALID_HTML_TAGS),
104109
];
105110

106111
/// The list of default passes run when `--doc-coverage` is passed to rustdoc.

src/test/rustdoc-ui/intra-link-errors.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
#![allow(unclosed_html_tags)]
12
#![deny(broken_intra_doc_links)]
23
//~^ NOTE lint level is defined
34

0 commit comments

Comments
 (0)