omena-query 0.4.0

Omena query boundary over CME producer query fragments
Documentation
use std::collections::BTreeSet;

use omena_query_core::split_top_level_value_arguments;

pub(super) fn collect_query_var_references_in_value(value: &str) -> Vec<String> {
    let mut references = BTreeSet::new();
    let mut index = 0usize;
    let mut quote: Option<char> = None;
    while index < value.len() {
        let Some(ch) = value[index..].chars().next() else {
            break;
        };
        if let Some(quote_ch) = quote {
            index += ch.len_utf8();
            if ch == '\\' {
                if let Some(escaped) = value[index..].chars().next() {
                    index += escaped.len_utf8();
                }
            } else if ch == quote_ch {
                quote = None;
            }
            continue;
        }

        match ch {
            '"' | '\'' => {
                quote = Some(ch);
                index += ch.len_utf8();
            }
            _ if function_name_starts_at(value, index, "var") => {
                let open_index = index + "var".len();
                let Some(close_index) = matching_paren_end(value, open_index) else {
                    index += ch.len_utf8();
                    continue;
                };
                collect_var_references_from_arguments(
                    &value[open_index + 1..close_index],
                    &mut references,
                );
                index = close_index + 1;
            }
            _ => index += ch.len_utf8(),
        }
    }
    references.into_iter().collect()
}

fn collect_var_references_from_arguments(arguments: &str, references: &mut BTreeSet<String>) {
    let parts = split_top_level_value_arguments(arguments, 0)
        .map(|segments| segments.into_iter().map(|segment| segment.text).collect())
        .unwrap_or_else(|| vec![arguments]);
    let Some(first_argument) = parts.first().map(|part| part.trim()) else {
        return;
    };
    if first_argument.starts_with("--") {
        references.insert(first_argument.to_string());
    }
    for fallback in parts.iter().skip(1) {
        for reference in collect_query_var_references_in_value(fallback) {
            references.insert(reference);
        }
    }
}

fn function_name_starts_at(value: &str, index: usize, function_name: &str) -> bool {
    value
        .get(index..index + function_name.len())
        .is_some_and(|name| name.eq_ignore_ascii_case(function_name))
        && value[index + function_name.len()..].starts_with('(')
}

fn matching_paren_end(source: &str, open_index: usize) -> Option<usize> {
    if source.as_bytes().get(open_index).copied()? != b'(' {
        return None;
    }
    let mut index = open_index + 1;
    let mut depth = 1usize;
    let mut quote: Option<char> = None;
    while index < source.len() {
        let ch = source[index..].chars().next()?;
        if let Some(quote_ch) = quote {
            index += ch.len_utf8();
            if ch == '\\' {
                if let Some(escaped) = source[index..].chars().next() {
                    index += escaped.len_utf8();
                }
            } else if ch == quote_ch {
                quote = None;
            }
            continue;
        }
        match ch {
            '"' | '\'' => quote = Some(ch),
            '(' => depth += 1,
            ')' => {
                depth -= 1;
                if depth == 0 {
                    return Some(index);
                }
            }
            _ => {}
        }
        index += ch.len_utf8();
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn nested_var_fallbacks_remain_value_facts_not_declaration_scanning() {
        assert_eq!(
            collect_query_var_references_in_value("linear-gradient(var(--a), var(--b, var(--c)))"),
            ["--a", "--b", "--c"]
        );
        assert!(collect_query_var_references_in_value("'var(--quoted)'").is_empty());
    }
}