flarelint 0.1.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 +1082 -0
- package/Cargo.toml +32 -0
- package/LICENSE-APACHE +176 -0
- package/LICENSE-MIT +21 -0
- package/README.md +165 -0
- package/bin/flarelint.js +78 -0
- package/package.json +52 -0
- package/scripts/postinstall.mjs +83 -0
- package/src/config.rs +197 -0
- package/src/diagnostics.rs +146 -0
- package/src/formatter.rs +129 -0
- package/src/lib.rs +9 -0
- package/src/main.rs +178 -0
- package/src/parser.rs +215 -0
- package/src/rules/do_storage.rs +400 -0
- package/src/rules/mod.rs +120 -0
- package/src/rules/node_compat.rs +501 -0
- package/src/rules/routes.rs +268 -0
- package/src/rules/waituntil.rs +400 -0
package/src/config.rs
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
use serde::{Deserialize, Serialize};
|
|
2
|
+
use std::fs;
|
|
3
|
+
use std::path::{Path, PathBuf};
|
|
4
|
+
|
|
5
|
+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
6
|
+
pub struct CloudflareConfig {
|
|
7
|
+
#[serde(default)]
|
|
8
|
+
pub name: Option<String>,
|
|
9
|
+
#[serde(default)]
|
|
10
|
+
pub main: Option<String>,
|
|
11
|
+
#[serde(default)]
|
|
12
|
+
pub compatibility_date: Option<String>,
|
|
13
|
+
#[serde(default)]
|
|
14
|
+
pub compatibility_flags: Vec<String>,
|
|
15
|
+
#[serde(default)]
|
|
16
|
+
pub pages_build_output_dir: Option<String>,
|
|
17
|
+
#[serde(skip)]
|
|
18
|
+
pub config_path: Option<PathBuf>,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
impl CloudflareConfig {
|
|
22
|
+
pub fn has_nodejs_compat(&self) -> bool {
|
|
23
|
+
self.compatibility_flags
|
|
24
|
+
.iter()
|
|
25
|
+
.any(|f| f == "nodejs_compat" || f == "nodejs_compat_v2" || f == "nodejs_als")
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
pub fn has_nodejs_compat_v2(&self) -> bool {
|
|
29
|
+
self.compatibility_flags
|
|
30
|
+
.iter()
|
|
31
|
+
.any(|f| f == "nodejs_compat_v2")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
pub fn has_flag(&self, flag: &str) -> bool {
|
|
35
|
+
self.compatibility_flags.iter().any(|f| f == flag)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
pub fn add_flag(&mut self, flag: impl Into<String>) {
|
|
39
|
+
let f = flag.into();
|
|
40
|
+
if !self.compatibility_flags.contains(&f) {
|
|
41
|
+
self.compatibility_flags.push(f);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
pub fn find_and_load(start_dir: &Path) -> Option<Self> {
|
|
46
|
+
let mut current = if start_dir.is_file() {
|
|
47
|
+
start_dir.parent()
|
|
48
|
+
} else {
|
|
49
|
+
Some(start_dir)
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
while let Some(dir) = current {
|
|
53
|
+
let candidates = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"];
|
|
54
|
+
for candidate in candidates {
|
|
55
|
+
let file = dir.join(candidate);
|
|
56
|
+
if file.is_file()
|
|
57
|
+
&& let Ok(config) = Self::load_file(&file)
|
|
58
|
+
{
|
|
59
|
+
return Some(config);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
current = dir.parent();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
None
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
pub fn load_file(path: &Path) -> Result<Self, String> {
|
|
69
|
+
let content = fs::read_to_string(path)
|
|
70
|
+
.map_err(|e| format!("Failed to read config file {}: {}", path.display(), e))?;
|
|
71
|
+
|
|
72
|
+
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
|
73
|
+
let mut config: Self = match ext {
|
|
74
|
+
"json" | "jsonc" => {
|
|
75
|
+
let clean_json = strip_jsonc_comments(&content);
|
|
76
|
+
serde_json::from_str(&clean_json)
|
|
77
|
+
.map_err(|e| format!("Failed to parse JSON config {}: {}", path.display(), e))?
|
|
78
|
+
}
|
|
79
|
+
"toml" => parse_simple_toml(&content)?,
|
|
80
|
+
_ => {
|
|
81
|
+
let clean_json = strip_jsonc_comments(&content);
|
|
82
|
+
if let Ok(cfg) = serde_json::from_str(&clean_json) {
|
|
83
|
+
cfg
|
|
84
|
+
} else {
|
|
85
|
+
parse_simple_toml(&content)?
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
config.config_path = Some(path.to_path_buf());
|
|
91
|
+
Ok(config)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fn strip_jsonc_comments(input: &str) -> String {
|
|
96
|
+
let mut output = String::with_capacity(input.len());
|
|
97
|
+
let mut chars = input.chars().peekable();
|
|
98
|
+
let mut in_string = false;
|
|
99
|
+
let mut escape = false;
|
|
100
|
+
|
|
101
|
+
while let Some(c) = chars.next() {
|
|
102
|
+
if in_string {
|
|
103
|
+
output.push(c);
|
|
104
|
+
if escape {
|
|
105
|
+
escape = false;
|
|
106
|
+
} else if c == '\\' {
|
|
107
|
+
escape = true;
|
|
108
|
+
} else if c == '"' {
|
|
109
|
+
in_string = false;
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if c == '"' {
|
|
115
|
+
in_string = true;
|
|
116
|
+
output.push(c);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if c == '/'
|
|
121
|
+
&& let Some(&next) = chars.peek()
|
|
122
|
+
{
|
|
123
|
+
if next == '/' {
|
|
124
|
+
chars.next();
|
|
125
|
+
for line_c in chars.by_ref() {
|
|
126
|
+
if line_c == '\n' {
|
|
127
|
+
output.push('\n');
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
continue;
|
|
132
|
+
} else if next == '*' {
|
|
133
|
+
chars.next();
|
|
134
|
+
let mut prev = ' ';
|
|
135
|
+
for block_c in chars.by_ref() {
|
|
136
|
+
if prev == '*' && block_c == '/' {
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
if block_c == '\n' {
|
|
140
|
+
output.push('\n');
|
|
141
|
+
}
|
|
142
|
+
prev = block_c;
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
output.push(c);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
output
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
fn parse_simple_toml(content: &str) -> Result<CloudflareConfig, String> {
|
|
155
|
+
let mut config = CloudflareConfig::default();
|
|
156
|
+
|
|
157
|
+
for line in content.lines() {
|
|
158
|
+
let trimmed = line.trim();
|
|
159
|
+
if trimmed.starts_with('#') || trimmed.is_empty() {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if let Some((key, val)) = trimmed.split_once('=') {
|
|
164
|
+
let key = key.trim();
|
|
165
|
+
let val = val.trim();
|
|
166
|
+
|
|
167
|
+
match key {
|
|
168
|
+
"name" => {
|
|
169
|
+
config.name = Some(val.trim_matches('"').trim_matches('\'').to_string());
|
|
170
|
+
}
|
|
171
|
+
"main" => {
|
|
172
|
+
config.main = Some(val.trim_matches('"').trim_matches('\'').to_string());
|
|
173
|
+
}
|
|
174
|
+
"compatibility_date" => {
|
|
175
|
+
config.compatibility_date =
|
|
176
|
+
Some(val.trim_matches('"').trim_matches('\'').to_string());
|
|
177
|
+
}
|
|
178
|
+
"compatibility_flags" => {
|
|
179
|
+
let cleaned = val.trim_matches('[').trim_matches(']');
|
|
180
|
+
for flag in cleaned.split(',') {
|
|
181
|
+
let clean_flag = flag.trim().trim_matches('"').trim_matches('\'');
|
|
182
|
+
if !clean_flag.is_empty() {
|
|
183
|
+
config.compatibility_flags.push(clean_flag.to_string());
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
"pages_build_output_dir" => {
|
|
188
|
+
config.pages_build_output_dir =
|
|
189
|
+
Some(val.trim_matches('"').trim_matches('\'').to_string());
|
|
190
|
+
}
|
|
191
|
+
_ => {}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
Ok(config)
|
|
197
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
use serde::{Deserialize, Serialize};
|
|
2
|
+
use std::fmt;
|
|
3
|
+
use std::path::PathBuf;
|
|
4
|
+
|
|
5
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
|
6
|
+
#[serde(rename_all = "lowercase")]
|
|
7
|
+
pub enum Severity {
|
|
8
|
+
Warning,
|
|
9
|
+
Error,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
impl fmt::Display for Severity {
|
|
13
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
14
|
+
match self {
|
|
15
|
+
Severity::Warning => write!(f, "warning"),
|
|
16
|
+
Severity::Error => write!(f, "error"),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
22
|
+
pub struct SourceLocation {
|
|
23
|
+
pub line: usize,
|
|
24
|
+
pub column: usize,
|
|
25
|
+
pub offset: usize,
|
|
26
|
+
pub length: usize,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
impl SourceLocation {
|
|
30
|
+
pub fn new(line: usize, column: usize, offset: usize, length: usize) -> Self {
|
|
31
|
+
Self {
|
|
32
|
+
line,
|
|
33
|
+
column,
|
|
34
|
+
offset,
|
|
35
|
+
length,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
41
|
+
pub struct FixSuggestion {
|
|
42
|
+
pub message: String,
|
|
43
|
+
pub replacement: Option<String>,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
47
|
+
pub struct Diagnostic {
|
|
48
|
+
pub rule: String,
|
|
49
|
+
pub message: String,
|
|
50
|
+
pub severity: Severity,
|
|
51
|
+
pub file_path: PathBuf,
|
|
52
|
+
pub location: Option<SourceLocation>,
|
|
53
|
+
pub suggestion: Option<FixSuggestion>,
|
|
54
|
+
pub code_snippet: Option<String>,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
impl Diagnostic {
|
|
58
|
+
pub fn error(
|
|
59
|
+
rule: impl Into<String>,
|
|
60
|
+
message: impl Into<String>,
|
|
61
|
+
file_path: impl Into<PathBuf>,
|
|
62
|
+
) -> Self {
|
|
63
|
+
Self {
|
|
64
|
+
rule: rule.into(),
|
|
65
|
+
message: message.into(),
|
|
66
|
+
severity: Severity::Error,
|
|
67
|
+
file_path: file_path.into(),
|
|
68
|
+
location: None,
|
|
69
|
+
suggestion: None,
|
|
70
|
+
code_snippet: None,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
pub fn warning(
|
|
75
|
+
rule: impl Into<String>,
|
|
76
|
+
message: impl Into<String>,
|
|
77
|
+
file_path: impl Into<PathBuf>,
|
|
78
|
+
) -> Self {
|
|
79
|
+
Self {
|
|
80
|
+
rule: rule.into(),
|
|
81
|
+
message: message.into(),
|
|
82
|
+
severity: Severity::Warning,
|
|
83
|
+
file_path: file_path.into(),
|
|
84
|
+
location: None,
|
|
85
|
+
suggestion: None,
|
|
86
|
+
code_snippet: None,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
pub fn with_location(mut self, location: SourceLocation) -> Self {
|
|
91
|
+
self.location = Some(location);
|
|
92
|
+
self
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
pub fn with_suggestion(
|
|
96
|
+
mut self,
|
|
97
|
+
message: impl Into<String>,
|
|
98
|
+
replacement: Option<String>,
|
|
99
|
+
) -> Self {
|
|
100
|
+
self.suggestion = Some(FixSuggestion {
|
|
101
|
+
message: message.into(),
|
|
102
|
+
replacement,
|
|
103
|
+
});
|
|
104
|
+
self
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
pub fn with_snippet(mut self, snippet: impl Into<String>) -> Self {
|
|
108
|
+
self.code_snippet = Some(snippet.into());
|
|
109
|
+
self
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
|
114
|
+
pub struct LintReport {
|
|
115
|
+
pub total_files_scanned: usize,
|
|
116
|
+
pub diagnostics: Vec<Diagnostic>,
|
|
117
|
+
pub error_count: usize,
|
|
118
|
+
pub warning_count: usize,
|
|
119
|
+
pub elapsed_ns: u128,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
impl LintReport {
|
|
123
|
+
pub fn new() -> Self {
|
|
124
|
+
Self::default()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
pub fn add_diagnostic(&mut self, diagnostic: Diagnostic) {
|
|
128
|
+
match diagnostic.severity {
|
|
129
|
+
Severity::Error => self.error_count += 1,
|
|
130
|
+
Severity::Warning => self.warning_count += 1,
|
|
131
|
+
}
|
|
132
|
+
self.diagnostics.push(diagnostic);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
pub fn extend(&mut self, other: LintReport) {
|
|
136
|
+
self.total_files_scanned += other.total_files_scanned;
|
|
137
|
+
self.error_count += other.error_count;
|
|
138
|
+
self.warning_count += other.warning_count;
|
|
139
|
+
self.diagnostics.extend(other.diagnostics);
|
|
140
|
+
self.elapsed_ns += other.elapsed_ns;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
pub fn has_errors(&self) -> bool {
|
|
144
|
+
self.error_count > 0
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/formatter.rs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
use colored::*;
|
|
2
|
+
use comfy_table::modifiers::UTF8_ROUND_CORNERS;
|
|
3
|
+
use comfy_table::presets::UTF8_FULL;
|
|
4
|
+
use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table};
|
|
5
|
+
|
|
6
|
+
use crate::diagnostics::{LintReport, Severity};
|
|
7
|
+
|
|
8
|
+
pub fn format_report_human(report: &LintReport, verbose: bool) -> String {
|
|
9
|
+
let mut out = String::new();
|
|
10
|
+
|
|
11
|
+
out.push_str(&format!(
|
|
12
|
+
"\n{}\n",
|
|
13
|
+
"─── FLARELINT // EDGE COMPATIBILITY & AST AUDITOR ───"
|
|
14
|
+
.bold()
|
|
15
|
+
.white()
|
|
16
|
+
));
|
|
17
|
+
|
|
18
|
+
if report.diagnostics.is_empty() {
|
|
19
|
+
let duration_ms = report.elapsed_ns as f64 / 1_000_000.0;
|
|
20
|
+
let per_file_us = if report.total_files_scanned > 0 {
|
|
21
|
+
(report.elapsed_ns as f64 / report.total_files_scanned as f64) / 1_000.0
|
|
22
|
+
} else {
|
|
23
|
+
0.0
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
out.push_str(&format!(
|
|
27
|
+
"{} Clean! Scanned {} files in {:.2}ms ({:.1}µs/file). 0 errors, 0 warnings.\n\n",
|
|
28
|
+
"✔".green().bold(),
|
|
29
|
+
report.total_files_scanned.to_string().bold().white(),
|
|
30
|
+
duration_ms,
|
|
31
|
+
per_file_us
|
|
32
|
+
));
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let mut table = Table::new();
|
|
37
|
+
table
|
|
38
|
+
.load_preset(UTF8_FULL)
|
|
39
|
+
.apply_modifier(UTF8_ROUND_CORNERS)
|
|
40
|
+
.set_content_arrangement(ContentArrangement::Dynamic)
|
|
41
|
+
.set_header(vec![
|
|
42
|
+
Cell::new("SEVERITY")
|
|
43
|
+
.add_attribute(Attribute::Bold)
|
|
44
|
+
.fg(Color::White),
|
|
45
|
+
Cell::new("RULE")
|
|
46
|
+
.add_attribute(Attribute::Bold)
|
|
47
|
+
.fg(Color::White),
|
|
48
|
+
Cell::new("LOCATION")
|
|
49
|
+
.add_attribute(Attribute::Bold)
|
|
50
|
+
.fg(Color::White),
|
|
51
|
+
Cell::new("MESSAGE")
|
|
52
|
+
.add_attribute(Attribute::Bold)
|
|
53
|
+
.fg(Color::White),
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
for diag in &report.diagnostics {
|
|
57
|
+
let (sev_str, sev_color) = match diag.severity {
|
|
58
|
+
Severity::Error => ("ERROR".to_string(), Color::Red),
|
|
59
|
+
Severity::Warning => ("WARN".to_string(), Color::Yellow),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
let loc_str = if let Some(loc) = &diag.location {
|
|
63
|
+
format!("{}:{}:{}", diag.file_path.display(), loc.line, loc.column)
|
|
64
|
+
} else {
|
|
65
|
+
diag.file_path.display().to_string()
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
let mut msg_content = diag.message.clone();
|
|
69
|
+
if verbose {
|
|
70
|
+
if let Some(snippet) = &diag.code_snippet {
|
|
71
|
+
msg_content.push_str(&format!("\n › {}", snippet.dimmed()));
|
|
72
|
+
}
|
|
73
|
+
if let Some(suggestion) = &diag.suggestion {
|
|
74
|
+
msg_content.push_str(&format!("\n ↳ Fix: {}", suggestion.message.cyan()));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
table.add_row(vec![
|
|
79
|
+
Cell::new(sev_str)
|
|
80
|
+
.fg(sev_color)
|
|
81
|
+
.add_attribute(Attribute::Bold),
|
|
82
|
+
Cell::new(&diag.rule).fg(Color::Cyan),
|
|
83
|
+
Cell::new(loc_str).fg(Color::DarkGrey),
|
|
84
|
+
Cell::new(msg_content),
|
|
85
|
+
]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
out.push_str(&table.to_string());
|
|
89
|
+
out.push('\n');
|
|
90
|
+
|
|
91
|
+
for diag in &report.diagnostics {
|
|
92
|
+
if !verbose && let Some(suggestion) = &diag.suggestion {
|
|
93
|
+
let loc_str = if let Some(loc) = &diag.location {
|
|
94
|
+
format!("{}:{}", diag.file_path.display(), loc.line)
|
|
95
|
+
} else {
|
|
96
|
+
diag.file_path.display().to_string()
|
|
97
|
+
};
|
|
98
|
+
out.push_str(&format!(
|
|
99
|
+
" {} [{}] {}\n",
|
|
100
|
+
"↳".cyan().bold(),
|
|
101
|
+
loc_str.dimmed(),
|
|
102
|
+
suggestion.message
|
|
103
|
+
));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let duration_ms = report.elapsed_ns as f64 / 1_000_000.0;
|
|
108
|
+
let status_str = if report.has_errors() {
|
|
109
|
+
"FAILED".red().bold()
|
|
110
|
+
} else {
|
|
111
|
+
"PASSED (WITH WARNINGS)".yellow().bold()
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
out.push_str(&format!(
|
|
115
|
+
"\n{} // Scanned {} files in {:.2}ms // {} errors, {} warnings\n\n",
|
|
116
|
+
status_str,
|
|
117
|
+
report.total_files_scanned.to_string().bold(),
|
|
118
|
+
duration_ms,
|
|
119
|
+
report.error_count.to_string().bold().red(),
|
|
120
|
+
report.warning_count.to_string().bold().yellow()
|
|
121
|
+
));
|
|
122
|
+
|
|
123
|
+
out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
pub fn format_report_json(report: &LintReport) -> Result<String, String> {
|
|
127
|
+
serde_json::to_string_pretty(report)
|
|
128
|
+
.map_err(|e| format!("Failed to serialize JSON report: {}", e))
|
|
129
|
+
}
|
package/src/lib.rs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pub mod config;
|
|
2
|
+
pub mod diagnostics;
|
|
3
|
+
pub mod formatter;
|
|
4
|
+
pub mod parser;
|
|
5
|
+
pub mod rules;
|
|
6
|
+
|
|
7
|
+
pub use config::CloudflareConfig;
|
|
8
|
+
pub use diagnostics::{Diagnostic, FixSuggestion, LintReport, Severity, SourceLocation};
|
|
9
|
+
pub use rules::{RuleCategory, run_linter_on_target};
|
package/src/main.rs
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
use clap::{Command, CommandFactory, Parser, Subcommand, ValueEnum};
|
|
2
|
+
use clap_complete::{Shell, generate};
|
|
3
|
+
use std::io;
|
|
4
|
+
use std::path::PathBuf;
|
|
5
|
+
use std::process::ExitCode;
|
|
6
|
+
|
|
7
|
+
use flarelint::config::CloudflareConfig;
|
|
8
|
+
use flarelint::formatter::{format_report_human, format_report_json};
|
|
9
|
+
use flarelint::rules::{RuleCategory, run_linter_on_target};
|
|
10
|
+
|
|
11
|
+
#[derive(Parser, Debug)]
|
|
12
|
+
#[command(
|
|
13
|
+
name = "flarelint",
|
|
14
|
+
author = "Brandon Hubbard",
|
|
15
|
+
version,
|
|
16
|
+
about = "Unified, nanosecond-fast Rust AST static analysis and edge compatibility linter for Cloudflare Workers & Astro applications",
|
|
17
|
+
long_about = "flarelint consolidates AST rules for Cloudflare Workers, Pages, Durable Objects, and Astro edge applications, validating Node.js compatibility, floating promises, transaction safety, and _routes.json limits."
|
|
18
|
+
)]
|
|
19
|
+
struct Cli {
|
|
20
|
+
#[command(subcommand)]
|
|
21
|
+
command: Option<Commands>,
|
|
22
|
+
|
|
23
|
+
/// Path to lint directly (defaults to check command)
|
|
24
|
+
#[arg(global = true)]
|
|
25
|
+
path: Option<PathBuf>,
|
|
26
|
+
|
|
27
|
+
/// Output report in JSON format
|
|
28
|
+
#[arg(short = 'j', long = "json", global = true)]
|
|
29
|
+
json: bool,
|
|
30
|
+
|
|
31
|
+
/// Treat warnings as errors
|
|
32
|
+
#[arg(long = "strict", global = true)]
|
|
33
|
+
strict: bool,
|
|
34
|
+
|
|
35
|
+
/// Show verbose diagnostics with inline code snippets and fix suggestions
|
|
36
|
+
#[arg(short = 'v', long = "verbose", global = true)]
|
|
37
|
+
verbose: bool,
|
|
38
|
+
|
|
39
|
+
/// Additional Cloudflare compatibility flags (e.g. nodejs_compat, nodejs_compat_v2)
|
|
40
|
+
#[arg(short = 'c', long = "compatibility-flag", global = true)]
|
|
41
|
+
compatibility_flag: Vec<String>,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#[derive(Subcommand, Debug)]
|
|
45
|
+
enum Commands {
|
|
46
|
+
/// Scans JS/TS/Astro AST for unsupported Node.js runtime built-in modules
|
|
47
|
+
#[command(name = "node-compat")]
|
|
48
|
+
NodeCompat {
|
|
49
|
+
/// Target file or directory to scan (defaults to current directory)
|
|
50
|
+
#[arg(default_value = ".")]
|
|
51
|
+
path: PathBuf,
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
/// Detects un-awaited async promises in request handlers not passed to ctx.waitUntil()
|
|
55
|
+
#[command(name = "waituntil")]
|
|
56
|
+
WaitUntil {
|
|
57
|
+
/// Target file or directory to scan (defaults to current directory)
|
|
58
|
+
#[arg(default_value = ".")]
|
|
59
|
+
path: PathBuf,
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
/// Lints Durable Object storage transaction patterns, concurrent write hazards, and un-awaited storage operations
|
|
63
|
+
#[command(name = "do-storage")]
|
|
64
|
+
DoStorage {
|
|
65
|
+
/// Target file or directory to scan (defaults to current directory)
|
|
66
|
+
#[arg(default_value = ".")]
|
|
67
|
+
path: PathBuf,
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
/// Lints _routes.json files for rule overlap, invalid globs, and Cloudflare Pages 100-rule limit
|
|
71
|
+
#[command(name = "routes")]
|
|
72
|
+
Routes {
|
|
73
|
+
/// Target file or directory to scan (defaults to current directory)
|
|
74
|
+
#[arg(default_value = ".")]
|
|
75
|
+
path: PathBuf,
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
/// Comprehensive end-to-end linting running all rules
|
|
79
|
+
#[command(name = "check")]
|
|
80
|
+
Check {
|
|
81
|
+
/// Target file or directory to scan (defaults to current directory)
|
|
82
|
+
#[arg(default_value = ".")]
|
|
83
|
+
path: PathBuf,
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
/// Generates shell completion scripts (zsh, bash, fish, powershell, elvish)
|
|
87
|
+
#[command(name = "completions")]
|
|
88
|
+
Completions {
|
|
89
|
+
/// Shell to generate completions for
|
|
90
|
+
#[arg(value_enum)]
|
|
91
|
+
shell: ShellChoice,
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#[derive(Debug, Clone, Copy, ValueEnum)]
|
|
96
|
+
enum ShellChoice {
|
|
97
|
+
Zsh,
|
|
98
|
+
Bash,
|
|
99
|
+
Fish,
|
|
100
|
+
Powershell,
|
|
101
|
+
Elvish,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
impl From<ShellChoice> for Shell {
|
|
105
|
+
fn from(choice: ShellChoice) -> Self {
|
|
106
|
+
match choice {
|
|
107
|
+
ShellChoice::Zsh => Shell::Zsh,
|
|
108
|
+
ShellChoice::Bash => Shell::Bash,
|
|
109
|
+
ShellChoice::Fish => Shell::Fish,
|
|
110
|
+
ShellChoice::Powershell => Shell::PowerShell,
|
|
111
|
+
ShellChoice::Elvish => Shell::Elvish,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
fn print_completions<G: clap_complete::Generator>(generator: G, cmd: &mut Command) {
|
|
117
|
+
generate(
|
|
118
|
+
generator,
|
|
119
|
+
cmd,
|
|
120
|
+
cmd.get_name().to_string(),
|
|
121
|
+
&mut io::stdout(),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
fn main() -> ExitCode {
|
|
126
|
+
let cli = Cli::parse();
|
|
127
|
+
|
|
128
|
+
let mut custom_config = CloudflareConfig::default();
|
|
129
|
+
for flag in &cli.compatibility_flag {
|
|
130
|
+
custom_config.add_flag(flag);
|
|
131
|
+
}
|
|
132
|
+
let override_config = if !custom_config.compatibility_flags.is_empty() {
|
|
133
|
+
Some(custom_config)
|
|
134
|
+
} else {
|
|
135
|
+
None
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
let (category, target_path) = match &cli.command {
|
|
139
|
+
Some(Commands::Completions { shell }) => {
|
|
140
|
+
let mut cmd = Cli::command();
|
|
141
|
+
print_completions(Shell::from(*shell), &mut cmd);
|
|
142
|
+
return ExitCode::SUCCESS;
|
|
143
|
+
}
|
|
144
|
+
Some(Commands::NodeCompat { path }) => (RuleCategory::NodeCompat, path.clone()),
|
|
145
|
+
Some(Commands::WaitUntil { path }) => (RuleCategory::WaitUntil, path.clone()),
|
|
146
|
+
Some(Commands::DoStorage { path }) => (RuleCategory::DoStorage, path.clone()),
|
|
147
|
+
Some(Commands::Routes { path }) => (RuleCategory::Routes, path.clone()),
|
|
148
|
+
Some(Commands::Check { path }) => (RuleCategory::All, path.clone()),
|
|
149
|
+
None => {
|
|
150
|
+
let path = cli.path.clone().unwrap_or_else(|| PathBuf::from("."));
|
|
151
|
+
(RuleCategory::All, path)
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
let report = match run_linter_on_target(&target_path, category, override_config) {
|
|
156
|
+
Ok(r) => r,
|
|
157
|
+
Err(err) => {
|
|
158
|
+
eprintln!("flarelint error: {}", err);
|
|
159
|
+
return ExitCode::FAILURE;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if cli.json {
|
|
164
|
+
match format_report_json(&report) {
|
|
165
|
+
Ok(json_str) => println!("{}", json_str),
|
|
166
|
+
Err(e) => eprintln!("Error formatting JSON: {}", e),
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
let output = format_report_human(&report, cli.verbose);
|
|
170
|
+
print!("{}", output);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if report.has_errors() || (cli.strict && report.warning_count > 0) {
|
|
174
|
+
ExitCode::FAILURE
|
|
175
|
+
} else {
|
|
176
|
+
ExitCode::SUCCESS
|
|
177
|
+
}
|
|
178
|
+
}
|