use regex::Regex;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io;
use std::path::Path;
use walkdir::WalkDir;
use crate::cli::command::LayoutmapOptions;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LayoutFinding {
ZIndex {
file: String,
line: usize,
selector: String,
z_index: i32,
},
Sticky {
file: String,
line: usize,
selector: String,
position: String,
},
Grid {
file: String,
line: usize,
selector: String,
},
Flex {
file: String,
line: usize,
selector: String,
},
}
pub fn scan_css_layout(root: &Path, opts: &LayoutmapOptions) -> io::Result<Vec<LayoutFinding>> {
let mut findings = Vec::new();
let css_extensions = ["css", "scss", "sass", "less"];
let js_extensions = ["js", "jsx", "ts", "tsx"];
for entry in WalkDir::new(root)
.follow_links(false)
.into_iter()
.filter_entry(|e| !is_ignored(e.path()))
{
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let is_css = css_extensions.contains(&ext);
let is_js = js_extensions.contains(&ext);
if !is_css && !is_js {
continue;
}
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let relative_path = path
.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.to_string();
if is_excluded(&relative_path, &opts.exclude) {
continue;
}
if is_css {
parse_css_file(&content, &relative_path, opts, &mut findings);
} else if is_js {
parse_css_in_js(&content, &relative_path, opts, &mut findings);
}
}
if opts.zindex_only {
findings.retain(|f| matches!(f, LayoutFinding::ZIndex { .. }));
}
if opts.sticky_only {
findings.retain(|f| matches!(f, LayoutFinding::Sticky { .. }));
}
if opts.grid_only {
findings.retain(|f| matches!(f, LayoutFinding::Grid { .. }));
}
if let Some(min_z) = opts.min_zindex {
findings.retain(|f| match f {
LayoutFinding::ZIndex { z_index, .. } => *z_index >= min_z,
_ => true,
});
}
Ok(findings)
}
fn is_ignored(path: &Path) -> bool {
let path_str = path.to_string_lossy();
path_str.contains("node_modules")
|| path_str.contains(".git")
|| path_str.contains("dist/")
|| path_str.contains("build/")
|| path_str.contains("target/")
|| path_str.contains(".next/")
|| path_str.contains("coverage/")
}
fn is_excluded(path: &str, exclude_patterns: &[String]) -> bool {
if exclude_patterns.is_empty() {
return false;
}
for pattern in exclude_patterns {
if glob_matches(pattern, path) {
return true;
}
}
false
}
fn glob_matches(pattern: &str, path: &str) -> bool {
let pattern = pattern.replace('\\', "/");
let path = path.replace('\\', "/");
if pattern.contains("**") {
let core = pattern.trim_start_matches("**/").trim_end_matches("/**");
if path.contains(core) {
return true;
}
}
let simple_pattern = pattern
.trim_start_matches("**/")
.trim_end_matches("/**")
.trim_matches('*');
if !simple_pattern.is_empty() && path.contains(simple_pattern) {
return true;
}
false
}
fn parse_css_file(
content: &str,
file_path: &str,
opts: &LayoutmapOptions,
findings: &mut Vec<LayoutFinding>,
) {
let mut current_selector = String::new();
let mut brace_depth: usize = 0;
let zindex_re = Regex::new(r"z-index\s*:\s*(-?\d+)").unwrap();
let position_re = Regex::new(r"position\s*:\s*(sticky|fixed)").unwrap();
let display_grid_re = Regex::new(r"display\s*:\s*grid").unwrap();
let display_flex_re = Regex::new(r"display\s*:\s*flex").unwrap();
let selector_re = Regex::new(r"^([^{]+)\{").unwrap();
for (line_num, line) in content.lines().enumerate() {
let line_num = line_num + 1; let trimmed = line.trim();
let open_braces = line.matches('{').count();
let close_braces = line.matches('}').count();
if let Some(caps) = selector_re.captures(trimmed)
&& brace_depth == 0
{
current_selector = caps
.get(1)
.map(|m| m.as_str().trim().to_string())
.unwrap_or_default();
}
brace_depth = brace_depth.saturating_add(open_braces);
brace_depth = brace_depth.saturating_sub(close_braces);
if brace_depth == 0 {
current_selector.clear();
}
if !opts.sticky_only
&& !opts.grid_only
&& let Some(caps) = zindex_re.captures(line)
&& let Some(z_str) = caps.get(1)
&& let Ok(z) = z_str.as_str().parse::<i32>()
{
findings.push(LayoutFinding::ZIndex {
file: file_path.to_string(),
line: line_num,
selector: current_selector.clone(),
z_index: z,
});
}
if !opts.zindex_only
&& !opts.grid_only
&& let Some(caps) = position_re.captures(line)
&& let Some(pos) = caps.get(1)
{
findings.push(LayoutFinding::Sticky {
file: file_path.to_string(),
line: line_num,
selector: current_selector.clone(),
position: pos.as_str().to_string(),
});
}
if !opts.zindex_only && !opts.sticky_only && display_grid_re.is_match(line) {
findings.push(LayoutFinding::Grid {
file: file_path.to_string(),
line: line_num,
selector: current_selector.clone(),
});
}
if !opts.zindex_only
&& !opts.sticky_only
&& !opts.grid_only
&& display_flex_re.is_match(line)
{
findings.push(LayoutFinding::Flex {
file: file_path.to_string(),
line: line_num,
selector: current_selector.clone(),
});
}
}
}
fn parse_css_in_js(
content: &str,
file_path: &str,
opts: &LayoutmapOptions,
findings: &mut Vec<LayoutFinding>,
) {
let has_css_in_js = content.contains("styled.")
|| content.contains("styled(")
|| content.contains("css`")
|| content.contains("@emotion");
if !has_css_in_js {
return;
}
let template_re = Regex::new(r"`([^`]*(?:z-index|position|display)[^`]*)`").unwrap();
let zindex_re = Regex::new(r"z-index\s*:\s*(-?\d+)").unwrap();
let position_re = Regex::new(r"position\s*:\s*(sticky|fixed)").unwrap();
let display_grid_re = Regex::new(r"display\s*:\s*grid").unwrap();
let display_flex_re = Regex::new(r"display\s*:\s*flex").unwrap();
let styled_re = Regex::new(r"(?:const|let)\s+(\w+)\s*=\s*styled").unwrap();
let mut current_component = String::new();
for (line_num, line) in content.lines().enumerate() {
let line_num = line_num + 1;
if let Some(caps) = styled_re.captures(line)
&& let Some(name) = caps.get(1)
{
current_component = name.as_str().to_string();
}
if let Some(caps) = template_re.captures(line) {
let css_content = caps.get(1).map(|m| m.as_str()).unwrap_or("");
let selector = if current_component.is_empty() {
"(inline)".to_string()
} else {
current_component.clone()
};
if !opts.sticky_only
&& !opts.grid_only
&& let Some(zcaps) = zindex_re.captures(css_content)
&& let Some(z_str) = zcaps.get(1)
&& let Ok(z) = z_str.as_str().parse::<i32>()
{
findings.push(LayoutFinding::ZIndex {
file: file_path.to_string(),
line: line_num,
selector: selector.clone(),
z_index: z,
});
}
if !opts.zindex_only
&& !opts.grid_only
&& let Some(pcaps) = position_re.captures(css_content)
&& let Some(pos) = pcaps.get(1)
{
findings.push(LayoutFinding::Sticky {
file: file_path.to_string(),
line: line_num,
selector: selector.clone(),
position: pos.as_str().to_string(),
});
}
if !opts.zindex_only && !opts.sticky_only && display_grid_re.is_match(css_content) {
findings.push(LayoutFinding::Grid {
file: file_path.to_string(),
line: line_num,
selector: selector.clone(),
});
}
if !opts.zindex_only
&& !opts.sticky_only
&& !opts.grid_only
&& display_flex_re.is_match(css_content)
{
findings.push(LayoutFinding::Flex {
file: file_path.to_string(),
line: line_num,
selector: selector.clone(),
});
}
}
}
}