fallow-engine 3.3.0

Typed analysis engine facade for fallow consumers
Documentation
pub(super) fn preprocessor_virtual_stylesheet(source: &str) -> Option<String> {
    let clean = strip_preprocessor_comments(source);
    let output = render_preprocessor_children(&clean, 0, clean.len(), 0);
    (!output.trim().is_empty()).then_some(output)
}

pub(super) fn strip_preprocessor_comments(source: &str) -> String {
    let mut out = String::with_capacity(source.len());
    let bytes = source.as_bytes();
    let mut cursor = 0;
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'/') {
            out.push_str(&source[cursor..i]);
            out.push_str("  ");
            i += 2;
            while i < bytes.len() && bytes[i] != b'\n' {
                out.push(' ');
                i += 1;
            }
            cursor = i;
            continue;
        }
        i += 1;
    }
    out.push_str(&source[cursor..]);
    out
}

pub(super) fn render_preprocessor_children(
    source: &str,
    start: usize,
    end: usize,
    indent: usize,
) -> String {
    let bytes = source.as_bytes();
    let mut output = String::new();
    let mut statement_start = start;
    let mut i = start;
    while i < end {
        if bytes[i] == b'{' {
            let prelude = source[statement_start..i].trim();
            let Some(close) = find_matching_brace(source, i, end) else {
                return output;
            };
            if let Some(block) = render_preprocessor_block(source, prelude, i + 1, close, indent) {
                output.push_str(&block);
            }
            i = close + 1;
            statement_start = i;
        } else if bytes[i] == b';' {
            i += 1;
            statement_start = i;
        } else {
            i += 1;
        }
    }
    output
}

pub(super) fn render_preprocessor_block(
    source: &str,
    prelude: &str,
    body_start: usize,
    body_end: usize,
    indent: usize,
) -> Option<String> {
    let prelude = prelude.trim();
    if prelude.is_empty()
        || prelude.contains("#{")
        || prelude.starts_with("@mixin")
        || prelude.starts_with("@function")
        || prelude.starts_with("@for")
        || prelude.starts_with("@each")
        || prelude.starts_with("@if")
        || prelude.starts_with("@else")
        || prelude.starts_with("@while")
    {
        return None;
    }
    if prelude.starts_with("@media")
        || prelude.starts_with("@supports")
        || prelude.starts_with("@container")
        || prelude.starts_with("@layer")
    {
        let body = render_preprocessor_children(source, body_start, body_end, indent + 1);
        if body.trim().is_empty() {
            return None;
        }
        let mut output = String::new();
        push_indent(&mut output, indent);
        output.push_str(prelude);
        output.push_str(" {\n");
        output.push_str(&body);
        push_indent(&mut output, indent);
        output.push_str("}\n");
        return Some(output);
    }
    if prelude.starts_with('@') || prelude.ends_with(':') {
        return None;
    }

    let selectors = clean_preprocessor_selector_list(prelude)?;
    let (declarations, children) =
        render_preprocessor_body(source, body_start, body_end, indent + 1);
    if declarations.is_empty() && children.trim().is_empty() {
        return None;
    }
    let mut output = String::new();
    push_indent(&mut output, indent);
    output.push_str(&selectors);
    output.push_str(" {\n");
    for declaration in declarations {
        push_indent(&mut output, indent + 1);
        output.push_str(&declaration);
        output.push('\n');
    }
    output.push_str(&children);
    push_indent(&mut output, indent);
    output.push_str("}\n");
    Some(output)
}

pub(super) fn render_preprocessor_body(
    source: &str,
    body_start: usize,
    body_end: usize,
    indent: usize,
) -> (Vec<String>, String) {
    let bytes = source.as_bytes();
    let mut declarations = Vec::new();
    let mut children = String::new();
    let mut statement_start = body_start;
    let mut i = body_start;
    while i < body_end {
        match bytes[i] {
            b'{' => {
                let prelude = source[statement_start..i].trim();
                let Some(close) = find_matching_brace(source, i, body_end) else {
                    break;
                };
                if let Some(block) =
                    render_preprocessor_block(source, prelude, i + 1, close, indent)
                {
                    children.push_str(&block);
                }
                i = close + 1;
                statement_start = i;
            }
            b';' => {
                let statement = source[statement_start..=i].trim();
                if let Some(declaration) = normalize_preprocessor_declaration(statement) {
                    declarations.push(declaration);
                }
                i += 1;
                statement_start = i;
            }
            _ => i += 1,
        }
    }
    (declarations, children)
}

pub(super) fn clean_preprocessor_selector_list(prelude: &str) -> Option<String> {
    let children: Vec<&str> = prelude
        .split(',')
        .map(str::trim)
        .filter(|selector| {
            !selector.is_empty()
                && !selector.contains("#{")
                && !selector.starts_with('@')
                && !selector.ends_with(':')
        })
        .collect();
    if children.is_empty() {
        None
    } else {
        Some(children.join(", "))
    }
}

pub(super) fn normalize_preprocessor_declaration(statement: &str) -> Option<String> {
    let statement = statement.trim().trim_end_matches(';').trim();
    if statement.is_empty()
        || statement.starts_with('$')
        || statement.starts_with("@include")
        || statement.starts_with("@extend")
        || statement.starts_with("@debug")
        || statement.starts_with("@warn")
        || statement.starts_with("@error")
        || statement.contains("#{")
    {
        return None;
    }
    let (property, value) = statement.split_once(':')?;
    let property = property.trim();
    let value = value.trim();
    if property.is_empty() || value.is_empty() || property.starts_with('@') {
        return None;
    }
    Some(format!(
        "{property}: {};",
        normalize_preprocessor_value(value)
    ))
}

pub(super) fn normalize_preprocessor_value(value: &str) -> String {
    let mut out = String::with_capacity(value.len());
    let bytes = value.as_bytes();
    let mut cursor = 0;
    let mut i = 0;
    while i < bytes.len() {
        if (bytes[i] == b'$' || bytes[i] == b'@') && is_preprocessor_ident_start(bytes.get(i + 1)) {
            out.push_str(&value[cursor..i]);
            out.push_str("var(--fallow-preprocessor-var)");
            i += 2;
            while i < bytes.len() && is_preprocessor_ident_continue(bytes[i]) {
                i += 1;
            }
            cursor = i;
        } else {
            i += 1;
        }
    }
    out.push_str(&value[cursor..]);
    out
}

pub(super) fn is_preprocessor_ident_start(byte: Option<&u8>) -> bool {
    byte.is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_' || *b == b'-')
}

pub(super) fn is_preprocessor_ident_continue(byte: u8) -> bool {
    byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')
}

pub(super) fn push_indent(output: &mut String, indent: usize) {
    for _ in 0..indent {
        output.push_str("  ");
    }
}

pub(super) fn find_matching_brace(source: &str, open: usize, limit: usize) -> Option<usize> {
    let bytes = source.as_bytes();
    let mut depth = 0usize;
    let mut i = open;
    while i < limit {
        match bytes[i] {
            b'{' => depth += 1,
            b'}' => {
                depth = depth.saturating_sub(1);
                if depth == 0 {
                    return Some(i);
                }
            }
            _ => {}
        }
        i += 1;
    }
    None
}