version_sync/
contains_regex.rs

1#![cfg(feature = "contains_regex")]
2use regex::{escape, Regex, RegexBuilder};
3use semver::{Version, VersionReq};
4
5use crate::helpers::{read_file, version_matches_request, Result};
6
7/// Matches a full or partial SemVer version number.
8const SEMVER_RE: &str = concat!(
9    r"(?P<major>0|[1-9]\d*)",
10    r"(?:\.(?P<minor>0|[1-9]\d*)",
11    r"(?:\.(?P<patch>0|[1-9]\d*)",
12    r"(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)",
13    r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?",
14    r"(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?",
15    r")?", // Close patch plus prerelease and buildmetadata.
16    r")?", // Close minor.
17);
18
19/// Check that `path` contain the regular expression given by
20/// `template`.
21///
22/// This function only checks that there is at least one match for the
23/// `template` given. Use [`check_only_contains_regex`] if you want to
24/// ensure that all references to your package version is up to date.
25///
26/// The placeholders `{name}` and `{version}` will be replaced with
27/// `pkg_name` and `pkg_version`, if they are present in `template`.
28/// It is okay if `template` do not contain these placeholders.
29///
30/// The matching is done in multi-line mode, which means that `^` in
31/// the regular expression will match the beginning of any line in the
32/// file, not just the very beginning of the file.
33///
34/// # Errors
35///
36/// If the regular expression cannot be found, an `Err` is returned
37/// with a succinct error message. Status information has then already
38/// been printed on `stdout`.
39pub fn check_contains_regex(
40    path: &str,
41    template: &str,
42    pkg_name: &str,
43    pkg_version: &str,
44) -> Result<()> {
45    // Expand the placeholders in the template.
46    let pattern = template
47        .replace("{name}", &escape(pkg_name))
48        .replace("{version}", &escape(pkg_version));
49    let mut builder = RegexBuilder::new(&pattern);
50    builder.multi_line(true);
51    let re = builder
52        .build()
53        .map_err(|err| format!("could not parse template: {}", err))?;
54    let text = read_file(path).map_err(|err| format!("could not read {}: {}", path, err))?;
55
56    println!("Searching for \"{pattern}\" in {path}...");
57    match re.find(&text) {
58        Some(m) => {
59            let line_no = text[..m.start()].lines().count();
60            println!("{} (line {}) ... ok", path, line_no + 1);
61            Ok(())
62        }
63        None => Err(format!("could not find \"{pattern}\" in {path}")),
64    }
65}
66
67/// Check that `path` only contains matches to the regular expression
68/// given by `template`.
69///
70/// While the [`check_contains_regex`] function verifies the existance
71/// of _at least one match_, this function verifies that _all matches_
72/// use the correct version number. Use this if you have a file which
73/// should always reference the current version of your package.
74///
75/// The check proceeds in two steps:
76///
77/// 1. Replace `{version}` in `template` by a regular expression which
78///    will match _any_ SemVer version number. This allows, say,
79///    `"docs.rs/{name}/{version}/"` to match old and outdated
80///    occurrences of your package.
81///
82/// 2. Find all matches in the file and check the version number in
83///    each match for compatibility with `pkg_version`. It is enough
84///    for the version number to be compatible, meaning that
85///    `"foo/{version}/bar" matches `"foo/1.2/bar"` when `pkg_version`
86///    is `"1.2.3"`.
87///
88/// It is an error if there are no matches for `template` at all.
89///
90/// The matching is done in multi-line mode, which means that `^` in
91/// the regular expression will match the beginning of any line in the
92/// file, not just the very beginning of the file.
93///
94/// # Errors
95///
96/// If any of the matches are incompatible with `pkg_version`, an
97/// `Err` is returned with a succinct error message. Status
98/// information has then already been printed on `stdout`.
99pub fn check_only_contains_regex(
100    path: &str,
101    template: &str,
102    pkg_name: &str,
103    pkg_version: &str,
104) -> Result<()> {
105    let version = Version::parse(pkg_version)
106        .map_err(|err| format!("bad package version {:?}: {}", pkg_version, err))?;
107
108    let pattern = template
109        .replace("{name}", &escape(pkg_name))
110        .replace("{version}", SEMVER_RE);
111    let re = RegexBuilder::new(&pattern)
112        .multi_line(true)
113        .build()
114        .map_err(|err| format!("could not parse template: {}", err))?;
115
116    let semver_re = Regex::new(SEMVER_RE).unwrap();
117
118    let text = read_file(path).map_err(|err| format!("could not read {}: {}", path, err))?;
119
120    println!("Searching for \"{template}\" in {path}...");
121    let mut errors = 0;
122    let mut has_match = false;
123
124    for m in re.find_iter(&text) {
125        has_match = true;
126        let line_no = text[..m.start()].lines().count() + 1;
127
128        for semver in semver_re.find_iter(m.as_str()) {
129            let semver_request = VersionReq::parse(semver.as_str())
130                .map_err(|err| format!("could not parse version: {}", err))?;
131            let result = version_matches_request(&version, &semver_request);
132            match result {
133                Err(err) => {
134                    errors += 1;
135                    println!(
136                        "{} (line {}) ... found \"{}\", which does not match version \"{}\": {}",
137                        path,
138                        line_no,
139                        semver.as_str(),
140                        pkg_version,
141                        err
142                    );
143                }
144                Ok(()) => {
145                    println!("{path} (line {line_no}) ... ok");
146                }
147            }
148        }
149    }
150
151    if !has_match {
152        return Err(format!("{path} ... found no matches for \"{template}\""));
153    }
154
155    if errors > 0 {
156        return Err(format!("{path} ... found {errors} errors"));
157    }
158
159    Ok(())
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use std::io::Write;
166
167    #[test]
168    fn bad_regex() {
169        // Check that the error from a bad pattern doesn't contain
170        // the (?m) prefix.
171        assert_eq!(
172            check_contains_regex("README.md", "Version {version} [ups", "foobar", "1.2.3"),
173            Err([
174                r"could not parse template: regex parse error:",
175                r"    Version 1\.2\.3 [ups",
176                r"                    ^",
177                r"error: unclosed character class"
178            ]
179            .join("\n"))
180        )
181    }
182
183    #[test]
184    fn not_found() {
185        assert_eq!(
186            check_contains_regex("README.md", "should not be found", "foobar", "1.2.3"),
187            Err(String::from(
188                "could not find \"should not be found\" in README.md"
189            ))
190        )
191    }
192
193    #[test]
194    fn escaping() {
195        assert_eq!(
196            check_contains_regex(
197                "README.md",
198                "escaped: {name}-{version}, not escaped: foo*bar-1.2.3",
199                "foo*bar",
200                "1.2.3"
201            ),
202            Err([
203                r#"could not find "escaped: foo\*bar-1\.2\.3,"#,
204                r#"not escaped: foo*bar-1.2.3" in README.md"#
205            ]
206            .join(" "))
207        )
208    }
209
210    #[test]
211    fn good_pattern() {
212        assert_eq!(
213            check_contains_regex("README.md", "{name}", "version-sync", "1.2.3"),
214            Ok(())
215        )
216    }
217
218    #[test]
219    fn line_boundaries() {
220        // The regex crate doesn't treat \r\n as a line boundary
221        // (https://github.com/rust-lang/regex/issues/244), so
222        // version-sync makes sure to normalize \r\n to \n when
223        // reading files.
224        let mut file = tempfile::NamedTempFile::new().unwrap();
225
226        file.write_all(b"first line\r\nsecond line\r\nthird line\r\n")
227            .unwrap();
228        assert_eq!(
229            check_contains_regex(file.path().to_str().unwrap(), "^second line$", "", ""),
230            Ok(())
231        )
232    }
233
234    #[test]
235    fn semver_regex() {
236        // We anchor the regex here to better match the behavior when
237        // users call check_only_contains_regex with a string like
238        // "foo {version}" which also contains more than just
239        // "{version}".
240        let re = Regex::new(&format!("^{SEMVER_RE}$")).unwrap();
241        assert!(re.is_match("1.2.3"));
242        assert!(re.is_match("1.2"));
243        assert!(re.is_match("1"));
244        assert!(re.is_match("1.2.3-foo.bar.baz.42+build123.2021.12.11"));
245        assert!(!re.is_match("01"));
246        assert!(!re.is_match("01.02.03"));
247    }
248
249    #[test]
250    fn only_contains_success() {
251        let mut file = tempfile::NamedTempFile::new().unwrap();
252        file.write_all(
253            b"first:  docs.rs/foo/1.2.3/foo/fn.bar.html
254              second: docs.rs/foo/1.2.3/foo/fn.baz.html",
255        )
256        .unwrap();
257
258        assert_eq!(
259            check_only_contains_regex(
260                file.path().to_str().unwrap(),
261                "docs.rs/{name}/{version}/{name}/",
262                "foo",
263                "1.2.3"
264            ),
265            Ok(())
266        )
267    }
268
269    #[test]
270    fn only_contains_success_compatible() {
271        let mut file = tempfile::NamedTempFile::new().unwrap();
272        file.write_all(
273            b"first:  docs.rs/foo/1.2/foo/fn.bar.html
274              second: docs.rs/foo/1/foo/fn.baz.html",
275        )
276        .unwrap();
277
278        assert_eq!(
279            check_only_contains_regex(
280                file.path().to_str().unwrap(),
281                "docs.rs/{name}/{version}/{name}/",
282                "foo",
283                "1.2.3"
284            ),
285            Ok(())
286        )
287    }
288
289    #[test]
290    fn only_contains_failure() {
291        let mut file = tempfile::NamedTempFile::new().unwrap();
292        file.write_all(
293            b"first:  docs.rs/foo/1.0.0/foo/ <- error
294              second: docs.rs/foo/2.0.0/foo/ <- ok
295              third:  docs.rs/foo/3.0.0/foo/ <- error",
296        )
297        .unwrap();
298
299        assert_eq!(
300            check_only_contains_regex(
301                file.path().to_str().unwrap(),
302                "docs.rs/{name}/{version}/{name}/",
303                "foo",
304                "2.0.0"
305            ),
306            Err(format!("{} ... found 2 errors", file.path().display()))
307        )
308    }
309
310    #[test]
311    fn only_contains_fails_if_no_match() {
312        let mut file = tempfile::NamedTempFile::new().unwrap();
313        file.write_all(b"not a match").unwrap();
314
315        assert_eq!(
316            check_only_contains_regex(
317                file.path().to_str().unwrap(),
318                "docs.rs/{name}/{version}/{name}/",
319                "foo",
320                "1.2.3"
321            ),
322            Err(format!(
323                r#"{} ... found no matches for "docs.rs/{{name}}/{{version}}/{{name}}/""#,
324                file.path().display()
325            ))
326        );
327    }
328}