merman-core 0.8.0

Mermaid parser + semantic model (headless; parity-focused).
Documentation
use super::super::{
    LINETYPE_BIDIRECTIONAL_DOTTED, LINETYPE_BIDIRECTIONAL_SOLID, LINETYPE_DOTTED,
    LINETYPE_DOTTED_CROSS, LINETYPE_DOTTED_OPEN, LINETYPE_DOTTED_POINT, LINETYPE_SOLID,
    LINETYPE_SOLID_ARROW_BOTTOM_REVERSE, LINETYPE_SOLID_ARROW_BOTTOM_REVERSE_DOTTED,
    LINETYPE_SOLID_ARROW_TOP_REVERSE, LINETYPE_SOLID_ARROW_TOP_REVERSE_DOTTED,
    LINETYPE_SOLID_BOTTOM, LINETYPE_SOLID_BOTTOM_DOTTED, LINETYPE_SOLID_CROSS, LINETYPE_SOLID_OPEN,
    LINETYPE_SOLID_POINT, LINETYPE_SOLID_TOP, LINETYPE_SOLID_TOP_DOTTED,
    LINETYPE_STICK_ARROW_BOTTOM_REVERSE, LINETYPE_STICK_ARROW_BOTTOM_REVERSE_DOTTED,
    LINETYPE_STICK_ARROW_TOP_REVERSE, LINETYPE_STICK_ARROW_TOP_REVERSE_DOTTED,
    LINETYPE_STICK_BOTTOM, LINETYPE_STICK_BOTTOM_DOTTED, LINETYPE_STICK_TOP,
    LINETYPE_STICK_TOP_DOTTED,
};

pub(super) const HALF_ARROW_TYPES: [(&str, i32); 16] = [
    ("--|\\", LINETYPE_SOLID_TOP_DOTTED),
    ("--|/", LINETYPE_SOLID_BOTTOM_DOTTED),
    ("--\\\\", LINETYPE_STICK_TOP_DOTTED),
    ("--//", LINETYPE_STICK_BOTTOM_DOTTED),
    ("/|--", LINETYPE_SOLID_ARROW_TOP_REVERSE_DOTTED),
    ("\\|--", LINETYPE_SOLID_ARROW_BOTTOM_REVERSE_DOTTED),
    ("//--", LINETYPE_STICK_ARROW_TOP_REVERSE_DOTTED),
    ("\\\\--", LINETYPE_STICK_ARROW_BOTTOM_REVERSE_DOTTED),
    ("-|\\", LINETYPE_SOLID_TOP),
    ("-|/", LINETYPE_SOLID_BOTTOM),
    ("-\\\\", LINETYPE_STICK_TOP),
    ("-//", LINETYPE_STICK_BOTTOM),
    ("/|-", LINETYPE_SOLID_ARROW_TOP_REVERSE),
    ("\\|-", LINETYPE_SOLID_ARROW_BOTTOM_REVERSE),
    ("//-", LINETYPE_STICK_ARROW_TOP_REVERSE),
    ("\\\\-", LINETYPE_STICK_ARROW_BOTTOM_REVERSE),
];

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ActorBoundary {
    Declaration,
    StatementEnd,
    Text,
    TextOrComma,
    SignalSource,
}

pub(super) struct ActorScan<'input> {
    pub text: &'input str,
    pub scan_end: usize,
    pub token_end: usize,
    pub config_allowed: bool,
}

pub(super) fn scan_actor(
    input: &str,
    start: usize,
    boundary: ActorBoundary,
) -> Option<ActorScan<'_>> {
    let mut end = start;
    let bytes = input.as_bytes();
    let uses_id_rules = matches!(
        boundary,
        ActorBoundary::Declaration | ActorBoundary::StatementEnd
    );

    while end < input.len() {
        if !input.is_char_boundary(end) {
            end += 1;
            continue;
        }
        if !uses_id_rules && signal_type_at(input, end).is_some() {
            break;
        }
        if !uses_id_rules && input[end..].starts_with("--") {
            break;
        }
        let ch = input[end..].chars().next()?;
        let b = bytes[end];
        if b == b'\n' || b == b';' || b == b',' || b == b':' || b == b'<' || b == b'>' {
            break;
        }
        if uses_id_rules {
            if b == b'@' {
                break;
            }
            if boundary == ActorBoundary::Declaration
                && is_ecmascript_whitespace(ch)
                && declaration_alias_starts_at(input, start, end)
            {
                break;
            }
        } else {
            if b == b'+' || b == b'/' || b == b'\\' || b == b')' {
                break;
            }
            if boundary == ActorBoundary::Text && end == start && b == b'-' {
                break;
            }
            if b == b'(' {
                if boundary == ActorBoundary::SignalSource
                    && central_suffix_precedes_signal(input, end)
                {
                    end += 2;
                    continue;
                }
                break;
            }
        }

        end += ch.len_utf8();
    }

    let mut token_end = end;
    while token_end > start {
        let ch = input[start..token_end].chars().next_back()?;
        if !is_ecmascript_whitespace(ch) {
            break;
        }
        token_end -= ch.len_utf8();
    }
    if token_end == start {
        return None;
    }

    let text = trim_start_ecmascript(&input[start..token_end]);
    Some(ActorScan {
        text,
        scan_end: end,
        token_end,
        config_allowed: boundary == ActorBoundary::Declaration
            && declaration_id_allows_config(text),
    })
}

pub(super) fn signal_type_at(input: &str, position: usize) -> Option<(usize, i32)> {
    let rest = input.get(position..)?;
    half_arrow_type(rest)
        .or_else(|| {
            rest.starts_with("<<-->>")
                .then_some((6, LINETYPE_BIDIRECTIONAL_DOTTED))
        })
        .or_else(|| {
            rest.starts_with("<<->>")
                .then_some((5, LINETYPE_BIDIRECTIONAL_SOLID))
        })
        .or_else(|| rest.starts_with("-->>").then_some((4, LINETYPE_DOTTED)))
        .or_else(|| rest.starts_with("->>").then_some((3, LINETYPE_SOLID)))
        .or_else(|| rest.starts_with("-->").then_some((3, LINETYPE_DOTTED_OPEN)))
        .or_else(|| rest.starts_with("->").then_some((2, LINETYPE_SOLID_OPEN)))
        .or_else(|| {
            rest.starts_with("--x")
                .then_some((3, LINETYPE_DOTTED_CROSS))
        })
        .or_else(|| rest.starts_with("-x").then_some((2, LINETYPE_SOLID_CROSS)))
        .or_else(|| {
            rest.starts_with("--)")
                .then_some((3, LINETYPE_DOTTED_POINT))
        })
        .or_else(|| rest.starts_with("-)").then_some((2, LINETYPE_SOLID_POINT)))
}

pub(super) fn config_followed_by_alias(input: &str, position: usize) -> bool {
    let mut alias_start = position;
    let mut saw_whitespace = false;
    while let Some(ch) = input[alias_start..].chars().next() {
        if ch == '\n' || !is_ecmascript_whitespace(ch) {
            break;
        }
        saw_whitespace = true;
        alias_start += ch.len_utf8();
    }
    saw_whitespace
        && starts_with_ci_at(input, alias_start, "as")
        && char_at_is_ecmascript_whitespace(input, alias_start + "as".len())
}

pub(super) fn is_ecmascript_whitespace(ch: char) -> bool {
    matches!(
        ch,
        '\u{0009}'
            | '\u{000A}'
            | '\u{000B}'
            | '\u{000C}'
            | '\u{000D}'
            | '\u{0020}'
            | '\u{00A0}'
            | '\u{1680}'
            | '\u{2000}'
            ..='\u{200A}'
                | '\u{2028}'
                | '\u{2029}'
                | '\u{202F}'
                | '\u{205F}'
                | '\u{3000}'
                | '\u{FEFF}'
    )
}

pub(super) fn trim_start_ecmascript(value: &str) -> &str {
    value.trim_start_matches(is_ecmascript_whitespace)
}

pub(super) fn trim_end_ecmascript(value: &str) -> &str {
    value.trim_end_matches(is_ecmascript_whitespace)
}

pub(super) fn trim_ecmascript(value: &str) -> &str {
    value.trim_matches(is_ecmascript_whitespace)
}

fn half_arrow_type(rest: &str) -> Option<(usize, i32)> {
    HALF_ARROW_TYPES
        .iter()
        .find_map(|(arrow, ty)| rest.starts_with(arrow).then_some((arrow.len(), *ty)))
}

fn declaration_id_allows_config(actor_id: &str) -> bool {
    !actor_id.is_empty()
        && actor_id.chars().all(|ch| {
            !is_ecmascript_whitespace(ch) && !matches!(ch, '<' | '>' | ':' | ',' | ';' | '@')
        })
}

fn central_suffix_precedes_signal(input: &str, position: usize) -> bool {
    if !input[position..].starts_with("()") {
        return false;
    }
    let mut signal_start = position + 2;
    while let Some(ch) = input[signal_start..].chars().next() {
        if ch == '\n' || !is_ecmascript_whitespace(ch) {
            break;
        }
        signal_start += ch.len_utf8();
    }
    signal_type_at(input, signal_start).is_some()
}

fn declaration_alias_starts_at(input: &str, actor_start: usize, whitespace_start: usize) -> bool {
    if input[actor_start..whitespace_start]
        .chars()
        .any(is_ecmascript_whitespace)
    {
        return false;
    }

    let mut alias_start = whitespace_start;
    while let Some(ch) = input[alias_start..].chars().next() {
        if !is_ecmascript_whitespace(ch) || ch == '\n' {
            break;
        }
        alias_start += ch.len_utf8();
    }
    starts_with_ci_at(input, alias_start, "as")
        && char_at_is_ecmascript_whitespace(input, alias_start + 2)
}

fn char_at_is_ecmascript_whitespace(input: &str, position: usize) -> bool {
    input
        .get(position..)
        .and_then(|rest| rest.chars().next())
        .is_some_and(is_ecmascript_whitespace)
}

fn starts_with_ci_at(input: &str, position: usize, expected: &str) -> bool {
    let Some(actual) = input
        .as_bytes()
        .get(position..position.saturating_add(expected.len()))
    else {
        return false;
    };
    actual.eq_ignore_ascii_case(expected.as_bytes())
}