1#![warn(rust_2024_compatibility, clippy::all)]
2#![allow(unsafe_attr_outside_unsafe, unsafe_op_in_unsafe_fn)]
3
4mod actions;
32mod config;
33mod helpers;
34mod modifiers;
35mod typos;
36mod validation;
37
38use config::DatastarConfig;
39use dictator_decree_abi::{Decree, DecreeMetadata, Diagnostics};
40use helpers::parse_tags;
41
42#[derive(Default)]
44pub struct DatastarHygiene {
45 config: DatastarConfig,
46}
47
48impl DatastarHygiene {
49 #[must_use]
51 pub fn new() -> Self {
52 Self::default()
53 }
54
55 #[must_use]
57 pub const fn with_config(config: DatastarConfig) -> Self {
58 Self { config }
59 }
60}
61
62impl Decree for DatastarHygiene {
63 fn name(&self) -> &str {
64 "datastar"
65 }
66
67 fn lint(&self, _path: &str, source: &str) -> Diagnostics {
68 let mut diags = Diagnostics::new();
69
70 let tags = parse_tags(source);
72
73 for tag in &tags {
74 if self.config.check_alpine_vue {
76 validation::check_alpine_vue(tag, &mut diags);
77 }
78
79 if self.config.check_required_values {
81 validation::check_required_values(tag, &mut diags);
82 }
83
84 if self.config.check_for_template {
86 validation::check_for_on_template(tag, &mut diags);
87 }
88
89 if self.config.check_typos {
91 typos::check_typos(tag, &mut diags);
92 }
93
94 if self.config.check_modifiers {
96 modifiers::check_modifiers(tag, &mut diags);
97 }
98
99 if self.config.check_actions {
101 actions::check_actions(tag, &mut diags);
102 }
103 }
104
105 diags
106 }
107
108 fn metadata(&self) -> DecreeMetadata {
109 DecreeMetadata {
110 abi_version: dictator_decree_abi::ABI_VERSION.to_string(),
111 decree_version: env!("CARGO_PKG_VERSION").to_string(),
112 description: "Datastar HTML attribute hygiene and best practices".to_string(),
113 dectauthors: Some(env!("CARGO_PKG_AUTHORS").to_string()),
114 supported_extensions: vec!["html".to_string(), "htm".to_string()],
115 supported_filenames: vec![],
116 skip_filenames: vec![],
117 capabilities: vec![dictator_decree_abi::Capability::Lint],
118 }
119 }
120}
121
122#[must_use]
124pub fn init_decree() -> Box<dyn Decree> {
125 Box::new(DatastarHygiene::default())
126}
127
128wit_bindgen::generate!({
133 path: "wit/decree.wit",
134 world: "decree",
135});
136
137struct PluginImpl;
138
139impl exports::dictator::decree::lints::Guest for PluginImpl {
140 fn name() -> String {
141 DatastarHygiene::default().name().to_string()
142 }
143
144 fn lint(path: String, source: String) -> Vec<exports::dictator::decree::lints::Diagnostic> {
145 let decree = DatastarHygiene::default();
146 let diags = decree.lint(&path, &source);
147 diags
148 .into_iter()
149 .map(|d| exports::dictator::decree::lints::Diagnostic {
150 rule: d.rule,
151 message: d.message,
152 severity: if d.enforced {
153 exports::dictator::decree::lints::Severity::Info
154 } else {
155 exports::dictator::decree::lints::Severity::Error
156 },
157 span: exports::dictator::decree::lints::Span {
158 start: d.span.start as u32,
159 end: d.span.end as u32,
160 },
161 })
162 .collect()
163 }
164
165 fn metadata() -> exports::dictator::decree::lints::DecreeMetadata {
166 let decree = DatastarHygiene::default();
167 let meta = decree.metadata();
168 exports::dictator::decree::lints::DecreeMetadata {
169 abi_version: meta.abi_version,
170 decree_version: meta.decree_version,
171 description: meta.description,
172 dectauthors: meta.dectauthors,
173 supported_extensions: meta.supported_extensions,
174 supported_filenames: meta.supported_filenames,
175 skip_filenames: meta.skip_filenames,
176 capabilities: meta
177 .capabilities
178 .into_iter()
179 .map(|c| match c {
180 dictator_decree_abi::Capability::Lint => {
181 exports::dictator::decree::lints::Capability::Lint
182 }
183 dictator_decree_abi::Capability::AutoFix => {
184 exports::dictator::decree::lints::Capability::AutoFix
185 }
186 dictator_decree_abi::Capability::Streaming => {
187 exports::dictator::decree::lints::Capability::Streaming
188 }
189 dictator_decree_abi::Capability::RuntimeConfig => {
190 exports::dictator::decree::lints::Capability::RuntimeConfig
191 }
192 dictator_decree_abi::Capability::RichDiagnostics => {
193 exports::dictator::decree::lints::Capability::RichDiagnostics
194 }
195 })
196 .collect(),
197 }
198 }
199}
200
201export!(PluginImpl);
202
203#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn test_full_lint() {
213 let decree = DatastarHygiene::default();
214 let html = r#"
215 <div data-signals:count="0"
216 data-show="$count > 0"
217 data-on:click="$count++">
218 Count: <span data-text="$count"></span>
219 </div>
220 "#;
221 let diags = decree.lint("test.html", html);
222 assert!(
223 diags.is_empty(),
224 "Expected no diagnostics, got: {:?}",
225 diags
226 );
227 }
228
229 #[test]
230 fn test_detects_alpine_attrs() {
231 let decree = DatastarHygiene::default();
232 let html = r#"<div x-show="visible" @click="handle()">"#;
233 let diags = decree.lint("test.html", html);
234 assert_eq!(diags.len(), 2);
235 assert!(diags
236 .iter()
237 .all(|d| d.rule == "datastar/no-alpine-vue-attrs"));
238 }
239
240 #[test]
241 fn test_detects_typo() {
242 let decree = DatastarHygiene::default();
243 let html = r#"<div data-intersects="@get('/foo')">"#;
244 let diags = decree.lint("test.html", html);
245 assert!(diags.iter().any(|d| d.rule == "datastar/typo"));
246 }
247
248 #[test]
249 fn test_metadata() {
250 let decree = DatastarHygiene::default();
251 let meta = decree.metadata();
252 assert_eq!(meta.supported_extensions, vec!["html", "htm"]);
253 assert!(meta
254 .capabilities
255 .contains(&dictator_decree_abi::Capability::Lint));
256 }
257}