#![cfg(feature = "markdown_deps_updated")]
use pulldown_cmark::CodeBlockKind::Fenced;
use pulldown_cmark::{Event, Parser, Tag};
use semver::{Version, VersionReq};
use toml::Value;
use crate::helpers::{indent, read_file, version_matches_request, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
struct CodeBlock {
content: String,
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) => VersionReq::parse(version)
.map_err(|err| format!("could not parse dependency: {}", err)),
None => Err(format!("no dependency on {pkg_name}")),
}
}
Err(err) => Err(format!("{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();
let mut current_block = None;
for (event, range) in parser.into_offset_iter() {
match event {
Event::Start(Tag::CodeBlock(Fenced(lang))) if is_toml_block(&lang) => {
let line_count = text[..range.start].chars().filter(|&ch| ch == '\n').count();
current_block = Some(CodeBlock {
first_line: line_count + 2,
content: String::new(),
});
}
Event::Text(code) => {
if let Some(block) = current_block.as_mut() {
block.content.push_str(&code);
}
}
Event::End(Tag::CodeBlock(_)) => {
if let Some(block) = current_block.take() {
code_blocks.push(block);
}
}
_ => {}
}
}
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 = Version::parse(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: String::new(),
first_line: 2
}]
);
}
#[test]
fn no_close_fence() {
assert_eq!(
find_toml_blocks("```toml\n"),
vec![CodeBlock {
content: String::new(),
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: String::from("foo\n"),
first_line: 3
}]
);
}
#[test]
fn blockquote_toml_block() {
let text = "> This is a blockquote\n\
>\n\
> ```toml\n\
> foo\n\
> \n\
> bar\n\
>\n\
> ```\n\
";
assert_eq!(
find_toml_blocks(text),
vec![CodeBlock {
content: String::from("foo\n\n bar\n\n"),
first_line: 4
}]
);
}
#[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(), VersionReq::parse("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(), VersionReq::parse("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(), VersionReq::parse("*").unwrap());
}
#[test]
fn dev_dependencies() {
let block = "[dev-dependencies]\n\
foobar = '1.5'";
let request = extract_version_request("foobar", block);
assert_eq!(request.unwrap(), VersionReq::parse("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: \
unexpected character 'b' while parsing patch version number"
);
}
#[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!(request.is_err());
}
#[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\": unexpected end of input while parsing minor version number"
))
);
}
}