Skip to content

Commit

Permalink
Add assert_only_contains_regex!
Browse files Browse the repository at this point in the history
This macro compliments the existing `assert_contains_regex!`. While
`assert_contains_regex!` implements the “exists” quantifier, the new
macro implements a “for all” quantifier.

In more details, the macro uses two steps:

1. Replace `{version}` in the `template` by a regular expression which
   will match _any_ SemVer version number. This allows, say,
   `docs.rs/{name}/{version}/` to match old and outdated occurrences
   of your package.

2. Find all matches in the file and check each match against
   `template`. This time we replace `{version}` with `pkg_version` so
   that we can do an accurate comparison with the actual package
   version.

This allows you to ensure that, say, all `docs.rs` links are updated.

Fixes #72.
  • Loading branch information
mgeisler committed Dec 11, 2021
1 parent 5d5bbc8 commit 19b1ed5
Show file tree
Hide file tree
Showing 7 changed files with 305 additions and 10 deletions.
4 changes: 3 additions & 1 deletion .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ jobs:
run: |
git commit --all -m "Update changelog for version ${{ needs.setup.outputs.new-version }}"
- name: Update TOML code blocks
- name: Update README version numbers
run: |
import fileinput, re, sys
Expand All @@ -103,6 +103,8 @@ jobs:
MAJOR_MINOR = '.'.join(NEW_VERSION.split('.')[:2])
for line in fileinput.input(inplace=True):
line = re.sub(f'https://docs.rs/{NAME}/[^/]+/',
f'https://docs.rs/{NAME}/{NEW_VERSION}/', line)
line = re.sub(f'{NAME} = "[^"]+"',
f'{NAME} = "{MAJOR_MINOR}"', line)
line = re.sub(f'{NAME} = {{ version = "[^"]+"',
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ edition = "2018"
default = ["markdown_deps_updated", "html_root_url_updated", "contains_regex"]
markdown_deps_updated = ["pulldown-cmark", "semver", "toml"]
html_root_url_updated = ["url", "semver", "syn", "proc-macro2"]
contains_regex = ["regex"]
contains_regex = ["regex", "semver"]

[dependencies]
pulldown-cmark = { version = "0.8", default-features = false, optional = true }
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ Contributions will be accepted under the same license.
[build-status]: https://github.com/mgeisler/version-sync/actions?query=workflow%3Abuild+branch%3Amaster
[codecov]: https://codecov.io/gh/mgeisler/version-sync
[crates-io]: https://crates.io/crates/version-sync
[api-docs]: https://docs.rs/version-sync/
[api-docs]: https://docs.rs/version-sync/0.9.3/
[rust-2018]: https://doc.rust-lang.org/edition-guide/rust-2018/
[mit]: LICENSE
[issue-17]: https://github.com/mgeisler/version-sync/issues/17
Expand Down
220 changes: 216 additions & 4 deletions src/contains_regex.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
#![cfg(feature = "contains_regex")]
use regex::{escape, RegexBuilder};
use regex::{escape, Regex, RegexBuilder};
use semver::{Version, VersionReq};

use crate::helpers::{read_file, Result};
use crate::helpers::{read_file, version_matches_request, Result};

/// Matches a full or partial SemVer version number.
const SEMVER_RE: &str = concat!(
r"(?P<major>0|[1-9]\d*)",
r"(?:\.(?P<minor>0|[1-9]\d*)",
r"(?:\.(?P<patch>0|[1-9]\d*)",
r"(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)",
r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?",
r"(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?",
r")?", // Close patch plus prerelease and buildmetadata.
r")?", // Close minor.
);

/// Check that `path` contain the regular expression given by
/// `template`.
///
/// This function only checks that there is at least one match for the
/// `template` given. Use [`check_only_contains_regex`] if you want to
/// ensure that all references to your package version is up to date.
///
/// The placeholders `{name}` and `{version}` will be replaced with
/// `pkg_name` and `pkg_version`, if they are present in `template`.
/// It is okay if `template` do not contain these placeholders.
Expand Down Expand Up @@ -47,6 +64,104 @@ pub fn check_contains_regex(
}
}

/// Check that `path` only contains matches to the regular expression
/// given by `template`.
///
/// While the [`check_contains_regex`] function verifies the existance
/// of _at least one match_, this function verifies that _all matches_
/// use the correct version number. Use this if you have a file which
/// should always reference the current version of your package.
///
/// The check proceeds in two steps:
///
/// 1. Replace `{version}` in `template` by a regular expression which
/// will match _any_ SemVer version number. This allows, say,
/// `"docs.rs/{name}/{version}/"` to match old and outdated
/// occurrences of your package.
///
/// 2. Find all matches in the file and check the version number in
/// each match for compatibility with `pkg_version`. It is enough
/// for the version number to be compatible, meaning that
/// `"foo/{version}/bar" matches `"foo/1.2/bar"` when `pkg_version`
/// is `"1.2.3"`.
///
/// It is an error if there are no matches for `template` at all.
///
/// The matching is done in multi-line mode, which means that `^` in
/// the regular expression will match the beginning of any line in the
/// file, not just the very beginning of the file.
///
/// # Errors
///
/// If any of the matches are incompatible with `pkg_version`, an
/// `Err` is returned with a succinct error message. Status
/// information has then already been printed on `stdout`.
pub fn check_only_contains_regex(
path: &str,
template: &str,
pkg_name: &str,
pkg_version: &str,
) -> Result<()> {
let version = Version::parse(pkg_version)
.map_err(|err| format!("bad package version {:?}: {}", pkg_version, err))?;

let pattern = template
.replace("{name}", &escape(pkg_name))
.replace("{version}", SEMVER_RE);
let re = RegexBuilder::new(&pattern)
.multi_line(true)
.build()
.map_err(|err| format!("could not parse template: {}", err))?;

let semver_re = Regex::new(&SEMVER_RE).unwrap();

let text = read_file(path).map_err(|err| format!("could not read {}: {}", path, err))?;

println!("Searching for \"{}\" in {}...", template, path);
let mut errors = 0;
let mut has_match = false;

for m in re.find_iter(&text) {
has_match = true;
let line_no = text[..m.start()].lines().count() + 1;

for semver in semver_re.find_iter(m.as_str()) {
let semver_request = VersionReq::parse(semver.as_str())
.map_err(|err| format!("could not parse version: {}", err))?;
let result = version_matches_request(&version, &semver_request);
match result {
Err(err) => {
errors += 1;
println!(
"{} (line {}) ... found \"{}\", which does not match version \"{}\": {}",
path,
line_no,
semver.as_str(),
pkg_version,
err
);
}
Ok(()) => {
println!("{} (line {}) ... ok", path, line_no);
}
}
}
}

if !has_match {
return Err(format!(
"{} ... found no matches for \"{}\"",
path, template
));
}

if errors > 0 {
return Err(format!("{} ... found {} errors", path, errors));
}

return Ok(());
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -115,13 +230,110 @@ mod tests {
use std::io::Write;
let mut file = tempfile::NamedTempFile::new().unwrap();

println!("Path: {}", file.path().to_str().unwrap());

file.write_all(b"first line\r\nsecond line\r\nthird line\r\n")
.unwrap();
assert_eq!(
check_contains_regex(file.path().to_str().unwrap(), "^second line$", "", ""),
Ok(())
)
}

#[test]
fn semver_regex() {
// We anchor the regex here to better match the behavior when
// users call check_only_contains_regex with a string like
// "foo {version}" which also contains more than just
// "{version}".
let re = Regex::new(&format!("^{}$", SEMVER_RE)).unwrap();
assert!(re.is_match("1.2.3"));
assert!(re.is_match("1.2"));
assert!(re.is_match("1"));
assert!(re.is_match("1.2.3-foo.bar.baz.42+build123.2021.12.11"));
assert!(!re.is_match("01"));
assert!(!re.is_match("01.02.03"));
}

#[test]
fn only_contains_success() {
use std::io::Write;
let mut file = tempfile::NamedTempFile::new().unwrap();
file.write_all(
b"first: docs.rs/foo/1.2.3/foo/fn.bar.html
second: docs.rs/foo/1.2.3/foo/fn.baz.html",
)
.unwrap();

assert_eq!(
check_only_contains_regex(
file.path().to_str().unwrap(),
"docs.rs/{name}/{version}/{name}/",
"foo",
"1.2.3"
),
Ok(())
)
}

#[test]
fn only_contains_success_compatible() {
use std::io::Write;
let mut file = tempfile::NamedTempFile::new().unwrap();
file.write_all(
b"first: docs.rs/foo/1.2/foo/fn.bar.html
second: docs.rs/foo/1/foo/fn.baz.html",
)
.unwrap();

assert_eq!(
check_only_contains_regex(
file.path().to_str().unwrap(),
"docs.rs/{name}/{version}/{name}/",
"foo",
"1.2.3"
),
Ok(())
)
}

#[test]
fn only_contains_failure() {
use std::io::Write;
let mut file = tempfile::NamedTempFile::new().unwrap();
file.write_all(
b"first: docs.rs/foo/1.0.0/foo/ <- error
second: docs.rs/foo/2.0.0/foo/ <- ok
third: docs.rs/foo/3.0.0/foo/ <- error",
)
.unwrap();

assert_eq!(
check_only_contains_regex(
file.path().to_str().unwrap(),
"docs.rs/{name}/{version}/{name}/",
"foo",
"2.0.0"
),
Err(format!("{} ... found 2 errors", file.path().display()))
)
}

#[test]
fn only_contains_fails_if_no_match() {
use std::io::Write;
let mut file = tempfile::NamedTempFile::new().unwrap();
file.write_all(b"not a match").unwrap();

assert_eq!(
check_only_contains_regex(
file.path().to_str().unwrap(),
"docs.rs/{name}/{version}/{name}/",
"foo",
"1.2.3"
),
Err(format!(
r#"{} ... found no matches for "docs.rs/{{name}}/{{version}}/{{name}}/""#,
file.path().display()
))
);
}
}
6 changes: 5 additions & 1 deletion src/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ pub fn indent(text: &str) -> String {
}

/// Verify that the version range request matches the given version.
#[cfg(any(feature = "html_root_url_updated", feature = "markdown_deps_updated"))]
#[cfg(any(
feature = "html_root_url_updated",
feature = "markdown_deps_updated",
feature = "contains_regex"
))]
pub fn version_matches_request(
version: &semver::Version,
request: &semver::VersionReq,
Expand Down
75 changes: 73 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
//! version. See [`assert_contains_regex`] and
//! [`assert_contains_substring`].
//!
//! * A `README.md` file which should only mention the current
//! version. See [`assert_only_contains_regex`].
//!
//! * The [`html_root_url`] attribute that tells other crates where to
//! find your documentation. See [`assert_html_root_url_updated`].
//!
Expand Down Expand Up @@ -65,7 +68,8 @@
//!
//! * `markdown_deps_updated` enables [`assert_markdown_deps_updated`].
//! * `html_root_url_updated` enables [`assert_html_root_url_updated`].
//! * `contains_regex` enables [`assert_contains_regex`].
//! * `contains_regex` enables [`assert_contains_regex`] and
//! [`assert_only_contains_regex`].
//!
//! All of these features are enabled by default. If you disable all
//! of them, you can still use [`assert_contains_substring`] to
Expand All @@ -88,7 +92,7 @@ mod html_root_url;
mod markdown_deps;

#[cfg(feature = "contains_regex")]
pub use crate::contains_regex::check_contains_regex;
pub use crate::contains_regex::{check_contains_regex, check_only_contains_regex};
pub use crate::contains_substring::check_contains_substring;
#[cfg(feature = "html_root_url_updated")]
pub use crate::html_root_url::check_html_root_url;
Expand Down Expand Up @@ -325,3 +329,70 @@ macro_rules! assert_contains_regex {
}
};
}

/// Assert that all versions numbers are up to date via a regex.
///
/// This macro allows you verify that the current version number is
/// mentioned in a particular file, such as a README file. You do this
/// by specifying a regular expression which will be matched against
/// the contents of the file.
///
/// The macro calls [`check_only_contains_regex`] on the file name
/// given. The package name and current package version is
/// automatically taken from the `$CARGO_PKG_NAME` and
/// `$CARGO_PKG_VERSION` environment variables. These environment
/// variables are automatically set by Cargo when compiling your
/// crate.
///
/// This macro is enabled by the `contains_regex` feature.
///
/// # Usage
///
/// The typical way to use this macro is from an integration test:
///
/// ```rust
/// #[test]
/// # fn fake_hidden_test_case() {}
/// # // The above function ensures test_readme_mentions_version is
/// # // compiled.
/// fn test_readme_links_are_updated() {
/// version_sync::assert_only_contains_regex!("README.md", "docs.rs/{name}/{version}/");
/// }
///
/// # fn main() {
/// # test_readme_links_are_updated();
/// # }
/// ```
///
/// Tests are run with the current directory set to directory where
/// your `Cargo.toml` file is, so this will find a `README.md` file
/// next to your `Cargo.toml` file. It will then check that all links
/// to docs.rs for your crate contain the current version of your
/// crate.
///
/// The regular expression can contain placeholders which are replaced
/// as follows:
///
/// * `{version}`: the version number of your package.
/// * `{name}`: the name of your package.
///
/// The `{version}` placeholder will match compatible versions,
/// meaning that `{version}` will match all of `1.2.3`, `1.2`, and `1`
/// when your package is at version `1.2.3`.
///
/// # Panics
///
/// If the regular expression cannot be found or if some matches are
/// not updated, `panic!` will be invoked and your integration test
/// will fail.
#[macro_export]
#[cfg(feature = "contains_regex")]
macro_rules! assert_only_contains_regex {
($path:expr, $format:expr) => {
let pkg_name = env!("CARGO_PKG_NAME");
let pkg_version = env!("CARGO_PKG_VERSION");
if let Err(err) = $crate::check_only_contains_regex($path, $format, pkg_name, pkg_version) {
panic!("{}", err);
}
};
}
Loading

0 comments on commit 19b1ed5

Please sign in to comment.