use pulldown_cmark::{Event, Parser, Tag};
use semver_parser::range::parse as parse_request;
use semver_parser::range::VersionReq;
use semver_parser::version::parse as parse_version;
use toml::Value;
use crate::helpers::{indent, read_file, version_matches_request, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
struct CodeBlock<'a> {
content: &'a str,
first_line: usize,
}
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")
.and_then(|version| version.as_str())
.or_else(|| dep.get("git").and(Some("*")))
.or_else(|| dep.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 parser = Parser::new(text);
let mut code_blocks = Vec::new();
for (event, range) in parser.into_offset_iter() {
match event {
Event::Start(Tag::CodeBlock(ref lang)) if is_toml_block(lang) => {
let line_count = text[..range.start].lines().count();
let code_block = &text[range];
let start = 1 + code_block.find('\n').unwrap_or(0);
let end = 1 + code_block.rfind('\n').unwrap_or(0);
code_blocks.push(CodeBlock {
content: &code_block[start..end],
first_line: 2 + line_count,
});
}
_ => {}
}
}
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(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_markdown_file() {
assert_eq!(find_toml_blocks(""), vec![]);
}
#[test]
fn indented_code_block() {
assert_eq!(find_toml_blocks(" code block\n"), vec![]);
}
#[test]
fn empty_toml_block() {
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
}]
);
}
#[test]
fn nonempty_toml_block() {
let text = "Preceding text.\n\
```toml\n\
foo\n\
```\n\
Trailing text";
assert_eq!(
find_toml_blocks(&text),
vec![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"));
}
#[test]
fn simple() {
let block = "[dependencies]\n\
foobar = '1.5'";
let request = extract_version_request("foobar", block);
assert_eq!(request.unwrap(), parse_request("1.5").unwrap());
}
#[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(), parse_request("1.5").unwrap());
}
#[test]
fn git_dependency() {
let block = "[dependencies]\n\
foobar = { git = 'https://example.net/foobar.git' }";
let request = extract_version_request("foobar", block);
assert_eq!(request.unwrap(), parse_request("*").unwrap());
}
#[test]
fn dev_dependencies() {
let block = "[dev-dependencies]\n\
foobar = '1.5'";
let request = extract_version_request("foobar", block);
assert_eq!(request.unwrap(), parse_request("1.5").unwrap());
}
#[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: \
encountered unexpected token: AlphaNumeric(\"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"
);
}
#[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 more input"
))
);
}
}