tui-lipan 0.1.0

Opinionated, component-based TUI framework for Rust - declarative components, reconciliation, layout engine, focus, overlays, and rich widgets on top of ratatui.
Documentation
use std::sync::Arc;

use super::super::diagram::{
    ClassMemberSpec, ClassNodeSpec, ClassRelationSpec, ClassSpec, ClassVisibilitySpec,
    ParsedDiagram,
};
use super::{MermaidParseError, significant_lines, strip_quotes};

pub(crate) fn parse(src: &str) -> Result<ParsedDiagram, MermaidParseError> {
    let lines = significant_lines(src);
    let mut classes = Vec::<ClassNodeSpec>::new();
    let mut relations = Vec::new();
    let mut idx = 1usize;
    while idx < lines.len() {
        let (line_no, line) = &lines[idx];
        if let Some(name) = line
            .strip_prefix("class ")
            .and_then(|s| s.strip_suffix("{"))
        {
            let name = name.trim();
            let mut members = Vec::new();
            idx += 1;
            while idx < lines.len() && lines[idx].1 != "}" {
                members.push(parse_member(&lines[idx].1));
                idx += 1;
            }
            upsert_class(&mut classes, name).members.extend(members);
        } else if let Some((arrow, from, to, from_cardinality, to_cardinality, label)) =
            relation_parts(line)
        {
            upsert_class(&mut classes, from);
            upsert_class(&mut classes, to);
            relations.push(ClassRelationSpec {
                from: Arc::from(from),
                to: Arc::from(to),
                arrow: Arc::from(arrow),
                from_cardinality: from_cardinality.map(Arc::from),
                to_cardinality: to_cardinality.map(Arc::from),
                label: label.map(Arc::from),
            });
        } else if let Some((class_name, member)) = line.split_once(':') {
            upsert_class(&mut classes, class_name.trim())
                .members
                .push(parse_member(member));
        } else {
            return Err(MermaidParseError::new(
                "expected class declaration or relation",
                Some(*line_no),
            ));
        }
        idx += 1;
    }
    if classes.is_empty() {
        return Err(MermaidParseError::new("empty class diagram", None));
    }
    Ok(ParsedDiagram::Class(ClassSpec { classes, relations }))
}

type RelationParts<'a> = (
    &'static str,
    &'a str,
    &'a str,
    Option<&'a str>,
    Option<&'a str>,
    Option<&'a str>,
);

fn relation_parts(line: &str) -> Option<RelationParts<'_>> {
    ["<|--", "<|..", "*--", "o--", "..>", "-->", "--"]
        .into_iter()
        .find_map(|arrow| {
            let (from_raw, rest) = line.split_once(arrow)?;
            let (to_raw, label) = rest
                .split_once(':')
                .map(|(to, label)| (to.trim(), Some(strip_quotes(label))))
                .unwrap_or_else(|| (rest.trim(), None));
            let (from, from_cardinality) = parse_relation_endpoint(from_raw)?;
            let (to, to_cardinality) = parse_relation_endpoint(to_raw)?;
            Some((
                arrow,
                from,
                to,
                from_cardinality,
                to_cardinality,
                label.filter(|label| !label.is_empty()),
            ))
        })
}

fn parse_relation_endpoint(raw: &str) -> Option<(&str, Option<&str>)> {
    let trimmed = raw.trim();
    let (trimmed, leading_cardinality) = take_leading_cardinality(trimmed);
    let (trimmed, trailing_cardinality) = take_trailing_cardinality(trimmed);
    let name = strip_quotes(trimmed).trim();
    (!name.is_empty()).then_some((
        name,
        trailing_cardinality
            .or(leading_cardinality)
            .filter(|card| !card.is_empty()),
    ))
}

fn take_leading_cardinality(raw: &str) -> (&str, Option<&str>) {
    if !raw.starts_with('"') {
        return (raw, None);
    }
    let rest = &raw[1..];
    let Some(end) = rest.find('"') else {
        return (raw, None);
    };
    (rest[end + 1..].trim(), Some(rest[..end].trim()))
}

fn take_trailing_cardinality(raw: &str) -> (&str, Option<&str>) {
    if !raw.ends_with('"') {
        return (raw, None);
    }
    let prefix = &raw[..raw.len().saturating_sub(1)];
    let Some(start) = prefix.rfind('"') else {
        return (raw, None);
    };
    (prefix[..start].trim(), Some(prefix[start + 1..].trim()))
}

fn upsert_class<'a>(classes: &'a mut Vec<ClassNodeSpec>, name: &str) -> &'a mut ClassNodeSpec {
    if let Some(pos) = classes.iter().position(|c| c.name.as_ref() == name) {
        &mut classes[pos]
    } else {
        classes.push(ClassNodeSpec {
            name: Arc::from(strip_quotes(name)),
            members: Vec::new(),
        });
        classes.last_mut().unwrap()
    }
}

fn parse_member(raw: &str) -> ClassMemberSpec {
    let raw = raw.trim();
    let (visibility, rest) = match raw.chars().next() {
        Some('+') => (ClassVisibilitySpec::Public, &raw[1..]),
        Some('-') => (ClassVisibilitySpec::Private, &raw[1..]),
        Some('#') => (ClassVisibilitySpec::Protected, &raw[1..]),
        Some('~') => (ClassVisibilitySpec::Package, &raw[1..]),
        _ => (ClassVisibilitySpec::Public, raw),
    };
    let method = rest.contains('(');
    let (name, ty) = if let Some((name, ty)) = rest.split_once(':') {
        (name.trim(), Some(Arc::from(ty.trim())))
    } else if let Some((first, second)) = rest.split_once(' ') {
        if method {
            (first.trim(), Some(Arc::from(second.trim())))
        } else {
            (second.trim(), Some(Arc::from(first.trim())))
        }
    } else {
        (rest.trim(), None)
    };
    ClassMemberSpec {
        visibility,
        name: Arc::from(name),
        ty,
        method,
    }
}

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

    #[test]
    fn parses_relations_with_cardinality_and_labels_without_creating_fake_classes() {
        let ParsedDiagram::Class(spec) = parse(
            "classDiagram\n\
             class User {\n\
                 +String id\n\
             }\n\
             class Order {\n\
                 +String orderId\n\
             }\n\
             User \"1\" --> \"*\" Order : places",
        )
        .unwrap() else {
            panic!("expected class diagram");
        };

        assert_eq!(spec.classes.len(), 2);
        assert_eq!(spec.relations.len(), 1);
        assert_eq!(spec.relations[0].from.as_ref(), "User");
        assert_eq!(spec.relations[0].to.as_ref(), "Order");
        assert_eq!(spec.relations[0].from_cardinality.as_deref(), Some("1"));
        assert_eq!(spec.relations[0].to_cardinality.as_deref(), Some("*"));
        assert_eq!(spec.relations[0].label.as_deref(), Some("places"));
    }

    #[test]
    fn parses_field_members_as_name_colon_type() {
        let member = parse_member("+String username");
        assert_eq!(member.name.as_ref(), "username");
        assert_eq!(member.ty.as_deref(), Some("String"));
        assert!(!member.method);

        let method = parse_member("+login() Boolean");
        assert_eq!(method.name.as_ref(), "login()");
        assert_eq!(method.ty.as_deref(), Some("Boolean"));
        assert!(method.method);
    }
}