Skip to main content

squeeze/
semver.rs

1use super::{Anchor, ByteSet, Finder, RunClass, RunRule};
2use std::ops::Range;
3
4#[derive(Default)]
5pub struct Semver {}
6
7impl Semver {
8    fn is_boundary_before(input: &[u8], pos: usize) -> bool {
9        if pos == 0 {
10            return true;
11        }
12        let b = input[pos - 1];
13        !b.is_ascii_alphanumeric() && b != b'.'
14    }
15
16    fn is_boundary_after(input: &[u8], pos: usize) -> bool {
17        if pos >= input.len() {
18            return true;
19        }
20        let b = input[pos];
21        if b.is_ascii_alphanumeric() {
22            return false;
23        }
24        // A trailing '.' is sentence/file punctuation (`Upgrade to 1.2.3.`,
25        // `pkg-1.2.3.tar.gz`) unless it extends a dotted-number run
26        // (`1.2.3.4`, IPv4-and-friends stay rejected).
27        if b == b'.' {
28            return !(pos + 1 < input.len() && input[pos + 1].is_ascii_digit());
29        }
30        // '-'/'+' reach this point only when no valid prerelease/build
31        // identifier followed them, so they are ordinary boundaries
32        // (`1.0.0->2.0.0`, `1.2.3+`), not vetoes.
33        true
34    }
35
36    fn parse_digits(input: &[u8], pos: usize) -> Option<usize> {
37        if pos >= input.len() || !input[pos].is_ascii_digit() {
38            return None;
39        }
40        let mut end = pos + 1;
41        while end < input.len() && input[end].is_ascii_digit() {
42            end += 1;
43        }
44        Some(end)
45    }
46
47    fn parse_prerelease_or_build(input: &[u8], pos: usize) -> usize {
48        let mut end = pos;
49        while end < input.len()
50            && (input[end].is_ascii_alphanumeric() || input[end] == b'-' || input[end] == b'.')
51        {
52            end += 1;
53        }
54        // Don't end on a dot
55        while end > pos && input[end - 1] == b'.' {
56            end -= 1;
57        }
58        end
59    }
60}
61
62impl Finder for Semver {
63    fn line_agnostic(&self) -> bool {
64        // Matches never contain a line terminator and `\n`/`\r` end every
65        // walk exactly like the end of the input does.
66        true
67    }
68
69    fn id(&self) -> &'static str {
70        "semver"
71    }
72
73    fn dispatchable(&self) -> bool {
74        true
75    }
76
77    fn could_start_at(&self, byte: u8) -> bool {
78        byte.is_ascii_digit() || byte == b'v' || byte == b'V'
79    }
80
81    fn could_start_after(&self, prev: u8, _cur: u8) -> bool {
82        !prev.is_ascii_alphanumeric() && prev != b'.'
83    }
84
85    fn could_continue_with(&self, cur: u8, next: u8) -> bool {
86        if cur.is_ascii_digit() {
87            next.is_ascii_digit() || next == b'.'
88        } else {
89            next.is_ascii_digit()
90        }
91    }
92
93    fn anchor(&self) -> Option<Anchor> {
94        Some(Anchor::new(
95            ByteSet::from_bytes(b"."),
96            ByteSet::from_fn(|b| b.is_ascii_digit() || b == b'v' || b == b'V'),
97        ))
98    }
99
100    fn run_rules(&self) -> Vec<RunRule> {
101        // `major.minor.`: two dotted numbers before the patch number.
102        vec![
103            RunRule::new(RunClass::Digit, 1, crate::RUN_CAP)
104                .followed_by(b".")
105                .then(RunClass::Digit, 1, crate::RUN_CAP)
106                .followed_by(b"."),
107        ]
108    }
109
110    fn try_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
111        let start = pos;
112        let mut p = pos;
113
114        if p < input.len() && (input[p] == b'v' || input[p] == b'V') {
115            if p + 1 < input.len() && input[p + 1].is_ascii_digit() {
116                p += 1;
117            } else {
118                return None;
119            }
120        } else if p < input.len() && input[p].is_ascii_digit() {
121            // ok
122        } else {
123            return None;
124        }
125
126        if !Self::is_boundary_before(input, start) {
127            return None;
128        }
129
130        let major_end = Self::parse_digits(input, p)?;
131        if major_end >= input.len() || input[major_end] != b'.' {
132            return None;
133        }
134        let minor_end = Self::parse_digits(input, major_end + 1)?;
135        if minor_end >= input.len() || input[minor_end] != b'.' {
136            return None;
137        }
138        let patch_end = Self::parse_digits(input, minor_end + 1)?;
139
140        let mut end = patch_end;
141        if end < input.len() && input[end] == b'-' {
142            let pre_end = Self::parse_prerelease_or_build(input, end + 1);
143            if pre_end > end + 1 {
144                end = pre_end;
145            }
146        }
147        if end < input.len() && input[end] == b'+' {
148            let build_end = Self::parse_prerelease_or_build(input, end + 1);
149            if build_end > end + 1 {
150                end = build_end;
151            }
152        }
153
154        if Self::is_boundary_after(input, end) {
155            Some(start..end)
156        } else {
157            None
158        }
159    }
160
161    fn find(&self, s: &str) -> Option<Range<usize>> {
162        // Single code path: find() is the try_at scan, so the two can
163        // never diverge.
164        let input = s.as_bytes();
165        (0..input.len())
166            .filter(|&idx| self.could_start_at(input[idx]))
167            .find_map(|idx| self.try_at(input, idx))
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn id_should_return_semver() {
177        let finder = Semver::default();
178        assert_eq!("semver", finder.id());
179    }
180
181    #[test]
182    fn find_should_extract_simple_version() {
183        let finder = Semver::default();
184        let input = "version 1.0.0 released";
185        let range = finder.find(input).unwrap();
186        assert_eq!("1.0.0", &input[range]);
187    }
188
189    #[test]
190    fn find_should_extract_version_with_v_prefix() {
191        let finder = Semver::default();
192        let input = "tag v2.3.1 created";
193        let range = finder.find(input).unwrap();
194        assert_eq!("v2.3.1", &input[range]);
195    }
196
197    #[test]
198    fn find_should_extract_version_with_uppercase_v() {
199        let finder = Semver::default();
200        let input = "V1.0.0";
201        let range = finder.find(input).unwrap();
202        assert_eq!("V1.0.0", &input[range]);
203    }
204
205    #[test]
206    fn find_should_extract_version_with_prerelease() {
207        let finder = Semver::default();
208        let input = "v1.0.0-rc.1 is out";
209        let range = finder.find(input).unwrap();
210        assert_eq!("v1.0.0-rc.1", &input[range]);
211    }
212
213    #[test]
214    fn find_should_extract_version_with_build_meta() {
215        let finder = Semver::default();
216        let input = "1.0.0+build.42";
217        let range = finder.find(input).unwrap();
218        assert_eq!("1.0.0+build.42", &input[range]);
219    }
220
221    #[test]
222    fn find_should_extract_version_with_prerelease_and_build() {
223        let finder = Semver::default();
224        let input = "v2.0.0-alpha.1+build.123";
225        let range = finder.find(input).unwrap();
226        assert_eq!("v2.0.0-alpha.1+build.123", &input[range]);
227    }
228
229    #[test]
230    fn find_should_extract_version_with_large_numbers() {
231        let finder = Semver::default();
232        let input = "100.200.300";
233        let range = finder.find(input).unwrap();
234        assert_eq!("100.200.300", &input[range]);
235    }
236
237    #[test]
238    fn find_should_extract_version_with_zeros() {
239        let finder = Semver::default();
240        let input = "0.0.0";
241        let range = finder.find(input).unwrap();
242        assert_eq!("0.0.0", &input[range]);
243    }
244
245    #[test]
246    fn find_should_reject_two_components() {
247        let finder = Semver::default();
248        assert!(finder.find("1.0 only").is_none());
249    }
250
251    #[test]
252    fn find_should_reject_four_components() {
253        let finder = Semver::default();
254        assert!(finder.find("1.0.0.0 too many").is_none());
255    }
256
257    #[test]
258    fn find_should_reject_preceded_by_alphanumeric() {
259        let finder = Semver::default();
260        assert!(finder.find("a1.0.0").is_none());
261    }
262
263    #[test]
264    fn find_should_reject_preceded_by_dot() {
265        let finder = Semver::default();
266        assert!(finder.find(".1.0.0").is_none());
267    }
268
269    #[test]
270    fn find_should_handle_empty_input() {
271        let finder = Semver::default();
272        assert!(finder.find("").is_none());
273    }
274
275    #[test]
276    fn find_should_extract_version_at_start() {
277        let finder = Semver::default();
278        let input = "1.2.3 is the version";
279        let range = finder.find(input).unwrap();
280        assert_eq!("1.2.3", &input[range]);
281    }
282
283    #[test]
284    fn find_should_extract_version_at_end() {
285        let finder = Semver::default();
286        let input = "version is 1.2.3";
287        let range = finder.find(input).unwrap();
288        assert_eq!("1.2.3", &input[range]);
289    }
290
291    #[test]
292    fn find_should_extract_multiple_versions_iteratively() {
293        let finder = Semver::default();
294        let input = "from 1.0.0 to 2.0.0";
295
296        let mut results = Vec::new();
297        let mut idx = 0;
298        while idx < input.len() {
299            if let Some(range) = finder.find(&input[idx..]) {
300                results.push(&input[idx + range.start..idx + range.end]);
301                idx += range.end;
302            } else {
303                break;
304            }
305        }
306
307        assert_eq!(vec!["1.0.0", "2.0.0"], results);
308    }
309
310    #[test]
311    fn find_should_extract_version_with_hyphen_prerelease() {
312        let finder = Semver::default();
313        let input = "1.0.0-beta-2";
314        let range = finder.find(input).unwrap();
315        assert_eq!("1.0.0-beta-2", &input[range]);
316    }
317
318    #[test]
319    fn find_should_extract_version_in_parentheses() {
320        let finder = Semver::default();
321        let input = "(v1.2.3)";
322        let range = finder.find(input).unwrap();
323        assert_eq!("v1.2.3", &input[range]);
324    }
325
326    #[test]
327    fn find_should_not_match_ip_address() {
328        let finder = Semver::default();
329        assert!(finder.find("192.168.1.1").is_none());
330    }
331
332    #[test]
333    fn try_at_simple() {
334        let finder = Semver::default();
335        let input = b"1.2.3 rest";
336        assert_eq!(finder.try_at(input, 0), Some(0..5));
337    }
338
339    #[test]
340    fn try_at_with_v_prefix() {
341        let finder = Semver::default();
342        let input = b"v1.2.3 rest";
343        assert_eq!(finder.try_at(input, 0), Some(0..6));
344    }
345
346    #[test]
347    fn try_at_preceded_by_alpha() {
348        let finder = Semver::default();
349        let input = b"a1.2.3";
350        assert!(finder.try_at(input, 1).is_none());
351    }
352
353    #[test]
354    fn try_at_preceded_by_dot() {
355        let finder = Semver::default();
356        let input = b".1.2.3";
357        assert!(finder.try_at(input, 1).is_none());
358    }
359
360    #[test]
361    fn try_at_v_without_digit() {
362        let finder = Semver::default();
363        let input = b"vx";
364        assert!(finder.try_at(input, 0).is_none());
365    }
366
367    #[test]
368    fn try_at_single_digit() {
369        let finder = Semver::default();
370        let input = b"1";
371        assert!(finder.try_at(input, 0).is_none());
372    }
373
374    #[test]
375    fn find_prerelease_with_dots() {
376        let finder = Semver::default();
377        let input = "1.0.0-alpha.1.2";
378        let range = finder.find(input).unwrap();
379        assert_eq!("1.0.0-alpha.1.2", &input[range]);
380    }
381
382    #[test]
383    fn find_build_with_dots() {
384        let finder = Semver::default();
385        let input = "1.0.0+build.1.2";
386        let range = finder.find(input).unwrap();
387        assert_eq!("1.0.0+build.1.2", &input[range]);
388    }
389
390    #[test]
391    fn find_backtracks_trailing_dot_in_prerelease() {
392        // The trailing '.' is punctuation, not part of the prerelease.
393        let finder = Semver::default();
394        let input = "1.0.0-beta.";
395        let range = finder.find(input).unwrap();
396        assert_eq!("1.0.0-beta", &input[range]);
397    }
398
399    #[test]
400    fn find_prerelease_with_trailing_dot_in_text() {
401        let finder = Semver::default();
402        let input = "use 1.0.0-beta. done";
403        let range = finder.find(input).unwrap();
404        assert_eq!("1.0.0-beta", &input[range]);
405    }
406}