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/src/data.rs ADDED
@@ -0,0 +1,266 @@
1
+ use std::{
2
+ collections::{HashMap, HashSet},
3
+ sync::OnceLock,
4
+ };
5
+
6
+ use anyhow::{Context, Result};
7
+ use serde::Deserialize;
8
+
9
+ use crate::{cli::RuntimeKind, version::RuntimeVersion};
10
+
11
+ static NODE_SPEC: &str = include_str!("../data/node.ron");
12
+ static DENO_SPEC: &str = include_str!("../data/mdn/deno.ron");
13
+ static BUN_SPEC: &str = include_str!("../data/mdn/bun.ron");
14
+ static SAFARI_SPEC: &str = include_str!("../data/mdn/safari.ron");
15
+ static CHROME_SPEC: &str = include_str!("../data/mdn/chrome.ron");
16
+ static FIREFOX_SPEC: &str = include_str!("../data/mdn/firefox.ron");
17
+ static NODE_RUNTIME: OnceLock<RuntimeDb> = OnceLock::new();
18
+ static DENO_RUNTIME: OnceLock<RuntimeDb> = OnceLock::new();
19
+ static BUN_RUNTIME: OnceLock<RuntimeDb> = OnceLock::new();
20
+ static SAFARI_RUNTIME: OnceLock<RuntimeDb> = OnceLock::new();
21
+ static CHROME_RUNTIME: OnceLock<RuntimeDb> = OnceLock::new();
22
+ static FIREFOX_RUNTIME: OnceLock<RuntimeDb> = OnceLock::new();
23
+
24
+ #[derive(Debug, Deserialize)]
25
+ struct RuntimeSpec {
26
+ schema: u32,
27
+ runtime: String,
28
+ features: Vec<FeatureSpec>,
29
+ }
30
+
31
+ #[derive(Debug, Deserialize)]
32
+ struct FeatureSpec {
33
+ name: String,
34
+ version: RuntimeVersion,
35
+ detect: Vec<DetectRule>,
36
+ }
37
+
38
+ #[derive(Debug, Clone, Deserialize)]
39
+ pub enum DetectRule {
40
+ Global(String),
41
+ MemberChain(String),
42
+ Property(String),
43
+ }
44
+
45
+ #[derive(Debug)]
46
+ pub struct RuntimeDb {
47
+ name: String,
48
+ features: Vec<Feature>,
49
+ globals: HashMap<String, usize>,
50
+ member_chains: HashMap<String, usize>,
51
+ properties: HashMap<String, usize>,
52
+ fast_patterns: Vec<String>,
53
+ }
54
+
55
+ #[derive(Debug, Clone)]
56
+ pub struct Feature {
57
+ pub id: usize,
58
+ pub name: String,
59
+ pub version: RuntimeVersion,
60
+ }
61
+
62
+ impl RuntimeDb {
63
+ fn from_spec(spec: RuntimeSpec) -> Result<Self> {
64
+ anyhow::ensure!(
65
+ spec.schema == 1,
66
+ "unsupported {} schema {}",
67
+ spec.runtime,
68
+ spec.schema
69
+ );
70
+
71
+ let mut db = Self {
72
+ name: spec.runtime,
73
+ features: Vec::with_capacity(spec.features.len()),
74
+ globals: HashMap::new(),
75
+ member_chains: HashMap::new(),
76
+ properties: HashMap::new(),
77
+ fast_patterns: Vec::new(),
78
+ };
79
+
80
+ let mut patterns = HashSet::new();
81
+ for feature in spec.features {
82
+ let index = db.features.len();
83
+ db.features.push(Feature {
84
+ id: index,
85
+ name: feature.name,
86
+ version: feature.version,
87
+ });
88
+
89
+ for rule in feature.detect {
90
+ match rule {
91
+ DetectRule::Global(name) => {
92
+ db.insert_highest_global(name.clone(), index);
93
+ patterns.insert(name);
94
+ }
95
+ DetectRule::MemberChain(name) => {
96
+ db.insert_highest_member_chain(name.clone(), index);
97
+ patterns.insert(name);
98
+ }
99
+ DetectRule::Property(name) => {
100
+ db.insert_highest_property(name.clone(), index);
101
+ patterns.insert(name);
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ db.fast_patterns = patterns.into_iter().collect();
108
+ db.fast_patterns
109
+ .sort_by_key(|pattern| std::cmp::Reverse(pattern.len()));
110
+ Ok(db)
111
+ }
112
+
113
+ pub fn name(&self) -> &str {
114
+ &self.name
115
+ }
116
+
117
+ pub fn fast_patterns(&self) -> &[String] {
118
+ &self.fast_patterns
119
+ }
120
+
121
+ pub fn feature_for_pattern(&self, pattern: &str) -> Option<&Feature> {
122
+ self.globals
123
+ .get(pattern)
124
+ .or_else(|| self.member_chains.get(pattern))
125
+ .or_else(|| self.properties.get(pattern))
126
+ .and_then(|index| self.features.get(*index))
127
+ }
128
+
129
+ pub fn is_global_or_member_pattern(&self, pattern: &str) -> bool {
130
+ self.globals.contains_key(pattern) || self.member_chains.contains_key(pattern)
131
+ }
132
+
133
+ pub fn is_property_pattern(&self, pattern: &str) -> bool {
134
+ self.properties.contains_key(pattern)
135
+ }
136
+
137
+ pub fn match_global(&self, name: &str) -> Option<&Feature> {
138
+ self.globals
139
+ .get(name)
140
+ .and_then(|index| self.features.get(*index))
141
+ }
142
+
143
+ pub fn match_member_chain(&self, name: &str) -> Option<&Feature> {
144
+ self.member_chains
145
+ .get(name)
146
+ .and_then(|index| self.features.get(*index))
147
+ }
148
+
149
+ pub fn match_property(&self, name: &str) -> Option<&Feature> {
150
+ self.properties
151
+ .get(name)
152
+ .and_then(|index| self.features.get(*index))
153
+ }
154
+
155
+ fn insert_highest_global(&mut self, name: String, index: usize) {
156
+ insert_highest(&self.features, &mut self.globals, name, index);
157
+ }
158
+
159
+ fn insert_highest_member_chain(&mut self, name: String, index: usize) {
160
+ insert_highest(&self.features, &mut self.member_chains, name, index);
161
+ }
162
+
163
+ fn insert_highest_property(&mut self, name: String, index: usize) {
164
+ insert_highest(&self.features, &mut self.properties, name, index);
165
+ }
166
+ }
167
+
168
+ fn insert_highest(
169
+ features: &[Feature],
170
+ map: &mut HashMap<String, usize>,
171
+ key: String,
172
+ candidate: usize,
173
+ ) {
174
+ match map.get(&key).copied() {
175
+ Some(existing) if features[existing].version >= features[candidate].version => {}
176
+ _ => {
177
+ map.insert(key, candidate);
178
+ }
179
+ }
180
+ }
181
+
182
+ #[cfg(test)]
183
+ pub fn node_runtime() -> Result<&'static RuntimeDb> {
184
+ runtime(RuntimeKind::Node)
185
+ }
186
+
187
+ pub fn runtime(kind: RuntimeKind) -> Result<&'static RuntimeDb> {
188
+ match kind {
189
+ RuntimeKind::All => anyhow::bail!("all is not a concrete runtime database"),
190
+ RuntimeKind::Node => load_runtime(&NODE_RUNTIME, NODE_SPEC, "data/node.ron"),
191
+ RuntimeKind::Deno => load_runtime(&DENO_RUNTIME, DENO_SPEC, "data/mdn/deno.ron"),
192
+ RuntimeKind::Bun => load_runtime(&BUN_RUNTIME, BUN_SPEC, "data/mdn/bun.ron"),
193
+ RuntimeKind::Safari => load_runtime(&SAFARI_RUNTIME, SAFARI_SPEC, "data/mdn/safari.ron"),
194
+ RuntimeKind::Chrome => load_runtime(&CHROME_RUNTIME, CHROME_SPEC, "data/mdn/chrome.ron"),
195
+ RuntimeKind::Firefox => {
196
+ load_runtime(&FIREFOX_RUNTIME, FIREFOX_SPEC, "data/mdn/firefox.ron")
197
+ }
198
+ }
199
+ }
200
+
201
+ fn load_runtime(
202
+ lock: &'static OnceLock<RuntimeDb>,
203
+ source: &'static str,
204
+ source_name: &str,
205
+ ) -> Result<&'static RuntimeDb> {
206
+ if let Some(runtime) = lock.get() {
207
+ return Ok(runtime);
208
+ }
209
+
210
+ let spec = ron::from_str::<RuntimeSpec>(source)
211
+ .with_context(|| format!("failed to parse {source_name}"))?;
212
+ let runtime = RuntimeDb::from_spec(spec)?;
213
+ Ok(lock.get_or_init(|| runtime))
214
+ }
215
+
216
+ #[cfg(test)]
217
+ mod tests {
218
+ use crate::cli::RuntimeKind;
219
+
220
+ use super::{node_runtime, runtime};
221
+
222
+ #[test]
223
+ fn loads_node_ron_database() {
224
+ let db = node_runtime().unwrap();
225
+ assert_eq!(db.name(), "node");
226
+ assert_eq!(
227
+ db.match_global("Temporal").unwrap().version.to_string(),
228
+ "26.0.0"
229
+ );
230
+ assert_eq!(
231
+ db.match_property("toSorted").unwrap().version.to_string(),
232
+ "20.0.0"
233
+ );
234
+ }
235
+
236
+ #[test]
237
+ fn loads_mdn_runtime_databases() {
238
+ let deno = runtime(RuntimeKind::Deno).unwrap();
239
+ let bun = runtime(RuntimeKind::Bun).unwrap();
240
+ let safari = runtime(RuntimeKind::Safari).unwrap();
241
+ let chrome = runtime(RuntimeKind::Chrome).unwrap();
242
+ let firefox = runtime(RuntimeKind::Firefox).unwrap();
243
+
244
+ assert_eq!(deno.name(), "deno");
245
+ assert_eq!(bun.name(), "bun");
246
+ assert_eq!(safari.name(), "safari");
247
+ assert_eq!(chrome.name(), "chrome");
248
+ assert_eq!(firefox.name(), "firefox");
249
+ assert_eq!(
250
+ deno.match_global("Temporal").unwrap().version.to_string(),
251
+ "2.7.0"
252
+ );
253
+ assert_eq!(
254
+ bun.match_global("fetch").unwrap().version.to_string(),
255
+ "1.0.0"
256
+ );
257
+ assert_eq!(
258
+ chrome
259
+ .match_property("toSorted")
260
+ .unwrap()
261
+ .version
262
+ .to_string(),
263
+ "110.0.0"
264
+ );
265
+ }
266
+ }
package/src/engines.rs ADDED
@@ -0,0 +1,154 @@
1
+ use std::{fs, path::PathBuf};
2
+
3
+ use anyhow::{Context, Result};
4
+ use node_semver::{Range, Version};
5
+ use serde_json::Value;
6
+
7
+ use crate::version::RuntimeVersion;
8
+
9
+ #[derive(Debug, Clone)]
10
+ pub struct EnginesReport {
11
+ pub package_json: PathBuf,
12
+ pub declared: Option<String>,
13
+ pub required: RuntimeVersion,
14
+ pub fixed: bool,
15
+ pub severity: EnginesSeverity,
16
+ }
17
+
18
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
19
+ pub enum EnginesSeverity {
20
+ Info,
21
+ Warning,
22
+ }
23
+
24
+ pub fn check_engines(
25
+ root: &std::path::Path,
26
+ required: RuntimeVersion,
27
+ fix: bool,
28
+ ) -> Result<Option<EnginesReport>> {
29
+ let package_json = root.join("package.json");
30
+ if !package_json.exists() {
31
+ return Ok(None);
32
+ }
33
+
34
+ let text = fs::read_to_string(&package_json)
35
+ .with_context(|| format!("failed to read {}", package_json.display()))?;
36
+ let mut json: Value = serde_json::from_str(&text)
37
+ .with_context(|| format!("failed to parse {}", package_json.display()))?;
38
+ let declared = json
39
+ .get("engines")
40
+ .and_then(|engines| engines.get("node"))
41
+ .and_then(Value::as_str)
42
+ .map(str::to_owned);
43
+
44
+ let compatible = declared
45
+ .as_deref()
46
+ .is_some_and(|range| range_allows_required(range, required));
47
+ let severity = declared
48
+ .as_deref()
49
+ .map(|range| engines_severity(range, required))
50
+ .unwrap_or(EnginesSeverity::Warning);
51
+ let needs_fix = !required.is_zero() && !compatible;
52
+
53
+ let mut fixed = false;
54
+ if fix && needs_fix {
55
+ set_engines_node(&mut json, &format!(">={required}"))?;
56
+ fs::write(
57
+ &package_json,
58
+ format!("{}\n", serde_json::to_string_pretty(&json)?),
59
+ )
60
+ .with_context(|| format!("failed to write {}", package_json.display()))?;
61
+ fixed = true;
62
+ }
63
+
64
+ if needs_fix || fixed {
65
+ Ok(Some(EnginesReport {
66
+ package_json,
67
+ declared,
68
+ required,
69
+ fixed,
70
+ severity,
71
+ }))
72
+ } else {
73
+ Ok(None)
74
+ }
75
+ }
76
+
77
+ fn range_allows_required(range: &str, required: RuntimeVersion) -> bool {
78
+ let Ok(range) = Range::parse(range) else {
79
+ return false;
80
+ };
81
+ let Ok(version) = Version::parse(required.to_string()) else {
82
+ return false;
83
+ };
84
+ range.satisfies(&version)
85
+ }
86
+
87
+ fn set_engines_node(json: &mut Value, value: &str) -> Result<()> {
88
+ let root = json
89
+ .as_object_mut()
90
+ .ok_or_else(|| anyhow::anyhow!("package.json root must be an object"))?;
91
+ let engines = root
92
+ .entry("engines")
93
+ .or_insert_with(|| Value::Object(Default::default()));
94
+ let engines = engines
95
+ .as_object_mut()
96
+ .ok_or_else(|| anyhow::anyhow!("package.json engines must be an object"))?;
97
+ engines.insert("node".to_owned(), Value::String(value.to_owned()));
98
+ Ok(())
99
+ }
100
+
101
+ fn engines_severity(range: &str, required: RuntimeVersion) -> EnginesSeverity {
102
+ if declared_lower_bound(range).is_some_and(|declared| declared > required) {
103
+ EnginesSeverity::Info
104
+ } else {
105
+ EnginesSeverity::Warning
106
+ }
107
+ }
108
+
109
+ fn declared_lower_bound(range: &str) -> Option<RuntimeVersion> {
110
+ let start = range.find(|ch: char| ch.is_ascii_digit())?;
111
+ let version = range[start..]
112
+ .chars()
113
+ .take_while(|ch| ch.is_ascii_digit() || *ch == '.')
114
+ .collect::<String>();
115
+ version.parse().ok()
116
+ }
117
+
118
+ #[cfg(test)]
119
+ mod tests {
120
+ use super::{EnginesSeverity, engines_severity, range_allows_required};
121
+ use crate::version::RuntimeVersion;
122
+
123
+ #[test]
124
+ fn checks_npm_style_ranges() {
125
+ let required = RuntimeVersion {
126
+ major: 24,
127
+ minor: 0,
128
+ patch: 0,
129
+ };
130
+ assert!(range_allows_required(">=22", required));
131
+ assert!(!range_allows_required("^22.0.0", required));
132
+ }
133
+
134
+ #[test]
135
+ fn classifies_stricter_ranges_as_info() {
136
+ let required = RuntimeVersion {
137
+ major: 24,
138
+ minor: 0,
139
+ patch: 0,
140
+ };
141
+ assert_eq!(
142
+ engines_severity("^24.13.1", required),
143
+ EnginesSeverity::Info
144
+ );
145
+ assert_eq!(
146
+ engines_severity(">=24.13.1", required),
147
+ EnginesSeverity::Info
148
+ );
149
+ assert_eq!(
150
+ engines_severity("^23.0.0", required),
151
+ EnginesSeverity::Warning
152
+ );
153
+ }
154
+ }
package/src/help.rs ADDED
@@ -0,0 +1,192 @@
1
+ use terminal_size::{Width, terminal_size};
2
+
3
+ const LABEL_COLUMN_WIDTH: usize = 28;
4
+
5
+ type Rgb = (u8, u8, u8);
6
+
7
+ const EMERALD_500: Rgb = (16, 185, 129);
8
+ const SKY_500: Rgb = (14, 165, 233);
9
+ const NEUTRAL_500: Rgb = (115, 115, 115);
10
+ const NEUTRAL_600: Rgb = (82, 82, 82);
11
+ const NEUTRAL_700: Rgb = (64, 64, 64);
12
+ const WHITE: Rgb = (255, 255, 255);
13
+
14
+ pub fn print_help() {
15
+ print!("{}", create_help_body());
16
+ }
17
+
18
+ fn create_help_body() -> String {
19
+ let body = [
20
+ create_header(),
21
+ create_section("Usage", &[usage_row()]),
22
+ create_section("Arguments", &[argument_row()]),
23
+ create_section(
24
+ "Options",
25
+ &[
26
+ option_row(
27
+ &["--fast"],
28
+ "use FFF text scanning; faster but less precise",
29
+ ),
30
+ option_row(
31
+ &["--runtime <runtime>"],
32
+ "target all, node, deno, bun, safari, chrome, or firefox",
33
+ ),
34
+ option_row(&["--summary"], "print only the summary panel"),
35
+ option_row(
36
+ &["--inspect <feature>"],
37
+ "print every detection for one feature",
38
+ ),
39
+ option_row(&["--fix"], "update package.json engines.node when useful"),
40
+ option_row(&["-h", "--help"], "display help for command"),
41
+ ],
42
+ ),
43
+ ]
44
+ .into_iter()
45
+ .filter(|section| !section.is_empty())
46
+ .collect::<Vec<_>>()
47
+ .join("\n\n");
48
+
49
+ format!("{body}\n")
50
+ }
51
+
52
+ fn create_header() -> String {
53
+ let wordmark = gradient("runtime-checker", EMERALD_500, SKY_500);
54
+ let version = badge(env!("CARGO_PKG_VERSION"));
55
+ create_streak(&format!("{wordmark} {version}"))
56
+ }
57
+
58
+ fn usage_row() -> String {
59
+ format!(
60
+ " {} {}",
61
+ gradient("runtime-checker", EMERALD_500, SKY_500),
62
+ fg_rgb("<dir> [options]", WHITE)
63
+ )
64
+ }
65
+
66
+ fn argument_row() -> String {
67
+ let label = gradient("<dir>", EMERALD_500, SKY_500);
68
+ format!(
69
+ " {}{}",
70
+ pad_ansi(&label, LABEL_COLUMN_WIDTH),
71
+ fg_rgb("directory to scan", WHITE)
72
+ )
73
+ }
74
+
75
+ fn option_row(flags: &[&str], description: &str) -> String {
76
+ let flags = format_aliases(flags);
77
+ format!(
78
+ " {}{}",
79
+ pad_ansi(&flags, LABEL_COLUMN_WIDTH),
80
+ fg_rgb(description, WHITE)
81
+ )
82
+ }
83
+
84
+ fn create_section(title: &str, rows: &[String]) -> String {
85
+ if rows.is_empty() {
86
+ return String::new();
87
+ }
88
+
89
+ let heading = format!("{} {}", fg_rgb(title, WHITE), fg_rgb("»", NEUTRAL_700));
90
+ let mut section = heading;
91
+ for row in trim_empty_edges(rows) {
92
+ section.push('\n');
93
+ section.push_str(row);
94
+ }
95
+ section
96
+ }
97
+
98
+ fn format_aliases(aliases: &[&str]) -> String {
99
+ let separator = fg_rgb(", ", NEUTRAL_500);
100
+ aliases
101
+ .iter()
102
+ .map(|alias| gradient(alias, EMERALD_500, SKY_500))
103
+ .collect::<Vec<_>>()
104
+ .join(&separator)
105
+ }
106
+
107
+ fn trim_empty_edges(rows: &[String]) -> &[String] {
108
+ let Some(start) = rows.iter().position(|row| !row.is_empty()) else {
109
+ return &[];
110
+ };
111
+ let end = rows
112
+ .iter()
113
+ .rposition(|row| !row.is_empty())
114
+ .unwrap_or(start);
115
+ &rows[start..=end]
116
+ }
117
+
118
+ fn create_streak(content: &str) -> String {
119
+ let columns = terminal_width().saturating_sub(1).max(1);
120
+ let content_width = visible_width(content);
121
+ let right_width = columns.saturating_sub(content_width + 3).max(1);
122
+ format!(
123
+ "{} {} {}",
124
+ fg_rgb("─", NEUTRAL_700),
125
+ content,
126
+ fg_rgb("─".repeat(right_width), NEUTRAL_700)
127
+ )
128
+ }
129
+
130
+ fn terminal_width() -> usize {
131
+ terminal_size()
132
+ .map(|(Width(width), _)| width as usize)
133
+ .unwrap_or(80)
134
+ .max(40)
135
+ }
136
+
137
+ fn badge(label: &str) -> String {
138
+ format!(
139
+ "\x1b[48;2;{};{};{}m\x1b[38;2;{};{};{}m {label} \x1b[0m",
140
+ NEUTRAL_600.0, NEUTRAL_600.1, NEUTRAL_600.2, WHITE.0, WHITE.1, WHITE.2
141
+ )
142
+ }
143
+
144
+ fn fg_rgb(text: impl AsRef<str>, color: Rgb) -> String {
145
+ let text = text.as_ref();
146
+ format!(
147
+ "\x1b[38;2;{};{};{}m{text}\x1b[0m",
148
+ color.0, color.1, color.2
149
+ )
150
+ }
151
+
152
+ fn gradient(text: &str, from: Rgb, to: Rgb) -> String {
153
+ let chars = text.chars().collect::<Vec<_>>();
154
+ let denominator = chars.len().saturating_sub(1).max(1) as f32;
155
+ chars
156
+ .iter()
157
+ .enumerate()
158
+ .map(|(index, ch)| {
159
+ let amount = index as f32 / denominator;
160
+ let red = interpolate(from.0, to.0, amount);
161
+ let green = interpolate(from.1, to.1, amount);
162
+ let blue = interpolate(from.2, to.2, amount);
163
+ format!("\x1b[38;2;{red};{green};{blue}m{ch}\x1b[0m")
164
+ })
165
+ .collect()
166
+ }
167
+
168
+ fn interpolate(from: u8, to: u8, amount: f32) -> u8 {
169
+ (from as f32 + (to as f32 - from as f32) * amount).round() as u8
170
+ }
171
+
172
+ fn pad_ansi(value: &str, width: usize) -> String {
173
+ let padding = width.saturating_sub(visible_width(value));
174
+ format!("{value}{}", " ".repeat(padding))
175
+ }
176
+
177
+ fn visible_width(value: &str) -> usize {
178
+ let mut width = 0;
179
+ let mut chars = value.chars();
180
+ while let Some(ch) = chars.next() {
181
+ if ch == '\x1b' {
182
+ for next in chars.by_ref() {
183
+ if next.is_ascii_alphabetic() {
184
+ break;
185
+ }
186
+ }
187
+ } else {
188
+ width += 1;
189
+ }
190
+ }
191
+ width
192
+ }