Skip to content

Commit 4f3b85e

Browse files
website: make tutorial testable (#3879)
1 parent 7ff45d3 commit 4f3b85e

File tree

9 files changed

+429
-224
lines changed

9 files changed

+429
-224
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tools/website-test/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@ version = "0.1.0"
44
edition = "2021"
55
build = "build.rs"
66
publish = false
7-
rust-version = "1.62"
7+
rust-version = "1.81"
88

99
[dependencies]
1010
yew-agent = { path = "../../packages/yew-agent/" }
1111

1212
[dev-dependencies]
1313
derive_more = { version = "2.0", features = ["from"] }
1414
gloo = "0.11"
15+
gloo-net = "0.6"
1516
js-sys = "0.3"
17+
serde = { version = "1.0", features = ["derive"] }
1618
wasm-bindgen = "0.2"
1719
wasm-bindgen-futures = "0.4"
1820
weblog = "0.3.0"

tools/website-test/build.rs

Lines changed: 186 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,141 @@
11
use std::collections::HashMap;
2-
use std::fmt::{self, Write};
2+
use std::error::Error;
3+
use std::fmt::Write;
4+
use std::fs::File;
5+
use std::io::{self, BufRead, BufReader, ErrorKind, Read, Seek, SeekFrom};
36
use std::path::{Path, PathBuf};
7+
use std::process::ExitCode;
48
use std::{env, fs};
59

610
use glob::glob;
711

12+
type Result<T = ()> = core::result::Result<T, Box<dyn Error + 'static>>;
13+
14+
macro_rules! e {
15+
($($fmt:tt),* $(,)?) => {
16+
return Err(format!($($fmt),*).into())
17+
};
18+
}
19+
20+
macro_rules! assert {
21+
($condition:expr, $($fmt:tt),* $(,)?) => {
22+
if !$condition { e!($($fmt),*) }
23+
};
24+
}
25+
826
#[derive(Debug, Default)]
927
struct Level {
1028
nested: HashMap<String, Level>,
1129
files: Vec<PathBuf>,
1230
}
1331

14-
fn main() {
15-
let home = env::var("CARGO_MANIFEST_DIR").unwrap();
16-
let pattern = format!("{home}/../../website/docs/**/*.md*");
17-
let base = format!("{home}/../../website");
18-
let base = Path::new(&base).canonicalize().unwrap();
19-
let dir_pattern = format!("{home}/../../website/docs/**");
20-
for dir in glob(&dir_pattern).unwrap() {
21-
println!("cargo:rerun-if-changed={}", dir.unwrap().display());
22-
}
23-
24-
let mut level = Level::default();
32+
fn should_combine_code_blocks(path: &Path) -> io::Result<bool> {
33+
const FLAG: &[u8] = b"<!-- COMBINE CODE BLOCKS -->";
2534

26-
for entry in glob(&pattern).unwrap() {
27-
let path = entry.unwrap();
28-
let path = Path::new(&path).canonicalize().unwrap();
29-
println!("cargo:rerun-if-changed={}", path.display());
30-
let rel = path.strip_prefix(&base).unwrap();
35+
let mut file = File::open(path)?;
36+
match file.seek(SeekFrom::End(-32)) {
37+
Ok(_) => (),
38+
Err(e) if e.kind() == ErrorKind::InvalidInput => return Ok(false),
39+
Err(e) => return Err(e),
40+
}
41+
let mut buf = [0u8; 32];
42+
file.read_exact(&mut buf)?;
43+
Ok(buf.trim_ascii_end().ends_with(FLAG))
44+
}
3145

32-
let mut parts = vec![];
46+
fn apply_diff(src: &mut String, preamble: &str, added: &str, removed: &str) -> Result {
47+
assert!(
48+
!preamble.is_empty() || !removed.is_empty(),
49+
"Failure on applying a diff: \nNo preamble or text to remove provided, unable to find \
50+
location to insert:\n{added}\nIn the following text:\n{src}",
51+
);
52+
53+
let mut matches = src.match_indices(if preamble.is_empty() {
54+
removed
55+
} else {
56+
preamble
57+
});
58+
let Some((preamble_start, _)) = matches.next() else {
59+
e!(
60+
"Failure on applying a diff: \ncouldn't find the following text:\n{preamble}\n\nIn \
61+
the following text:\n{src}"
62+
)
63+
};
64+
65+
assert!(
66+
matches.next().is_none(),
67+
"Failure on applying a diff: \nAmbiguous preamble:\n{preamble}\nIn the following \
68+
text:\n{src}\nWhile trying to remove the following text:\n{removed}\nAnd add the \
69+
following:\n{added}\n"
70+
);
71+
72+
let preamble_end = preamble_start + preamble.len();
73+
assert!(
74+
src.get(preamble_end..preamble_end + removed.len()) == Some(removed),
75+
"Failure on applying a diff: \nText to remove not found:\n{removed}\n\nIn the following \
76+
text:\n{src}",
77+
);
78+
79+
src.replace_range(preamble_end..preamble_end + removed.len(), added);
80+
Ok(())
81+
}
3382

34-
for part in rel {
35-
parts.push(part.to_str().unwrap());
83+
fn combined_code_blocks(path: &Path) -> Result<String> {
84+
let file = BufReader::new(File::open(path)?);
85+
let mut res = String::new();
86+
87+
let mut err = Ok(());
88+
let mut lines = file
89+
.lines()
90+
.filter_map(|i| i.map_err(|e| err = Err(e)).ok());
91+
while let Some(line) = lines.next() {
92+
if !line.starts_with("```rust") {
93+
continue;
3694
}
3795

38-
level.insert(path.clone(), &parts[..]);
96+
let mut preamble = String::new();
97+
let mut added = String::new();
98+
let mut removed = String::new();
99+
let mut diff_applied = false;
100+
for line in &mut lines {
101+
if line.starts_with("```") {
102+
if !added.is_empty() || !removed.is_empty() {
103+
apply_diff(&mut res, &preamble, &added, &removed)?;
104+
} else if !diff_applied {
105+
// if no diff markers were found, just add the contents
106+
res += &preamble;
107+
}
108+
break;
109+
} else if let Some(line) = line.strip_prefix('+') {
110+
if line.starts_with(char::is_whitespace) {
111+
added += " ";
112+
}
113+
added += line;
114+
added += "\n";
115+
} else if let Some(line) = line.strip_prefix('-') {
116+
if line.starts_with(char::is_whitespace) {
117+
removed += " ";
118+
}
119+
removed += line;
120+
removed += "\n";
121+
} else if line.trim_ascii() == "// ..." {
122+
// disregard the preamble
123+
preamble.clear();
124+
} else {
125+
if !added.is_empty() || !removed.is_empty() {
126+
diff_applied = true;
127+
apply_diff(&mut res, &preamble, &added, &removed)?;
128+
preamble += &added;
129+
added.clear();
130+
removed.clear();
131+
}
132+
preamble += &line;
133+
preamble += "\n";
134+
}
135+
}
39136
}
40137

41-
let out = format!("{}/website_tests.rs", env::var("OUT_DIR").unwrap());
42-
43-
fs::write(out, level.to_contents()).unwrap();
138+
Ok(res)
44139
}
45140

46141
impl Level {
@@ -53,14 +148,14 @@ impl Level {
53148
}
54149
}
55150

56-
fn to_contents(&self) -> String {
151+
fn to_contents(&self) -> Result<String> {
57152
let mut dst = String::new();
58153

59-
self.write_inner(&mut dst, 0).unwrap();
60-
dst
154+
self.write_inner(&mut dst, 0)?;
155+
Ok(dst)
61156
}
62157

63-
fn write_into(&self, dst: &mut String, name: &str, level: usize) -> fmt::Result {
158+
fn write_into(&self, dst: &mut String, name: &str, level: usize) -> Result {
64159
self.write_space(dst, level);
65160
let name = name.replace(['-', '.'], "_");
66161
writeln!(dst, "pub mod {name} {{")?;
@@ -73,24 +168,33 @@ impl Level {
73168
Ok(())
74169
}
75170

76-
fn write_inner(&self, dst: &mut String, level: usize) -> fmt::Result {
171+
fn write_inner(&self, dst: &mut String, level: usize) -> Result {
77172
for (name, nested) in &self.nested {
78173
nested.write_into(dst, name, level)?;
79174
}
80175

81-
self.write_space(dst, level);
82-
83176
for file in &self.files {
84-
let stem = Path::new(file)
177+
let stem = file
85178
.file_stem()
86-
.unwrap()
179+
.ok_or_else(|| format!("no filename in path {file:?}"))?
87180
.to_str()
88-
.unwrap()
181+
.ok_or_else(|| format!("non-UTF8 path: {file:?}"))?
89182
.replace('-', "_");
90183

91-
self.write_space(dst, level);
92-
93-
writeln!(dst, "#[doc = include_str!(r\"{}\")]", file.display())?;
184+
if should_combine_code_blocks(file)? {
185+
let res = combined_code_blocks(file)?;
186+
self.write_space(dst, level);
187+
writeln!(dst, "/// ```rust, no_run")?;
188+
for line in res.lines() {
189+
self.write_space(dst, level);
190+
writeln!(dst, "/// {line}")?;
191+
}
192+
self.write_space(dst, level);
193+
writeln!(dst, "/// ```")?;
194+
} else {
195+
self.write_space(dst, level);
196+
writeln!(dst, "#[doc = include_str!(r\"{}\")]", file.display())?;
197+
}
94198
self.write_space(dst, level);
95199
writeln!(dst, "pub fn {stem}_md() {{}}")?;
96200
}
@@ -104,3 +208,48 @@ impl Level {
104208
}
105209
}
106210
}
211+
212+
fn inner_main() -> Result {
213+
let home = env::var("CARGO_MANIFEST_DIR")?;
214+
let pattern = format!("{home}/../../website/docs/**/*.md*");
215+
let base = format!("{home}/../../website");
216+
let base = Path::new(&base).canonicalize()?;
217+
let dir_pattern = format!("{home}/../../website/docs/**");
218+
for dir in glob(&dir_pattern)? {
219+
println!("cargo:rerun-if-changed={}", dir?.display());
220+
}
221+
222+
let mut level = Level::default();
223+
224+
for entry in glob(&pattern)? {
225+
let path = entry?.canonicalize()?;
226+
println!("cargo:rerun-if-changed={}", path.display());
227+
let rel = path.strip_prefix(&base)?;
228+
229+
let mut parts = vec![];
230+
231+
for part in rel {
232+
parts.push(
233+
part.to_str()
234+
.ok_or_else(|| format!("Non-UTF8 path: {rel:?}"))?,
235+
);
236+
}
237+
238+
level.insert(path.clone(), &parts[..]);
239+
}
240+
241+
let out = format!("{}/website_tests.rs", env::var("OUT_DIR")?);
242+
243+
fs::write(out, level.to_contents()?)?;
244+
Ok(())
245+
}
246+
247+
fn main() -> ExitCode {
248+
match inner_main() {
249+
Ok(_) => ExitCode::SUCCESS,
250+
Err(e) => {
251+
eprintln!("{e}");
252+
ExitCode::FAILURE
253+
}
254+
}
255+
}

tools/website-test/src/lib.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1 @@
1-
#![allow(clippy::needless_doctest_main)]
2-
pub mod tutorial;
3-
41
include!(concat!(env!("OUT_DIR"), "/website_tests.rs"));

tools/website-test/src/tutorial.rs

Lines changed: 0 additions & 7 deletions
This file was deleted.

website/README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,55 @@ Note this only builds for English locale unlike a production build.
2424
> Documentation is written in `mdx`, a superset of markdown empowered with jsx.
2525
> JetBrains and VSCode both provide MDX plugins.
2626
27+
## Testing
28+
29+
```console
30+
cargo make website-test
31+
```
32+
33+
[`website-test`](../tools/website-test) is a tool to test all code blocks in the docs as Rust doctests.
34+
It gathers the Rust code blocks automatically, but by default they're all tested separate. In case of a
35+
walkthrough, it makes more sense to combine the changes described in the blocks & test the code as one.
36+
For this end `website-test` scans all doc files for a special flag:
37+
38+
```html
39+
<!-- COMBINE CODE BLOCKS -->
40+
```
41+
If a file ends with this specific comment (and an optional newline after it), all code blocks will be
42+
sown together, with respect to the diff markers in them. For example:
43+
44+
```md
45+
\`\`\`rust
46+
fn main() {
47+
println!("Hello, World");
48+
}
49+
\`\`\`
50+
51+
\`\`\`rust
52+
fn main() {
53+
- println!("Hello, World");
54+
+ println!("Goodbye, World");
55+
}
56+
\`\`\`
57+
58+
<!-- COMBINE CODE BLOCKS -->
59+
```
60+
61+
Will be tested as:
62+
```rust
63+
fn main() {
64+
println!("Goodbye, World");
65+
}
66+
```
67+
68+
:::warning
69+
The current implementation only uses the code before the diff or the code to remove as context,
70+
so make sure there's enough of it. The test assembler will tell you if there isn't.
71+
:::
72+
73+
While assembling the code blocks, the test assembler will put special meaning into a code
74+
line `// ...`. This line tells the test assembler to disregard any previous context for applying a diff
75+
2776
## Production Build
2877

2978
```console

website/docs/concepts/function-components/properties.mdx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -318,14 +318,12 @@ Props are evaluated in the order they're specified, as shown by the following ex
318318
#[derive(yew::Properties, PartialEq)]
319319
struct Props { first: usize, second: usize, last: usize }
320320

321-
fn main() {
322-
let mut g = 1..=3;
323-
let props = yew::props!(Props { first: g.next().unwrap(), second: g.next().unwrap(), last: g.next().unwrap() });
321+
let mut g = 1..=3;
322+
let props = yew::props!(Props { first: g.next().unwrap(), second: g.next().unwrap(), last: g.next().unwrap() });
324323

325-
assert_eq!(props.first, 1);
326-
assert_eq!(props.second, 2);
327-
assert_eq!(props.last, 3);
328-
}
324+
assert_eq!(props.first, 1);
325+
assert_eq!(props.second, 2);
326+
assert_eq!(props.last, 3);
329327
```
330328

331329
## Anti Patterns

0 commit comments

Comments
 (0)