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/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
|
+
}
|