merman-editor-core 0.8.0-alpha.3

Protocol-neutral editor intelligence core for Merman.
Documentation
use merman_analysis::{AnalysisOptions, Analyzer, FenceMarker, FenceTextIndexSource, SourceKind};
use merman_editor_core::{DocumentKind, DocumentUri, DocumentWorkspace, Position};
use std::sync::Arc;

#[test]
fn plain_mermaid_documents_create_single_snapshot_fence() {
    let mut workspace = DocumentWorkspace::new();
    let snapshot = workspace.upsert(
        "file:///tmp/example.mmd",
        1,
        "flowchart TD\nclassDef highlight fill:#f00\nA-->B\n".to_string(),
        DocumentKind::Diagram,
    );

    assert_eq!(snapshot.uri.as_str(), "file:///tmp/example.mmd");
    assert_eq!(snapshot.fences.len(), 1);
    assert_eq!(snapshot.source.kind, SourceKind::Diagram);
    assert_eq!(snapshot.fences[0].source_id, "document");
    assert_eq!(snapshot.fences[0].body_start, 0);
    assert_eq!(snapshot.fences[0].body_end, snapshot.text.len());
    assert_eq!(snapshot.fences[0].source.kind, SourceKind::Diagram);
    assert_eq!(
        snapshot.fences[0].diagram_type.as_deref(),
        Some("flowchart-v2")
    );
    assert!(
        snapshot.fences[0]
            .text_index
            .has_directive_prefix("classDef")
    );
}

#[test]
fn cloned_snapshots_share_immutable_text_buffers() {
    let mut workspace = DocumentWorkspace::new();
    let snapshot = workspace.upsert(
        "file:///tmp/example.mmd",
        1,
        "flowchart TD\nA-->B\n".to_string(),
        DocumentKind::Diagram,
    );
    let cloned = snapshot.clone();

    assert!(Arc::ptr_eq(&snapshot.text, &cloned.text));
    let snapshot_fence_text = snapshot.fences[0].text.source_arc();
    let cloned_fence_text = cloned.fences[0].text.source_arc();
    assert!(Arc::ptr_eq(&snapshot_fence_text, &cloned_fence_text));
    assert!(Arc::ptr_eq(&snapshot.text, &snapshot_fence_text));
}

#[test]
fn markdown_documents_create_multiple_fence_local_snapshots() {
    let mut workspace = DocumentWorkspace::new();
    let snapshot = workspace.upsert(
        "file:///tmp/example.markdown",
        1,
        concat!(
            "before\n",
            "```mermaid\n",
            "flowchart TD\n",
            "A-->B\n",
            "```\n",
            "middle\n",
            "```mermaid\n",
            "sequenceDiagram\n",
            "Alice->>Bob: Hi\n",
            "```\n",
            "after\n",
        )
        .to_string(),
        DocumentKind::Markdown,
    );

    assert_eq!(snapshot.fences.len(), 2);
    assert_eq!(snapshot.source.kind, SourceKind::Markdown);
    assert_eq!(snapshot.fences[0].source_id, "mermaid-fence-1");
    assert_eq!(snapshot.fences[1].source_id, "mermaid-fence-2");
    assert_eq!(snapshot.fences[0].source.diagram_index, Some(0));
    assert_eq!(snapshot.fences[1].source.diagram_index, Some(1));
    assert_eq!(
        snapshot.fences[0].diagram_type.as_deref(),
        Some("flowchart-v2")
    );
    assert_eq!(snapshot.fences[1].diagram_type.as_deref(), Some("sequence"));
    assert_eq!(
        snapshot.fences[0].text_index.source(),
        FenceTextIndexSource::ParserComplete
    );
    assert_eq!(
        snapshot.fences[1].text_index.source(),
        FenceTextIndexSource::ParserComplete
    );
    assert!(snapshot.fences[0].text_index.node_ids().any(|id| id == "A"));
    assert!(
        snapshot.fences[1]
            .text_index
            .node_ids()
            .any(|id| id == "Alice")
    );
}

#[test]
fn markdown_documents_use_shared_fence_policy_for_tilde_fences() {
    let mut workspace = DocumentWorkspace::new();
    let snapshot = workspace.upsert(
        "file:///tmp/example.mdx",
        1,
        "before\n~~~mermaid\nsequenceDiagram\nAlice->>Bob: Hi\n~~~~\nafter\n".to_string(),
        DocumentKind::Mdx,
    );

    assert_eq!(snapshot.source.kind, SourceKind::Mdx);
    assert_eq!(snapshot.fences.len(), 1);
    assert_eq!(snapshot.fences[0].source_id, "mermaid-fence-1");
    assert_eq!(snapshot.fences[0].source.kind, SourceKind::Mdx);
    assert_eq!(
        snapshot.fences[0].fence_delimiter.unwrap().marker(),
        FenceMarker::Tilde
    );
    assert_eq!(snapshot.fences[0].diagram_type.as_deref(), Some("sequence"));
}

#[test]
fn cursor_lookup_distinguishes_prose_from_mermaid_fences() {
    let mut workspace = DocumentWorkspace::new();
    let snapshot = workspace.upsert(
        "file:///tmp/example.md",
        1,
        "before\n```mermaid\nflowchart TD\nA-->B\n```\nafter\n".to_string(),
        DocumentKind::Markdown,
    );

    assert!(snapshot.fence_at_position(Position::new(0, 2)).is_none());
    let fence = snapshot
        .fence_at_position(Position::new(2, 4))
        .expect("cursor inside fence");
    assert_eq!(fence.diagram_type.as_deref(), Some("flowchart-v2"));
}

#[test]
fn cursor_lookup_includes_unclosed_markdown_fence_at_eof() {
    let mut workspace = DocumentWorkspace::new();
    for (source, position) in [
        (
            "before\n```mermaid\nflowchart TD\nA-->",
            Position::new(3, 4),
        ),
        (
            "before\n```mermaid\nflowchart TD\nA-->\n",
            Position::new(4, 0),
        ),
    ] {
        let snapshot = workspace.upsert(
            "file:///tmp/example.md",
            1,
            source.to_string(),
            DocumentKind::Markdown,
        );

        let fence = snapshot
            .fence_at_position(position)
            .expect("EOF should remain inside unclosed Mermaid fence");
        assert_eq!(fence.diagram_type.as_deref(), Some("flowchart-v2"));
    }
}

#[test]
fn build_snapshot_does_not_cache_document() {
    let workspace = DocumentWorkspace::new();
    let uri = DocumentUri::new("file:///tmp/example.mmd");
    let snapshot = workspace.build_snapshot(
        uri.clone(),
        1,
        "flowchart TD\nA-->B\n".to_string(),
        DocumentKind::Diagram,
    );

    assert_eq!(snapshot.uri, uri);
    assert_eq!(
        snapshot.fences[0].diagram_type.as_deref(),
        Some("flowchart-v2")
    );
    assert!(workspace.get(&uri).is_none());
}

#[test]
fn replacing_document_version_drops_stale_fence_state() {
    let mut workspace = DocumentWorkspace::new();
    let uri = DocumentUri::new("file:///tmp/example.mmd");

    let first = workspace.upsert(
        uri.clone(),
        1,
        "flowchart TD\nA-->B\n".to_string(),
        DocumentKind::Diagram,
    );
    let second = workspace.upsert(
        uri.clone(),
        2,
        "sequenceDiagram\nAlice->>Bob: Hi\n".to_string(),
        DocumentKind::Diagram,
    );

    assert_eq!(first.version, 1);
    assert_eq!(second.version, 2);

    let stored = workspace.get(&uri).unwrap();
    assert_eq!(stored.version, 2);
    assert_eq!(stored.fences.len(), 1);
    assert_eq!(stored.fences[0].diagram_type.as_deref(), Some("sequence"));
    assert!(!stored.text.contains("flowchart TD"));
}

#[test]
fn replacing_analyzer_drops_cached_snapshots() {
    let limited_analyzer = Analyzer::with_options(
        AnalysisOptions::default().with_max_source_bytes(Some("flowchart TD\nA-->B\n".len() - 1)),
    );
    let mut workspace = DocumentWorkspace::with_analyzer(limited_analyzer);
    let uri = DocumentUri::new("file:///tmp/example.mmd");

    let limited = workspace.upsert(
        uri.clone(),
        1,
        "flowchart TD\nA-->B\n".to_string(),
        DocumentKind::Diagram,
    );

    assert!(limited.fences.is_empty());
    assert!(workspace.get(&uri).is_some());

    workspace.replace_analyzer(Analyzer::new());

    assert!(workspace.get(&uri).is_none());
    let rebuilt = workspace.upsert(
        uri,
        1,
        "flowchart TD\nA-->B\n".to_string(),
        DocumentKind::Diagram,
    );
    assert_eq!(
        rebuilt.fences[0].diagram_type.as_deref(),
        Some("flowchart-v2")
    );
}