use anyhow::{Context, Result};
use lightningcss::properties::display::{Display, DisplayKeyword};
use lightningcss::properties::Property;
use lightningcss::rules::CssRule;
use lightningcss::selector::{Combinator, Component, Selector};
use lightningcss::stylesheet::{ParserOptions, StyleSheet};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::Path;
use std::process::Command;
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CssBlockProfile {
pub block: String,
pub elements: BTreeMap<String, CssElementInfo>,
pub has_containment: Vec<(String, String)>,
pub direct_child_nesting: Vec<(String, String)>,
pub descendant_nesting: Vec<(String, String)>,
pub sibling_relationships: Vec<(String, String)>,
pub layout_children: Vec<(String, String)>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CssElementInfo {
pub has_grid_column: bool,
pub grid_column_reverts: bool,
pub has_grid_row: bool,
pub has_grid_template: bool,
pub display_values: BTreeSet<String>,
pub is_mode_switcher: bool,
pub flex_shrink_zero: bool,
pub flex_wrap: bool,
pub has_sizing: bool,
pub variable_child_refs: BTreeSet<String>,
}
pub fn extract_css_profiles(
repo: &Path,
git_ref: &str,
) -> Result<HashMap<String, CssBlockProfile>> {
let mut profiles = HashMap::new();
let css_files = find_component_css_files(repo, git_ref)?;
info!(count = css_files.len(), "component CSS files found");
for (component_dir, css_path) in &css_files {
let Some(source) = read_git_file(repo, git_ref, css_path) else {
continue;
};
match extract_css_block_profile(&source, component_dir) {
Ok(profile) => {
debug!(
block = %profile.block,
elements = profile.elements.len(),
has_rules = profile.has_containment.len(),
file = %css_path,
"CSS profile extracted"
);
if let Some(existing) = profiles.get_mut(&profile.block) {
merge_css_profile(existing, profile);
} else {
profiles.insert(profile.block.clone(), profile);
}
}
Err(e) => {
warn!(file = %css_path, %e, "failed to parse CSS");
}
}
}
info!(profiles = profiles.len(), "CSS profiles extracted");
Ok(profiles)
}
pub fn extract_css_class_inventory(repo: &Path, git_ref: &str) -> Result<HashSet<String>> {
let css_files = find_component_css_files(repo, git_ref)?;
let all_css_files = find_all_css_files(repo, git_ref)?;
let mut classes = HashSet::new();
let process_source = |source: &str, classes: &mut HashSet<String>| {
let Ok(stylesheet) = StyleSheet::parse(source, ParserOptions::default()) else {
return;
};
for rule in &stylesheet.rules.0 {
collect_classes_from_rule(rule, classes);
}
};
for (_component_dir, css_path) in &css_files {
if let Some(source) = crate::git_utils::read_git_file(repo, git_ref, css_path) {
process_source(&source, &mut classes);
}
}
for css_path in &all_css_files {
if let Some(source) = crate::git_utils::read_git_file(repo, git_ref, css_path) {
process_source(&source, &mut classes);
}
}
info!(
classes = classes.len(),
git_ref = git_ref,
"CSS class inventory extracted"
);
Ok(classes)
}
pub fn extract_css_class_inventory_from_dir(dir: &Path) -> Result<HashSet<String>> {
let mut classes = HashSet::new();
fn walk_dir(dir: &Path, classes: &mut HashSet<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = path.file_name().unwrap_or_default().to_string_lossy();
if name.starts_with('.') || name == "node_modules" {
continue;
}
walk_dir(&path, classes);
} else if path.extension().is_some_and(|e| e == "css") {
let fname = path.file_name().unwrap_or_default().to_string_lossy();
if fname.contains(".min.") || fname.contains(".map") {
continue;
}
if let Ok(source) = std::fs::read_to_string(&path) {
if let Ok(stylesheet) = StyleSheet::parse(&source, ParserOptions::default()) {
for rule in &stylesheet.rules.0 {
collect_classes_from_rule(rule, classes);
}
}
}
}
}
}
walk_dir(dir, &mut classes);
info!(
classes = classes.len(),
dir = %dir.display(),
"CSS class inventory extracted from dir"
);
Ok(classes)
}
fn collect_classes_from_rule(rule: &CssRule, classes: &mut HashSet<String>) {
match rule {
CssRule::Style(style_rule) => {
for selector in style_rule.selectors.0.iter() {
collect_classes_from_selector(selector, classes);
}
}
CssRule::Media(m) => {
for r in &m.rules.0 {
collect_classes_from_rule(r, classes);
}
}
CssRule::Supports(s) => {
for r in &s.rules.0 {
collect_classes_from_rule(r, classes);
}
}
CssRule::LayerBlock(l) => {
for r in &l.rules.0 {
collect_classes_from_rule(r, classes);
}
}
_ => {}
}
}
fn collect_classes_from_selector(selector: &Selector, classes: &mut HashSet<String>) {
for component in selector.iter() {
match component {
Component::Class(name) => {
classes.insert(name.as_ref().to_string());
}
Component::Negation(selectors) => {
for sel in selectors.iter() {
collect_classes_from_selector(sel, classes);
}
}
Component::Is(selectors) | Component::Where(selectors) => {
for sel in selectors.iter() {
collect_classes_from_selector(sel, classes);
}
}
_ => {}
}
}
}
fn find_all_css_files(repo: &Path, git_ref: &str) -> Result<Vec<String>> {
let output = Command::new("git")
.args([
"-C",
&repo.to_string_lossy(),
"ls-tree",
"-r",
"--name-only",
git_ref,
])
.output()
.context("Failed to run 'git ls-tree' for CSS file discovery")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"git ls-tree failed for CSS file discovery at ref {}: {}",
git_ref,
stderr
);
}
let mut files = Vec::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
let path = line.trim();
if !path.ends_with(".css") {
continue;
}
if path.contains(".min.css")
|| path.contains(".map")
|| path.contains("/examples/")
|| path.contains("/test")
{
continue;
}
if path.contains("/components/") {
continue;
}
files.push(path.to_string());
}
Ok(files)
}
pub fn extract_css_profiles_from_dir(dir: &Path) -> Result<HashMap<String, CssBlockProfile>> {
let mut profiles = HashMap::new();
let components_dir = if dir.join("components").exists() {
dir.join("components")
} else if dir.join("dist/components").exists() {
dir.join("dist/components")
} else if dir.join("src/patternfly/components").exists() {
dir.join("src/patternfly/components")
} else {
dir.to_path_buf()
};
if !components_dir.exists() {
warn!(path = %components_dir.display(), "CSS components directory not found");
return Ok(profiles);
}
for entry in std::fs::read_dir(&components_dir)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let component_dir_name = entry.file_name().to_string_lossy().to_string();
for css_entry in std::fs::read_dir(entry.path())? {
let css_entry = css_entry?;
let css_path = css_entry.path();
if css_path.extension().is_none_or(|e| e != "css") {
continue;
}
let fname = css_path.file_name().unwrap_or_default().to_string_lossy();
if fname.contains(".min.") || fname.contains(".map") {
continue;
}
match std::fs::read_to_string(&css_path) {
Ok(source) => match extract_css_block_profile(&source, &component_dir_name) {
Ok(profile) => {
debug!(
block = %profile.block,
elements = profile.elements.len(),
file = %css_path.display(),
"CSS profile extracted from dir"
);
if let Some(existing) = profiles.get_mut(&profile.block) {
merge_css_profile(existing, profile);
} else {
profiles.insert(profile.block.clone(), profile);
}
}
Err(e) => {
warn!(file = %css_path.display(), %e, "failed to parse CSS");
}
},
Err(e) => {
warn!(file = %css_path.display(), %e, "failed to read CSS file");
}
}
}
}
info!(profiles = profiles.len(), "CSS profiles extracted from dir");
Ok(profiles)
}
fn find_component_css_files(repo: &Path, git_ref: &str) -> Result<Vec<(String, String)>> {
let output = Command::new("git")
.args([
"-C",
&repo.to_string_lossy(),
"ls-tree",
"-r",
"--name-only",
git_ref,
])
.output()
.context("Failed to run 'git ls-tree' for CSS file discovery")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"git ls-tree failed for CSS file discovery at ref {}: {}",
git_ref,
stderr
);
}
let mut candidates: HashMap<String, Vec<String>> = HashMap::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
let path = line.trim();
if !path.ends_with(".css") {
continue;
}
if path.contains(".min.css")
|| path.contains(".map")
|| path.contains("/examples/")
|| path.contains("/test")
{
continue;
}
let parts: Vec<&str> = path.split('/').collect();
let Some(comp_idx) = parts.iter().position(|&p| p == "components") else {
continue;
};
if comp_idx + 2 >= parts.len() {
continue;
}
let component_dir = parts[comp_idx + 1].to_string();
candidates
.entry(component_dir)
.or_default()
.push(path.to_string());
}
let mut files = Vec::new();
for (dir, mut paths) in candidates {
let expected_stem = pascal_to_kebab(&dir);
let expected_name = format!("{}.css", expected_stem);
paths.sort_by(|a, b| {
let a_name = a.rsplit('/').next().unwrap_or(a);
let b_name = b.rsplit('/').next().unwrap_or(b);
let a_match = a_name == expected_name;
let b_match = b_name == expected_name;
b_match
.cmp(&a_match)
.then_with(|| a_name.len().cmp(&b_name.len()))
});
for path in paths {
files.push((dir.clone(), path));
}
}
Ok(files)
}
fn pascal_to_kebab(s: &str) -> String {
let mut result = String::with_capacity(s.len() + 4);
for (i, ch) in s.chars().enumerate() {
if ch.is_uppercase() && i > 0 {
result.push('-');
}
result.push(ch.to_ascii_lowercase());
}
result
}
fn merge_css_profile(existing: &mut CssBlockProfile, other: CssBlockProfile) {
for (name, info) in other.elements {
let entry = existing.elements.entry(name).or_default();
entry.has_grid_column |= info.has_grid_column;
entry.grid_column_reverts |= info.grid_column_reverts;
entry.has_grid_row |= info.has_grid_row;
entry.has_grid_template |= info.has_grid_template;
entry.display_values.extend(info.display_values);
entry.is_mode_switcher |= info.is_mode_switcher;
entry.flex_shrink_zero |= info.flex_shrink_zero;
entry.flex_wrap |= info.flex_wrap;
entry.has_sizing |= info.has_sizing;
entry.variable_child_refs.extend(info.variable_child_refs);
}
for pair in other.has_containment {
if !existing.has_containment.contains(&pair) {
existing.has_containment.push(pair);
}
}
for pair in other.direct_child_nesting {
if !existing.direct_child_nesting.contains(&pair) {
existing.direct_child_nesting.push(pair);
}
}
for pair in other.descendant_nesting {
if !existing.descendant_nesting.contains(&pair) {
existing.descendant_nesting.push(pair);
}
}
for pair in other.sibling_relationships {
if !existing.sibling_relationships.contains(&pair) {
existing.sibling_relationships.push(pair);
}
}
for pair in other.layout_children {
if !existing.layout_children.contains(&pair) {
existing.layout_children.push(pair);
}
}
}
fn extract_css_block_profile(source: &str, _component_dir: &str) -> Result<CssBlockProfile> {
let stylesheet = StyleSheet::parse(source, ParserOptions::default())
.map_err(|e| anyhow::anyhow!("CSS parse error: {}", e))?;
let block_class = detect_block_class(&stylesheet)
.ok_or_else(|| anyhow::anyhow!("could not detect block class from CSS"))?;
let block_name = derive_block_name(&block_class);
let mut profile = CssBlockProfile {
block: block_name,
..Default::default()
};
let mut selector_groups: Vec<BTreeSet<String>> = Vec::new();
for rule in &stylesheet.rules.0 {
extract_from_rule(rule, &block_class, &mut profile, &mut selector_groups);
}
resolve_display_var_values(source, &block_class, &mut profile);
for info in profile.elements.values_mut() {
let has_contents = info.display_values.contains("contents");
let has_flex_or_grid =
info.display_values.contains("flex") || info.display_values.contains("grid");
let has_var_display = info.display_values.contains("var");
info.is_mode_switcher = has_contents && has_flex_or_grid
|| has_var_display && (has_contents || has_flex_or_grid);
}
extract_variable_nesting(source, &block_class, &mut profile);
detect_grid_column_reverts(source, &block_class, &mut profile);
infer_layout_children(&mut profile, &selector_groups);
Ok(profile)
}
fn detect_block_class(stylesheet: &StyleSheet) -> Option<String> {
for rule in &stylesheet.rules.0 {
if let CssRule::Style(style_rule) = rule {
for selector in style_rule.selectors.0.iter() {
let components: Vec<&Component> = selector.iter().collect();
if let Some(Component::Class(class_name)) = components.first() {
let name = class_name.as_ref();
if name.contains("__") || name.starts_with("pf-m-") {
continue;
}
return Some(name.to_string());
}
}
}
}
None
}
fn derive_block_name(block_class: &str) -> String {
let stripped = block_class
.strip_prefix("pf-v6-c-")
.or_else(|| block_class.strip_prefix("pf-v5-c-"))
.or_else(|| block_class.strip_prefix("pf-c-"))
.unwrap_or(block_class);
kebab_to_camel(stripped)
}
fn extract_from_rule(
rule: &CssRule,
class_prefix: &str,
profile: &mut CssBlockProfile,
selector_groups: &mut Vec<BTreeSet<String>>,
) {
match rule {
CssRule::Style(style_rule) => {
for selector in style_rule.selectors.0.iter() {
extract_selector_relationships(selector, class_prefix, profile);
}
let mut rule_elements = BTreeSet::new();
for selector in style_rule.selectors.0.iter() {
if let Some(element_name) = extract_element_from_selector(selector, class_prefix) {
if !element_name.is_empty() {
rule_elements.insert(element_name);
}
}
}
if rule_elements.len() > 1 {
if !selector_groups.contains(&rule_elements) {
selector_groups.push(rule_elements);
}
}
for selector in style_rule.selectors.0.iter() {
if let Some(element_name) = extract_element_from_selector(selector, class_prefix) {
let info = profile.elements.entry(element_name).or_default();
for property in style_rule.declarations.declarations.iter() {
match property {
Property::GridColumn(..) => {
info.has_grid_column = true;
}
Property::GridRow(..) => {
info.has_grid_row = true;
}
Property::GridTemplateColumns(..) => {
info.has_grid_template = true;
}
Property::GridTemplateRows(..) => {
info.has_grid_template = true;
}
Property::Display(display) => {
let display_str = match display {
Display::Keyword(DisplayKeyword::None) => "none",
Display::Pair(pair) => {
let s = format!("{:?}", pair);
if s.contains("Flex") {
"flex"
} else if s.contains("Grid") {
"grid"
} else if s.contains("Contents") {
"contents"
} else {
"other"
}
}
_ => "other",
};
info.display_values.insert(display_str.to_string());
}
Property::FlexShrink(shrink, _) if *shrink == 0.0 => {
info.flex_shrink_zero = true;
}
Property::FlexWrap(wrap, _) => {
let s = format!("{:?}", wrap);
if s.contains("Wrap") && !s.contains("NoWrap") {
info.flex_wrap = true;
}
}
Property::Width(..)
| Property::MaxWidth(..)
| Property::MaxHeight(..) => {
info.has_sizing = true;
}
Property::Unparsed(unparsed) => {
let prop_name = unparsed.property_id.name();
if prop_name == "grid-column"
|| prop_name == "grid-column-start"
|| prop_name == "grid-column-end"
{
info.has_grid_column = true;
} else if prop_name == "grid-row"
|| prop_name == "grid-row-start"
|| prop_name == "grid-row-end"
{
info.has_grid_row = true;
} else if prop_name == "grid-template-columns"
|| prop_name == "grid-template-rows"
{
info.has_grid_template = true;
} else if prop_name == "display" {
info.display_values.insert("var".to_string());
} else if prop_name == "width"
|| prop_name == "max-width"
|| prop_name == "max-height"
{
info.has_sizing = true;
}
}
_ => {}
}
}
}
}
}
CssRule::Media(media_rule) => {
for inner_rule in &media_rule.rules.0 {
extract_from_rule(inner_rule, class_prefix, profile, selector_groups);
}
}
_ => {}
}
}
fn infer_layout_children(profile: &mut CssBlockProfile, selector_groups: &[BTreeSet<String>]) {
let mut seen = std::collections::HashSet::new();
let is_container = |el: &str| -> bool {
if el.is_empty() {
return true; }
let Some(info) = profile.elements.get(el) else {
return false;
};
if info.flex_wrap {
return true;
}
if info.display_values.contains("grid") && info.has_grid_template {
return true;
}
false
};
for group in selector_groups {
let containers: Vec<&String> = group.iter().filter(|el| is_container(el)).collect();
let children: Vec<&String> = group.iter().filter(|el| !is_container(el)).collect();
if containers.is_empty() || children.is_empty() {
continue;
}
let best_container = containers.iter().max_by_key(|c| c.len()).unwrap();
for child in &children {
let pair = (best_container.to_string(), child.to_string());
if seen.insert(pair.clone()) {
debug!(
container = %pair.0,
child = %pair.1,
block = %profile.block,
"CSS layout container → child inferred"
);
profile.layout_children.push(pair);
}
}
}
}
fn extract_element_from_selector(selector: &Selector, class_prefix: &str) -> Option<String> {
for component in selector.iter() {
if let Component::Class(class_name) = component {
let name = class_name.as_ref();
if let Some(suffix) = name.strip_prefix(class_prefix) {
if suffix.is_empty() {
return Some(String::new()); }
if let Some(element) = suffix.strip_prefix("__") {
let element = element.split('.').next().unwrap_or(element);
return Some(element.to_string());
}
}
}
}
None
}
fn extract_selector_relationships(
selector: &Selector,
class_prefix: &str,
profile: &mut CssBlockProfile,
) {
let components: Vec<&Component> = selector.iter().collect();
for component in &components {
if let Component::Has(has_selectors) = component {
if let Some(parent_el) = extract_element_from_selector(selector, class_prefix) {
for has_selector in has_selectors.iter() {
if let Some(child_el) =
extract_element_from_selector(has_selector, class_prefix)
{
if !child_el.is_empty() {
profile.has_containment.push((parent_el.clone(), child_el));
}
}
}
}
}
}
let mut prev_element: Option<String> = None;
let mut prev_combinator: Option<Combinator> = None;
for component in selector.iter_raw_match_order() {
match component {
Component::Combinator(comb) => {
prev_combinator = Some(*comb);
}
Component::Class(class_name) => {
let name = class_name.as_ref();
if let Some(suffix) = name.strip_prefix(class_prefix) {
let element = if suffix.is_empty() {
String::new()
} else if let Some(el) = suffix.strip_prefix("__") {
el.to_string()
} else {
continue;
};
if let (Some(child_el), Some(combinator)) = (&prev_element, &prev_combinator) {
if !(element.is_empty() && child_el.is_empty()) {
match combinator {
Combinator::Child => {
profile
.direct_child_nesting
.push((element.clone(), child_el.clone()));
}
Combinator::Descendant => {
profile
.descendant_nesting
.push((element.clone(), child_el.clone()));
}
Combinator::NextSibling | Combinator::LaterSibling => {
profile
.sibling_relationships
.push((element.clone(), child_el.clone()));
}
_ => {}
}
}
}
prev_element = Some(element);
prev_combinator = None;
}
}
_ => {}
}
}
}
fn extract_variable_nesting(source: &str, class_prefix: &str, profile: &mut CssBlockProfile) {
let var_prefix = format!("--{}", class_prefix);
for line in source.lines() {
let trimmed = line.trim();
if !trimmed.starts_with(&var_prefix) {
continue;
}
let var_name = trimmed.split(':').next().unwrap_or(trimmed).trim();
if let Some(after_prefix) = var_name.strip_prefix(&var_prefix) {
if let Some(element_part) = after_prefix.strip_prefix("__") {
let parts: Vec<&str> = element_part.split("--").collect();
if parts.len() >= 2 {
let parent_element = parts[0];
let info = profile
.elements
.entry(parent_element.to_string())
.or_default();
for child_ref in &parts[1..] {
if child_ref.chars().next().is_none_or(|c| c.is_uppercase()) {
break;
}
if *child_ref == "m" {
break;
}
info.variable_child_refs.insert(child_ref.to_string());
}
}
}
}
}
}
fn resolve_display_var_values(source: &str, block_class: &str, profile: &mut CssBlockProfile) {
let var_prefix = format!("--{}", block_class);
for line in source.lines() {
let trimmed = line.trim();
if !trimmed.starts_with(&var_prefix) {
continue;
}
if !trimmed.contains("--Display") {
continue;
}
let Some(colon_idx) = trimmed.find(':') else {
continue;
};
let value = trimmed[colon_idx + 1..].trim().trim_end_matches(';').trim();
if value.starts_with("var(") {
continue;
}
let var_name = trimmed[..colon_idx].trim();
if let Some(dunder_idx) = var_name.rfind("__") {
let after_dunder = &var_name[dunder_idx + 2..];
if let Some(prop_idx) = after_dunder.find("--Display") {
let element = &after_dunder[..prop_idx];
if !element.is_empty() {
let info = profile.elements.entry(element.to_string()).or_default();
info.display_values.insert(value.to_string());
}
}
}
}
}
fn detect_grid_column_reverts(source: &str, block_class: &str, profile: &mut CssBlockProfile) {
let var_prefix = format!("--{}", block_class);
for line in source.lines() {
let trimmed = line.trim();
if !trimmed.starts_with(&var_prefix) {
continue;
}
if !trimmed.contains("GridColumn") && !trimmed.contains("Order") {
continue;
}
if let Some(colon_idx) = trimmed.find(':') {
let value = trimmed[colon_idx + 1..].trim().trim_end_matches(';').trim();
let is_revert = value == "initial"
|| value == "unset"
|| value == "revert"
|| value.starts_with("var(") && value.contains("initial");
if !is_revert {
continue;
}
let var_name = &trimmed[..colon_idx].trim();
if let Some(dunder_idx) = var_name.rfind("__") {
let after_dunder = &var_name[dunder_idx + 2..];
if let Some(prop_idx) = after_dunder.find("--") {
let element = &after_dunder[..prop_idx];
if !element.is_empty() {
let info = profile.elements.entry(element.to_string()).or_default();
info.grid_column_reverts = true;
}
}
}
}
}
}
fn kebab_to_camel(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut capitalize_next = false;
for ch in s.chars() {
if ch == '-' {
capitalize_next = true;
} else if capitalize_next {
result.push(ch.to_ascii_uppercase());
capitalize_next = false;
} else {
result.push(ch);
}
}
result
}
use crate::git_utils::read_git_file;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_element_from_class() {
assert_eq!(
extract_element_from_class("pf-v6-c-masthead__main", "pf-v6-c-masthead"),
Some("main".to_string())
);
assert_eq!(
extract_element_from_class("pf-v6-c-masthead", "pf-v6-c-masthead"),
Some(String::new())
);
assert_eq!(
extract_element_from_class("pf-v6-c-button", "pf-v6-c-masthead"),
None
);
}
#[test]
fn test_kebab_to_camel() {
assert_eq!(kebab_to_camel("modal-box"), "modalBox");
assert_eq!(kebab_to_camel("masthead"), "masthead");
assert_eq!(kebab_to_camel("about-modal-box"), "aboutModalBox");
}
#[test]
fn test_variable_nesting_extraction() {
let source = r#"
.pf-v6-c-masthead {
--pf-v6-c-masthead__main--toggle--content--GridColumn: 2;
--pf-v6-c-masthead__main--Display: contents;
--pf-v6-c-masthead__brand--GridColumn: -1 / 1;
--pf-v6-c-masthead__logo--MaxHeight: 2rem;
}
"#;
let mut profile = CssBlockProfile::default();
extract_variable_nesting(source, "pf-v6-c-masthead", &mut profile);
let main_info = profile.elements.get("main").unwrap();
assert!(
main_info.variable_child_refs.contains("toggle"),
"Expected 'toggle' in main's child refs: {:?}",
main_info.variable_child_refs
);
assert!(main_info.variable_child_refs.contains("content"));
}
}
#[cfg(test)]
fn extract_element_from_class(class: &str, prefix: &str) -> Option<String> {
if let Some(suffix) = class.strip_prefix(prefix) {
if suffix.is_empty() {
return Some(String::new());
}
if let Some(element) = suffix.strip_prefix("__") {
return Some(element.to_string());
}
}
None
}
#[cfg(test)]
pub fn parse_css_for_test(source: &str, component_dir: &str) -> Result<CssBlockProfile> {
extract_css_block_profile(source, component_dir)
}
#[cfg(test)]
mod integration_tests {
use super::*;
#[test]
#[ignore] fn test_parse_real_masthead_css() {
let source = std::fs::read_to_string("/tmp/package/components/Masthead/masthead.css")
.expect("Need /tmp/package/components/Masthead/masthead.css - run: cd /tmp && curl -sL https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-6.0.0.tgz | tar xzf -");
let profile = extract_css_block_profile(&source, "Masthead").unwrap();
println!("Block: {}", profile.block);
println!(
"Elements: {:?}",
profile.elements.keys().collect::<Vec<_>>()
);
println!("\nElement details:");
for (name, info) in &profile.elements {
println!(
" {}: grid_col={}, display={:?}, mode_switch={}, var_children={:?}",
name,
info.has_grid_column,
info.display_values,
info.is_mode_switcher,
info.variable_child_refs
);
}
println!("\n:has() containment: {:?}", profile.has_containment);
println!("Descendant nesting: {:?}", profile.descendant_nesting);
println!("Sibling relationships: {:?}", profile.sibling_relationships);
let main = profile.elements.get("main").expect("should have __main");
assert!(
!main.display_values.is_empty(),
"main should have display values: {:?}",
main
);
assert!(
main.variable_child_refs.contains("toggle"),
"main should reference toggle: {:?}",
main.variable_child_refs
);
let brand = profile.elements.get("brand").expect("should have __brand");
assert!(brand.has_grid_column, "brand should have grid-column");
let toggle = profile.elements.get("toggle");
if let Some(t) = toggle {
assert!(!t.has_grid_column, "toggle should NOT have grid-column");
}
let logo = profile.elements.get("logo");
if let Some(l) = logo {
assert!(!l.has_grid_column, "logo should NOT have grid-column");
}
assert!(
main.variable_child_refs.contains("toggle"),
"variable refs should show toggle inside main"
);
assert!(
main.variable_child_refs.contains("content"),
"variable refs should show content relates to main"
);
}
}
#[cfg(test)]
mod selector_relationship_tests {
use super::*;
fn profile_from_css(css: &str) -> CssBlockProfile {
extract_css_block_profile(css, "test").unwrap()
}
#[test]
fn test_descendant_combinator_extracts_nesting() {
let css = r#"
.pf-v6-c-toolbar { display: flex; }
.pf-v6-c-toolbar__group .pf-v6-c-toolbar__item { flex: 1; }
"#;
let profile = profile_from_css(css);
assert!(
profile
.descendant_nesting
.contains(&("group".to_string(), "item".to_string())),
"Should extract group → item from descendant selector. Got: {:?}",
profile.descendant_nesting
);
}
#[test]
fn test_child_combinator_extracts_direct_child_nesting() {
let css = r#"
.pf-v6-c-drawer { display: flex; }
.pf-v6-c-drawer__content > .pf-v6-c-drawer__body { overflow: auto; }
"#;
let profile = profile_from_css(css);
assert!(
profile
.direct_child_nesting
.contains(&("content".to_string(), "body".to_string())),
"Should extract content → body from child combinator into direct_child_nesting. Got: {:?}",
profile.direct_child_nesting
);
assert!(
!profile
.descendant_nesting
.contains(&("content".to_string(), "body".to_string())),
"Child combinator should NOT go into descendant_nesting. Got: {:?}",
profile.descendant_nesting
);
}
#[test]
fn test_sibling_combinator_extracts_relationship() {
let css = r#"
.pf-v6-c-card { display: flex; }
.pf-v6-c-card__actions + .pf-v6-c-card__title { margin: 0; }
"#;
let profile = profile_from_css(css);
assert!(
profile
.sibling_relationships
.contains(&("actions".to_string(), "title".to_string())),
"Should extract actions ~ title from sibling selector. Got: {:?}",
profile.sibling_relationships
);
}
#[test]
fn test_later_sibling_combinator() {
let css = r#"
.pf-v6-c-data-list { display: flex; }
.pf-v6-c-data-list__cell ~ .pf-v6-c-data-list__cell { border: 0; }
"#;
let profile = profile_from_css(css);
assert!(
profile
.sibling_relationships
.contains(&("cell".to_string(), "cell".to_string())),
"Should extract cell ~ cell from later-sibling selector. Got: {:?}",
profile.sibling_relationships
);
}
#[test]
fn test_multiple_descendant_selectors() {
let css = r#"
.pf-v6-c-toolbar { display: flex; }
.pf-v6-c-toolbar__expandable-content .pf-v6-c-toolbar__group { flex: 1; }
.pf-v6-c-toolbar__expandable-content .pf-v6-c-toolbar__item { flex: 0; }
"#;
let profile = profile_from_css(css);
assert!(
profile
.descendant_nesting
.contains(&("expandable-content".to_string(), "group".to_string())),
"Should extract expandable-content → group. Got: {:?}",
profile.descendant_nesting
);
assert!(
profile
.descendant_nesting
.contains(&("expandable-content".to_string(), "item".to_string())),
"Should extract expandable-content → item. Got: {:?}",
profile.descendant_nesting
);
}
#[test]
fn test_modifier_on_parent_still_extracts_element() {
let css = r#"
.pf-v6-c-toolbar { display: flex; }
.pf-v6-c-toolbar__group:where(.pf-m-toggle-group) .pf-v6-c-toolbar__item { display: none; }
"#;
let profile = profile_from_css(css);
assert!(
profile
.descendant_nesting
.contains(&("group".to_string(), "item".to_string())),
"Should extract group → item even with :where() modifier. Got: {:?}",
profile.descendant_nesting
);
}
#[test]
fn test_no_nesting_without_combinators() {
let css = r#"
.pf-v6-c-card { display: flex; }
.pf-v6-c-card__header { padding: 0; }
.pf-v6-c-card__title { font-weight: bold; }
"#;
let profile = profile_from_css(css);
assert!(
profile.descendant_nesting.is_empty(),
"Should have no descendant nesting for single-class selectors. Got: {:?}",
profile.descendant_nesting
);
}
#[test]
fn test_layout_container_flex_wrap() {
let css = r#"
.pf-v6-c-toolbar { display: grid; }
.pf-v6-c-toolbar__content-section,
.pf-v6-c-toolbar__group,
.pf-v6-c-toolbar__item {
display: flex;
}
.pf-v6-c-toolbar__content-section {
flex-wrap: wrap;
row-gap: 8px;
column-gap: 16px;
}
"#;
let profile = profile_from_css(css);
assert!(
profile
.layout_children
.contains(&("content-section".to_string(), "group".to_string())),
"content-section should be container of group. Got: {:?}",
profile.layout_children
);
assert!(
profile
.layout_children
.contains(&("content-section".to_string(), "item".to_string())),
"content-section should be container of item. Got: {:?}",
profile.layout_children
);
}
#[test]
fn test_layout_container_no_false_positives() {
let css = r#"
.pf-v6-c-card { display: flex; }
.pf-v6-c-card__header,
.pf-v6-c-card__body,
.pf-v6-c-card__footer {
padding: 16px;
}
"#;
let profile = profile_from_css(css);
assert!(
profile.layout_children.is_empty(),
"No container in shared rule → no layout_children. Got: {:?}",
profile.layout_children
);
}
#[test]
fn test_layout_container_description_list_grid() {
let css = r#"
.pf-v6-c-description-list {
display: grid;
grid-template-columns: 1fr;
}
.pf-v6-c-description-list__group {
display: grid;
grid-template-rows: auto 1fr;
grid-column: 1;
}
.pf-v6-c-description-list__group,
.pf-v6-c-description-list__term,
.pf-v6-c-description-list__description {
padding: 8px;
}
"#;
let profile = profile_from_css(css);
assert!(
profile
.layout_children
.contains(&("group".to_string(), "term".to_string())),
"group should be container of term. Got: {:?}",
profile.layout_children
);
assert!(
profile
.layout_children
.contains(&("group".to_string(), "description".to_string())),
"group should be container of description. Got: {:?}",
profile.layout_children
);
}
#[test]
fn test_layout_container_empty_state() {
let css = r#"
.pf-v6-c-empty-state { display: flex; }
.pf-v6-c-empty-state__footer {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.pf-v6-c-empty-state__footer,
.pf-v6-c-empty-state__actions {
display: flex;
}
"#;
let profile = profile_from_css(css);
assert!(
profile
.layout_children
.contains(&("footer".to_string(), "actions".to_string())),
"footer should be container of actions. Got: {:?}",
profile.layout_children
);
}
#[test]
fn test_card_header_title_nesting() {
let css = r#"
.pf-v6-c-card { display: flex; }
.pf-v6-c-card__header .pf-v6-c-card__title { padding: 0; }
"#;
let profile = profile_from_css(css);
assert!(
profile
.descendant_nesting
.contains(&("header".to_string(), "title".to_string())),
"Card: header → title. Got: {:?}",
profile.descendant_nesting
);
}
#[test]
fn test_grid_template_detection() {
let css = r#"
.pf-v6-c-description-list {
display: grid;
grid-template-columns: repeat(1, 1fr);
}
.pf-v6-c-description-list__group {
display: grid;
grid-template-rows: auto 1fr;
grid-column: 1;
}
"#;
let profile = profile_from_css(css);
let root = profile.elements.get("").unwrap();
assert!(root.has_grid_template, "Root should have grid-template");
assert!(!root.has_grid_column, "Root should NOT have grid-column");
let group = profile.elements.get("group").unwrap();
assert!(group.has_grid_template, "Group should have grid-template");
assert!(
group.has_grid_column,
"Group should have grid-column (it's a child of root grid)"
);
}
#[test]
fn test_direct_child_vs_descendant_separation() {
let css = r#"
.pf-v6-c-drawer { display: flex; }
.pf-v6-c-drawer__content > .pf-v6-c-drawer__body { padding: 0; }
.pf-v6-c-drawer__panel .pf-v6-c-drawer__head { display: grid; }
"#;
let profile = profile_from_css(css);
assert!(
profile
.direct_child_nesting
.contains(&("content".to_string(), "body".to_string())),
"content > body should be direct child. Got direct: {:?}",
profile.direct_child_nesting
);
assert!(
!profile
.descendant_nesting
.contains(&("content".to_string(), "body".to_string())),
"content > body should NOT be descendant"
);
assert!(
profile
.descendant_nesting
.contains(&("panel".to_string(), "head".to_string())),
"panel head should be descendant. Got descendant: {:?}",
profile.descendant_nesting
);
assert!(
!profile
.direct_child_nesting
.contains(&("panel".to_string(), "head".to_string())),
"panel head should NOT be direct child"
);
}
#[test]
fn test_grid_template_from_unparsed_var() {
let css = r#"
.pf-v6-c-toolbar {
display: grid;
grid-template-columns: var(--pf-v6-c-toolbar--GridTemplateColumns);
}
"#;
let profile = profile_from_css(css);
let root = profile.elements.get("").unwrap();
assert!(
root.has_grid_template,
"Should detect grid-template from unparsed var(). Got: {:?}",
root
);
}
}