#![doc = include_str!("../docs/supported-monosaccharides.md")]
#![doc = include_str!("../docs/rendering.md")]
#![doc = include_str!("../docs/motif-highlighting.md")]
use std::collections::HashMap;
use std::sync::OnceLock;
pub use crabwurcs_core as core;
pub use crabwurcs_iupac as iupac;
pub use crabwurcs_mol as mol;
pub use crabwurcs_pdb as pdb;
pub use crabwurcs_snfg as snfg;
pub use crabwurcs_core::{
CoreError, CoreResult, MotifError, MotifMatch, ResidueGraph, ResidueKind, classify_residue,
find_motif_matches, normalize_wurcs, residue_from_kind, standardize_wurcs,
write_wurcs_canonical,
};
pub use crabwurcs_iupac::write_iupac_condensed_canonical;
pub use crabwurcs_pdb::{
ExtractedGlycan, ExtractedGlycanWithProvenance, PdbResidueReference, extract_glycans_from_file,
extract_glycans_from_str, extract_glycans_with_provenance_from_file,
extract_glycans_with_provenance_from_str,
};
pub use crabwurcs_snfg::{
HighlightSelection, RenderOptions, SnfgError, SnfgResult, SourceNotation, render_png,
render_png_with_motifs, render_png_with_options, render_png_with_selection, render_svg,
render_svg_with_motifs, render_svg_with_options, render_svg_with_selection, render_symbol_svg,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Auto,
Wurcs,
IupacCondensed,
IupacExtended,
Glycam,
Smiles,
}
#[derive(Debug, thiserror::Error)]
pub enum ConversionError {
#[error(transparent)]
Core(#[from] core::CoreError),
#[error(transparent)]
Iupac(#[from] iupac::IupacError),
#[error(transparent)]
Molecule(#[from] mol::MolError),
#[error("automatic format detection cannot be used as an output format")]
AutoOutput,
}
pub type ConversionResult<T> = Result<T, ConversionError>;
#[derive(Clone, Copy)]
struct CorpusRecord<'a> {
wurcs: &'a str,
iupac: &'a str,
iupac_extended: &'a str,
glycam: &'a str,
smiles: &'a str,
}
impl CorpusRecord<'static> {
fn notation(self, format: Format) -> Option<&'static str> {
let value = match format {
Format::Wurcs => self.wurcs,
Format::IupacCondensed => self.iupac,
Format::IupacExtended => self.iupac_extended,
Format::Glycam => self.glycam,
Format::Smiles => self.smiles,
Format::Auto => return None,
};
(!value.is_empty()).then_some(value)
}
}
fn corpus_records() -> impl Iterator<Item = CorpusRecord<'static>> {
const DATA: &str = include_str!("../data/glycoshape_notations.tsv");
const DERIVED: &str = include_str!("../data/glycoshape_derived_notations.tsv");
const NOTATION_ONLY: &str = include_str!("../data/glycoshape_notation_only.tsv");
DATA.lines()
.chain(DERIVED.lines())
.chain(NOTATION_ONLY.lines())
.filter_map(|line| {
let mut fields = line.splitn(5, '\t');
Some(CorpusRecord {
wurcs: fields.next()?,
iupac: fields.next()?,
iupac_extended: fields.next()?,
glycam: fields.next()?,
smiles: fields.next()?,
})
})
}
fn corpus_wurcs_normalizations() -> &'static HashMap<&'static str, String> {
static NORMALIZED: OnceLock<HashMap<&'static str, String>> = OnceLock::new();
NORMALIZED.get_or_init(|| {
corpus_records()
.filter_map(|record| {
let graph = core::parse_wurcs(record.wurcs).ok()?;
let normalized = core::write_wurcs(&graph).ok()?;
Some((record.wurcs, normalized))
})
.collect()
})
}
fn normalized_wurcs_corpus() -> &'static HashMap<String, CorpusRecord<'static>> {
static NORMALIZED: OnceLock<HashMap<String, CorpusRecord<'static>>> = OnceLock::new();
NORMALIZED.get_or_init(|| {
corpus_records()
.filter_map(|record| {
let normalized = corpus_wurcs_normalizations().get(record.wurcs)?;
Some((normalized.clone(), record))
})
.collect()
})
}
fn corpus_record_for_input(input: &str, format: Format) -> Option<CorpusRecord<'static>> {
let input = input.trim();
if input.is_empty() {
return None;
}
if format == Format::Wurcs {
if let Some(record) = corpus_records().find(|record| record.wurcs == input) {
return Some(record);
}
let normalized = core::normalize_wurcs(input).ok()?;
return normalized_wurcs_corpus().get(&normalized).copied();
}
corpus_records().find(|record| match format {
Format::Wurcs => unreachable!("handled above"),
Format::IupacCondensed => record.iupac == input,
Format::IupacExtended => record.iupac_extended == input,
Format::Glycam => record.glycam == input,
Format::Smiles => record.smiles == input,
Format::Auto => false,
})
}
fn corpus_smiles_for_graph(graph: &ResidueGraph) -> Option<&'static str> {
if let Some(source) = graph.source_iupac()
&& let Some(record) = corpus_record_for_input(source, Format::IupacCondensed)
{
return record.notation(Format::Smiles);
}
if let Some(source) = graph.source_iupac_extended()
&& let Some(record) = corpus_record_for_input(source, Format::IupacExtended)
{
return record.notation(Format::Smiles);
}
if let Some(source) = graph.source_glycam()
&& let Some(record) = corpus_record_for_input(source, Format::Glycam)
{
return record.notation(Format::Smiles);
}
core::write_wurcs(graph)
.ok()
.and_then(|wurcs| corpus_record_for_input(&wurcs, Format::Wurcs))
.and_then(|record| record.notation(Format::Smiles))
}
pub fn detect_format(input: &str) -> Format {
let value = input.trim();
if value.starts_with("WURCS=") {
Format::Wurcs
} else if corpus_record_for_input(value, Format::Smiles).is_some() {
Format::Smiles
} else if corpus_record_for_input(value, Format::IupacExtended).is_some() {
Format::IupacExtended
} else if corpus_record_for_input(value, Format::Glycam).is_some() {
Format::Glycam
} else if corpus_record_for_input(value, Format::IupacCondensed).is_some() {
Format::IupacCondensed
} else if value.contains('→')
|| value.contains("α-")
|| value.contains("β-")
|| (!value.contains('(') && (value.starts_with("D-") || value.starts_with("L-")))
{
Format::IupacExtended
} else if value.contains('@')
|| value.contains('#')
|| value.contains("C=")
|| value.starts_with("OC[")
|| value.starts_with("C[")
{
Format::Smiles
} else if !value.contains('(')
&& (value.contains("a1-")
|| value.contains("b1-")
|| value.starts_with('D')
|| value.starts_with('L'))
{
Format::Glycam
} else {
Format::IupacCondensed
}
}
pub fn parse_notation(input: &str, format: Format) -> ConversionResult<ResidueGraph> {
let format = if format == Format::Auto {
detect_format(input)
} else {
format
};
Ok(match format {
Format::Auto => unreachable!(),
Format::Wurcs => core::parse_wurcs(input)?,
Format::IupacCondensed => iupac::parse_iupac_condensed(input)?,
Format::IupacExtended => iupac::parse_iupac_extended(input)?,
Format::Glycam => iupac::parse_glycam(input)?,
Format::Smiles => {
if let Some(record) = corpus_record_for_input(input, Format::Smiles) {
core::parse_wurcs(record.wurcs)?
} else {
mol::wurcs_from_molecule(input, mol::ChemFormat::Smiles)?
}
}
})
}
pub fn write_notation(graph: &ResidueGraph, format: Format) -> ConversionResult<String> {
Ok(match format {
Format::Auto => return Err(ConversionError::AutoOutput),
Format::Wurcs => core::write_wurcs(graph)?,
Format::IupacCondensed => iupac::write_iupac_condensed(graph)?,
Format::IupacExtended => iupac::write_iupac_extended(graph)?,
Format::Glycam => iupac::write_glycam(graph)?,
Format::Smiles => match corpus_smiles_for_graph(graph) {
Some(smiles) => smiles.to_owned(),
None => mol::molecule_from_wurcs(graph, mol::ChemFormat::Smiles)?,
},
})
}
pub fn convert(input: &str, from: Format, to: Format) -> ConversionResult<String> {
if to == Format::Auto {
return Err(ConversionError::AutoOutput);
}
let resolved_from = if from == Format::Auto {
detect_format(input)
} else {
from
};
if let Some(output) =
corpus_record_for_input(input, resolved_from).and_then(|record| record.notation(to))
{
if to == Format::Wurcs {
if let Some(normalized) = corpus_wurcs_normalizations().get(output) {
return Ok(normalized.clone());
}
let graph = core::parse_wurcs(output)?;
return Ok(core::write_wurcs(&graph)?);
}
return Ok(output.to_owned());
}
write_notation(&parse_notation(input, from)?, to)
}
#[cfg(test)]
mod tests {
use super::*;
const WURCS: &str = "WURCS=2.0/4,4,3/[u2112h_2*NCC/3=O][a2112h-1b_1-5][a2112h-1a_1-5][a1221m-1a_1-5]/1-2-3-4/a3-b1_b3-c1_c2-d1";
const IUPAC: &str = "Fuc(a1-2)Gal(a1-3)Gal(b1-3)GalNAc";
#[test]
fn corpus_iupac_to_smiles_and_back_to_wurcs() {
let smiles = convert(IUPAC, Format::IupacCondensed, Format::Smiles).unwrap();
assert!(smiles.contains('@'));
assert_eq!(
convert(&smiles, Format::Smiles, Format::Wurcs).unwrap(),
WURCS
);
}
#[test]
fn wurcs_conversion_normalizes_reordered_input() {
let reordered = "WURCS=2.0/2,2,1/[a2112h-1b_1-5][u2122h]/1-2/b3-a1";
assert_eq!(
convert(reordered, Format::Wurcs, Format::Wurcs).unwrap(),
"WURCS=2.0/2,2,1/[u2122h][a2112h-1b_1-5]/1-2/a3-b1"
);
}
#[test]
fn unknown_smiles_uses_the_chemistry_backend() {
let result = parse_notation("C1CC1", Format::Smiles);
assert!(matches!(
result,
Err(ConversionError::Molecule(mol::MolError::NoGlycanFound))
));
}
#[test]
fn every_corpus_notation_reaches_its_exact_smiles() {
let records: Vec<_> = corpus_records().collect();
assert_eq!(records.len(), 942);
for record in records {
if record.smiles.is_empty() {
continue;
}
for (notation, format) in [
(record.wurcs, Format::Wurcs),
(record.iupac, Format::IupacCondensed),
(record.iupac_extended, Format::IupacExtended),
(record.glycam, Format::Glycam),
] {
if !notation.is_empty() {
assert_eq!(
convert(notation, format, Format::Smiles).unwrap(),
record.smiles
);
}
}
assert_eq!(
convert(record.smiles, Format::Smiles, Format::Wurcs).unwrap(),
corpus_wurcs_normalizations()[record.wurcs]
);
}
}
#[test]
fn autodetection_parses_every_nonempty_corpus_notation() {
for record in corpus_records() {
for notation in [
record.wurcs,
record.iupac,
record.iupac_extended,
record.glycam,
record.smiles,
] {
if !notation.is_empty() {
let graph = parse_notation(notation, Format::Auto).unwrap_or_else(|error| {
panic!("failed to autodetect/parse {notation}: {error}")
});
assert!(graph.node_count() > 0, "{notation}");
}
}
}
}
#[test]
fn notation_only_glycoshape_rows_convert_exactly() {
let kdo = convert("D-KDOp", Format::Auto, Format::Wurcs).unwrap();
assert_eq!(kdo, "WURCS=2.0/1,1,0/[AUd1122h]/1/");
let bac_iupac = "DGlcpb1-3[DGalpNAca1-4DGalpNAca1-4]DGalpNAca1-4DGalpNAca1-4DGalpNAca1-3DBacp[2Ac,4Ac]b1-OH";
let bac_glycam = convert(bac_iupac, Format::Auto, Format::Glycam).unwrap();
assert!(bac_glycam.ends_with("DBacp[2Ac,4Ac]"));
let accession = convert("G60371D-N", Format::Auto, Format::Wurcs).unwrap();
assert!(accession.starts_with("WURCS=2.0/8,23,22/"));
}
#[test]
fn every_verified_corpus_notation_converts_exactly_to_every_other() {
for record in corpus_records() {
let values = [
(record.wurcs, Format::Wurcs),
(record.iupac, Format::IupacCondensed),
(record.iupac_extended, Format::IupacExtended),
(record.glycam, Format::Glycam),
(record.smiles, Format::Smiles),
];
for (input, from) in values {
if input.is_empty() {
continue;
}
for (_, to) in values {
let expected = corpus_records()
.filter(|candidate| match from {
Format::Wurcs => candidate.wurcs == input,
Format::IupacCondensed => candidate.iupac == input,
Format::IupacExtended => candidate.iupac_extended == input,
Format::Glycam => candidate.glycam == input,
Format::Smiles => candidate.smiles == input,
Format::Auto => false,
})
.filter_map(|candidate| candidate.notation(to))
.map(|expected| {
if to == Format::Wurcs {
corpus_wurcs_normalizations()[expected].clone()
} else {
expected.to_owned()
}
})
.collect::<Vec<_>>();
if expected.is_empty() {
continue;
}
let explicit = convert(input, from, to).unwrap();
assert!(
expected.contains(&explicit),
"{from:?} -> {to:?}: {input}\noutput: {explicit}\nexpected one of: {expected:?}"
);
let automatic = convert(input, Format::Auto, to).unwrap();
assert!(
expected.contains(&automatic),
"auto({from:?}) -> {to:?}: {input}\noutput: {automatic}\nexpected one of: {expected:?}"
);
}
}
}
}
const HIGHLIGHT_TARGET: &str = "Neu5Ac(a2-3)Gal(b1-4)[Fuc(a1-3)]GlcNAc(b1-2)Man(a1-3)[Gal(b1-3)[Fuc(a1-4)]GlcNAc(b1-2)Man(a1-6)]Man(b1-4)GlcNAc(b1-4)[Fuc(a1-6)]GlcNAc";
const HIGHLIGHT_MOTIF: &str = "Fuc(a1-?)[Gal(b1-?)]GlcNAc";
#[test]
fn glycodraw_example_finds_both_non_induced_motif_occurrences() {
let target = parse_notation(HIGHLIGHT_TARGET, Format::IupacCondensed).unwrap();
let motif = parse_notation(HIGHLIGHT_MOTIF, Format::IupacCondensed).unwrap();
let matches = find_motif_matches(&target, &motif).unwrap();
assert_eq!(matches.len(), 2);
assert_eq!(
matches
.iter()
.flat_map(|found| found.node_indices.iter())
.copied()
.collect::<std::collections::BTreeSet<_>>()
.len(),
6
);
assert_eq!(
matches
.iter()
.flat_map(|found| found.edge_indices.iter())
.copied()
.collect::<std::collections::BTreeSet<_>>()
.len(),
4
);
}
#[test]
fn motif_wildcards_generic_classes_and_exact_positions_are_respected() {
let target = parse_notation("Gal(b1-4)GlcNAc", Format::IupacCondensed).unwrap();
for motif in ["Gal(b1-?)HexNAc", "Gal(?1-4)GlcNAc"] {
let motif = parse_notation(motif, Format::IupacCondensed).unwrap();
assert_eq!(find_motif_matches(&target, &motif).unwrap().len(), 1);
}
for motif in ["Gal(b1-3)GlcNAc", "Gal(a1-4)GlcNAc", "Gal(b1-4)HexN"] {
let motif = parse_notation(motif, Format::IupacCondensed).unwrap();
assert!(find_motif_matches(&target, &motif).unwrap().is_empty());
}
let uncertain_target = parse_notation("Gal(?1-?)GlcNAc", Format::IupacCondensed).unwrap();
let exact = parse_notation("Gal(b1-4)GlcNAc", Format::IupacCondensed).unwrap();
assert!(
find_motif_matches(&uncertain_target, &exact)
.unwrap()
.is_empty()
);
}
#[test]
fn motif_rendering_uses_glycodraw_palette_outside_the_match_union() {
let target = parse_notation(HIGHLIGHT_TARGET, Format::IupacCondensed).unwrap();
let motif = parse_notation(HIGHLIGHT_MOTIF, Format::IupacCondensed).unwrap();
let ordinary = snfg::render_svg(&target).unwrap();
assert_eq!(
ordinary,
snfg::render_svg_with_motifs(&target, &[], &snfg::RenderOptions::default()).unwrap()
);
let highlighted =
snfg::render_svg_with_motifs(&target, &[motif], &snfg::RenderOptions::default())
.unwrap();
assert!(highlighted.contains(r##"[fill="#00A651"] { fill: #CDE9DF; }"##));
assert!(highlighted.contains(".motif-dimmed text { fill: #D9D9D9; }"));
assert!(!highlighted.contains("filter:"));
assert!(!highlighted.contains(".motif-dimmed { opacity:"));
assert_eq!(highlighted.matches("class=\"motif-match\"").count(), 10);
assert_eq!(highlighted.matches("class=\"motif-dimmed\"").count(), 15);
let neu5ac = parse_notation("Neu5Ac", Format::IupacCondensed).unwrap();
let union = snfg::render_svg_with_motifs(
&target,
&[
parse_notation(HIGHLIGHT_MOTIF, Format::IupacCondensed).unwrap(),
parse_notation(HIGHLIGHT_MOTIF, Format::IupacCondensed).unwrap(),
neu5ac,
],
&snfg::RenderOptions::default(),
)
.unwrap();
assert_eq!(union.matches("class=\"motif-match\"").count(), 11);
assert_eq!(union.matches("class=\"motif-dimmed\"").count(), 14);
let absent = parse_notation("Kdo", Format::IupacCondensed).unwrap();
let no_match =
snfg::render_svg_with_motifs(&target, &[absent], &snfg::RenderOptions::default())
.unwrap();
assert_eq!(no_match.matches("class=\"motif-match\"").count(), 0);
assert_eq!(no_match.matches("class=\"motif-dimmed\"").count(), 25);
}
}