use std::collections::BTreeSet;
#[derive(Debug, Clone, Default)]
pub struct BemStructure {
pub block: Option<String>,
pub elements: BTreeSet<String>,
pub modifiers: BTreeSet<String>,
pub raw_tokens: BTreeSet<String>,
}
pub fn extract_style_tokens(source: &str) -> Vec<StyleToken> {
let mut tokens = Vec::new();
let mut seen = BTreeSet::new();
let bytes = source.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if i + 7 <= len && &bytes[i..i + 7] == b"styles." {
if i > 0 && is_ident_char(bytes[i - 1]) {
i += 1;
continue;
}
let start = i + 7;
if start >= len || !is_ident_start(bytes[start]) {
i += 1;
continue;
}
let first_end = read_ident(bytes, start);
let first = &source[start..first_end];
if first == "modifiers" {
if first_end < len && bytes[first_end] == b'.' {
let mod_start = first_end + 1;
if mod_start < len && is_ident_start(bytes[mod_start]) {
let mod_end = read_ident(bytes, mod_start);
let modifier = source[mod_start..mod_end].to_string();
let key = format!("modifiers.{modifier}");
if seen.insert(key) {
tokens.push(StyleToken::Modifier(modifier));
}
}
}
} else {
let token = if first_end < len
&& bytes[first_end] == b'}'
&& first_end + 3 <= len
&& bytes[first_end + 1] == b'_'
&& bytes[first_end + 2] == b'_'
{
let suffix_start = first_end + 3;
let mut suffix_end = suffix_start;
while suffix_end < len
&& (is_ident_char(bytes[suffix_end]) || bytes[suffix_end] == b'-')
{
suffix_end += 1;
}
if suffix_end > suffix_start {
let suffix = &source[suffix_start..suffix_end];
let camel_suffix = super::kebab_to_camel_case(&capitalize_first(suffix));
format!("{first}{camel_suffix}")
} else {
first.to_string()
}
} else {
first.to_string()
};
if seen.insert(token.clone()) {
tokens.push(StyleToken::ClassToken(token));
}
}
i = first_end;
} else {
i += 1;
}
}
tokens
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StyleToken {
ClassToken(String),
Modifier(String),
}
pub fn extract_bem_block_from_import(source: &str) -> Option<String> {
for line in source.lines() {
let trimmed = line.trim();
if !trimmed.starts_with("import ") {
continue;
}
if !trimmed.contains("@patternfly/react-styles/css/") {
continue;
}
let after_import = trimmed.strip_prefix("import ")?;
let binding = after_import.split_whitespace().next()?;
if binding != "styles" {
continue;
}
let quote_start = trimmed.rfind('\'')?;
let path = &trimmed[..quote_start];
let block = path.rsplit('/').next()?;
return Some(block.to_string());
}
None
}
pub fn parse_bem_structure(tokens: &[StyleToken], block_override: Option<&str>) -> BemStructure {
let mut structure = BemStructure::default();
let mut class_tokens: Vec<&str> = Vec::new();
for token in tokens {
match token {
StyleToken::ClassToken(name) => {
structure.raw_tokens.insert(name.clone());
class_tokens.push(name);
}
StyleToken::Modifier(name) => {
structure.modifiers.insert(name.clone());
}
}
}
if class_tokens.is_empty() {
return structure;
}
let block = if let Some(override_block) = block_override {
override_block.to_string()
} else {
class_tokens.sort_by_key(|t| t.len());
class_tokens[0].to_string()
};
structure.block = Some(block.clone());
for token in &class_tokens {
if *token == block {
continue;
}
if token.starts_with(&block) {
let suffix = &token[block.len()..];
if suffix.starts_with(|c: char| c.is_uppercase()) {
let element = lowercase_first(suffix);
structure.elements.insert(element);
}
}
}
structure
}
pub fn classify_bem_relationship(
child_block: Option<&str>,
child_tokens: &BTreeSet<String>,
parent_block: &str,
) -> BemRelationship {
if let Some(block) = child_block {
if block != parent_block {
return BemRelationship::Independent {
block_name: block.to_string(),
};
}
}
for token in child_tokens {
if let Some(suffix) = token.strip_prefix(parent_block) {
if suffix.starts_with(|c: char| c.is_uppercase()) {
return BemRelationship::Element {
element_name: lowercase_first(suffix),
};
}
}
}
BemRelationship::Unknown
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BemRelationship {
Element { element_name: String },
Independent { block_name: String },
Unknown,
}
fn is_ident_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
}
fn is_ident_start(b: u8) -> bool {
b.is_ascii_alphabetic() || b == b'_' || b == b'$'
}
fn read_ident(bytes: &[u8], start: usize) -> usize {
let mut end = start;
while end < bytes.len() && is_ident_char(bytes[end]) {
end += 1;
}
end
}
fn lowercase_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_lowercase().to_string() + chars.as_str(),
}
}
fn capitalize_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_style_tokens() {
let source = r#"
<div className={css(styles.menu, isPlain && styles.modifiers.plain)}>
<ul className={css(styles.menuList)}>
<li className={css(styles.menuListItem, styles.modifiers.disabled)}>
<span className={css(styles.menuItemMain)} />
</li>
</ul>
</div>
"#;
let tokens = extract_style_tokens(source);
assert!(tokens.contains(&StyleToken::ClassToken("menu".into())));
assert!(tokens.contains(&StyleToken::ClassToken("menuList".into())));
assert!(tokens.contains(&StyleToken::ClassToken("menuListItem".into())));
assert!(tokens.contains(&StyleToken::ClassToken("menuItemMain".into())));
assert!(tokens.contains(&StyleToken::Modifier("plain".into())));
assert!(tokens.contains(&StyleToken::Modifier("disabled".into())));
}
#[test]
fn test_parse_bem_structure() {
let tokens = vec![
StyleToken::ClassToken("menu".into()),
StyleToken::ClassToken("menuList".into()),
StyleToken::ClassToken("menuListItem".into()),
StyleToken::ClassToken("menuItemMain".into()),
StyleToken::Modifier("expanded".into()),
StyleToken::Modifier("disabled".into()),
];
let bem = parse_bem_structure(&tokens, None);
assert_eq!(bem.block, Some("menu".into()));
assert!(bem.elements.contains("list"));
assert!(bem.elements.contains("listItem"));
assert!(bem.elements.contains("itemMain"));
assert!(bem.modifiers.contains("expanded"));
assert!(bem.modifiers.contains("disabled"));
}
#[test]
fn test_parse_bem_modal_box() {
let tokens = vec![
StyleToken::ClassToken("modalBox".into()),
StyleToken::ClassToken("modalBoxBody".into()),
StyleToken::ClassToken("modalBoxHeader".into()),
StyleToken::ClassToken("modalBoxFooter".into()),
StyleToken::ClassToken("modalBoxDescription".into()),
StyleToken::ClassToken("modalBoxClose".into()),
];
let bem = parse_bem_structure(&tokens, None);
assert_eq!(bem.block, Some("modalBox".into()));
assert!(bem.elements.contains("body"));
assert!(bem.elements.contains("header"));
assert!(bem.elements.contains("footer"));
assert!(bem.elements.contains("description"));
assert!(bem.elements.contains("close"));
}
#[test]
fn test_classify_bem_element() {
let child_tokens: BTreeSet<String> = vec!["menuList".to_string()].into_iter().collect();
let rel = classify_bem_relationship(None, &child_tokens, "menu");
assert_eq!(
rel,
BemRelationship::Element {
element_name: "list".into()
}
);
}
#[test]
fn test_extract_style_tokens_with_utf8() {
let source = r#"
/**
* © Copyright 2024 Company
* Licensed under Apache License
*/
import styles from './component.css';
const Component = () => (
<div className={styles.menu}>
<span className={styles.menuList} />
</div>
);
"#;
let tokens = extract_style_tokens(source);
assert!(tokens.contains(&StyleToken::ClassToken("menu".into())));
assert!(tokens.contains(&StyleToken::ClassToken("menuList".into())));
}
#[test]
fn test_classify_bem_independent() {
let child_tokens: BTreeSet<String> = vec!["menuToggle".to_string()].into_iter().collect();
let rel = classify_bem_relationship(Some("menuToggle"), &child_tokens, "dropdown");
assert_eq!(
rel,
BemRelationship::Independent {
block_name: "menuToggle".into()
}
);
}
#[test]
fn test_label_labelgroup_collision_returns_independent() {
let child_tokens: BTreeSet<String> = vec![
"labelGroup".to_string(),
"labelGroupLabel".to_string(),
"labelGroupList".to_string(),
"labelGroupListItem".to_string(),
"labelGroupClose".to_string(),
"labelGroupMain".to_string(),
]
.into_iter()
.collect();
let rel = classify_bem_relationship(Some("label-group"), &child_tokens, "label");
assert_eq!(
rel,
BemRelationship::Independent {
block_name: "label-group".into()
},
"LabelGroup has its own BEM block 'label-group' — must be Independent, not Element"
);
}
#[test]
fn test_alert_alertgroup_collision_returns_independent() {
let child_tokens: BTreeSet<String> =
vec!["alertGroup".to_string(), "alertGroupItem".to_string()]
.into_iter()
.collect();
let rel = classify_bem_relationship(Some("alert-group"), &child_tokens, "alert");
assert_eq!(
rel,
BemRelationship::Independent {
block_name: "alert-group".into()
},
"AlertGroup has its own BEM block 'alert-group' — must be Independent, not Element"
);
}
#[test]
fn test_menu_menutoggle_collision_returns_independent() {
let child_tokens: BTreeSet<String> = vec![
"menuToggle".to_string(),
"menuToggleIcon".to_string(),
"menuToggleCount".to_string(),
]
.into_iter()
.collect();
let rel = classify_bem_relationship(Some("menu-toggle"), &child_tokens, "menu");
assert_eq!(
rel,
BemRelationship::Independent {
block_name: "menu-toggle".into()
},
"MenuToggle has its own BEM block 'menu-toggle' — must be Independent, not Element"
);
}
#[test]
fn test_form_formcontrol_collision_returns_independent() {
let child_tokens: BTreeSet<String> =
vec!["formControl".to_string(), "formControlIcon".to_string()]
.into_iter()
.collect();
let rel = classify_bem_relationship(Some("form-control"), &child_tokens, "form");
assert_eq!(
rel,
BemRelationship::Independent {
block_name: "form-control".into()
},
"FormControl has its own BEM block 'form-control' — must be Independent, not Element"
);
}
#[test]
fn test_true_bem_element_same_block() {
let child_tokens: BTreeSet<String> =
vec!["menuList".to_string(), "menuListItem".to_string()]
.into_iter()
.collect();
let rel = classify_bem_relationship(None, &child_tokens, "menu");
assert_eq!(
rel,
BemRelationship::Element {
element_name: "list".into()
},
"MenuList without its own block should be classified as BEM element of menu"
);
}
#[test]
fn test_true_bem_element_with_same_block_name() {
let child_tokens: BTreeSet<String> = vec!["toolbarGroup".to_string()].into_iter().collect();
let rel = classify_bem_relationship(Some("toolbar"), &child_tokens, "toolbar");
assert_eq!(
rel,
BemRelationship::Element {
element_name: "group".into()
},
"ToolbarGroup with same block as parent should be a BEM element"
);
}
#[test]
fn test_no_matching_tokens_no_block_returns_unknown() {
let child_tokens: BTreeSet<String> = vec!["divider".to_string()].into_iter().collect();
let rel = classify_bem_relationship(None, &child_tokens, "menu");
assert_eq!(
rel,
BemRelationship::Unknown,
"Unrelated tokens with no own block should be Unknown"
);
}
#[test]
fn test_independent_block_no_token_collision() {
let child_tokens: BTreeSet<String> =
vec!["pagination".to_string(), "paginationNav".to_string()]
.into_iter()
.collect();
let rel = classify_bem_relationship(Some("pagination"), &child_tokens, "table");
assert_eq!(
rel,
BemRelationship::Independent {
block_name: "pagination".into()
},
"Component with own block unrelated to parent should be Independent"
);
}
#[test]
fn test_empty_tokens_with_own_block_returns_independent() {
let child_tokens: BTreeSet<String> = BTreeSet::new();
let rel = classify_bem_relationship(Some("badge"), &child_tokens, "button");
assert_eq!(
rel,
BemRelationship::Independent {
block_name: "badge".into()
},
"Component with own block but no tokens should still be Independent"
);
}
#[test]
fn test_empty_tokens_no_block_returns_unknown() {
let child_tokens: BTreeSet<String> = BTreeSet::new();
let rel = classify_bem_relationship(None, &child_tokens, "button");
assert_eq!(
rel,
BemRelationship::Unknown,
"No tokens and no block should be Unknown"
);
}
#[test]
fn template_literal_composes_bem_element() {
let source = r#"<div className={css(`${styles.form}__alert`, className)}>"#;
let tokens = extract_style_tokens(source);
assert!(
tokens.contains(&StyleToken::ClassToken("formAlert".into())),
"Expected 'formAlert' from template literal. Got: {:?}",
tokens
);
assert!(
!tokens.contains(&StyleToken::ClassToken("form".into())),
"Should NOT record bare 'form' when used in template literal"
);
}
#[test]
fn template_literal_kebab_suffix() {
let source = r#"<div className={css(`${styles.fileUpload}__helper-text`)}>"#;
let tokens = extract_style_tokens(source);
assert!(
tokens.contains(&StyleToken::ClassToken("fileUploadHelperText".into())),
"Expected 'fileUploadHelperText' from kebab suffix. Got: {:?}",
tokens
);
}
#[test]
fn direct_styles_ref_unchanged() {
let source = r#"<div className={css(styles.form, className)}>"#;
let tokens = extract_style_tokens(source);
assert!(
tokens.contains(&StyleToken::ClassToken("form".into())),
"Direct styles.form should still record 'form'. Got: {:?}",
tokens
);
}
#[test]
fn both_direct_and_template_in_same_file() {
let source = r#"
<form className={css(styles.form, className)}>
<div className={css(`${styles.form}__alert`)}>
"#;
let tokens = extract_style_tokens(source);
assert!(
tokens.contains(&StyleToken::ClassToken("form".into())),
"Direct use should record 'form'. Got: {:?}",
tokens
);
assert!(
tokens.contains(&StyleToken::ClassToken("formAlert".into())),
"Template use should record 'formAlert'. Got: {:?}",
tokens
);
}
#[test]
fn template_literal_single_word_suffix() {
let source = r#"<div className={css(`${styles.emptyState}__header`)}>"#;
let tokens = extract_style_tokens(source);
assert!(
tokens.contains(&StyleToken::ClassToken("emptyStateHeader".into())),
"Expected 'emptyStateHeader'. Got: {:?}",
tokens
);
}
}