Skip to content

Commit 08f8759

Browse files
GuillaumeGomezintel-lab-lkp
authored andcommitted
Use new --output-format=doctest rustdoc command line flag to improve doctest handling
The goal of this patch is to remove the use of 2 unstable rustdoc features (`--no-run` and `--test-builder`) and replace it with a stable feature: `--output-format=doctest`, which was added in rust-lang/rust#134531. Before this patch, the code was using very hacky methods in order to retrieve doctests, modify them as needed and then concatenate all of them in one file. Now, with this new flag, it instead asks rustdoc to provide the doctests code with their associated information such as file path and line number. Signed-off-by: Guillaume Gomez <[email protected]>
1 parent 19272b3 commit 08f8759

File tree

4 files changed

+329
-52
lines changed

4 files changed

+329
-52
lines changed

rust/Makefile

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,14 +205,15 @@ quiet_cmd_rustdoc_test_kernel = RUSTDOC TK $<
205205
rm -rf $(objtree)/$(obj)/test/doctests/kernel; \
206206
mkdir -p $(objtree)/$(obj)/test/doctests/kernel; \
207207
OBJTREE=$(abspath $(objtree)) \
208-
$(RUSTDOC) --test $(filter-out --remap-path-prefix=%,$(rust_flags)) \
208+
$(RUSTDOC) --output-format=doctest $(filter-out --remap-path-prefix=%,$(rust_flags)) \
209209
-L$(objtree)/$(obj) --extern ffi --extern pin_init \
210210
--extern kernel --extern build_error --extern macros \
211211
--extern bindings --extern uapi \
212-
--no-run --crate-name kernel -Zunstable-options \
212+
--crate-name kernel -Zunstable-options \
213213
--sysroot=/dev/null \
214214
--test-builder $(objtree)/scripts/rustdoc_test_builder \
215-
$< $(rustdoc_test_kernel_quiet); \
215+
$< $(rustdoc_test_kernel_quiet) > rustdoc.json; \
216+
cat rustdoc.json | $(objtree)/scripts/rustdoc_test_builder; \
216217
$(objtree)/scripts/rustdoc_test_gen
217218

218219
%/doctests_kernel_generated.rs %/doctests_kernel_generated_kunit.c: \

scripts/json.rs

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
// SPDX-License-Identifier: GPL-2.0
2+
3+
//! JSON parser used to parse rustdoc output when retrieving doctests.
4+
5+
use std::collections::HashMap;
6+
use std::iter::Peekable;
7+
use std::str::FromStr;
8+
9+
#[derive(Debug, PartialEq, Eq)]
10+
pub(crate) enum JsonValue {
11+
Object(HashMap<String, JsonValue>),
12+
String(String),
13+
Number(i32),
14+
Bool(bool),
15+
Array(Vec<JsonValue>),
16+
Null,
17+
}
18+
19+
fn parse_ident<I: Iterator<Item = char>>(
20+
iter: &mut I,
21+
output: JsonValue,
22+
ident: &str,
23+
) -> Result<JsonValue, String> {
24+
let mut ident_iter = ident.chars().skip(1);
25+
26+
loop {
27+
let i = ident_iter.next();
28+
if i.is_none() {
29+
return Ok(output);
30+
}
31+
let c = iter.next();
32+
if i != c {
33+
if let Some(c) = c {
34+
return Err(format!("Unexpected character `{c}` when parsing `{ident}`"));
35+
}
36+
return Err(format!("Missing character when parsing `{ident}`"));
37+
}
38+
}
39+
}
40+
41+
fn parse_string<I: Iterator<Item = char>>(iter: &mut I) -> Result<JsonValue, String> {
42+
let mut out = String::new();
43+
44+
while let Some(c) = iter.next() {
45+
match c {
46+
'\\' => {
47+
let Some(c) = iter.next() else { break };
48+
match c {
49+
'"' | '\\' | '/' => out.push(c),
50+
'b' => out.push(char::from(0x8u8)),
51+
'f' => out.push(char::from(0xCu8)),
52+
't' => out.push('\t'),
53+
'r' => out.push('\r'),
54+
'n' => out.push('\n'),
55+
_ => {
56+
// This code doesn't handle codepoints so we put the string content as is.
57+
out.push('\\');
58+
out.push(c);
59+
}
60+
}
61+
}
62+
'"' => {
63+
return Ok(JsonValue::String(out));
64+
}
65+
_ => out.push(c),
66+
}
67+
}
68+
Err(format!("Unclosed JSON string `{out}`"))
69+
}
70+
71+
fn parse_number<I: Iterator<Item = char>>(
72+
iter: &mut Peekable<I>,
73+
digit: char,
74+
) -> Result<JsonValue, String> {
75+
let mut nb = String::new();
76+
77+
nb.push(digit);
78+
loop {
79+
// We peek next character to prevent taking it from the iterator in case it's a comma.
80+
if matches!(iter.peek(), Some(',' | '}' | ']')) {
81+
break;
82+
}
83+
let Some(c) = iter.next() else { break };
84+
if c.is_whitespace() {
85+
break;
86+
} else if !c.is_ascii_digit() {
87+
return Err(format!("Error when parsing number `{nb}`: found `{c}`"));
88+
}
89+
nb.push(c);
90+
}
91+
i32::from_str(&nb)
92+
.map(|nb| JsonValue::Number(nb))
93+
.map_err(|error| format!("Invalid number: `{error}`"))
94+
}
95+
96+
fn parse_array<I: Iterator<Item = char>>(iter: &mut Peekable<I>) -> Result<JsonValue, String> {
97+
let mut values = Vec::new();
98+
99+
'main: loop {
100+
let Some(c) = iter.next() else {
101+
return Err("Unclosed array".to_string());
102+
};
103+
if c.is_whitespace() {
104+
continue;
105+
} else if c == ']' {
106+
break;
107+
}
108+
values.push(parse(iter, c)?);
109+
while let Some(c) = iter.next() {
110+
if c.is_whitespace() {
111+
continue;
112+
} else if c == ',' {
113+
break;
114+
} else if c == ']' {
115+
break 'main;
116+
} else {
117+
return Err(format!("Unexpected `{c}` when parsing array"));
118+
}
119+
}
120+
}
121+
Ok(JsonValue::Array(values))
122+
}
123+
124+
fn parse_object<I: Iterator<Item = char>>(iter: &mut Peekable<I>) -> Result<JsonValue, String> {
125+
let mut values = HashMap::new();
126+
127+
'main: loop {
128+
let Some(c) = iter.next() else {
129+
return Err("Unclosed object".to_string());
130+
};
131+
let key;
132+
if c.is_whitespace() {
133+
continue;
134+
} else if c == '"' {
135+
let JsonValue::String(k) = parse_string(iter)? else {
136+
unreachable!()
137+
};
138+
key = k;
139+
} else if c == '}' {
140+
break;
141+
} else {
142+
return Err(format!("Expected `\"` when parsing Object, found `{c}`"));
143+
}
144+
145+
// We then get the `:` separator.
146+
loop {
147+
let Some(c) = iter.next() else {
148+
return Err(format!("Missing value after key `{key}`"));
149+
};
150+
if c.is_whitespace() {
151+
continue;
152+
} else if c == ':' {
153+
break;
154+
} else {
155+
return Err(format!(
156+
"Expected `:` after key, found `{c}` when parsing object"
157+
));
158+
}
159+
}
160+
// Then the value.
161+
let value = loop {
162+
let Some(c) = iter.next() else {
163+
return Err(format!("Missing value after key `{key}`"));
164+
};
165+
if c.is_whitespace() {
166+
continue;
167+
} else {
168+
break parse(iter, c)?;
169+
}
170+
};
171+
172+
if values.contains_key(&key) {
173+
return Err(format!("Duplicated key `{key}`"));
174+
}
175+
values.insert(key, value);
176+
177+
while let Some(c) = iter.next() {
178+
if c.is_whitespace() {
179+
continue;
180+
} else if c == ',' {
181+
break;
182+
} else if c == '}' {
183+
break 'main;
184+
} else {
185+
return Err(format!("Unexpected `{c}` when parsing array"));
186+
}
187+
}
188+
}
189+
Ok(JsonValue::Object(values))
190+
}
191+
192+
fn parse<I: Iterator<Item = char>>(iter: &mut Peekable<I>, c: char) -> Result<JsonValue, String> {
193+
match c {
194+
'{' => parse_object(iter),
195+
'"' => parse_string(iter),
196+
'[' => parse_array(iter),
197+
't' => parse_ident(iter, JsonValue::Bool(true), "true"),
198+
'f' => parse_ident(iter, JsonValue::Bool(false), "false"),
199+
'n' => parse_ident(iter, JsonValue::Null, "null"),
200+
c => {
201+
if c.is_ascii_digit() || c == '-' {
202+
parse_number(iter, c)
203+
} else {
204+
Err(format!("Unexpected `{c}` character"))
205+
}
206+
}
207+
}
208+
}
209+
210+
impl JsonValue {
211+
pub(crate) fn parse(input: &str) -> Result<Self, String> {
212+
let mut iter = input.chars().peekable();
213+
let mut value = None;
214+
215+
while let Some(c) = iter.next() {
216+
if c.is_whitespace() {
217+
continue;
218+
}
219+
value = Some(parse(&mut iter, c)?);
220+
break;
221+
}
222+
while let Some(c) = iter.next() {
223+
if c.is_whitespace() {
224+
continue;
225+
} else {
226+
return Err(format!("Unexpected character `{c}` after content"));
227+
}
228+
}
229+
if let Some(value) = value {
230+
Ok(value)
231+
} else {
232+
Err("Empty content".to_string())
233+
}
234+
}
235+
}

scripts/rustdoc_test_builder.rs

Lines changed: 84 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -15,60 +15,100 @@
1515
//! from that. For the moment, we generate ourselves a new name, `{file}_{number}` instead, in
1616
//! the `gen` script (done there since we need to be aware of all the tests in a given file).
1717
18+
use std::collections::HashMap;
1819
use std::io::Read;
20+
use std::fs::create_dir_all;
1921

20-
fn main() {
21-
let mut stdin = std::io::stdin().lock();
22-
let mut body = String::new();
23-
stdin.read_to_string(&mut body).unwrap();
22+
use json::JsonValue;
2423

25-
// Find the generated function name looking for the inner function inside `main()`.
26-
//
27-
// The line we are looking for looks like one of the following:
28-
//
29-
// ```
30-
// fn main() { #[allow(non_snake_case)] fn _doctest_main_rust_kernel_file_rs_28_0() {
31-
// fn main() { #[allow(non_snake_case)] fn _doctest_main_rust_kernel_file_rs_37_0() -> Result<(), impl ::core::fmt::Debug> {
32-
// ```
33-
//
34-
// It should be unlikely that doctest code matches such lines (when code is formatted properly).
35-
let rustdoc_function_name = body
36-
.lines()
37-
.find_map(|line| {
38-
Some(
39-
line.split_once("fn main() {")?
40-
.1
41-
.split_once("fn ")?
42-
.1
43-
.split_once("()")?
44-
.0,
45-
)
46-
.filter(|x| x.chars().all(|c| c.is_alphanumeric() || c == '_'))
47-
})
48-
.expect("No test function found in `rustdoc`'s output.");
24+
mod json;
4925

50-
// Qualify `Result` to avoid the collision with our own `Result` coming from the prelude.
51-
let body = body.replace(
52-
&format!("{rustdoc_function_name}() -> Result<(), impl ::core::fmt::Debug> {{"),
53-
&format!(
54-
"{rustdoc_function_name}() -> ::core::result::Result<(), impl ::core::fmt::Debug> {{"
55-
),
56-
);
26+
fn generate_doctest(file: &str, line: i32, doctest_code: &HashMap<String, JsonValue>) -> bool {
27+
// FIXME: Once let chain feature is stable, please use it instead.
28+
let Some(JsonValue::Object(wrapper)) = doctest_code.get("wrapper") else { return false };
29+
let Some(JsonValue::String(before)) = wrapper.get("before") else { return false };
30+
let Some(JsonValue::String(after)) = wrapper.get("after") else { return false };
31+
let Some(JsonValue::String(code)) = doctest_code.get("code") else { return false };
32+
let Some(JsonValue::String(crate_level_code)) = doctest_code.get("crate_level") else { return false };
5733

58-
// For tests that get generated with `Result`, like above, `rustdoc` generates an `unwrap()` on
34+
// For tests that get generated with `Result`, `rustdoc` generates an `unwrap()` on
5935
// the return value to check there were no returned errors. Instead, we use our assert macro
6036
// since we want to just fail the test, not panic the kernel.
6137
//
6238
// We save the result in a variable so that the failed assertion message looks nicer.
63-
let body = body.replace(
64-
&format!("}} {rustdoc_function_name}().unwrap() }}"),
65-
&format!("}} let test_return_value = {rustdoc_function_name}(); assert!(test_return_value.is_ok()); }}"),
66-
);
39+
let after = if let Some(JsonValue::Bool(true)) = wrapper.get("returns_result") {
40+
"\n} let test_return_value = _inner(); assert!(test_return_value.is_ok()); }"
41+
} else {
42+
after.as_str()
43+
};
44+
let code = format!("{crate_level_code}\n{before}\n{code}{after}\n");
45+
46+
let file = file
47+
.strip_suffix(".rs")
48+
.unwrap_or(file)
49+
.strip_prefix("../rust/kernel/")
50+
.unwrap_or(file)
51+
.replace('/', "_");
52+
let path = format!("rust/test/doctests/kernel/{file}-{line}.rs");
53+
54+
std::fs::write(path, code.as_bytes()).unwrap();
55+
true
56+
}
57+
58+
fn main() {
59+
let mut stdin = std::io::stdin().lock();
60+
let mut body = String::new();
61+
stdin.read_to_string(&mut body).unwrap();
62+
63+
let JsonValue::Object(rustdoc) = JsonValue::parse(&body).unwrap() else {
64+
panic!("Expected an object")
65+
};
66+
if let Some(JsonValue::Number(format_version)) = rustdoc.get("format_version") {
67+
if *format_version != 2 {
68+
panic!("unsupported rustdoc format version: {format_version}");
69+
}
70+
} else {
71+
panic!("missing `format_version` field");
72+
}
73+
let Some(JsonValue::Array(doctests)) = rustdoc.get("doctests") else {
74+
panic!("`doctests` field is missing or has the wrong type");
75+
};
6776

68-
// Figure out a smaller test name based on the generated function name.
69-
let name = rustdoc_function_name.split_once("_rust_kernel_").unwrap().1;
77+
// We ignore the error since it will fail when generating doctests below if the folder doesn't
78+
// exist.
79+
let _ = create_dir_all("rust/test/doctests/kernel");
7080

71-
let path = format!("rust/test/doctests/kernel/{name}");
81+
let mut nb_generated = 0;
82+
for doctest in doctests {
83+
let JsonValue::Object(doctest) = doctest else {
84+
unreachable!()
85+
};
86+
// We check if we need to skip this test by checking it's a rust code and it's not ignored.
87+
if let Some(JsonValue::Object(attributes)) = doctest.get("doctest_attributes") {
88+
if attributes.get("rust") != Some(&JsonValue::Bool(true)) {
89+
continue;
90+
} else if let Some(JsonValue::String(ignore)) = attributes.get("ignore") {
91+
if ignore != "None" {
92+
continue;
93+
}
94+
}
95+
}
96+
if let (
97+
Some(JsonValue::String(file)),
98+
Some(JsonValue::Number(line)),
99+
Some(JsonValue::Object(doctest_code)),
100+
) = (
101+
doctest.get("file"),
102+
doctest.get("line"),
103+
doctest.get("doctest_code"),
104+
) {
105+
if generate_doctest(file, *line, doctest_code) {
106+
nb_generated += 1;
107+
}
108+
}
109+
}
72110

73-
std::fs::write(path, body.as_bytes()).unwrap();
111+
if nb_generated == 0 {
112+
panic!("No test function found in `rustdoc`'s output.");
113+
}
74114
}

0 commit comments

Comments
 (0)