check_versions/
lib.rs

1#![deprecated(since = "0.1.1", note = "please use the `version-sync` crate instead")]
2extern crate pulldown_cmark;
3extern crate toml;
4extern crate semver_parser;
5
6use std::fs::File;
7use std::io::Read;
8use std::result;
9
10use pulldown_cmark::{Parser, Event, Tag};
11use semver_parser::range::parse as parse_request;
12use semver_parser::range::{VersionReq, Op};
13use semver_parser::version::Version;
14use semver_parser::version::parse as parse_version;
15use toml::Value;
16
17/// The common result type, our errors will be simple strings.
18type Result<T> = result::Result<T, String>;
19
20/// A fenced code block.
21#[derive(Debug, Clone, PartialEq, Eq)]
22struct CodeBlock<'a> {
23    /// Text between the fences.
24    content: &'a str,
25    /// Line number starting with 1.
26    first_line: usize,
27}
28
29impl<'a> CodeBlock<'a> {
30    /// Contruct a new code block from text[start..end]. This only
31    /// works for fenced code blocks. The `start` index must be the
32    /// first line of data in the code block, `end` must be right
33    /// after the final newline of a fenced code block.
34    fn new(text: &'a str, start: usize, end: usize) -> CodeBlock {
35        // A code block with no closing fence is reported as being
36        // closed at the end of the file. In that case, we cannot be
37        // sure to find a final newline.
38        let last_nl = match text[..end - 1].rfind('\n') {
39            Some(i) => i + 1,
40            None => start,
41        };
42        let first_line = 1 + text[..start].lines().count();
43        CodeBlock {
44            content: &text[start..last_nl],
45            first_line: first_line,
46        }
47    }
48}
49
50/// Return all data from `path`.
51fn read_file(path: &str) -> std::io::Result<String> {
52    let mut file = File::open(path)?;
53    let mut buf = String::new();
54    file.read_to_string(&mut buf)?;
55    Ok(buf)
56}
57
58/// Indent every line in text by four spaces.
59fn indent(text: &str) -> String {
60    text.lines()
61        .map(|line| String::from("    ") + line)
62        .collect::<Vec<_>>()
63        .join("\n")
64}
65
66/// Verify that the version range request matches the given version.
67fn version_matches_request(version: &Version, request: &VersionReq) -> Result<()> {
68    if request.predicates.len() != 1 {
69        // Can only handle simple dependencies
70        return Ok(());
71    }
72
73    let pred = &request.predicates[0];
74    match pred.op {
75        Op::Tilde | Op::Compatible => {
76            if pred.major != version.major {
77                return Err(format!(
78                    "expected major version {}, found {}",
79                    version.major,
80                    pred.major,
81                ));
82            }
83            if let Some(minor) = pred.minor {
84                if minor != version.minor {
85                    return Err(format!("expected minor version {}, found {}",
86                                       version.minor,
87                                       minor));
88                }
89            }
90            if let Some(patch) = pred.patch {
91                if patch != version.patch {
92                    return Err(format!("expected patch version {}, found {}",
93                                       version.patch,
94                                       patch));
95                }
96            }
97        }
98        _ => return Ok(()), // We cannot check other operators.
99    }
100
101    Ok(())
102}
103
104/// Extract a dependency on the given package from a TOML code block.
105fn extract_version_request(pkg_name: &str, block: &str) -> Result<VersionReq> {
106    match block.parse::<Value>() {
107        Ok(value) => {
108            let version = value
109                .get("dependencies")
110                .or_else(|| value.get("dev-dependencies"))
111                .and_then(|deps| deps.get(pkg_name))
112                .and_then(|dep| dep.get("version").or_else(|| Some(dep)))
113                .and_then(|version| version.as_str());
114            match version {
115                Some(version) => {
116                    parse_request(version)
117                        .map_err(|err| format!("could not parse dependency: {}", err))
118                }
119                None => Err(format!("no dependency on {}", pkg_name)),
120            }
121        }
122        Err(err) => Err(format!("TOML parse error: {}", err)),
123    }
124}
125
126/// Check if a code block language line says the block is TOML code.
127fn is_toml_block(lang: &str) -> bool {
128    // Split the language line as LangString::parse from rustdoc:
129    // https://github.com/rust-lang/rust/blob/1.20.0/src/librustdoc/html/markdown.rs#L922
130    lang.split(|c: char| !(c == '_' || c == '-' || c.is_alphanumeric()))
131        .any(|token| token.trim() == "toml")
132}
133
134/// Find all TOML code blocks in a Markdown text.
135fn find_toml_blocks(text: &str) -> Vec<CodeBlock> {
136    let mut parser = Parser::new(text);
137    let mut code_blocks = Vec::new();
138    let mut start = 0;
139    // A normal for-loop doesn't work since that would borrow the
140    // parser mutably for the duration of the loop body, preventing us
141    // from calling get_offset later.
142    while let Some(event) = parser.next() {
143        match event {
144            Event::Start(Tag::CodeBlock(_)) => {
145                start = parser.get_offset();
146            }
147            Event::End(Tag::CodeBlock(lang)) => {
148                // Only fenced code blocks have language information.
149                if is_toml_block(&lang) {
150                    let end = parser.get_offset();
151                    code_blocks.push(CodeBlock::new(text, start, end));
152                }
153            }
154            _ => {}
155        }
156    }
157
158    code_blocks
159}
160
161/// Check dependencies in Markdown code blocks.
162///
163/// This function finds all TOML code blocks in `path` and looks for
164/// dependencies on `pkg_name` in those blocks. A code block fails the
165/// check if it has a dependency on `pkg_name` that doesn't match
166/// `pkg_version`, or if it has no dependency on `pkg_name` at all.
167/// Code blocks also fails the check if they cannot be parsed as TOML.
168///
169/// # Errors
170///
171/// If any block failed the check, an `Err` is returned that can be
172/// used to make a test fail or pass.
173pub fn check_markdown_deps(path: &str, pkg_name: &str, pkg_version: &str) -> Result<()> {
174    let text = read_file(path)
175        .map_err(|err| format!("could not read {}: {}", path, err))?;
176    let version = parse_version(pkg_version)
177        .map_err(|err| format!("bad package version {:?}: {}", pkg_version, err))?;
178
179    println!("Checking code blocks in {}...", path);
180    let mut failed = false;
181    for block in find_toml_blocks(&text) {
182        let result = extract_version_request(pkg_name, block.content)
183            .and_then(|request| version_matches_request(&version, &request));
184        match result {
185            Err(err) => {
186                failed = true;
187                println!("{} (line {}) ... {} in", path, block.first_line, err);
188                println!("{}\n", indent(block.content));
189            }
190            Ok(()) => println!("{} (line {}) ... ok", path, block.first_line),
191        }
192    }
193
194    if failed {
195        return Err(format!("dependency errors in {}", path));
196    }
197    Ok(())
198}
199
200/// Assert that dependencies on the current package are up to date.
201///
202/// The macro will call [`check_markdown_deps`] on the file name given
203/// in order to check that the TOML examples found all depend on a
204/// current version of your package. The package name is automatically
205/// taken from the `$CARGO_PKG_NAME` environment variable and the
206/// version is taken from `$CARGO_PKG_VERSION`. These environment
207/// variables are automatically set by Cargo when compiling your
208/// crate.
209///
210/// # Usage
211///
212/// The typical way to use this macro is from an integration test:
213///
214/// ```rust,no_run
215/// #[macro_use]
216/// extern crate check_versions;
217///
218/// #[test]
219/// # fn fake_hidden_test_case() {}
220/// # // The above function ensures test_readme_deps is compiled.
221/// fn test_readme_deps() {
222///     assert_markdown_deps_updated!("README.md");
223/// }
224///
225/// # fn main() {}
226/// ```
227///
228/// Tests are run with the current directory set to directory where
229/// your `Cargo.toml` file is, so this will find a `README.md` file
230/// next to your `Cargo.toml` file.
231///
232/// # Panics
233///
234/// If any TOML code block fails the check, `panic!` will be invoked.
235///
236/// [`check_markdown_deps`]: fn.check_markdown_deps.html
237#[macro_export]
238macro_rules! assert_markdown_deps_updated {
239    ($path:expr) => {
240        let pkg_name = env!("CARGO_PKG_NAME");
241        let pkg_version = env!("CARGO_PKG_VERSION");
242        if let Err(err) = $crate::check_markdown_deps($path, pkg_name, pkg_version) {
243            panic!(err);
244        }
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn code_block_new() {
254        let text = "Preceding text.\n\
255                    ```\n\
256                    foo\n\
257                    ```\n\
258                    Trailing text";
259        let start = text.find("```\n").unwrap() + 4;
260        let end = text.rfind("```\n").unwrap() + 4;
261        assert_eq!(CodeBlock::new(text, start, end),
262                   CodeBlock { content: "foo\n", first_line: 3 });
263    }
264
265    #[test]
266    fn is_toml_block_simple() {
267        assert!(!is_toml_block("rust"))
268    }
269
270    #[test]
271    fn is_toml_block_comma() {
272        assert!(is_toml_block("foo,toml"))
273    }
274
275    mod test_version_matches_request {
276        use super::*;
277
278        #[test]
279        fn implicit_compatible() {
280            let version = parse_version("1.2.3").unwrap();
281            let request = parse_request("1.2.3").unwrap();
282            assert_eq!(version_matches_request(&version, &request), Ok(()));
283        }
284
285        #[test]
286        fn compatible() {
287            let version = parse_version("1.2.3").unwrap();
288            let request = parse_request("^1.2.3").unwrap();
289            assert_eq!(version_matches_request(&version, &request), Ok(()));
290        }
291
292        #[test]
293        fn tilde() {
294            let version = parse_version("1.2.3").unwrap();
295            let request = parse_request("~1.2.3").unwrap();
296            assert_eq!(version_matches_request(&version, &request), Ok(()));
297        }
298
299        #[test]
300        fn no_patch() {
301            let version = parse_version("1.2.3").unwrap();
302            let request = parse_request("1.2").unwrap();
303            assert_eq!(version_matches_request(&version, &request), Ok(()));
304        }
305
306        #[test]
307        fn no_minor() {
308            let version = parse_version("1.2.3").unwrap();
309            let request = parse_request("1").unwrap();
310            assert_eq!(version_matches_request(&version, &request), Ok(()));
311        }
312
313        #[test]
314        fn multiple_predicates() {
315            let version = parse_version("1.2.3").unwrap();
316            let request = parse_request(">= 1.2.3, < 2.0").unwrap();
317            assert_eq!(version_matches_request(&version, &request), Ok(()));
318        }
319
320        #[test]
321        fn unhandled_operator() {
322            let version = parse_version("1.2.3").unwrap();
323            let request = parse_request("< 2.0").unwrap();
324            assert_eq!(version_matches_request(&version, &request), Ok(()));
325        }
326
327        #[test]
328        fn bad_major() {
329            let version = parse_version("2.0.0").unwrap();
330            let request = parse_request("1.2.3").unwrap();
331            assert_eq!(version_matches_request(&version, &request),
332                       Err(String::from("expected major version 2, found 1")));
333        }
334
335        #[test]
336        fn bad_minor() {
337            let version = parse_version("1.3.0").unwrap();
338            let request = parse_request("1.2.3").unwrap();
339            assert_eq!(version_matches_request(&version, &request),
340                       Err(String::from("expected minor version 3, found 2")));
341        }
342
343        #[test]
344        fn bad_patch() {
345            let version = parse_version("1.2.4").unwrap();
346            let request = parse_request("1.2.3").unwrap();
347            assert_eq!(version_matches_request(&version, &request),
348                       Err(String::from("expected patch version 4, found 3")));
349        }
350    }
351
352    mod test_extract_version_request {
353        use super::*;
354
355        #[test]
356        fn simple() {
357            let block = "[dependencies]\n\
358                         foobar = '1.5'";
359            let request = extract_version_request("foobar", block);
360            assert_eq!(request.unwrap().predicates,
361                       parse_request("1.5").unwrap().predicates);
362        }
363
364        #[test]
365        fn table() {
366            let block = "[dependencies]\n\
367                         foobar = { version = '1.5', default-features = false }";
368            let request = extract_version_request("foobar", block);
369            assert_eq!(request.unwrap().predicates,
370                       parse_request("1.5").unwrap().predicates);
371        }
372
373        #[test]
374        fn dev_dependencies() {
375            let block = "[dev-dependencies]\n\
376                         foobar = '1.5'";
377            let request = extract_version_request("foobar", block);
378            assert_eq!(request.unwrap().predicates,
379                       parse_request("1.5").unwrap().predicates);
380        }
381
382        #[test]
383        fn bad_version() {
384            let block = "[dependencies]\n\
385                         foobar = '1.5.bad'";
386            let request = extract_version_request("foobar", block);
387            assert_eq!(request.unwrap_err(),
388                       "could not parse dependency: Extra junk after valid predicate: .bad");
389        }
390
391        #[test]
392        fn missing_dependency() {
393            let block = "[dependencies]\n\
394                         baz = '1.5.8'";
395            let request = extract_version_request("foobar", block);
396            assert_eq!(request.unwrap_err(), "no dependency on foobar");
397        }
398
399        #[test]
400        fn empty() {
401            let request = extract_version_request("foobar", "");
402            assert_eq!(request.unwrap_err(), "no dependency on foobar");
403        }
404
405        #[test]
406        fn bad_toml() {
407            let block = "[dependencies]\n\
408                         foobar = 1.5.8";
409            let request = extract_version_request("foobar", block);
410            assert_eq!(request.unwrap_err(),
411                       "TOML parse error: expected newline, found a period at line 2");
412        }
413    }
414
415    mod test_find_toml_blocks {
416        use super::*;
417
418        #[test]
419        fn empty() {
420            assert_eq!(find_toml_blocks(""), vec![]);
421        }
422
423        #[test]
424        fn indented_block() {
425            assert_eq!(find_toml_blocks("    code block\n"), vec![]);
426        }
427
428        #[test]
429        fn single() {
430            assert_eq!(find_toml_blocks("```toml\n```"),
431                       vec![CodeBlock { content: "", first_line: 2 }]);
432        }
433
434        #[test]
435        fn no_close_fence() {
436            assert_eq!(find_toml_blocks("```toml\n"),
437                       vec![CodeBlock { content: "", first_line: 2 }]);
438        }
439    }
440
441    mod test_check_markdown_deps {
442        use super::*;
443
444        #[test]
445        fn bad_path() {
446            let no_such_file = if cfg!(unix) {
447                "No such file or directory (os error 2)"
448            } else {
449                "The system cannot find the file specified. (os error 2)"
450            };
451            let errmsg = format!("could not read no-such-file.md: {}", no_such_file);
452            assert_eq!(check_markdown_deps("no-such-file.md", "foobar", "1.2.3"),
453                       Err(errmsg));
454        }
455
456        #[test]
457        fn bad_pkg_version() {
458            // This uses the README.md file from this crate.
459            assert_eq!(check_markdown_deps("README.md", "foobar", "1.2"),
460                       Err(String::from("bad package version \"1.2\": \
461                                         Expected dot")));
462        }
463    }
464}