cssforge-core 0.2.1

Core semantic analysis and lossless refactoring engine for CSSForge
Documentation
use std::ops::Range;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NodeKind {
    Style,
    AtBlock { name: String, params: String },
    AtStatement { name: String },
}

#[derive(Debug, Clone)]
pub struct SourceNode {
    pub kind: NodeKind,
    pub start: usize,
    pub end: usize,
    pub prelude_range: Range<usize>,
    pub body_range: Option<Range<usize>>,
}

impl SourceNode {
    pub fn prelude<'a>(&self, source: &'a str) -> &'a str {
        source[self.prelude_range.clone()].trim()
    }

    pub fn body<'a>(&self, source: &'a str) -> Option<&'a str> {
        self.body_range.as_ref().map(|range| &source[range.clone()])
    }
}

pub fn scan_nodes(source: &str, range: Range<usize>) -> Vec<SourceNode> {
    let bytes = source.as_bytes();
    let mut nodes = Vec::new();
    let mut i = range.start;

    while i < range.end {
        i = skip_trivia(source, i, range.end);
        if i >= range.end || bytes[i] == b'}' {
            break;
        }

        let start = i;
        let mut quote: Option<u8> = None;
        let mut escaped = false;
        let mut paren_depth = 0usize;
        let mut bracket_depth = 0usize;
        let mut consumed = false;

        while i < range.end {
            let b = bytes[i];

            if let Some(q) = quote {
                if escaped {
                    escaped = false;
                } else if b == b'\\' {
                    escaped = true;
                } else if b == q {
                    quote = None;
                }
                i += 1;
                continue;
            }

            if b == b'/' && i + 1 < range.end && bytes[i + 1] == b'*' {
                i = skip_comment(source, i, range.end);
                continue;
            }

            match b {
                b'\'' | b'"' => {
                    quote = Some(b);
                    i += 1;
                }
                b'(' => {
                    paren_depth += 1;
                    i += 1;
                }
                b')' => {
                    paren_depth = paren_depth.saturating_sub(1);
                    i += 1;
                }
                b'[' => {
                    bracket_depth += 1;
                    i += 1;
                }
                b']' => {
                    bracket_depth = bracket_depth.saturating_sub(1);
                    i += 1;
                }
                b'{' if paren_depth == 0 && bracket_depth == 0 => {
                    let open = i;
                    if let Some(close) = find_matching_brace(source, open, range.end) {
                        let prelude = &source[start..open];
                        let (kind, prelude_start) = classify_block(prelude, start);
                        nodes.push(SourceNode {
                            kind,
                            start,
                            end: close + 1,
                            prelude_range: prelude_start..open,
                            body_range: Some(open + 1..close),
                        });
                        i = close + 1;
                    } else {
                        i = range.end;
                    }
                    consumed = true;
                    break;
                }
                b';' if paren_depth == 0 && bracket_depth == 0 => {
                    let raw = source[start..i].trim();
                    if raw.starts_with('@') {
                        let name = at_rule_name(raw);
                        nodes.push(SourceNode {
                            kind: NodeKind::AtStatement { name },
                            start,
                            end: i + 1,
                            prelude_range: start..i,
                            body_range: None,
                        });
                    }
                    i += 1;
                    consumed = true;
                    break;
                }
                b'}' if paren_depth == 0 && bracket_depth == 0 => {
                    consumed = true;
                    break;
                }
                _ => i += 1,
            }
        }

        if !consumed {
            break;
        }
    }

    nodes
}

fn classify_block(prelude: &str, absolute_start: usize) -> (NodeKind, usize) {
    let leading = prelude.len() - prelude.trim_start().len();
    let trimmed = prelude.trim();
    let prelude_start = absolute_start + leading;
    if trimmed.starts_with('@') {
        let name = at_rule_name(trimmed);
        let after_name = &trimmed[1 + name.len()..];
        let params = after_name.trim().to_string();
        (NodeKind::AtBlock { name, params }, prelude_start)
    } else {
        (NodeKind::Style, prelude_start)
    }
}

fn at_rule_name(raw: &str) -> String {
    raw.trim_start_matches('@')
        .split(|c: char| c.is_ascii_whitespace() || c == '(' || c == ';' || c == '{')
        .next()
        .unwrap_or_default()
        .to_ascii_lowercase()
}

pub fn skip_trivia(source: &str, mut i: usize, end: usize) -> usize {
    let bytes = source.as_bytes();
    loop {
        while i < end && bytes[i].is_ascii_whitespace() {
            i += 1;
        }
        if i + 1 < end && bytes[i] == b'/' && bytes[i + 1] == b'*' {
            i = skip_comment(source, i, end);
            continue;
        }
        return i;
    }
}

pub fn is_whitespace_only(source: &str, range: Range<usize>) -> bool {
    source[range].bytes().all(|b| b.is_ascii_whitespace())
}

fn skip_comment(source: &str, i: usize, end: usize) -> usize {
    let bytes = source.as_bytes();
    let mut p = i + 2;
    while p + 1 < end {
        if bytes[p] == b'*' && bytes[p + 1] == b'/' {
            return p + 2;
        }
        p += 1;
    }
    end
}

fn find_matching_brace(source: &str, open: usize, end: usize) -> Option<usize> {
    let bytes = source.as_bytes();
    let mut depth = 1usize;
    let mut i = open + 1;
    let mut quote: Option<u8> = None;
    let mut escaped = false;

    while i < end {
        let b = bytes[i];

        if let Some(q) = quote {
            if escaped {
                escaped = false;
            } else if b == b'\\' {
                escaped = true;
            } else if b == q {
                quote = None;
            }
            i += 1;
            continue;
        }

        if b == b'/' && i + 1 < end && bytes[i + 1] == b'*' {
            i = skip_comment(source, i, end);
            continue;
        }

        match b {
            b'\'' | b'"' => quote = Some(b),
            b'{' => depth += 1,
            b'}' => {
                depth -= 1;
                if depth == 0 {
                    return Some(i);
                }
            }
            _ => {}
        }
        i += 1;
    }

    None
}

pub fn count_top_level_declarations(body: &str) -> usize {
    let bytes = body.as_bytes();
    let mut i = 0usize;
    let mut braces = 0usize;
    let mut parens = 0usize;
    let mut brackets = 0usize;
    let mut quote: Option<u8> = None;
    let mut escaped = false;
    let mut count = 0usize;
    let mut segment_has_colon = false;

    while i < bytes.len() {
        let b = bytes[i];
        if let Some(q) = quote {
            if escaped {
                escaped = false;
            } else if b == b'\\' {
                escaped = true;
            } else if b == q {
                quote = None;
            }
            i += 1;
            continue;
        }
        if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
            i = skip_comment(body, i, bytes.len());
            continue;
        }
        match b {
            b'\'' | b'"' => quote = Some(b),
            b'{' => braces += 1,
            b'}' => braces = braces.saturating_sub(1),
            b'(' => parens += 1,
            b')' => parens = parens.saturating_sub(1),
            b'[' => brackets += 1,
            b']' => brackets = brackets.saturating_sub(1),
            b':' if braces == 0 && parens == 0 && brackets == 0 => segment_has_colon = true,
            b';' if braces == 0 && parens == 0 && brackets == 0 => {
                if segment_has_colon {
                    count += 1;
                }
                segment_has_colon = false;
            }
            _ => {}
        }
        i += 1;
    }
    if segment_has_colon {
        count += 1;
    }
    count
}

pub fn count_ascii_case_insensitive_outside_comments(source: &str, needle: &str) -> usize {
    let lower_needle = needle.to_ascii_lowercase();
    let bytes = source.as_bytes();
    let mut i = 0usize;
    let mut quote: Option<u8> = None;
    let mut escaped = false;
    let mut count = 0usize;

    while i < bytes.len() {
        let b = bytes[i];
        if let Some(q) = quote {
            if escaped {
                escaped = false;
            } else if b == b'\\' {
                escaped = true;
            } else if b == q {
                quote = None;
            }
            i += 1;
            continue;
        }
        if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
            i = skip_comment(source, i, bytes.len());
            continue;
        }
        if b == b'\'' || b == b'"' {
            quote = Some(b);
            i += 1;
            continue;
        }
        if i + lower_needle.len() <= bytes.len() {
            let needle = lower_needle.as_bytes();
            let matches = bytes[i..i + needle.len()]
                .iter()
                .zip(needle.iter())
                .all(|(a, b)| a.to_ascii_lowercase() == *b);
            if matches {
                count += 1;
                i += needle.len();
                continue;
            }
        }
        i += 1;
    }
    count
}