dictator_datastar/
lib.rs

1#![warn(rust_2024_compatibility, clippy::all)]
2#![allow(unsafe_attr_outside_unsafe, unsafe_op_in_unsafe_fn)]
3
4//! # dictator-datastar
5//!
6//! Datastar hygiene decree for the Dictator structural linter.
7//! Validates Datastar HTML attributes for syntax and best practices.
8//!
9//! ## Rules
10//!
11//! - `datastar/no-alpine-vue-attrs` - Disallows Alpine.js/Vue.js style attributes
12//! - `datastar/require-value` - Requires values for expression-based attributes
13//! - `datastar/for-template` - Requires data-for on <template> elements
14//! - `datastar/typo` - Detects common typos in attribute names
15//! - `datastar/invalid-modifier` - Validates modifier syntax
16//! - `datastar/action-syntax` - Validates @action syntax
17//!
18//! ## Note on Attribute Order
19//!
20//! Datastar processes attributes in DOM order (depth-first, then attribute order).
21//! The order is **semantic**, not stylistic - dependencies between attributes
22//! require specific ordering. This decree does NOT enforce attribute ordering
23//! since correct order depends on the specific use case.
24//!
25//! ## Building
26//!
27//! ```bash
28//! cargo build --release --target wasm32-wasip1
29//! ```
30
31mod 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/// Datastar hygiene decree - enforces Datastar best practices.
43#[derive(Default)]
44pub struct DatastarHygiene {
45    config: DatastarConfig,
46}
47
48impl DatastarHygiene {
49    /// Create a new DatastarHygiene decree with default config.
50    #[must_use]
51    pub fn new() -> Self {
52        Self::default()
53    }
54
55    /// Create a new DatastarHygiene decree with custom config.
56    #[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        // Parse HTML tags
71        let tags = parse_tags(source);
72
73        for tag in &tags {
74            // Check for Alpine/Vue attributes
75            if self.config.check_alpine_vue {
76                validation::check_alpine_vue(tag, &mut diags);
77            }
78
79            // Check required values
80            if self.config.check_required_values {
81                validation::check_required_values(tag, &mut diags);
82            }
83
84            // Check data-for on template
85            if self.config.check_for_template {
86                validation::check_for_on_template(tag, &mut diags);
87            }
88
89            // Check for typos
90            if self.config.check_typos {
91                typos::check_typos(tag, &mut diags);
92            }
93
94            // Check modifier syntax
95            if self.config.check_modifiers {
96                modifiers::check_modifiers(tag, &mut diags);
97            }
98
99            // Check action syntax
100            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/// Factory for creating decree instance.
123#[must_use]
124pub fn init_decree() -> Box<dyn Decree> {
125    Box::new(DatastarHygiene::default())
126}
127
128// =============================================================================
129// WASM COMPONENT BINDINGS
130// =============================================================================
131
132wit_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// =============================================================================
204// TESTS
205// =============================================================================
206
207#[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}