skeptic/
lib.rs

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
14/// Returns a list of markdown files under a directory.
15///
16/// # Usage
17///
18/// List markdown files of `mdbook` which are under `<project dir>/book` usually:
19///
20/// ```rust
21/// extern crate skeptic;
22///
23/// use skeptic::markdown_files_of_directory;
24///
25/// fn main() {
26///     let _ = markdown_files_of_directory("book/");
27/// }
28/// ```
29pub 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
49/// Generates tests for specified markdown files.
50///
51/// # Usage
52///
53/// Generates doc tests for the specified files.
54///
55/// ```rust,no_run
56/// extern crate skeptic;
57///
58/// use skeptic::generate_doc_tests;
59///
60/// fn main() {
61///     generate_doc_tests(&["README.md"]);
62/// }
63/// ```
64///
65/// Or in case you want to add `mdbook` files:
66///
67/// ```rust,no_run
68/// extern crate skeptic;
69///
70/// use skeptic::*;
71///
72/// fn main() {
73///     let mut mdbook_files = markdown_files_of_directory("book/");
74///     mdbook_files.push("README.md".into());
75///     generate_doc_tests(&mdbook_files);
76/// }
77/// ```
78pub fn generate_doc_tests<T: Clone>(docs: &[T])
79where
80    T: AsRef<Path>,
81{
82    // This shortcut is specifically so examples in skeptic's on
83    // readme can call this function in non-build.rs contexts, without
84    // panicking below.
85    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    // Inform cargo that it needs to rerun the build script if one of the skeptic files are
97    // modified
98    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    // Oh this isn't actually a test but a legacy template
200    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(&sect));
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    // Same as rustdoc
324    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    // Test cases use the api from skeptic::rt
386    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/// Just like Rustdoc, ignore a "#" sign at the beginning of a line of code.
407/// These are commonly an indication to omit the line from user-facing
408/// documentation but include it for the purpose of playground links or skeptic
409/// testing.
410#[allow(clippy::manual_strip)] // Relies on str::strip_prefix(), MSRV 1.45
411fn clean_omitted_line(line: &str) -> &str {
412    // XXX To silence depreciation warning of trim_left and not bump rustc
413    // requirement upto 1.30 (for trim_start) we roll our own trim_left :(
414    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        // line consists of single "#" which might not be followed by newline on windows
424        &trimmed[1..]
425    } else {
426        line
427    }
428}
429
430/// Creates the Rust code that this test will be operating on.
431fn 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 we are not running, just compile the test without running it
462    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    // Can't open in write mode now as that would modify the last changed timestamp of the file
488    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                // No change avoid writing to avoid updating the timestamp of the file
494                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}