mod no_empty_variant;
pub mod prefer_design_tokens;
mod require_component;
mod require_title;
mod unique_variant_names;
mod valid_variant;
pub use no_empty_variant::NoEmptyVariant;
pub use prefer_design_tokens::{PreferDesignTokens, PreferDesignTokensConfig};
pub use require_component::RequireComponent;
pub use require_title::RequireTitle;
pub use unique_variant_names::UniqueVariantNames;
pub use valid_variant::ValidVariant;
use memchr::memmem;
use vize_carton::FxHashSet;
use crate::diagnostic::{LintDiagnostic, Severity};
#[derive(Debug, Clone, Default)]
pub struct MuseaLintResult {
pub diagnostics: Vec<LintDiagnostic>,
pub error_count: usize,
pub warning_count: usize,
}
impl MuseaLintResult {
#[inline]
pub fn has_errors(&self) -> bool {
self.error_count > 0
}
#[inline]
pub fn has_diagnostics(&self) -> bool {
!self.diagnostics.is_empty()
}
#[inline]
pub fn add_diagnostic(&mut self, diagnostic: LintDiagnostic) {
match diagnostic.severity {
Severity::Error => self.error_count += 1,
Severity::Warning => self.warning_count += 1,
}
self.diagnostics.push(diagnostic);
}
}
pub struct MuseaRuleMeta {
pub name: &'static str,
pub description: &'static str,
pub default_severity: Severity,
}
pub trait MuseaRule: Send + Sync {
fn meta(&self) -> &'static MuseaRuleMeta;
fn check(&self, source: &str, result: &mut MuseaLintResult);
}
pub struct MuseaLinter {
pub check_require_title: bool,
pub check_require_component: bool,
pub check_valid_variant: bool,
pub check_no_empty_variant: bool,
pub check_unique_variant_names: bool,
prefer_design_tokens: Option<PreferDesignTokens>,
}
impl MuseaLinter {
#[inline]
pub fn new() -> Self {
Self {
check_require_title: true,
check_require_component: true,
check_valid_variant: true,
check_no_empty_variant: true,
check_unique_variant_names: true,
prefer_design_tokens: None,
}
}
#[inline]
pub fn with_design_tokens(mut self, config: PreferDesignTokensConfig) -> Self {
self.prefer_design_tokens = Some(PreferDesignTokens::new(config));
self
}
pub fn lint(&self, source: &str) -> MuseaLintResult {
let mut result = MuseaLintResult::default();
let bytes = source.as_bytes();
self.check_art_block(bytes, &mut result);
self.check_variant_blocks(bytes, &mut result);
if let Some(ref token_rule) = self.prefer_design_tokens {
token_rule.check(source, &mut result);
}
result
}
#[inline]
fn check_art_block(&self, bytes: &[u8], result: &mut MuseaLintResult) {
let Some(art_start) = memmem::find(bytes, b"<art") else {
return;
};
let Some(tag_end) = memchr::memchr(b'>', &bytes[art_start..]) else {
return;
};
let art_tag = &bytes[art_start..art_start + tag_end];
if self.check_require_title && !has_attribute(art_tag, b"title=") {
result.add_diagnostic(
LintDiagnostic::error(
"musea/require-title",
"Missing required 'title' attribute in <art> block",
art_start as u32,
(art_start + tag_end) as u32,
)
.with_help("Add a title attribute: <art title=\"Component Name\">"),
);
}
if self.check_require_component && !has_attribute(art_tag, b"component=") {
result.add_diagnostic(
LintDiagnostic::warn(
"musea/require-component",
"Missing 'component' attribute in <art> block",
art_start as u32,
(art_start + tag_end) as u32,
)
.with_help("Add component=\"./Component.vue\""),
);
}
}
fn check_variant_blocks(&self, bytes: &[u8], result: &mut MuseaLintResult) {
let variant_finder = memmem::Finder::new(b"<variant");
let mut search_start = 0;
let mut seen_names: FxHashSet<&[u8]> = FxHashSet::default();
while let Some(variant_pos) = variant_finder.find(&bytes[search_start..]) {
let abs_pos = search_start + variant_pos;
let remaining = &bytes[abs_pos..];
let Some(tag_end) = memchr::memchr(b'>', remaining) else {
break;
};
let variant_tag = &remaining[..tag_end];
let is_self_closing = tag_end > 0 && remaining[tag_end - 1] == b'/';
let name_value = extract_name_attr_bytes(variant_tag);
if self.check_valid_variant && name_value.is_none() {
result.add_diagnostic(
LintDiagnostic::error(
"musea/valid-variant",
"Missing required 'name' attribute in <variant> block",
abs_pos as u32,
(abs_pos + tag_end) as u32,
)
.with_help("Add name=\"variant-name\" to the variant"),
);
}
if let Some(name) = name_value {
if self.check_unique_variant_names {
if seen_names.contains(name) {
result.add_diagnostic(
LintDiagnostic::error(
"musea/unique-variant-names",
"Duplicate variant name",
abs_pos as u32,
(abs_pos + tag_end) as u32,
)
.with_help("Use a unique name for each variant"),
);
} else {
seen_names.insert(name);
}
}
}
if self.check_no_empty_variant {
if is_self_closing {
result.add_diagnostic(
LintDiagnostic::warn(
"musea/no-empty-variant",
"Empty self-closing <variant /> block",
abs_pos as u32,
(abs_pos + tag_end + 1) as u32,
)
.with_help("Add template content inside the variant"),
);
search_start = abs_pos + tag_end + 1;
continue;
}
let after_open = &remaining[tag_end + 1..];
if let Some(close_pos) = memmem::find(after_open, b"</variant>") {
let content = &after_open[..close_pos];
if is_whitespace_only(content) {
result.add_diagnostic(
LintDiagnostic::warn(
"musea/no-empty-variant",
"Empty <variant> block with no content",
abs_pos as u32,
(abs_pos + tag_end + 1 + close_pos + 10) as u32,
)
.with_help("Add template content inside the variant"),
);
}
search_start = abs_pos + tag_end + 1 + close_pos + 10;
} else {
break;
}
} else {
search_start = abs_pos + tag_end + 1;
}
}
}
}
impl Default for MuseaLinter {
#[inline]
fn default() -> Self {
Self::new()
}
}
#[inline]
fn has_attribute(tag: &[u8], attr: &[u8]) -> bool {
memmem::find(tag, attr).is_some()
}
#[inline]
fn extract_name_attr_bytes(tag: &[u8]) -> Option<&[u8]> {
let name_pos = memmem::find(tag, b"name=")?;
let after_eq = &tag[name_pos + 5..];
let mut i = 0;
while i < after_eq.len() && after_eq[i].is_ascii_whitespace() {
i += 1;
}
if i >= after_eq.len() {
return None;
}
let quote = after_eq[i];
if quote != b'"' && quote != b'\'' {
return None;
}
let after_quote = &after_eq[i + 1..];
let end_quote = memchr::memchr(quote, after_quote)?;
Some(&after_quote[..end_quote])
}
#[inline]
fn is_whitespace_only(bytes: &[u8]) -> bool {
bytes.iter().all(|b| b.is_ascii_whitespace())
}
#[cfg(test)]
mod tests {
use super::{extract_name_attr_bytes, MuseaLinter};
#[test]
fn test_lint_valid_art_file() {
let source = r#"
<art title="Button" component="./Button.vue">
<variant name="default">
<Button>Click me</Button>
</variant>
</art>
"#;
let linter = MuseaLinter::new();
let result = linter.lint(source);
assert!(!result.has_errors());
}
#[test]
fn test_lint_missing_title() {
let source = r#"
<art component="./Button.vue">
<variant name="default">
<Button>Click me</Button>
</variant>
</art>
"#;
let linter = MuseaLinter::new();
let result = linter.lint(source);
assert!(result.has_errors());
}
#[test]
fn test_lint_duplicate_variant_names() {
let source = r#"
<art title="Button" component="./Button.vue">
<variant name="same">
<Button>One</Button>
</variant>
<variant name="same">
<Button>Two</Button>
</variant>
</art>
"#;
let linter = MuseaLinter::new();
let result = linter.lint(source);
assert!(result.has_errors());
assert_eq!(result.error_count, 1);
}
#[test]
fn test_lint_empty_variant() {
let source = r#"
<art title="Button" component="./Button.vue">
<variant name="empty"></variant>
</art>
"#;
let linter = MuseaLinter::new();
let result = linter.lint(source);
assert_eq!(result.warning_count, 1);
}
#[test]
fn test_extract_name_attr() {
assert_eq!(
extract_name_attr_bytes(b"<variant name=\"test\""),
Some(b"test".as_slice())
);
assert_eq!(
extract_name_attr_bytes(b"<variant name='test'"),
Some(b"test".as_slice())
);
assert_eq!(extract_name_attr_bytes(b"<variant "), None);
}
}