#![doc(html_root_url = "https://docs.rs/version-sync/0.3.1")]
#![deny(missing_docs)]
extern crate itertools;
extern crate pulldown_cmark;
extern crate semver_parser;
extern crate syntex_syntax as syntax;
extern crate toml;
extern crate url;
use std::fs::File;
use std::io::Read;
use std::result;
use pulldown_cmark::{Parser, Event, Tag};
use semver_parser::range::parse as parse_request;
use semver_parser::range::{VersionReq, Op};
use semver_parser::version::Version;
use semver_parser::version::parse as parse_version;
use syntax::parse::{ParseSess, parse_crate_attrs_from_source_str};
use toml::Value;
use url::Url;
use itertools::join;
type Result<T> = result::Result<T, String>;
#[derive(Debug, Clone, PartialEq, Eq)]
struct CodeBlock<'a> {
content: &'a str,
first_line: usize,
}
impl<'a> CodeBlock<'a> {
fn new(text: &'a str, start: usize, end: usize) -> CodeBlock {
let last_nl = match text[..end - 1].rfind('\n') {
Some(i) => i + 1,
None => start,
};
let first_line = 1 + text[..start].lines().count();
CodeBlock {
content: &text[start..last_nl],
first_line: first_line,
}
}
}
fn read_file(path: &str) -> std::io::Result<String> {
let mut file = File::open(path)?;
let mut buf = String::new();
file.read_to_string(&mut buf)?;
Ok(buf)
}
fn indent(text: &str) -> String {
join(text.lines().map(|line| String::from(" ") + line), "\n")
}
fn version_matches_request(version: &Version, request: &VersionReq) -> Result<()> {
if request.predicates.len() != 1 {
return Ok(());
}
let pred = &request.predicates[0];
match pred.op {
Op::Tilde | Op::Compatible => {
if pred.major != version.major {
return Err(format!(
"expected major version {}, found {}",
version.major,
pred.major,
));
}
if let Some(minor) = pred.minor {
if minor != version.minor {
return Err(format!("expected minor version {}, found {}",
version.minor,
minor));
}
}
if let Some(patch) = pred.patch {
if patch != version.patch {
return Err(format!("expected patch version {}, found {}",
version.patch,
patch));
}
}
if pred.pre != version.pre {
return Err(format!("expected pre-release {:?}, found {:?}",
join(&version.pre, "."),
join(&pred.pre, ".")));
}
}
_ => return Ok(()), }
Ok(())
}
fn extract_version_request(pkg_name: &str, block: &str) -> Result<VersionReq> {
match block.parse::<Value>() {
Ok(value) => {
let version = value
.get("dependencies")
.or_else(|| value.get("dev-dependencies"))
.and_then(|deps| deps.get(pkg_name))
.and_then(|dep| dep.get("version").or_else(|| Some(dep)))
.and_then(|version| version.as_str());
match version {
Some(version) => {
parse_request(version)
.map_err(|err| format!("could not parse dependency: {}", err))
}
None => Err(format!("no dependency on {}", pkg_name)),
}
}
Err(err) => Err(format!("TOML parse error: {}", err)),
}
}
fn is_toml_block(lang: &str) -> bool {
let mut has_toml = false;
for token in lang.split(|c: char| !(c == '_' || c == '-' || c.is_alphanumeric())) {
match token.trim() {
"no_sync" => return false,
"toml" => has_toml = true,
_ => {}
}
}
has_toml
}
fn find_toml_blocks(text: &str) -> Vec<CodeBlock> {
let mut parser = Parser::new(text);
let mut code_blocks = Vec::new();
let mut start = 0;
while let Some(event) = parser.next() {
match event {
Event::Start(Tag::CodeBlock(_)) => {
start = parser.get_offset();
}
Event::End(Tag::CodeBlock(lang)) => {
if is_toml_block(&lang) {
let end = parser.get_offset();
code_blocks.push(CodeBlock::new(text, start, end));
}
}
_ => {}
}
}
code_blocks
}
pub fn check_markdown_deps(path: &str, pkg_name: &str, pkg_version: &str) -> Result<()> {
let text = read_file(path)
.map_err(|err| format!("could not read {}: {}", path, err))?;
let version = parse_version(pkg_version)
.map_err(|err| format!("bad package version {:?}: {}", pkg_version, err))?;
println!("Checking code blocks in {}...", path);
let mut failed = false;
for block in find_toml_blocks(&text) {
let result = extract_version_request(pkg_name, block.content)
.and_then(|request| version_matches_request(&version, &request));
match result {
Err(err) => {
failed = true;
println!("{} (line {}) ... {} in", path, block.first_line, err);
println!("{}\n", indent(block.content));
}
Ok(()) => println!("{} (line {}) ... ok", path, block.first_line),
}
}
if failed {
return Err(format!("dependency errors in {}", path));
}
Ok(())
}
#[macro_export]
macro_rules! assert_markdown_deps_updated {
($path:expr) => {
let pkg_name = env!("CARGO_PKG_NAME");
let pkg_version = env!("CARGO_PKG_VERSION");
if let Err(err) = $crate::check_markdown_deps($path, pkg_name, pkg_version) {
panic!(err);
}
}
}
fn url_matches(value: &str, pkg_name: &str, version: &Version) -> Result<()> {
let url = Url::parse(value)
.map_err(|err| format!("parse error: {}", err))?;
if url.scheme() != "https" {
return Err(format!("expected \"https\", found {:?}", url.scheme()));
}
if url.domain() != Some("docs.rs") {
return Ok(());
}
let mut path_segments = url.path_segments()
.ok_or_else(|| String::from("no path in URL"))?;
let name = path_segments
.next()
.and_then(|path| if path.is_empty() { None } else { Some(path) })
.ok_or_else(|| String::from("missing package name"))?;
let request = path_segments
.next()
.and_then(|path| if path.is_empty() { None } else { Some(path) })
.ok_or_else(|| String::from("missing version number"))?;
if name != pkg_name {
Err(format!("expected package \"{}\", found \"{}\"", pkg_name, name))
} else {
parse_request(request)
.map_err(|err| format!("could not parse version in URL: {}", err))
.and_then(|request| version_matches_request(version, &request))
}
}
pub fn check_html_root_url(path: &str, pkg_name: &str, pkg_version: &str) -> Result<()> {
let code = read_file(path)
.map_err(|err| format!("could not read {}: {}", path, err))?;
let version = parse_version(pkg_version)
.map_err(|err| format!("bad package version {:?}: {}", pkg_version, err))?;
let session = ParseSess::new();
let attrs = parse_crate_attrs_from_source_str(path.to_owned(), code, &session)
.map_err(|err| format!("could not parse {}: {:?}", path, err))?;
println!("Checking doc attributes in {}...", path);
let mut failed = false;
for attr in attrs {
if !attr.check_name("doc") {
continue;
}
if let Some(meta_items) = attr.meta_item_list() {
for item in meta_items {
if let Some(name) = item.name() {
if name != "html_root_url" {
continue;
}
let codemap = session.codemap();
let loc = codemap.lookup_char_pos(item.span.lo);
let result =
item.value_str()
.ok_or(String::from("html_root_url attribute without URL"))
.and_then(|url| url_matches(&url.as_str(), pkg_name, &version));
match result {
Ok(_) => println!("{} (line {}) ... ok", path, loc.line),
Err(err) => {
failed = true;
println!("{} (line {}) ... {} in", path, loc.line, err);
if let Ok(snippet) = codemap.span_to_snippet(attr.span) {
println!("{}\n", indent(&snippet));
}
}
}
}
}
}
}
if failed {
return Err(format!("html_root_url errors in {}", path));
}
Ok(())
}
#[macro_export]
macro_rules! assert_html_root_url_updated {
($path:expr) => {
let pkg_name = env!("CARGO_PKG_NAME");
let pkg_version = env!("CARGO_PKG_VERSION");
if let Err(err) = $crate::check_html_root_url($path, pkg_name, pkg_version) {
panic!(err);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn code_block_new() {
let text = "Preceding text.\n\
```\n\
foo\n\
```\n\
Trailing text";
let start = text.find("```\n").unwrap() + 4;
let end = text.rfind("```\n").unwrap() + 4;
assert_eq!(CodeBlock::new(text, start, end),
CodeBlock { content: "foo\n", first_line: 3 });
}
#[test]
fn is_toml_block_simple() {
assert!(!is_toml_block("rust"));
}
#[test]
fn is_toml_block_comma() {
assert!(is_toml_block("foo,toml"));
}
#[test]
fn is_toml_block_no_sync() {
assert!(!is_toml_block("toml,no_sync"));
assert!(!is_toml_block("toml, no_sync"));
}
mod test_version_matches_request {
use super::*;
#[test]
fn implicit_compatible() {
let version = parse_version("1.2.3").unwrap();
let request = parse_request("1.2.3").unwrap();
assert_eq!(version_matches_request(&version, &request), Ok(()));
}
#[test]
fn compatible() {
let version = parse_version("1.2.3").unwrap();
let request = parse_request("^1.2.3").unwrap();
assert_eq!(version_matches_request(&version, &request), Ok(()));
}
#[test]
fn tilde() {
let version = parse_version("1.2.3").unwrap();
let request = parse_request("~1.2.3").unwrap();
assert_eq!(version_matches_request(&version, &request), Ok(()));
}
#[test]
fn no_patch() {
let version = parse_version("1.2.3").unwrap();
let request = parse_request("1.2").unwrap();
assert_eq!(version_matches_request(&version, &request), Ok(()));
}
#[test]
fn no_minor() {
let version = parse_version("1.2.3").unwrap();
let request = parse_request("1").unwrap();
assert_eq!(version_matches_request(&version, &request), Ok(()));
}
#[test]
fn multiple_predicates() {
let version = parse_version("1.2.3").unwrap();
let request = parse_request(">= 1.2.3, < 2.0").unwrap();
assert_eq!(version_matches_request(&version, &request), Ok(()));
}
#[test]
fn unhandled_operator() {
let version = parse_version("1.2.3").unwrap();
let request = parse_request("< 2.0").unwrap();
assert_eq!(version_matches_request(&version, &request), Ok(()));
}
#[test]
fn bad_major() {
let version = parse_version("2.0.0").unwrap();
let request = parse_request("1.2.3").unwrap();
assert_eq!(version_matches_request(&version, &request),
Err(String::from("expected major version 2, found 1")));
}
#[test]
fn bad_minor() {
let version = parse_version("1.3.0").unwrap();
let request = parse_request("1.2.3").unwrap();
assert_eq!(version_matches_request(&version, &request),
Err(String::from("expected minor version 3, found 2")));
}
#[test]
fn bad_patch() {
let version = parse_version("1.2.4").unwrap();
let request = parse_request("1.2.3").unwrap();
assert_eq!(version_matches_request(&version, &request),
Err(String::from("expected patch version 4, found 3")));
}
#[test]
fn bad_pre_release() {
let version = parse_version("1.2.3-rc2").unwrap();
let request = parse_request("1.2.3-rc1").unwrap();
assert_eq!(version_matches_request(&version, &request),
Err(String::from("expected pre-release \"rc2\", found \"rc1\"")));
}
}
mod test_extract_version_request {
use super::*;
#[test]
fn simple() {
let block = "[dependencies]\n\
foobar = '1.5'";
let request = extract_version_request("foobar", block);
assert_eq!(request.unwrap().predicates,
parse_request("1.5").unwrap().predicates);
}
#[test]
fn table() {
let block = "[dependencies]\n\
foobar = { version = '1.5', default-features = false }";
let request = extract_version_request("foobar", block);
assert_eq!(request.unwrap().predicates,
parse_request("1.5").unwrap().predicates);
}
#[test]
fn dev_dependencies() {
let block = "[dev-dependencies]\n\
foobar = '1.5'";
let request = extract_version_request("foobar", block);
assert_eq!(request.unwrap().predicates,
parse_request("1.5").unwrap().predicates);
}
#[test]
fn bad_version() {
let block = "[dependencies]\n\
foobar = '1.5.bad'";
let request = extract_version_request("foobar", block);
assert_eq!(request.unwrap_err(),
"could not parse dependency: Extra junk after valid predicate: .bad");
}
#[test]
fn missing_dependency() {
let block = "[dependencies]\n\
baz = '1.5.8'";
let request = extract_version_request("foobar", block);
assert_eq!(request.unwrap_err(), "no dependency on foobar");
}
#[test]
fn empty() {
let request = extract_version_request("foobar", "");
assert_eq!(request.unwrap_err(), "no dependency on foobar");
}
#[test]
fn bad_toml() {
let block = "[dependencies]\n\
foobar = 1.5.8";
let request = extract_version_request("foobar", block);
assert_eq!(request.unwrap_err(),
"TOML parse error: expected newline, found a period at line 2");
}
}
mod test_find_toml_blocks {
use super::*;
#[test]
fn empty() {
assert_eq!(find_toml_blocks(""), vec![]);
}
#[test]
fn indented_block() {
assert_eq!(find_toml_blocks(" code block\n"), vec![]);
}
#[test]
fn single() {
assert_eq!(find_toml_blocks("```toml\n```"),
vec![CodeBlock { content: "", first_line: 2 }]);
}
#[test]
fn no_close_fence() {
assert_eq!(find_toml_blocks("```toml\n"),
vec![CodeBlock { content: "", first_line: 2 }]);
}
}
mod test_url_matches {
use super::*;
#[test]
fn good_url() {
let ver = parse_version("1.2.3").unwrap();
assert_eq!(url_matches("https://docs.rs/foo/1.2.3", "foo", &ver),
Ok(()));
}
#[test]
fn trailing_slash() {
let ver = parse_version("1.2.3").unwrap();
assert_eq!(url_matches("https://docs.rs/foo/1.2.3/", "foo", &ver),
Ok(()));
}
#[test]
fn without_patch() {
let ver = parse_version("1.2.3").unwrap();
assert_eq!(url_matches("https://docs.rs/foo/1.2", "foo", &ver), Ok(()));
}
#[test]
fn without_minor() {
let ver = parse_version("1.2.3").unwrap();
assert_eq!(url_matches("https://docs.rs/foo/1", "foo", &ver), Ok(()));
}
#[test]
fn different_domain() {
let ver = parse_version("1.2.3").unwrap();
assert_eq!(url_matches("https://example.net/foo/", "bar", &ver), Ok(()));
}
#[test]
fn http_url() {
let ver = parse_version("1.2.3").unwrap();
assert_eq!(url_matches("http://docs.rs/foo/1.2.3", "foo", &ver),
Err(String::from("expected \"https\", found \"http\"")));
}
#[test]
fn bad_scheme() {
let ver = parse_version("1.2.3").unwrap();
assert_eq!(url_matches("mailto:foo@example.net", "foo", &ver),
Err(String::from("expected \"https\", found \"mailto\"")));
}
#[test]
fn no_package() {
let ver = parse_version("1.2.3").unwrap();
assert_eq!(url_matches("https://docs.rs", "foo", &ver),
Err(String::from("missing package name")));
}
#[test]
fn no_package_trailing_slash() {
let ver = parse_version("1.2.3").unwrap();
assert_eq!(url_matches("https://docs.rs/", "foo", &ver),
Err(String::from("missing package name")));
}
#[test]
fn no_version() {
let ver = parse_version("1.2.3").unwrap();
assert_eq!(url_matches("https://docs.rs/foo", "foo", &ver),
Err(String::from("missing version number")));
}
#[test]
fn no_version_trailing_slash() {
let ver = parse_version("1.2.3").unwrap();
assert_eq!(url_matches("https://docs.rs/foo/", "foo", &ver),
Err(String::from("missing version number")));
}
#[test]
fn bad_url() {
let ver = parse_version("1.2.3").unwrap();
assert_eq!(url_matches("docs.rs/foo/bar", "foo", &ver),
Err(String::from("parse error: relative URL without a base")));
}
#[test]
fn bad_pkg_version() {
let ver = parse_version("1.2.3").unwrap();
assert_eq!(url_matches("https://docs.rs/foo/1.2.bad/", "foo", &ver),
Err(String::from("could not parse version in URL: \
Extra junk after valid predicate: .bad")));
}
#[test]
fn wrong_pkg_name() {
let ver = parse_version("1.2.3").unwrap();
assert_eq!(url_matches("https://docs.rs/foo/1.2.3/", "bar", &ver),
Err(String::from("expected package \"bar\", found \"foo\"")));
}
}
mod test_check_markdown_deps {
use super::*;
#[test]
fn bad_path() {
let no_such_file = if cfg!(unix) {
"No such file or directory (os error 2)"
} else {
"The system cannot find the file specified. (os error 2)"
};
let errmsg = format!("could not read no-such-file.md: {}", no_such_file);
assert_eq!(check_markdown_deps("no-such-file.md", "foobar", "1.2.3"),
Err(errmsg));
}
#[test]
fn bad_pkg_version() {
assert_eq!(check_markdown_deps("README.md", "foobar", "1.2"),
Err(String::from("bad package version \"1.2\": \
Expected dot")));
}
}
mod test_check_html_root_url {
use super::*;
#[test]
fn bad_path() {
let no_such_file = if cfg!(unix) {
"No such file or directory (os error 2)"
} else {
"The system cannot find the file specified. (os error 2)"
};
let errmsg = format!("could not read no-such-file.md: {}", no_such_file);
assert_eq!(check_html_root_url("no-such-file.md", "foobar", "1.2.3"),
Err(errmsg));
}
#[test]
fn bad_pkg_version() {
assert_eq!(check_html_root_url("src/lib.rs", "foobar", "1.2"),
Err(String::from("bad package version \"1.2\": \
Expected dot")));
}
}
}