halley-config 0.1.0

Configuration loading and parsing for the Halley Wayland compositor.
Documentation
use crate::layout::{
    InitialWindowClusterParticipation, InitialWindowOverlapPolicy, InitialWindowSpawnPlacement,
    RuntimeTuning, WindowRule, WindowRulePattern,
};

#[derive(Default)]
struct PartialWindowRule {
    app_ids: Vec<WindowRulePattern>,
    titles: Vec<WindowRulePattern>,
    overlap_policy: Option<InitialWindowOverlapPolicy>,
    spawn_placement: Option<InitialWindowSpawnPlacement>,
    cluster_participation: Option<InitialWindowClusterParticipation>,
}

pub(crate) fn load_rules_section(raw: &str, out: &mut RuntimeTuning) -> Result<(), String> {
    out.window_rules.clear();
    let mut in_rules = false;
    let mut current_rule: Option<PartialWindowRule> = None;

    for (line_no, raw_line) in raw.lines().enumerate() {
        let line_no = line_no + 1;
        let trimmed = strip_rule_comment(raw_line);
        if trimmed.is_empty() {
            continue;
        }

        if !in_rules {
            if trimmed == "rules:" {
                in_rules = true;
            }
            continue;
        }

        if let Some(rule) = current_rule.as_mut() {
            if trimmed == "end" {
                out.window_rules.push(finalize_window_rule(rule, line_no)?);
                current_rule = None;
                continue;
            }
            parse_rule_entry(rule, trimmed, line_no)?;
            continue;
        }

        if trimmed == "rule:" {
            current_rule = Some(PartialWindowRule::default());
            continue;
        }
        if trimmed == "end" {
            return Ok(());
        }
        return Err(format!(
            "line {line_no}: expected `rule:` or `end` inside `rules:` block, got `{trimmed}`"
        ));
    }

    if current_rule.is_some() {
        return Err("unterminated `rule:` block in `rules:` section".to_string());
    }

    Ok(())
}

fn strip_rule_comment(line: &str) -> &str {
    let mut in_quotes = false;
    for (idx, ch) in line.char_indices() {
        if ch == '"' {
            in_quotes = !in_quotes;
        } else if ch == '#' && !in_quotes {
            return line[..idx].trim();
        }
    }
    line.trim()
}

fn finalize_window_rule(rule: &PartialWindowRule, line_no: usize) -> Result<WindowRule, String> {
    if rule.app_ids.is_empty() && rule.titles.is_empty() {
        return Err(format!(
            "line {line_no}: rule is missing required matcher; add `app-id` and/or `title`"
        ));
    }
    Ok(WindowRule {
        app_ids: rule.app_ids.clone(),
        titles: rule.titles.clone(),
        overlap_policy: rule
            .overlap_policy
            .unwrap_or(InitialWindowOverlapPolicy::None),
        spawn_placement: rule
            .spawn_placement
            .unwrap_or(InitialWindowSpawnPlacement::Adjacent),
        cluster_participation: rule
            .cluster_participation
            .unwrap_or(InitialWindowClusterParticipation::Layout),
    })
}

fn parse_rule_entry(
    rule: &mut PartialWindowRule,
    line: &str,
    line_no: usize,
) -> Result<(), String> {
    let Some((key, rest)) = line.split_once(char::is_whitespace) else {
        return Err(format!(
            "line {line_no}: expected `<key> <value>` inside rule"
        ));
    };
    let value = rest.trim();
    if value.is_empty() {
        return Err(format!("line {line_no}: missing value for `{key}`"));
    }

    match key {
        "app-id" | "app_id" => {
            rule.app_ids = parse_rule_app_ids(value, line_no)?;
        }
        "title" => {
            rule.titles = parse_rule_match_strings(value, line_no, "title")?;
        }
        "overlap-policy" | "overlap_policy" => {
            rule.overlap_policy = Some(parse_rule_overlap_policy(value, line_no)?);
        }
        "spawn-placement" | "spawn_placement" => {
            rule.spawn_placement = Some(parse_rule_spawn_placement(value, line_no)?);
        }
        "cluster-participation" | "cluster_participation" => {
            rule.cluster_participation = Some(parse_rule_cluster_participation(value, line_no)?);
        }
        _ => {
            return Err(format!("line {line_no}: unknown rule key `{key}`"));
        }
    }

    Ok(())
}

fn parse_rule_app_ids(value: &str, line_no: usize) -> Result<Vec<WindowRulePattern>, String> {
    parse_rule_match_strings(value, line_no, "app-id")
}

fn parse_rule_match_strings(
    value: &str,
    line_no: usize,
    field_name: &str,
) -> Result<Vec<WindowRulePattern>, String> {
    let trimmed = value.trim();
    if trimmed.starts_with('[') {
        return parse_string_array_literal(value, line_no, field_name);
    }
    Ok(vec![parse_rule_match_pattern(
        trimmed, line_no, field_name,
    )?])
}

fn parse_rule_overlap_policy(
    value: &str,
    line_no: usize,
) -> Result<InitialWindowOverlapPolicy, String> {
    match parse_quoted_string_literal(value, line_no)?.as_str() {
        "none" => Ok(InitialWindowOverlapPolicy::None),
        "parent-only" => Ok(InitialWindowOverlapPolicy::ParentOnly),
        "all" => Ok(InitialWindowOverlapPolicy::All),
        other => Err(format!(
            "line {line_no}: unknown overlap-policy `{other}`; expected `none`, `parent-only`, or `all`"
        )),
    }
}

fn parse_rule_spawn_placement(
    value: &str,
    line_no: usize,
) -> Result<InitialWindowSpawnPlacement, String> {
    match parse_quoted_string_literal(value, line_no)?.as_str() {
        "center" => Ok(InitialWindowSpawnPlacement::Center),
        "adjacent" => Ok(InitialWindowSpawnPlacement::Adjacent),
        "viewport-center" => Ok(InitialWindowSpawnPlacement::ViewportCenter),
        "cursor" => Ok(InitialWindowSpawnPlacement::Cursor),
        "app" => Ok(InitialWindowSpawnPlacement::App),
        other => Err(format!(
            "line {line_no}: unknown spawn-placement `{other}`; expected `center`, `adjacent`, `viewport-center`, `cursor`, or `app`"
        )),
    }
}

fn parse_rule_cluster_participation(
    value: &str,
    line_no: usize,
) -> Result<InitialWindowClusterParticipation, String> {
    match parse_quoted_string_literal(value, line_no)?.as_str() {
        "layout" => Ok(InitialWindowClusterParticipation::Layout),
        "float" => Ok(InitialWindowClusterParticipation::Float),
        other => Err(format!(
            "line {line_no}: unknown cluster-participation `{other}`; expected `layout` or `float`"
        )),
    }
}

fn parse_quoted_string_literal(value: &str, line_no: usize) -> Result<String, String> {
    let trimmed = value.trim();
    if !trimmed.starts_with('"') || !trimmed.ends_with('"') || trimmed.len() < 2 {
        return Err(format!(
            "line {line_no}: expected quoted string, got `{trimmed}`"
        ));
    }
    Ok(trimmed[1..trimmed.len() - 1].to_string())
}

fn parse_regex_literal(value: &str, line_no: usize) -> Result<String, String> {
    let trimmed = value.trim();
    if !trimmed.starts_with("r\"") || !trimmed.ends_with('"') || trimmed.len() < 3 {
        return Err(format!(
            "line {line_no}: expected regex literal, got `{trimmed}`"
        ));
    }
    Ok(trimmed[2..trimmed.len() - 1].to_string())
}

fn parse_rule_match_pattern(
    value: &str,
    line_no: usize,
    field_name: &str,
) -> Result<WindowRulePattern, String> {
    let trimmed = value.trim();
    if trimmed.starts_with("r\"") {
        let raw = parse_regex_literal(trimmed, line_no)?;
        let compiled = regex::Regex::new(&raw)
            .map_err(|err| format!("line {line_no}: invalid {field_name} regex `{raw}`: {err}"))?;
        Ok(WindowRulePattern::Regex(compiled))
    } else {
        Ok(WindowRulePattern::Exact(parse_quoted_string_literal(
            trimmed, line_no,
        )?))
    }
}

fn parse_string_array_literal(
    value: &str,
    line_no: usize,
    field_name: &str,
) -> Result<Vec<WindowRulePattern>, String> {
    let trimmed = value.trim();
    if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
        return Err(format!(
            "line {line_no}: expected string array literal, got `{trimmed}`"
        ));
    }
    let mut out = Vec::new();
    let mut rest = &trimmed[1..trimmed.len() - 1];
    while !rest.trim().is_empty() {
        rest = rest.trim_start();
        if !rest.starts_with('"') && !rest.starts_with("r\"") {
            return Err(format!(
                "line {line_no}: expected string or regex literal inside array, got `{rest}`"
            ));
        }
        let regex_prefix = rest.starts_with("r\"");
        let start = if regex_prefix { 2 } else { 1 };
        let mut escaped = false;
        let mut end_idx = None;
        for (idx, ch) in rest.char_indices().skip(start) {
            if escaped {
                escaped = false;
                continue;
            }
            if ch == '\\' && !regex_prefix {
                escaped = true;
                continue;
            }
            if ch == '"' {
                end_idx = Some(idx);
                break;
            }
        }
        let Some(end_idx) = end_idx else {
            return Err(format!(
                "line {line_no}: unterminated {field_name} matcher in array"
            ));
        };
        out.push(parse_rule_match_pattern(
            &rest[..=end_idx],
            line_no,
            field_name,
        )?);
        rest = rest[end_idx + 1..].trim_start();
        if rest.is_empty() {
            break;
        }
        if let Some(next) = rest.strip_prefix(',') {
            rest = next;
        } else {
            return Err(format!(
                "line {line_no}: expected `,` between {field_name} matchers, got `{rest}`"
            ));
        }
    }
    if out.is_empty() {
        return Err(format!(
            "line {line_no}: {field_name} array must not be empty"
        ));
    }
    Ok(out)
}