runtime-checker 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1972 -0
- package/Cargo.toml +46 -0
- package/LICENSE +29 -0
- package/README.md +26 -0
- package/data/mdn/README.md +30 -0
- package/data/mdn/bun.ron +1084 -0
- package/data/mdn/chrome.ron +7814 -0
- package/data/mdn/deno.ron +1497 -0
- package/data/mdn/firefox.ron +6452 -0
- package/data/mdn/node.ron +1311 -0
- package/data/mdn/safari.ron +6267 -0
- package/data/mdn-bcd.version +1 -0
- package/data/node.ron +3561 -0
- package/npm/bin/native/runtime-checker.exe +0 -0
- package/npm/bin/runtime-checker.js +23 -0
- package/npm/postinstall.js +29 -0
- package/npm/prepare-bin.js +11 -0
- package/package.json +42 -0
- package/src/analyzer.rs +405 -0
- package/src/bin/generate-mdn-data.rs +517 -0
- package/src/cli.rs +71 -0
- package/src/data.rs +266 -0
- package/src/engines.rs +154 -0
- package/src/help.rs +192 -0
- package/src/lib.rs +251 -0
- package/src/main.rs +12 -0
- package/src/report.rs +819 -0
- package/src/scanner.rs +404 -0
- package/src/version.rs +101 -0
package/src/scanner.rs
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
use std::{
|
|
2
|
+
collections::{HashMap, HashSet},
|
|
3
|
+
fs, io,
|
|
4
|
+
path::{Path, PathBuf},
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind};
|
|
8
|
+
use anyhow::{Context, Result};
|
|
9
|
+
use fff_grep::{
|
|
10
|
+
LineTerminator, Match, Matcher, NoError, Searcher, SearcherBuilder, Sink, SinkMatch,
|
|
11
|
+
};
|
|
12
|
+
use ignore::WalkBuilder;
|
|
13
|
+
|
|
14
|
+
use crate::{
|
|
15
|
+
data::{Feature, RuntimeDb},
|
|
16
|
+
version::RuntimeVersion,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
#[derive(Debug, Clone)]
|
|
20
|
+
pub struct SourceFile {
|
|
21
|
+
pub path: PathBuf,
|
|
22
|
+
pub text: String,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#[derive(Debug, Clone, Copy, Default)]
|
|
26
|
+
pub struct ScanStats {
|
|
27
|
+
pub line_count: usize,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#[derive(Debug, Clone)]
|
|
31
|
+
pub struct SourceScan {
|
|
32
|
+
pub files: Vec<SourceFile>,
|
|
33
|
+
pub stats: ScanStats,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#[derive(Debug, Clone)]
|
|
37
|
+
pub struct DetectedFeature {
|
|
38
|
+
pub feature: String,
|
|
39
|
+
pub version: RuntimeVersion,
|
|
40
|
+
pub path: PathBuf,
|
|
41
|
+
pub line: u64,
|
|
42
|
+
pub column: u64,
|
|
43
|
+
pub count: usize,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
pub trait Scanner {
|
|
47
|
+
type Output;
|
|
48
|
+
|
|
49
|
+
fn scan(&self, root: &Path, runtime: &RuntimeDb) -> Result<Self::Output>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
pub struct SourceDiscovery;
|
|
53
|
+
|
|
54
|
+
impl Scanner for SourceDiscovery {
|
|
55
|
+
type Output = SourceScan;
|
|
56
|
+
|
|
57
|
+
fn scan(&self, root: &Path, _runtime: &RuntimeDb) -> Result<Self::Output> {
|
|
58
|
+
let mut files = Vec::new();
|
|
59
|
+
let mut stats = ScanStats::default();
|
|
60
|
+
let mut builder = WalkBuilder::new(root);
|
|
61
|
+
builder.filter_entry(|entry| !is_ignored_dir(entry.path()));
|
|
62
|
+
|
|
63
|
+
for entry in builder.build() {
|
|
64
|
+
let entry = entry?;
|
|
65
|
+
let path = entry.path();
|
|
66
|
+
if !entry
|
|
67
|
+
.file_type()
|
|
68
|
+
.is_some_and(|file_type| file_type.is_file())
|
|
69
|
+
{
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if !is_source_file(path) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let text = fs::read_to_string(path)
|
|
77
|
+
.with_context(|| format!("failed to read {}", path.display()))?;
|
|
78
|
+
stats.line_count += count_lines(&text);
|
|
79
|
+
files.push(SourceFile {
|
|
80
|
+
path: path.to_path_buf(),
|
|
81
|
+
text,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
Ok(SourceScan { files, stats })
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#[derive(Clone, Copy)]
|
|
90
|
+
struct RuntimePattern<'a> {
|
|
91
|
+
runtime_index: usize,
|
|
92
|
+
runtime: &'a RuntimeDb,
|
|
93
|
+
feature: &'a Feature,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
pub struct FffMultiRuntimeScanner;
|
|
97
|
+
|
|
98
|
+
impl FffMultiRuntimeScanner {
|
|
99
|
+
pub fn scan_files(
|
|
100
|
+
&self,
|
|
101
|
+
runtimes: &[&RuntimeDb],
|
|
102
|
+
files: &[SourceFile],
|
|
103
|
+
) -> Result<Vec<Vec<DetectedFeature>>> {
|
|
104
|
+
let entries = combined_fast_patterns(runtimes);
|
|
105
|
+
if entries.is_empty() {
|
|
106
|
+
return Ok(vec![Vec::new(); runtimes.len()]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let pattern_refs: Vec<&str> = entries.iter().map(|(pattern, _)| *pattern).collect();
|
|
110
|
+
let matcher = AhoCorasickBuilder::new()
|
|
111
|
+
.match_kind(MatchKind::LeftmostLongest)
|
|
112
|
+
.build(pattern_refs)
|
|
113
|
+
.context("failed to build matcher")?;
|
|
114
|
+
let searcher = SearcherBuilder::new().line_number(true).build();
|
|
115
|
+
|
|
116
|
+
let mut detections_by_runtime = vec![Vec::new(); runtimes.len()];
|
|
117
|
+
let mut seen_by_runtime = vec![HashSet::new(); runtimes.len()];
|
|
118
|
+
for (file_index, file) in files.iter().enumerate() {
|
|
119
|
+
let mut sink = FffMultiRuntimeSink {
|
|
120
|
+
entries: &entries,
|
|
121
|
+
matcher: &matcher,
|
|
122
|
+
file_index,
|
|
123
|
+
path: &file.path,
|
|
124
|
+
detections_by_runtime: &mut detections_by_runtime,
|
|
125
|
+
seen_by_runtime: &mut seen_by_runtime,
|
|
126
|
+
};
|
|
127
|
+
searcher
|
|
128
|
+
.search_slice(
|
|
129
|
+
FffAhoMatcher { matcher: &matcher },
|
|
130
|
+
file.text.as_bytes(),
|
|
131
|
+
&mut sink,
|
|
132
|
+
)
|
|
133
|
+
.context("failed to search source with FFF")?;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
Ok(detections_by_runtime)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
fn combined_fast_patterns<'a>(
|
|
141
|
+
runtimes: &[&'a RuntimeDb],
|
|
142
|
+
) -> Vec<(&'a str, Vec<RuntimePattern<'a>>)> {
|
|
143
|
+
let mut by_pattern = HashMap::<&'a str, Vec<RuntimePattern<'a>>>::new();
|
|
144
|
+
for (runtime_index, runtime) in runtimes.iter().copied().enumerate() {
|
|
145
|
+
for pattern in runtime.fast_patterns() {
|
|
146
|
+
let Some(feature) = runtime.feature_for_pattern(pattern) else {
|
|
147
|
+
continue;
|
|
148
|
+
};
|
|
149
|
+
by_pattern
|
|
150
|
+
.entry(pattern.as_str())
|
|
151
|
+
.or_default()
|
|
152
|
+
.push(RuntimePattern {
|
|
153
|
+
runtime_index,
|
|
154
|
+
runtime,
|
|
155
|
+
feature,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let mut entries: Vec<_> = by_pattern.into_iter().collect();
|
|
161
|
+
entries.sort_by(|left, right| {
|
|
162
|
+
right
|
|
163
|
+
.0
|
|
164
|
+
.len()
|
|
165
|
+
.cmp(&left.0.len())
|
|
166
|
+
.then_with(|| left.0.cmp(right.0))
|
|
167
|
+
});
|
|
168
|
+
entries
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
struct FffAhoMatcher<'a> {
|
|
172
|
+
matcher: &'a AhoCorasick,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
impl Matcher for FffAhoMatcher<'_> {
|
|
176
|
+
type Error = NoError;
|
|
177
|
+
|
|
178
|
+
fn find_at(&self, haystack: &[u8], at: usize) -> std::result::Result<Option<Match>, NoError> {
|
|
179
|
+
Ok(self
|
|
180
|
+
.matcher
|
|
181
|
+
.find(&haystack[at..])
|
|
182
|
+
.map(|matched| Match::new(at + matched.start(), at + matched.end())))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
fn line_terminator(&self) -> Option<LineTerminator> {
|
|
186
|
+
Some(LineTerminator::byte(b'\n'))
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
struct FffMultiRuntimeSink<'a> {
|
|
191
|
+
entries: &'a [(&'a str, Vec<RuntimePattern<'a>>)],
|
|
192
|
+
matcher: &'a AhoCorasick,
|
|
193
|
+
file_index: usize,
|
|
194
|
+
path: &'a Path,
|
|
195
|
+
detections_by_runtime: &'a mut [Vec<DetectedFeature>],
|
|
196
|
+
seen_by_runtime: &'a mut [DetectionSeen],
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
impl Sink for FffMultiRuntimeSink<'_> {
|
|
200
|
+
type Error = io::Error;
|
|
201
|
+
|
|
202
|
+
fn matched(&mut self, _searcher: &Searcher, matched: &SinkMatch<'_>) -> io::Result<bool> {
|
|
203
|
+
let Ok(line) = std::str::from_utf8(matched.bytes()) else {
|
|
204
|
+
return Ok(true);
|
|
205
|
+
};
|
|
206
|
+
let line = line.trim_end_matches(['\r', '\n']);
|
|
207
|
+
let line_number = matched.line_number().unwrap_or(1);
|
|
208
|
+
|
|
209
|
+
for found in self.matcher.find_iter(line) {
|
|
210
|
+
for runtime_pattern in &self.entries[found.pattern()].1 {
|
|
211
|
+
if !is_fast_match(
|
|
212
|
+
runtime_pattern.runtime,
|
|
213
|
+
line,
|
|
214
|
+
found.start(),
|
|
215
|
+
found.end(),
|
|
216
|
+
self.entries[found.pattern()].0,
|
|
217
|
+
) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
push_detection(
|
|
221
|
+
&mut self.detections_by_runtime[runtime_pattern.runtime_index],
|
|
222
|
+
&mut self.seen_by_runtime[runtime_pattern.runtime_index],
|
|
223
|
+
runtime_pattern.feature,
|
|
224
|
+
self.file_index,
|
|
225
|
+
self.path,
|
|
226
|
+
line_number,
|
|
227
|
+
(found.start() + 1) as u64,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
Ok(true)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
impl SourceScan {
|
|
237
|
+
pub fn files(&self) -> &[SourceFile] {
|
|
238
|
+
&self.files
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
pub fn stats(&self) -> ScanStats {
|
|
242
|
+
self.stats
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
pub type DetectionSeen = HashSet<(usize, usize, u64, u64)>;
|
|
247
|
+
|
|
248
|
+
pub fn push_detection(
|
|
249
|
+
detections: &mut Vec<DetectedFeature>,
|
|
250
|
+
seen: &mut DetectionSeen,
|
|
251
|
+
feature: &Feature,
|
|
252
|
+
file_index: usize,
|
|
253
|
+
path: &Path,
|
|
254
|
+
line: u64,
|
|
255
|
+
column: u64,
|
|
256
|
+
) {
|
|
257
|
+
let key = (feature.id, file_index, line, column);
|
|
258
|
+
if seen.insert(key) {
|
|
259
|
+
detections.push(DetectedFeature {
|
|
260
|
+
feature: feature.name.clone(),
|
|
261
|
+
version: feature.version,
|
|
262
|
+
path: path.to_path_buf(),
|
|
263
|
+
line,
|
|
264
|
+
column,
|
|
265
|
+
count: 1,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
pub fn is_source_file(path: &Path) -> bool {
|
|
271
|
+
matches!(
|
|
272
|
+
path.extension().and_then(|extension| extension.to_str()),
|
|
273
|
+
Some("js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx" | "mts" | "cts")
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
fn is_ignored_dir(path: &Path) -> bool {
|
|
278
|
+
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
|
279
|
+
return false;
|
|
280
|
+
};
|
|
281
|
+
matches!(
|
|
282
|
+
name,
|
|
283
|
+
".git" | "node_modules" | "dist" | "build" | "coverage" | "target"
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
fn count_lines(text: &str) -> usize {
|
|
288
|
+
if text.is_empty() {
|
|
289
|
+
0
|
|
290
|
+
} else {
|
|
291
|
+
text.lines().count()
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
fn is_fast_match(runtime: &RuntimeDb, line: &str, start: usize, end: usize, pattern: &str) -> bool {
|
|
296
|
+
(runtime.is_property_pattern(pattern) && is_property_access(line, start, end))
|
|
297
|
+
|| (runtime.is_global_or_member_pattern(pattern)
|
|
298
|
+
&& is_safe_global_or_member_pattern(pattern)
|
|
299
|
+
&& has_identifier_boundaries(line, start, end))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
fn is_safe_global_or_member_pattern(pattern: &str) -> bool {
|
|
303
|
+
pattern.contains('.')
|
|
304
|
+
|| pattern
|
|
305
|
+
.chars()
|
|
306
|
+
.next()
|
|
307
|
+
.is_some_and(|ch| ch.is_ascii_uppercase())
|
|
308
|
+
|| matches!(
|
|
309
|
+
pattern,
|
|
310
|
+
"alert"
|
|
311
|
+
| "atob"
|
|
312
|
+
| "btoa"
|
|
313
|
+
| "caches"
|
|
314
|
+
| "cancelAnimationFrame"
|
|
315
|
+
| "cancelIdleCallback"
|
|
316
|
+
| "clearImmediate"
|
|
317
|
+
| "clearInterval"
|
|
318
|
+
| "clearTimeout"
|
|
319
|
+
| "console"
|
|
320
|
+
| "crypto"
|
|
321
|
+
| "document"
|
|
322
|
+
| "fetch"
|
|
323
|
+
| "global"
|
|
324
|
+
| "globalThis"
|
|
325
|
+
| "indexedDB"
|
|
326
|
+
| "localStorage"
|
|
327
|
+
| "location"
|
|
328
|
+
| "navigator"
|
|
329
|
+
| "performance"
|
|
330
|
+
| "process"
|
|
331
|
+
| "queueMicrotask"
|
|
332
|
+
| "reportError"
|
|
333
|
+
| "requestAnimationFrame"
|
|
334
|
+
| "requestIdleCallback"
|
|
335
|
+
| "self"
|
|
336
|
+
| "sessionStorage"
|
|
337
|
+
| "setImmediate"
|
|
338
|
+
| "setInterval"
|
|
339
|
+
| "setTimeout"
|
|
340
|
+
| "structuredClone"
|
|
341
|
+
| "window"
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
fn is_property_access(line: &str, start: usize, end: usize) -> bool {
|
|
346
|
+
previous_char(line, start) == Some('.')
|
|
347
|
+
&& !next_char(line, end).is_some_and(is_js_identifier_part)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
fn has_identifier_boundaries(line: &str, start: usize, end: usize) -> bool {
|
|
351
|
+
!previous_char(line, start).is_some_and(|ch| is_js_identifier_part(ch) || ch == '.')
|
|
352
|
+
&& !next_char(line, end).is_some_and(is_js_identifier_part)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
fn previous_char(text: &str, index: usize) -> Option<char> {
|
|
356
|
+
text.get(..index)?.chars().next_back()
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
fn next_char(text: &str, index: usize) -> Option<char> {
|
|
360
|
+
text.get(index..)?.chars().next()
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
fn is_js_identifier_part(ch: char) -> bool {
|
|
364
|
+
ch.is_ascii_alphanumeric() || ch == '_' || ch == '$'
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#[cfg(test)]
|
|
368
|
+
mod tests {
|
|
369
|
+
use std::fs;
|
|
370
|
+
|
|
371
|
+
use tempfile::tempdir;
|
|
372
|
+
|
|
373
|
+
use crate::{data::node_runtime, scanner::Scanner};
|
|
374
|
+
|
|
375
|
+
use super::SourceDiscovery;
|
|
376
|
+
|
|
377
|
+
#[test]
|
|
378
|
+
fn source_discovery_filters_before_scanners_run() {
|
|
379
|
+
let dir = tempdir().unwrap();
|
|
380
|
+
fs::write(dir.path().join("app.ts"), "Temporal.Now.instant();\n").unwrap();
|
|
381
|
+
fs::write(dir.path().join("README.md"), "Temporal.Now.instant();\n").unwrap();
|
|
382
|
+
|
|
383
|
+
for ignored in [
|
|
384
|
+
"node_modules",
|
|
385
|
+
".git",
|
|
386
|
+
"dist",
|
|
387
|
+
"build",
|
|
388
|
+
"coverage",
|
|
389
|
+
"target",
|
|
390
|
+
] {
|
|
391
|
+
let ignored_dir = dir.path().join(ignored);
|
|
392
|
+
fs::create_dir(&ignored_dir).unwrap();
|
|
393
|
+
fs::write(ignored_dir.join("ignored.ts"), "Temporal.Now.instant();\n").unwrap();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
let scan = SourceDiscovery
|
|
397
|
+
.scan(dir.path(), node_runtime().unwrap())
|
|
398
|
+
.unwrap();
|
|
399
|
+
|
|
400
|
+
assert_eq!(scan.stats().line_count, 1);
|
|
401
|
+
assert_eq!(scan.files().len(), 1);
|
|
402
|
+
assert_eq!(scan.files()[0].path.file_name().unwrap(), "app.ts");
|
|
403
|
+
}
|
|
404
|
+
}
|
package/src/version.rs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
use std::{cmp::Ordering, fmt, str::FromStr};
|
|
2
|
+
|
|
3
|
+
use serde::Deserialize;
|
|
4
|
+
|
|
5
|
+
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
|
|
6
|
+
pub struct RuntimeVersion {
|
|
7
|
+
pub major: u64,
|
|
8
|
+
pub minor: u64,
|
|
9
|
+
pub patch: u64,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
impl RuntimeVersion {
|
|
13
|
+
pub const ZERO: Self = Self {
|
|
14
|
+
major: 0,
|
|
15
|
+
minor: 0,
|
|
16
|
+
patch: 0,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
pub fn is_zero(self) -> bool {
|
|
20
|
+
self == Self::ZERO
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
impl fmt::Display for RuntimeVersion {
|
|
25
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
26
|
+
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
impl FromStr for RuntimeVersion {
|
|
31
|
+
type Err = anyhow::Error;
|
|
32
|
+
|
|
33
|
+
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
|
34
|
+
let value = value.trim().trim_start_matches('v');
|
|
35
|
+
let mut parts = value.split('.');
|
|
36
|
+
let major = parts
|
|
37
|
+
.next()
|
|
38
|
+
.unwrap_or("0")
|
|
39
|
+
.parse()
|
|
40
|
+
.map_err(|error| anyhow::anyhow!("invalid major version `{value}`: {error}"))?;
|
|
41
|
+
let minor = parts
|
|
42
|
+
.next()
|
|
43
|
+
.unwrap_or("0")
|
|
44
|
+
.parse()
|
|
45
|
+
.map_err(|error| anyhow::anyhow!("invalid minor version `{value}`: {error}"))?;
|
|
46
|
+
let patch = parts
|
|
47
|
+
.next()
|
|
48
|
+
.unwrap_or("0")
|
|
49
|
+
.parse()
|
|
50
|
+
.map_err(|error| anyhow::anyhow!("invalid patch version `{value}`: {error}"))?;
|
|
51
|
+
|
|
52
|
+
Ok(Self {
|
|
53
|
+
major,
|
|
54
|
+
minor,
|
|
55
|
+
patch,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
impl<'de> Deserialize<'de> for RuntimeVersion {
|
|
61
|
+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
62
|
+
where
|
|
63
|
+
D: serde::Deserializer<'de>,
|
|
64
|
+
{
|
|
65
|
+
let value = String::deserialize(deserializer)?;
|
|
66
|
+
value.parse().map_err(serde::de::Error::custom)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
impl PartialOrd for RuntimeVersion {
|
|
71
|
+
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
|
72
|
+
Some(self.cmp(other))
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
impl Ord for RuntimeVersion {
|
|
77
|
+
fn cmp(&self, other: &Self) -> Ordering {
|
|
78
|
+
(self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#[cfg(test)]
|
|
83
|
+
mod tests {
|
|
84
|
+
use super::RuntimeVersion;
|
|
85
|
+
|
|
86
|
+
#[test]
|
|
87
|
+
fn parses_short_versions() {
|
|
88
|
+
assert_eq!(
|
|
89
|
+
"24".parse::<RuntimeVersion>().unwrap(),
|
|
90
|
+
RuntimeVersion {
|
|
91
|
+
major: 24,
|
|
92
|
+
minor: 0,
|
|
93
|
+
patch: 0
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
assert_eq!(
|
|
97
|
+
"v20.5.1".parse::<RuntimeVersion>().unwrap().to_string(),
|
|
98
|
+
"20.5.1"
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|