Skip to main content

xpict_core/
cxsmiles.rs

1//! ChemAxon CXSMILES atom-label helpers.
2//!
3//! Shared by paint chrome ([`crate::doc`]) and language edges. No RDKit.
4
5use crate::scene::MoleculeIn;
6
7/// Parse CXSMILES ``|$a;b;c;$|`` aliases into a per-atom label list.
8///
9/// Empty segments → ``None``. Returns ``[]`` when no label block is present.
10/// Leading underscore in ChemAxon (``_R``) is stripped for display.
11pub fn cx_atom_labels(smiles_or_cx: &str) -> Vec<Option<String>> {
12    if smiles_or_cx.is_empty() {
13        return Vec::new();
14    }
15    let Some(start) = smiles_or_cx.find("|$") else {
16        return Vec::new();
17    };
18    let rest = &smiles_or_cx[start + 2..];
19    let Some(end) = rest.find("$|") else {
20        return Vec::new();
21    };
22    let raw = &rest[..end];
23    let mut parts: Vec<&str> = raw.split(';').collect();
24    if parts.last().is_some_and(|p| p.is_empty()) {
25        parts.pop();
26    }
27    let mut out = Vec::with_capacity(parts.len());
28    for p in parts {
29        let p = p.trim();
30        if p.is_empty() {
31            out.push(None);
32            continue;
33        }
34        let label = if let Some(stripped) = p.strip_prefix('_') {
35            if stripped.is_empty() {
36                p.to_string()
37            } else {
38                stripped.to_string()
39            }
40        } else {
41            p.to_string()
42        };
43        out.push(Some(label));
44    }
45    out
46}
47
48/// SMILES graph only — drop `` |$…$|`` / other CX trailers.
49pub fn smiles_base(smiles_or_cx: &str) -> String {
50    if smiles_or_cx.is_empty() {
51        return String::new();
52    }
53    smiles_or_cx
54        .split_once(" |")
55        .map(|(a, _)| a)
56        .unwrap_or(smiles_or_cx)
57        .trim()
58        .to_string()
59}
60
61/// Apply CX aliases onto atoms by index. Only non-empty slots change labels.
62pub fn apply_cx_by_index(mut molecule: MoleculeIn, source: &str) -> MoleculeIn {
63    let aliases = cx_atom_labels(source);
64    if aliases.is_empty() {
65        return molecule;
66    }
67    for a in &mut molecule.atoms {
68        let idx = a.index as usize;
69        if let Some(Some(label)) = aliases.get(idx) {
70            let label = label.trim();
71            if !label.is_empty() {
72                a.label = Some(label.to_string());
73            }
74        }
75    }
76    molecule
77}
78
79/// CX source string for a mol node (prefer ``cxsmiles``, else ``smiles``).
80pub fn cx_source<'a>(cxsmiles: Option<&'a str>, smiles: Option<&'a str>) -> Option<&'a str> {
81    for s in [cxsmiles, smiles] {
82        if let Some(t) = s.filter(|x| !x.trim().is_empty()) {
83            return Some(t);
84        }
85    }
86    None
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use crate::scene::{AtomIn, BondIn};
93
94    #[test]
95    fn parses_cx_aliases() {
96        let labels = cx_atom_labels("*C* |$;;R2;$|");
97        assert_eq!(labels.len(), 3);
98        assert!(labels[0].is_none());
99        assert!(labels[1].is_none());
100        assert_eq!(labels[2].as_deref(), Some("R2"));
101    }
102
103    #[test]
104    fn smiles_base_strips_trailer() {
105        assert_eq!(smiles_base("*C* |$;;R2;$|"), "*C*");
106    }
107
108    #[test]
109    fn apply_cx_sets_nonempty_slots() {
110        let mol = MoleculeIn {
111            id: None,
112            atoms: vec![
113                AtomIn {
114                    index: 0,
115                    element: Some("*".into()),
116                    z: Some(0),
117                    x: 0.0,
118                    y: 0.0,
119                    label: Some("*".into()),
120                    charge: 0,
121                },
122                AtomIn {
123                    index: 1,
124                    element: Some("C".into()),
125                    z: Some(6),
126                    x: 1.0,
127                    y: 0.0,
128                    label: None,
129                    charge: 0,
130                },
131                AtomIn {
132                    index: 2,
133                    element: Some("*".into()),
134                    z: Some(0),
135                    x: 2.0,
136                    y: 0.0,
137                    label: Some("*".into()),
138                    charge: 0,
139                },
140            ],
141            bonds: vec![
142                BondIn {
143                    index: 0,
144                    begin: 0,
145                    end: 1,
146                    order: 1.0,
147                    stereo: None,
148                    interior: None,
149                },
150                BondIn {
151                    index: 1,
152                    begin: 1,
153                    end: 2,
154                    order: 1.0,
155                    stereo: None,
156                    interior: None,
157                },
158            ],
159            color: None,
160            atom_shade: None,
161            bond_shade: None,
162            shade_vmin: None,
163            shade_vmax: None,
164            mark_atoms: vec![],
165            mark_bonds: vec![],
166            scale: 1.0,
167            weight: 1.0,
168        };
169        let out = apply_cx_by_index(mol, "*C* |$;;R2;$|");
170        assert_eq!(out.atoms[0].label.as_deref(), Some("*"));
171        assert!(out.atoms[1].label.is_none());
172        assert_eq!(out.atoms[2].label.as_deref(), Some("R2"));
173    }
174
175    #[test]
176    fn empty_and_underscore_edge_cases() {
177        assert!(cx_atom_labels("").is_empty());
178        assert!(cx_atom_labels("CCO").is_empty());
179        assert!(cx_atom_labels("|$no close").is_empty());
180        let labels = cx_atom_labels("*C |$_;$|");
181        assert_eq!(labels[0].as_deref(), Some("_")); // strip_prefix empty → keep
182        let labels2 = cx_atom_labels("*C |$_R;$|");
183        assert_eq!(labels2[0].as_deref(), Some("R"));
184        assert_eq!(smiles_base(""), "");
185        assert_eq!(smiles_base("CCO"), "CCO");
186        assert!(cx_source(None, None).is_none());
187        assert_eq!(cx_source(None, Some("  ")), None);
188        assert_eq!(cx_source(Some("CX"), Some("S")), Some("CX"));
189    }
190}