merman-core 0.4.1

Mermaid parser + semantic model (headless; parity-focused).
Documentation
use crate::{Error, ParseMetadata, Result};
use serde_json::{Value, json};
use std::collections::HashSet;

pub fn parse_pie(code: &str, meta: &ParseMetadata) -> Result<Value> {
    let mut raw_lines = code.lines();

    let mut header: Option<String> = None;
    for line in &mut raw_lines {
        let t = strip_inline_comment(line).trim();
        if !t.is_empty() {
            header = Some(t.to_string());
            break;
        }
    }

    let Some(header) = header else {
        return Ok(json!({}));
    };

    let mut it0 = header.split_whitespace();
    let Some(first) = it0.next() else {
        return Ok(json!({}));
    };
    if first != "pie" {
        return Ok(json!({ "error": "expected pie" }));
    }

    let mut show_data = false;
    let mut title: Option<String> = None;
    let mut acc_title: Option<String> = None;
    let mut acc_descr: Option<String> = None;
    let mut unsupported: Option<String> = None;

    fn token_boundary_ok(s: &str, token_len: usize) -> bool {
        let Some(rest) = s.get(token_len..) else {
            return true;
        };
        match rest.chars().next() {
            None => true,
            Some(c) => c.is_whitespace(),
        }
    }

    let header_after = header
        .trim_start_matches(|c: char| c.is_whitespace())
        .strip_prefix("pie")
        .unwrap_or("");
    let mut rest = header_after.trim_start();
    while !rest.is_empty() {
        if rest.starts_with("showData") && token_boundary_ok(rest, "showData".len()) {
            show_data = true;
            rest = rest["showData".len()..].trim_start();
            continue;
        }
        if rest.starts_with("title") && token_boundary_ok(rest, "title".len()) {
            let after = rest["title".len()..].trim_start();
            title = Some(after.to_string());
            rest = "";
            continue;
        }
        if rest.starts_with("accTitle") {
            if let Some(v) = parse_key_value(rest, "accTitle") {
                acc_title = Some(v);
                rest = "";
                continue;
            }
        }
        if rest.starts_with("accDescr") {
            if let Some(v) = parse_acc_descr_inline(rest) {
                acc_descr = Some(v);
                rest = "";
                continue;
            }
            if starts_acc_descr_block(rest) {
                let mut parts: Vec<String> = Vec::new();
                for next_line in raw_lines.by_ref() {
                    let s = strip_inline_comment(next_line);
                    if s.contains('}') {
                        let before = s.split('}').next().unwrap_or("").trim();
                        if !before.is_empty() {
                            parts.push(before.to_string());
                        }
                        break;
                    }
                    let trimmed = s.trim();
                    if trimmed.is_empty() {
                        continue;
                    }
                    parts.push(trimmed.to_string());
                }
                acc_descr = Some(parts.join("\n"));
                rest = "";
                continue;
            }
        }
        unsupported = Some(rest.split_whitespace().next().unwrap_or(rest).to_string());
        break;
    }

    if let Some(tok) = unsupported {
        return Err(Error::DiagramParse {
            diagram_type: meta.diagram_type.clone(),
            message: format!("unexpected pie header token: {tok}"),
        });
    }

    let mut sections: Vec<Value> = Vec::new();
    let mut seen: HashSet<String> = HashSet::new();

    let mut lines = raw_lines.peekable();
    while let Some(line) = lines.next() {
        let t = strip_inline_comment(line).trim();
        if t.is_empty() {
            continue;
        }

        if let Some(v) = parse_title_statement(t) {
            title = Some(v);
            continue;
        }

        if let Some(v) = parse_key_value(t, "accTitle") {
            acc_title = Some(v);
            continue;
        }

        if let Some(v) = parse_acc_descr_inline(t) {
            acc_descr = Some(v);
            continue;
        }

        if starts_acc_descr_block(t) {
            let mut parts: Vec<String> = Vec::new();
            for next_line in lines.by_ref() {
                let s = strip_inline_comment(next_line);
                if s.contains('}') {
                    let before = s.split('}').next().unwrap_or("").trim();
                    if !before.is_empty() {
                        parts.push(before.to_string());
                    }
                    break;
                }
                let trimmed = s.trim();
                if trimmed.is_empty() {
                    continue;
                }
                parts.push(trimmed.to_string());
            }
            acc_descr = Some(parts.join("\n"));
            continue;
        }

        if let Some((label, value)) = parse_section(t) {
            if value < 0.0 {
                return Err(Error::DiagramParse {
                    diagram_type: meta.diagram_type.clone(),
                    message: format!(
                        "\"{label}\" has invalid value: {value}. Negative values are not allowed in pie charts. All slice values must be >= 0."
                    ),
                });
            }
            if seen.insert(label.clone()) {
                sections.push(json!({ "label": label, "value": value }));
            }
            continue;
        }

        return Err(Error::DiagramParse {
            diagram_type: meta.diagram_type.clone(),
            message: format!("unexpected pie statement: {t}"),
        });
    }

    Ok(json!({
        "type": meta.diagram_type,
        "showData": show_data,
        "title": title,
        "accTitle": acc_title,
        "accDescr": acc_descr,
        "sections": sections,
    }))
}

fn strip_inline_comment(line: &str) -> &str {
    match line.find("%%") {
        Some(idx) => &line[..idx],
        None => line,
    }
}

fn parse_title_statement(line: &str) -> Option<String> {
    let t = line.trim_start();
    if !t.starts_with("title") {
        return None;
    }
    let rest = t.strip_prefix("title")?;
    match rest.chars().next() {
        None => Some(String::new()),
        Some(c) if c.is_whitespace() => Some(rest.trim_start().to_string()),
        _ => None,
    }
}

fn parse_key_value(line: &str, key: &str) -> Option<String> {
    let t = line.trim_start();
    if !t.starts_with(key) {
        return None;
    }
    let rest = t.strip_prefix(key)?.trim_start();
    let rest = rest.strip_prefix(':')?.trim_start();
    Some(rest.to_string())
}

fn parse_acc_descr_inline(line: &str) -> Option<String> {
    let t = line.trim_start();
    if !t.starts_with("accDescr") {
        return None;
    }
    let rest = t.strip_prefix("accDescr")?.trim_start();
    if let Some(rest) = rest.strip_prefix(':') {
        return Some(rest.trim_start().to_string());
    }
    None
}

fn starts_acc_descr_block(line: &str) -> bool {
    let t = line.trim_start();
    if !t.starts_with("accDescr") {
        return false;
    }
    let rest = t.trim_start_matches("accDescr").trim_start();
    rest.starts_with('{')
}

fn parse_section(line: &str) -> Option<(String, f64)> {
    let t = line.trim_start();
    let (label, rest) = parse_quoted_string(t)?;
    let rest = rest.trim_start();
    let rest = rest.strip_prefix(':')?.trim_start();

    let mut num = String::new();
    for c in rest.chars() {
        if c.is_ascii_digit() || c == '-' || c == '.' {
            num.push(c);
        } else {
            break;
        }
    }
    if num.is_empty() {
        return None;
    }
    let value: f64 = num.parse().ok()?;
    Some((label, value))
}

fn parse_quoted_string(input: &str) -> Option<(String, &str)> {
    let mut chars = input.chars();
    let quote = chars.next()?;
    if quote != '"' && quote != '\'' {
        return None;
    }
    let mut out = String::new();
    let mut escaped = false;
    let mut idx = 1;
    for c in chars {
        idx += c.len_utf8();
        if escaped {
            out.push(c);
            escaped = false;
            continue;
        }
        if c == '\\' {
            escaped = true;
            continue;
        }
        if c == quote {
            return Some((out, &input[idx..]));
        }
        out.push(c);
    }
    None
}

#[cfg(test)]
mod tests {
    use crate::{Engine, ParseOptions};

    #[test]
    fn pie_supports_title_statement_after_header() {
        let engine = Engine::new();
        let input = r#"
pie showData
  title Market Share
  "A" : 1
  "B" : 2
"#;

        let parsed = engine
            .parse_diagram_sync(input, ParseOptions::strict())
            .unwrap()
            .expect("diagram detected");

        assert_eq!(parsed.meta.diagram_type, "pie");
        assert_eq!(
            parsed.model.get("title").and_then(|v| v.as_str()),
            Some("Market Share")
        );
        assert_eq!(
            parsed.model.get("showData").and_then(|v| v.as_bool()),
            Some(true)
        );
    }

    #[test]
    fn pie_supports_header_acc_title_inline() {
        let engine = Engine::new();
        let input = r#"
pie accTitle: sample wow
  "A" : 1
"#;

        let parsed = engine
            .parse_diagram_sync(input, ParseOptions::strict())
            .unwrap()
            .expect("diagram detected");

        assert_eq!(parsed.meta.diagram_type, "pie");
        assert_eq!(
            parsed.model.get("accTitle").and_then(|v| v.as_str()),
            Some("sample wow")
        );
    }

    #[test]
    fn pie_supports_header_acc_descr_block() {
        let engine = Engine::new();
        let input = r#"
pie accDescr {
  first line
  second line
}
  "A" : 1
"#;

        let parsed = engine
            .parse_diagram_sync(input, ParseOptions::strict())
            .unwrap()
            .expect("diagram detected");

        assert_eq!(parsed.meta.diagram_type, "pie");
        assert_eq!(
            parsed.model.get("accDescr").and_then(|v| v.as_str()),
            Some("first line\nsecond line")
        );
    }
}