use anyhow::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};
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 descendant_nesting: Vec<(String, String)>,
pub sibling_relationships: 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 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(),
"CSS profile extracted"
);
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_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"
);
profiles.insert(profile.block.clone(), profile);
break; }
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()?;
if !output.status.success() {
anyhow::bail!("git ls-tree failed");
}
let mut seen_dirs = std::collections::HashSet::new();
let files: Vec<(String, String)> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let path = line.trim();
if !path.ends_with(".css") {
return None;
}
if path.contains(".min.css")
|| path.contains(".map")
|| path.contains("/examples/")
|| path.contains("/test")
{
return None;
}
let parts: Vec<&str> = path.split('/').collect();
let comp_idx = parts.iter().position(|&p| p == "components")?;
if comp_idx + 2 >= parts.len() {
return None;
}
let component_dir = parts[comp_idx + 1].to_string();
if !seen_dirs.insert(component_dir.clone()) {
return None;
}
Some((component_dir, path.to_string()))
})
.collect();
Ok(files)
}
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()
};
for rule in &stylesheet.rules.0 {
extract_from_rule(rule, &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);
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) {
match rule {
CssRule::Style(style_rule) => {
for selector in style_rule.selectors.0.iter() {
extract_selector_relationships(selector, class_prefix, profile);
}
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::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 == "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);
}
}
_ => {}
}
}
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() {
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::Descendant | Combinator::Child => {
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 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
}
fn read_git_file(repo: &Path, git_ref: &str, file_path: &str) -> Option<String> {
let output = Command::new("git")
.args(["show", &format!("{}:{}", git_ref, file_path)])
.current_dir(repo)
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).to_string())
} else {
None
}
}
#[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"
);
}
}