Skip to content

Commit 957bea4

Browse files
committed
test(linter): more documentation testing
1 parent 2fe39dd commit 957bea4

File tree

8 files changed

+549
-12
lines changed

8 files changed

+549
-12
lines changed

Cargo.lock

+333-9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/oxc_linter/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,4 @@ schemars = { workspace = true, features = ["indexmap2"] }
5757
static_assertions = { workspace = true }
5858
insta = { workspace = true }
5959
project-root = { workspace = true }
60+
markdown = { version = "1.0.0-alpha.18" }

crates/oxc_linter/src/rule.rs

+37-1
Original file line numberDiff line numberDiff line change
@@ -235,12 +235,48 @@ impl RuleWithSeverity {
235235
#[cfg(test)]
236236
mod test {
237237
use crate::rules::RULES;
238+
use itertools::Itertools as _;
239+
use markdown::{to_html_with_options, Options};
240+
use oxc_allocator::Allocator;
241+
use oxc_diagnostics::Error;
242+
use oxc_parser::Parser;
243+
use oxc_span::SourceType;
238244

239245
#[test]
240246
fn ensure_documentation() {
241247
assert!(!RULES.is_empty());
248+
let options = Options::gfm();
249+
let source_type = SourceType::default().with_jsx(true).with_always_strict(true);
250+
242251
for rule in RULES.iter() {
243-
assert!(rule.documentation().is_some_and(|s| !s.is_empty()), "{}", rule.name());
252+
let name = rule.name();
253+
assert!(
254+
rule.documentation().is_some_and(|s| !s.is_empty()),
255+
"Rule '{name}' is missing documentation."
256+
);
257+
// will panic if provided invalid markdown
258+
let html = to_html_with_options(rule.documentation().unwrap(), &options).unwrap();
259+
assert!(!html.is_empty());
260+
261+
// convert HTML to JSX, then use the parser to ensure valid HTML was generated
262+
let jsx =
263+
format!("const Documentation = <>{}</>;", html.replace("class=", "className="));
264+
let allocator = Allocator::default();
265+
let ret = Parser::new(&allocator, &jsx, source_type).parse();
266+
267+
let has_errors = !ret.errors.is_empty();
268+
let errors = ret
269+
.errors
270+
.into_iter()
271+
.map(|error| Error::new(error).with_source_code(jsx.clone()))
272+
.map(|e| format!("{e:?}"))
273+
.join("\n\n");
274+
275+
assert!(
276+
!ret.panicked && !has_errors,
277+
"Documentation for rule '{name}' has invalid syntax: {errors}"
278+
);
279+
assert!(!ret.program.body.is_empty(), "Documentation for rule '{name}' is empty.");
244280
}
245281
}
246282
}

crates/oxc_linter/src/rules/eslint/for_direction.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ declare_oxc_lint!(
3535
/// ```javascript
3636
/// for (var i = 0; i < 10; i--) {}
3737
///
38-
/// for (var = 10; i >= 0; i++) {}
38+
/// for (var i = 10; i >= 0; i++) {}
3939
/// ```
4040
ForDirection,
4141
correctness,

crates/oxc_linter/src/table.rs

+46
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,49 @@ impl RuleTableSection {
125125
s
126126
}
127127
}
128+
129+
#[cfg(test)]
130+
mod test {
131+
use super::*;
132+
use markdown::{to_html_with_options, Options};
133+
use std::sync::OnceLock;
134+
135+
static TABLE: OnceLock<RuleTable> = OnceLock::new();
136+
137+
fn table() -> &'static RuleTable {
138+
TABLE.get_or_init(|| RuleTable::new())
139+
}
140+
141+
#[test]
142+
fn test_table_no_links() {
143+
let options = Options::gfm();
144+
for section in &table().sections {
145+
let rendered_table = section.render_markdown_table(None);
146+
assert!(!rendered_table.is_empty());
147+
assert_eq!(rendered_table.split("\n").count(), 5 + section.rows.len());
148+
149+
let html = to_html_with_options(&rendered_table, &options).unwrap();
150+
assert!(!html.is_empty());
151+
assert!(html.contains("<table>"));
152+
}
153+
}
154+
155+
#[test]
156+
fn test_table_with_links() {
157+
const PREFIX: &str = "/foo/bar";
158+
const PREFIX_WITH_SLASH: &str = "/foo/bar/";
159+
160+
let options = Options::gfm();
161+
162+
for section in &table().sections {
163+
let rendered_table = section.render_markdown_table(Some(PREFIX));
164+
assert!(!rendered_table.is_empty());
165+
assert_eq!(rendered_table.split("\n").count(), 5 + section.rows.len());
166+
167+
let html = to_html_with_options(&rendered_table, &options).unwrap();
168+
assert!(!html.is_empty());
169+
assert!(html.contains("<table>"));
170+
assert!(html.contains(PREFIX_WITH_SLASH));
171+
}
172+
}
173+
}

tasks/website/Cargo.toml

+8-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,14 @@ serde = { workspace = true }
2727
bpaf = { workspace = true, features = ["docgen"] }
2828

2929
[dev-dependencies]
30-
insta = { workspace = true }
30+
oxc_allocator = { workspace = true }
31+
oxc_diagnostics = { workspace = true }
32+
oxc_parser = { workspace = true }
33+
oxc_span = { workspace = true }
34+
35+
insta = { workspace = true }
36+
markdown = { version = "1.0.0-alpha.18" }
37+
scraper = { version = "0.20.0" }
3138

3239
[package.metadata.cargo-shear]
3340
ignored = ["bpaf"]

tasks/website/src/linter/rules/mod.rs

+5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
mod doc_page;
22
mod html;
33
mod table;
4+
#[cfg(test)]
5+
mod test;
46

57
use std::{
68
borrow::Cow,
@@ -95,6 +97,9 @@ fn write_rule_doc_pages(table: &RuleTable, outdir: &Path) {
9597
let plugin_path = outdir.join(&rule.plugin);
9698
fs::create_dir_all(&plugin_path).unwrap();
9799
let page_path = plugin_path.join(format!("{}.md", rule.name));
100+
if page_path.exists() {
101+
fs::remove_file(&page_path).unwrap();
102+
}
98103
println!("{}", page_path.display());
99104
let docs = render_rule_docs_page(rule).unwrap();
100105
fs::write(&page_path, docs).unwrap();
+118
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
use markdown::{to_html, to_html_with_options, Options};
2+
use oxc_diagnostics::NamedSource;
3+
use scraper::{ElementRef, Html, Selector};
4+
use std::sync::{Arc, OnceLock};
5+
6+
use oxc_allocator::Allocator;
7+
use oxc_linter::table::RuleTable;
8+
use oxc_parser::Parser;
9+
use oxc_span::SourceType;
10+
11+
use super::{render_rule_docs_page, render_rules_table};
12+
13+
static TABLE: OnceLock<RuleTable> = OnceLock::new();
14+
15+
fn table() -> &'static RuleTable {
16+
TABLE.get_or_init(|| RuleTable::new())
17+
}
18+
19+
fn parse(filename: &str, jsx: &str) -> Result<(), String> {
20+
let filename = format!("{filename}.tsx");
21+
let source_type = SourceType::from_path(&filename).unwrap();
22+
parse_type(&filename, jsx, source_type)
23+
}
24+
25+
fn parse_type(filename: &str, source_text: &str, source_type: SourceType) -> Result<(), String> {
26+
let alloc = Allocator::default();
27+
let ret = Parser::new(&alloc, source_text, source_type).parse();
28+
29+
if ret.errors.is_empty() {
30+
Ok(())
31+
} else {
32+
let num_errs = ret.errors.len();
33+
let source = Arc::new(NamedSource::new(filename, source_text.to_string()));
34+
ret.errors
35+
.into_iter()
36+
.map(|e| e.with_source_code(Arc::clone(&source)))
37+
.for_each(|e| println!("{e:?}"));
38+
Err(format!("{} errors occurred while parsing {filename}.jsx", num_errs))
39+
}
40+
}
41+
42+
#[test]
43+
fn test_rules_table() {
44+
const PREFIX: &str = "/docs/guide/usage/linter/rules";
45+
let rendered_table = render_rules_table(table(), PREFIX);
46+
let html = to_html(&rendered_table);
47+
let jsx = format!("const Table = () => <>{html}</>");
48+
parse("rules-table", &jsx).unwrap();
49+
}
50+
51+
#[test]
52+
fn test_doc_pages() {
53+
let mut options = Options::gfm();
54+
options.compile.allow_dangerous_html = true;
55+
56+
for section in &table().sections {
57+
let category = section.category;
58+
let code = Selector::parse("code").unwrap();
59+
60+
for row in &section.rows {
61+
let filename = format!("{category}/{}/{}", row.plugin, row.name);
62+
let docs = render_rule_docs_page(row).unwrap();
63+
let docs = to_html_with_options(&docs, &options).unwrap();
64+
let docs = if let Some(end_of_autogen_comment) = docs.find("-->") {
65+
&docs[end_of_autogen_comment + 4..]
66+
} else {
67+
&docs
68+
};
69+
70+
// ensure the docs are valid JSX
71+
{
72+
let jsx = format!("const Docs = () => <>{docs}</>");
73+
parse(&filename, &jsx).unwrap();
74+
}
75+
76+
// ensure code examples are valid
77+
{
78+
let html = Html::parse_fragment(&docs);
79+
assert!(html.errors.is_empty(), "HTML parsing errors: {:#?}", html.errors);
80+
for code_el in html.select(&code) {
81+
let inner = code_el.inner_html();
82+
assert!(
83+
!inner.trim().is_empty(),
84+
"Rule '{}' has an empty code snippet",
85+
row.name
86+
);
87+
let inner = inner.replace("&lt;", "<").replace("&gt;", ">");
88+
let filename = filename.clone() + "/code-snippet";
89+
let source_type = source_type_from_code_element(code_el);
90+
parse_type(&filename, &inner, source_type).unwrap();
91+
}
92+
}
93+
}
94+
}
95+
}
96+
97+
fn default_source() -> SourceType {
98+
SourceType::default().with_typescript(true).with_jsx(true).with_always_strict(true)
99+
}
100+
fn source_type_from_code_element(code: ElementRef) -> SourceType {
101+
let Some(class) = code.attr("class") else {
102+
return default_source();
103+
};
104+
let maybe_class = class.split('-').collect::<Vec<_>>();
105+
let ["language", lang] = maybe_class.as_slice() else {
106+
return default_source();
107+
};
108+
109+
match *lang {
110+
"javascript" | "js" => SourceType::default().with_always_strict(true),
111+
"jsx" => SourceType::default().with_jsx(true).with_always_strict(true),
112+
"typescript" | "ts" => SourceType::default().with_typescript(true).with_always_strict(true),
113+
"tsx" => {
114+
SourceType::default().with_typescript(true).with_jsx(true).with_always_strict(true)
115+
}
116+
_ => default_source(),
117+
}
118+
}

0 commit comments

Comments
 (0)