smiles-rs 0.2.1

A SMILES parser producing molecular graphs, with no_std support
Documentation
//! Rendering-focused tests for SMILES output.

use geometric_traits::traits::SparseValuedMatrixRef;
use smiles_rs::{
    bond::{Bond, ring_num::RingNum},
    smiles::{Smiles, WildcardSmiles},
};

const SMILES_STR: &[&str] = &[
    "C1=CC=CC=C1",
    "[OH2]",
    "[Ti+4]",
    "[Co+3]",
    "CCO",
    "C#N",
    "[Ga+]$[As-]",
    "[te]1cccc1",
    "[Na+].[Cl-]",
    "C1CCCC2C1CCCC2",
    "C0CCCCC0C0CCCCC0",
    "C1:C:C:C:C:C1",
    "c1ccccc1",
    "c1ccccc1c2ccccc2",
    "COc(c1)cccc1C#N",
    "FC(Br)(Cl)F",
    "CC1CCC/C(C)=C1/C=C/C(C)=C/C=C/C(C)=C/C=C/C=C(C)/C=C/C=C(C)/C=C/C2=C(C)/CCCC2(C)C",
    "N[C@@H](C)C(=O)O",
    "N[CH](C)C(=O)O",
    "NC(C)C(=O)O",
    "[14cH]1ccccc1",
    "[14c@H]1ccccc1",
    "[2H]C(Cl)(Cl)Cl",
    "[C@@H](C)(N)C(=O)O",
    "C[C@H](N)C(=O)O",
    "OC(=O)[C@@H](N)C",
    "[K+].C=C.Cl[Pt-](Cl)Cl.O",
    "[Ti+4]",
    "CCN1C[C@]2(COC)CC[C@H](O)[C@@]34[C@@H]5C[C@H]6[C@H](OC)[C@@H]5[C@](O)(C[C@@H]6OC)[C@@](O)([C@@H](OC)[C@H]23)[C@@H]14",
    "C[C@@H]1C[C@@]2(O[C@H]2C)C(=O)O[C@@H]2CCN(C)C/C=C(/COC(=O)[C@]1(C)O)C2=O",
    "CC=C(C)C1=C(Cl)C(O)=C(C)C2=C1OC1=CC(O)=C(Cl)C(C)=C1C(=O)O2",
    "CC1=C[C@H](O)CC(C)(C)[C@H]1/C=C/C(C)=C/C=C/C(C)=C/C=C/C=C(C)/C=C/C=C(\\C)CO",
    "COC1(C23C14C5=C6C7=C8C5=C9C1=C5C%10=C%11C%12=C%13C%10=C%10C1=C8C1=C%10C8=C%10C%14=C%15C%16=C%17C(=C%12C%12=C%17C%17=C%18C%16=C%16C%15=C%15C%10=C1C7=C%15C1=C%16C(=C%18C7=C2C2=C%10C(=C5C9=C42)C%11=C%12C%10=C%177)C3=C16)C%14=C%138)OC",
    "C1CCCC=1",
    "C1CC2CC3C4C5C6C7C8C9C%10C%11C%12C%13C%14C%15C%16CC%17C%16%16C%15%15C%14%14C%13%13C%12%12C%11%11C%10%10C99C88C77C66C55C44C33C2C2C33C44C55C66C77C88C99C%10%10C%11%11C%12%12C%13%13C%14%14C%15%15C%16%16C%17C%17C%16%16C%15%15C%14%14C%13%13C%12%12C%11%11C%10%10C99C88C77C66C55C44C33C2C2C33C44C55C66C77C88C99C%10%10C%11%11C%12%12C%13%13C%14%14C%15%15C%16%16C%17C%17C%16%16C%15%15C%14%14C%13%13C%12%12C%11%11C%10%10C99C88C77C66C55C44C33C2C2C33C44C55C66C77C88C99C%10%10C%11%11C%12%12C%13%13C%14%14C%15%15C%16%16C%17C%17C%16%16C%15%15C%14%14C%13%13C%12%12C%11%11C%10%10C99C88C77C66C55C44C33C2C(C1)C3C4C5C6C7C8C9C%10C%11C%12C%13C%14C%15C%16C%17",
    "C1[C@H]([C@H]([C@@H](C(O1)(CO)O)O)O)O",
    "C(C(F)(F)I)(CC1=CC=CC=C1N=C=NC2=CC=CC=OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOF)(F)FOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOC2C",
    "CC(=O)OC(CC(=O)[O-])C",
    "CC(=O)OC(CC(=O)[O-])C",
];

fn parse_or_panic(s: &str) -> Smiles {
    Smiles::from_str(s).unwrap_or_else(|e| panic!("Failed to parse:\n{}", e.render(s)))
}

fn bond_count(smiles: &Smiles, bond: Bond) -> usize {
    smiles
        .bond_matrix()
        .sparse_entries()
        .filter(|((row, column), entry)| row < column && entry.bond() == bond)
        .count()
}

fn single_family_count(smiles: &Smiles) -> usize {
    bond_count(smiles, Bond::Single) + bond_count(smiles, Bond::Up) + bond_count(smiles, Bond::Down)
}

fn aromatic_bond_count(smiles: &Smiles) -> usize {
    smiles
        .bond_matrix()
        .sparse_entries()
        .filter(|((row, column), entry)| row < column && entry.aromatic())
        .count()
}

fn assert_render_round_trip_preserves_invariants(original: &str) {
    let parsed = parse_or_panic(original);
    let rendered = parsed.to_string();
    let reparsed = Smiles::from_str(&rendered).unwrap_or_else(|e| {
        panic!(
            "Failed to parse rendered SMILES.\nOriginal:\n{original}\nRendered:\n{rendered}\n{}",
            e.render(&rendered)
        )
    });
    let rerendered = reparsed.to_string();

    assert_eq!(
        rendered, rerendered,
        "Rendered SMILES did not reach a fixed point.\nOriginal:\n{original}\nRendered:\n{rendered}\nRerendered:\n{rerendered}"
    );

    assert_eq!(
        parsed.nodes().len(),
        reparsed.nodes().len(),
        "Node count changed after render round trip.\nOriginal:\n{original}\nRendered:\n{rendered}"
    );

    assert_eq!(
        parsed.number_of_bonds(),
        reparsed.number_of_bonds(),
        "Edge count changed after render round trip.\nOriginal:\n{original}\nRendered:\n{rendered}"
    );

    let parsed_aromatic = parsed.nodes().iter().filter(|n| n.aromatic()).count();
    let reparsed_aromatic = reparsed.nodes().iter().filter(|n| n.aromatic()).count();

    assert_eq!(
        parsed_aromatic, reparsed_aromatic,
        "Aromatic atom count changed after render round trip.\nOriginal:\n{original}\nRendered:\n{rendered}"
    );

    assert_eq!(
        single_family_count(&parsed),
        single_family_count(&reparsed),
        "Single-bond family count changed after render round trip.\nOriginal:\n{original}\nRendered:\n{rendered}"
    );

    for bond in [Bond::Double, Bond::Triple, Bond::Quadruple] {
        assert_eq!(
            bond_count(&parsed, bond),
            bond_count(&reparsed, bond),
            "Bond count for {bond:?} changed after render round trip.\nOriginal:\n{original}\nRendered:\n{rendered}"
        );
    }

    assert_eq!(
        aromatic_bond_count(&parsed),
        aromatic_bond_count(&reparsed),
        "Aromatic-bond count changed after render round trip.\nOriginal:\n{original}\nRendered:\n{rendered}"
    );
}

#[test]
fn test_render_round_trip_all_inputs() {
    for &original in SMILES_STR {
        assert_render_round_trip_preserves_invariants(original);
    }
}

/// Ladder of `rungs` rungs spelled as a snake through the rungs, using two
/// ring labels.
fn snake_ladder(rungs: usize) -> String {
    format!("C1CC2C1{}C1C2CC1", "C1C2C2C1".repeat((rungs - 4) / 2))
}

/// Ladder of `rungs` rungs spelled as one rail with the other as branches.
fn comb_ladder(rungs: usize) -> String {
    let mut ladder = String::from("C(C1)");
    for rung in 2..rungs {
        ladder.push_str(if rung.is_multiple_of(2) { "C(C12)" } else { "C(C21)" });
    }
    ladder.push_str(if rungs.is_multiple_of(2) { "C(C1)" } else { "C(C2)" });
    ladder
}

/// Ladder of `rungs` rungs spelled down one rail and back up the other, so
/// every rung but the last is a ring closure open at the turn.
fn u_ladder(rungs: u32) -> String {
    let atom = |rung| format!("C{}", RingNum::try_new(rung).unwrap());
    let down: String = (1..rungs).map(atom).collect();
    let up: String = (1..rungs).rev().map(atom).collect();
    format!("{down}CC{up}")
}

/// A 200-rung ladder spelled with two ring labels renders under label 100.
#[test]
fn ladder_keeps_ring_labels_below_one_hundred() {
    let smiles = parse_or_panic(&snake_ladder(200));
    for rendered in [smiles.render(), smiles.render_rooted(0)] {
        assert!(!rendered.contains("%("), "ring label above 99 in {rendered}");
        assert_eq!(
            parse_or_panic(&rendered).canonicalize().render(),
            smiles.canonicalize().render()
        );
    }
}

/// A ladder whose canonical render fits in 99 ring labels renders the same
/// from every spelling.
#[test]
fn ladder_within_ninety_nine_labels_renders_independently_of_spelling() {
    let rendered = parse_or_panic(&snake_ladder(198)).render();
    for spelling in [comb_ladder(198), u_ladder(198)] {
        assert_eq!(parse_or_panic(&spelling).render(), rendered);
    }
}

/// A ladder spelled so neither the canonical plan nor its own plan fits in 99
/// ring labels renders text that parses back and renders identically.
#[test]
fn ladder_above_ninety_nine_labels_round_trips() {
    let rendered = parse_or_panic(&u_ladder(200)).render();
    assert!(rendered.contains("%("), "expected a ring label above 99 in {rendered}");
    assert_eq!(parse_or_panic(&rendered).render(), rendered);
}

#[test]
fn wildcard_render_round_trip_preserves_invariants() {
    let original = "c1ccccc1*";
    let parsed = WildcardSmiles::from_str(original)
        .unwrap_or_else(|e| panic!("Failed to parse:\n{}", e.render(original)));
    let rendered = parsed.to_string();
    let reparsed = WildcardSmiles::from_str(&rendered).unwrap_or_else(|e| {
        panic!(
            "Failed to parse rendered wildcard SMILES.\nOriginal:\n{original}\nRendered:\n{rendered}\n{}",
            e.render(&rendered)
        )
    });

    assert_eq!(rendered, reparsed.to_string());
    assert_eq!(parsed.nodes().len(), reparsed.nodes().len());
    assert_eq!(parsed.number_of_bonds(), reparsed.number_of_bonds());
}

#[test]
fn benzene_kekule_roundtrip_preserves_bond_inventory() {
    assert_render_round_trip_preserves_invariants("C1=CC=CC=C1");
}