Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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