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