1use std::collections::HashMap;
2use std::env;
3use std::fs::File;
4use std::io::{self, Error as IoError, Read, Write};
5use std::mem;
6use std::path::{Path, PathBuf};
7
8use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag};
9
10pub mod rt;
11#[cfg(test)]
12mod tests;
13
14pub fn markdown_files_of_directory(dir: &str) -> Vec<PathBuf> {
30 use glob::{glob_with, MatchOptions};
31
32 let opts = MatchOptions {
33 case_sensitive: false,
34 require_literal_separator: false,
35 require_literal_leading_dot: false,
36 };
37 let mut out = Vec::new();
38
39 for path in glob_with(&format!("{}/**/*.md", dir), opts)
40 .expect("Failed to read glob pattern")
41 .filter_map(Result::ok)
42 {
43 out.push(path.to_str().unwrap().into());
44 }
45
46 out
47}
48
49pub fn generate_doc_tests<T: Clone>(docs: &[T])
79where
80 T: AsRef<Path>,
81{
82 if docs.is_empty() {
86 return;
87 }
88
89 let docs = docs
90 .iter()
91 .cloned()
92 .map(|path| path.as_ref().to_str().unwrap().to_owned())
93 .filter(|d| !d.ends_with(".skt.md"))
94 .collect::<Vec<_>>();
95
96 for doc in &docs {
99 println!("cargo:rerun-if-changed={}", doc);
100
101 let skt = format!("{}.skt.md", doc);
102 if Path::new(&skt).exists() {
103 println!("cargo:rerun-if-changed={}", skt);
104 }
105 }
106
107 let out_dir = env::var("OUT_DIR").unwrap();
108 let cargo_manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
109
110 let mut out_file = PathBuf::from(out_dir.clone());
111 out_file.push("skeptic-tests.rs");
112
113 let config = Config {
114 out_dir: PathBuf::from(out_dir),
115 root_dir: PathBuf::from(cargo_manifest_dir),
116 out_file,
117 target_triple: env::var("TARGET").expect("could not get target triple"),
118 docs,
119 };
120
121 run(&config);
122}
123
124struct Config {
125 out_dir: PathBuf,
126 root_dir: PathBuf,
127 out_file: PathBuf,
128 target_triple: String,
129 docs: Vec<String>,
130}
131
132fn run(config: &Config) {
133 let tests = extract_tests(config).unwrap();
134 emit_tests(config, tests).unwrap();
135}
136
137struct Test {
138 name: String,
139 text: Vec<String>,
140 ignore: bool,
141 no_run: bool,
142 should_panic: bool,
143 template: Option<String>,
144}
145
146struct DocTestSuite {
147 doc_tests: Vec<DocTest>,
148}
149
150struct DocTest {
151 path: PathBuf,
152 old_template: Option<String>,
153 tests: Vec<Test>,
154 templates: HashMap<String, String>,
155}
156
157fn extract_tests(config: &Config) -> Result<DocTestSuite, IoError> {
158 let mut doc_tests = Vec::new();
159 for doc in &config.docs {
160 let path = &mut config.root_dir.clone();
161 path.push(doc);
162 let new_tests = extract_tests_from_file(path)?;
163 doc_tests.push(new_tests);
164 }
165 Ok(DocTestSuite { doc_tests })
166}
167
168enum Buffer {
169 None,
170 Code(Vec<String>),
171 Heading(String),
172}
173
174fn extract_tests_from_file(path: &Path) -> Result<DocTest, IoError> {
175 let mut file = File::open(path)?;
176 let s = &mut String::new();
177 file.read_to_string(s)?;
178
179 let file_stem = &sanitize_test_name(path.file_stem().unwrap().to_str().unwrap());
180
181 let tests = extract_tests_from_string(s, file_stem);
182
183 let templates = load_templates(path)?;
184
185 Ok(DocTest {
186 path: path.to_owned(),
187 old_template: tests.1,
188 tests: tests.0,
189 templates,
190 })
191}
192
193fn extract_tests_from_string(s: &str, file_stem: &str) -> (Vec<Test>, Option<String>) {
194 let mut tests = Vec::new();
195 let mut buffer = Buffer::None;
196 let parser = Parser::new(s);
197 let mut section = None;
198 let mut code_block_start = 0;
199 let mut old_template = None;
201
202 for (event, range) in parser.into_offset_iter() {
203 let line_number = bytecount::count(&s.as_bytes()[0..range.start], b'\n');
204 match event {
205 Event::Start(Tag::Heading(level, ..)) if level < HeadingLevel::H3 => {
206 buffer = Buffer::Heading(String::new());
207 }
208 Event::End(Tag::Heading(level, ..)) if level < HeadingLevel::H3 => {
209 let cur_buffer = mem::replace(&mut buffer, Buffer::None);
210 if let Buffer::Heading(sect) = cur_buffer {
211 section = Some(sanitize_test_name(§));
212 }
213 }
214 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(ref info))) => {
215 let code_block_info = parse_code_block_info(info);
216 if code_block_info.is_rust {
217 buffer = Buffer::Code(Vec::new());
218 }
219 }
220 Event::Text(text) => {
221 if let Buffer::Code(ref mut buf) = buffer {
222 if buf.is_empty() {
223 code_block_start = line_number;
224 }
225 buf.extend(text.lines().map(|s| format!("{}\n", s)));
226 } else if let Buffer::Heading(ref mut buf) = buffer {
227 buf.push_str(&*text);
228 }
229 }
230 Event::End(Tag::CodeBlock(CodeBlockKind::Fenced(ref info))) => {
231 let code_block_info = parse_code_block_info(info);
232 if let Buffer::Code(buf) = mem::replace(&mut buffer, Buffer::None) {
233 if code_block_info.is_old_template {
234 old_template = Some(buf.into_iter().collect())
235 } else {
236 let name = if let Some(ref section) = section {
237 format!("{}_sect_{}_line_{}", file_stem, section, code_block_start)
238 } else {
239 format!("{}_line_{}", file_stem, code_block_start)
240 };
241 tests.push(Test {
242 name,
243 text: buf,
244 ignore: code_block_info.ignore,
245 no_run: code_block_info.no_run,
246 should_panic: code_block_info.should_panic,
247 template: code_block_info.template,
248 });
249 }
250 }
251 }
252 _ => (),
253 }
254 }
255 (tests, old_template)
256}
257
258fn load_templates(path: &Path) -> Result<HashMap<String, String>, IoError> {
259 let file_name = format!(
260 "{}.skt.md",
261 path.file_name().expect("no file name").to_string_lossy()
262 );
263 let path = path.with_file_name(&file_name);
264 if !path.exists() {
265 return Ok(HashMap::new());
266 }
267
268 let mut map = HashMap::new();
269
270 let mut file = File::open(path)?;
271 let s = &mut String::new();
272 file.read_to_string(s)?;
273 let parser = Parser::new(s);
274
275 let mut code_buffer = None;
276
277 for event in parser {
278 match event {
279 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(ref info))) => {
280 let code_block_info = parse_code_block_info(info);
281 if code_block_info.is_rust {
282 code_buffer = Some(Vec::new());
283 }
284 }
285 Event::Text(text) => {
286 if let Some(ref mut buf) = code_buffer {
287 buf.push(text.to_string());
288 }
289 }
290 Event::End(Tag::CodeBlock(CodeBlockKind::Fenced(ref info))) => {
291 let code_block_info = parse_code_block_info(info);
292 if let Some(buf) = code_buffer.take() {
293 if let Some(t) = code_block_info.template {
294 map.insert(t, buf.into_iter().collect());
295 }
296 }
297 }
298 _ => (),
299 }
300 }
301
302 Ok(map)
303}
304
305fn sanitize_test_name(s: &str) -> String {
306 s.to_ascii_lowercase()
307 .chars()
308 .map(|ch| {
309 if ch.is_ascii() && ch.is_alphanumeric() {
310 ch
311 } else {
312 '_'
313 }
314 })
315 .collect::<String>()
316 .split('_')
317 .filter(|s| !s.is_empty())
318 .collect::<Vec<_>>()
319 .join("_")
320}
321
322fn parse_code_block_info(info: &str) -> CodeBlockInfo {
323 let tokens = info.split(|c: char| !(c == '_' || c == '-' || c.is_alphanumeric()));
325
326 let mut seen_rust_tags = false;
327 let mut seen_other_tags = false;
328 let mut info = CodeBlockInfo {
329 is_rust: false,
330 should_panic: false,
331 ignore: false,
332 no_run: false,
333 is_old_template: false,
334 template: None,
335 };
336
337 for token in tokens {
338 match token {
339 "" => {}
340 "rust" => {
341 info.is_rust = true;
342 seen_rust_tags = true
343 }
344 "should_panic" => {
345 info.should_panic = true;
346 seen_rust_tags = true
347 }
348 "ignore" => {
349 info.ignore = true;
350 seen_rust_tags = true
351 }
352 "no_run" => {
353 info.no_run = true;
354 seen_rust_tags = true;
355 }
356 "skeptic-template" => {
357 info.is_old_template = true;
358 seen_rust_tags = true
359 }
360 _ if token.starts_with("skt-") => {
361 info.template = Some(token[4..].to_string());
362 seen_rust_tags = true;
363 }
364 _ => seen_other_tags = true,
365 }
366 }
367
368 info.is_rust &= !seen_other_tags || seen_rust_tags;
369
370 info
371}
372
373struct CodeBlockInfo {
374 is_rust: bool,
375 should_panic: bool,
376 ignore: bool,
377 no_run: bool,
378 is_old_template: bool,
379 template: Option<String>,
380}
381
382fn emit_tests(config: &Config, suite: DocTestSuite) -> Result<(), IoError> {
383 let mut out = String::new();
384
385 out.push_str("extern crate skeptic;\n");
387
388 for doc_test in suite.doc_tests {
389 for test in &doc_test.tests {
390 let test_string = {
391 if let Some(ref t) = test.template {
392 let template = doc_test.templates.get(t).unwrap_or_else(|| {
393 panic!("template {} not found for {}", t, doc_test.path.display())
394 });
395 create_test_runner(config, &Some(template.to_string()), test)?
396 } else {
397 create_test_runner(config, &doc_test.old_template, test)?
398 }
399 };
400 out.push_str(&test_string);
401 }
402 }
403 write_if_contents_changed(&config.out_file, &out)
404}
405
406#[allow(clippy::manual_strip)] fn clean_omitted_line(line: &str) -> &str {
412 let trimmed = if let Some(pos) = line.find(|c: char| !c.is_whitespace()) {
415 &line[pos..]
416 } else {
417 line
418 };
419
420 if trimmed.starts_with("# ") {
421 &trimmed[2..]
422 } else if line.trim() == "#" {
423 &trimmed[1..]
425 } else {
426 line
427 }
428}
429
430fn create_test_input(lines: &[String]) -> String {
432 lines
433 .iter()
434 .map(|s| clean_omitted_line(s).to_owned())
435 .collect()
436}
437
438fn create_test_runner(
439 config: &Config,
440 template: &Option<String>,
441 test: &Test,
442) -> Result<String, IoError> {
443 let template = template.clone().unwrap_or_else(|| String::from("{}"));
444 let test_text = create_test_input(&test.text);
445
446 let mut s: Vec<u8> = Vec::new();
447 if test.ignore {
448 writeln!(s, "#[ignore]")?;
449 }
450 if test.should_panic {
451 writeln!(s, "#[should_panic]")?;
452 }
453
454 writeln!(s, "#[test] fn {}() {{", test.name)?;
455 writeln!(
456 s,
457 " let s = &format!(r####\"\n{}\"####, r####\"{}\"####);",
458 template, test_text
459 )?;
460
461 if test.no_run {
463 writeln!(
464 s,
465 " skeptic::rt::compile_test(r#\"{}\"#, r#\"{}\"#, r#\"{}\"#, s);",
466 config.root_dir.to_str().unwrap(),
467 config.out_dir.to_str().unwrap(),
468 config.target_triple
469 )?;
470 } else {
471 writeln!(
472 s,
473 " skeptic::rt::run_test(r#\"{}\"#, r#\"{}\"#, r#\"{}\"#, s);",
474 config.root_dir.to_str().unwrap(),
475 config.out_dir.to_str().unwrap(),
476 config.target_triple
477 )?;
478 }
479
480 writeln!(s, "}}")?;
481 writeln!(s)?;
482
483 Ok(String::from_utf8(s).unwrap())
484}
485
486fn write_if_contents_changed(name: &Path, contents: &str) -> Result<(), IoError> {
487 match File::open(name) {
489 Ok(mut file) => {
490 let mut current_contents = String::new();
491 file.read_to_string(&mut current_contents)?;
492 if current_contents == contents {
493 return Ok(());
495 }
496 }
497 Err(ref err) if err.kind() == io::ErrorKind::NotFound => (),
498 Err(err) => return Err(err),
499 }
500 let mut file = File::create(name)?;
501 file.write_all(contents.as_bytes())?;
502 Ok(())
503}