use wasm_bindgen::prelude::*;
const WASM_MAX_ATOMS: usize = 10_000;
const WASM_MAX_INPUT_BYTES: usize = 1_000_000;
const WASM_MAX_BATCH_ITEMS: usize = 1_024;
const WASM_MAX_JSON_STRING_BYTES: usize = 100_000;
const WASM_MAX_SMARTS_MATCHES: usize = 10_000;
#[wasm_bindgen(start)]
pub fn start() {
console_error_panic_hook::set_once();
}
pub mod workflow;
#[wasm_bindgen]
pub struct MolHandle {
inner: std::rc::Rc<chematic_core::Molecule>,
}
#[wasm_bindgen]
impl MolHandle {
pub fn atom_count(&self) -> usize {
self.inner.atom_count()
}
pub fn bond_count(&self) -> usize {
self.inner.bond_count()
}
pub fn formula(&self) -> String {
molecular_formula(&self.inner)
}
pub fn canonical_smiles(&self) -> String {
chematic_smiles::canonical_smiles(&self.inner)
}
pub fn molecular_weight(&self) -> f64 {
chematic_chem::molecular_weight(&self.inner)
}
pub fn tpsa(&self) -> f64 {
chematic_chem::tpsa(&self.inner)
}
pub fn lipinski_passes(&self) -> bool {
chematic_chem::lipinski_passes(&self.inner)
}
pub fn heavy_atom_count(&self) -> usize {
chematic_chem::heavy_atom_count(&self.inner)
}
pub fn hbd_count(&self) -> usize {
chematic_chem::hbd_count(&self.inner)
}
pub fn hba_count(&self) -> usize {
chematic_chem::hba_count(&self.inner)
}
pub fn logp_crippen(&self) -> f64 {
chematic_chem::logp_crippen(&self.inner)
}
pub fn fsp3(&self) -> f64 {
chematic_chem::fsp3(&self.inner)
}
pub fn aromatic_ring_count(&self) -> usize {
chematic_chem::aromatic_ring_count(&self.inner)
}
pub fn qed(&self) -> f64 {
chematic_chem::qed(&self.inner)
}
pub fn exact_mass(&self) -> f64 {
chematic_chem::exact_mass(&self.inner)
}
pub fn rotatable_bond_count(&self) -> usize {
chematic_chem::rotatable_bond_count(&self.inner)
}
pub fn molar_refractivity(&self) -> f64 {
chematic_chem::molar_refractivity(&self.inner)
}
pub fn formal_charge_sum(&self) -> i32 {
chematic_chem::formal_charge_sum(&self.inner)
}
pub fn veber_passes(&self) -> bool {
chematic_chem::veber_passes(&self.inner)
}
pub fn egan_passes(&self) -> bool {
chematic_chem::egan_passes(&self.inner)
}
pub fn reos_passes(&self) -> bool {
chematic_chem::reos_passes(&self.inner)
}
pub fn ghose_passes(&self) -> bool {
chematic_chem::ghose_passes(&self.inner)
}
pub fn num_heteroatoms(&self) -> usize {
chematic_chem::num_heteroatoms(&self.inner)
}
pub fn ring_count(&self) -> usize {
chematic_chem::ring_count(&self.inner)
}
pub fn num_stereocenters(&self) -> usize {
chematic_chem::num_stereocenters(&self.inner)
}
pub fn pains_passes(&self) -> bool {
chematic_chem::pains_passes(&self.inner)
}
pub fn num_aromatic_heterocycles(&self) -> usize {
chematic_chem::num_aromatic_heterocycles(&self.inner)
}
pub fn num_aliphatic_heterocycles(&self) -> usize {
chematic_chem::num_aliphatic_heterocycles(&self.inner)
}
pub fn num_saturated_heterocycles(&self) -> usize {
chematic_chem::num_saturated_heterocycles(&self.inner)
}
pub fn num_spiro_atoms(&self) -> usize {
chematic_chem::num_spiro_atoms(&self.inner)
}
pub fn num_bridgehead_atoms(&self) -> usize {
chematic_chem::num_bridgehead_atoms(&self.inner)
}
pub fn depict_svg(&self) -> String {
chematic_depict::depict_svg(&self.inner)
}
pub fn depict_svg_opts(&self, opts: &DepictOptions) -> String {
chematic_depict::depict_svg_opts(&self.inner, &opts.to_render_options())
}
pub fn depict_png(&self) -> Vec<u8> {
let layout = chematic_depict::compute_layout(&self.inner);
chematic_depict::render_png(&self.inner, &layout)
}
pub fn wiener_index(&self) -> f64 {
chematic_chem::wiener_index(&self.inner)
}
pub fn kappa1(&self) -> f64 {
chematic_chem::kappa1(&self.inner)
}
pub fn kappa2(&self) -> f64 {
chematic_chem::kappa2(&self.inner)
}
pub fn kappa3(&self) -> f64 {
chematic_chem::kappa3(&self.inner)
}
pub fn chi0(&self) -> f64 {
chematic_chem::chi0(&self.inner)
}
pub fn chi1(&self) -> f64 {
chematic_chem::chi1(&self.inner)
}
pub fn chi2(&self) -> f64 {
chematic_chem::chi2(&self.inner)
}
pub fn chi3(&self) -> f64 {
chematic_chem::chi3(&self.inner)
}
pub fn chi4(&self) -> f64 {
chematic_chem::chi4(&self.inner)
}
pub fn chi0v(&self) -> f64 {
chematic_chem::chi0v(&self.inner)
}
pub fn chi1v(&self) -> f64 {
chematic_chem::chi1v(&self.inner)
}
pub fn chi2v(&self) -> f64 {
chematic_chem::chi2v(&self.inner)
}
pub fn chi3v(&self) -> f64 {
chematic_chem::chi3v(&self.inner)
}
pub fn chi4v(&self) -> f64 {
chematic_chem::chi4v(&self.inner)
}
pub fn bertz_ct(&self) -> f64 {
chematic_chem::bertz_ct(&self.inner)
}
pub fn labute_asa(&self) -> f64 {
chematic_chem::labute_asa(&self.inner)
}
pub fn morgan_fp_counts_json(&self, radius: u32) -> String {
let counts = chematic_fp::morgan_fp_counts(&self.inner, radius);
let mut pairs: Vec<(u64, u32)> = counts.into_iter().collect();
pairs.sort_by_key(|(k, _)| *k);
let entries: Vec<String> = pairs.iter().map(|(k, v)| format!("\"{k}\": {v}")).collect();
format!("{{{}}}", entries.join(", "))
}
pub fn sum_estate(&self) -> f64 {
chematic_chem::sum_estate(&self.inner)
}
pub fn max_estate(&self) -> f64 {
chematic_chem::max_estate(&self.inner)
}
pub fn min_estate(&self) -> f64 {
chematic_chem::min_estate(&self.inner)
}
pub fn num_aliphatic_rings(&self) -> usize {
chematic_chem::num_aliphatic_rings(&self.inner)
}
pub fn num_saturated_rings(&self) -> usize {
chematic_chem::num_saturated_rings(&self.inner)
}
pub fn num_unspecified_stereocenters(&self) -> usize {
chematic_chem::num_unspecified_stereocenters(&self.inner)
}
pub fn to_inchi(&self) -> String {
chematic_inchi::inchi(&self.inner)
}
pub fn to_inchikey(&self) -> String {
let inchi_str = chematic_inchi::inchi(&self.inner);
chematic_inchi::inchi_key(&inchi_str)
}
pub fn logd_at_ph(&self, ph: f64) -> f64 {
chematic_chem::logd_simple(&self.inner, ph)
}
pub fn pka_acid_value(&self) -> f64 {
chematic_chem::pka_acid(&self.inner).unwrap_or(f64::NAN)
}
pub fn pka_base_value(&self) -> f64 {
chematic_chem::pka_base(&self.inner).unwrap_or(f64::NAN)
}
pub fn bbb_score(&self) -> f64 {
chematic_chem::bbb_score(&self.inner)
}
pub fn bbb_passes(&self) -> bool {
chematic_chem::bbb_passes(&self.inner)
}
pub fn caco2_permeability(&self) -> f64 {
chematic_chem::caco2_permeability(&self.inner)
}
pub fn herg_risk_score(&self) -> f64 {
chematic_chem::herg_risk_score(&self.inner)
}
pub fn cyp3a4_inhibition_risk(&self) -> f64 {
chematic_chem::cyp3a4_inhibition_risk(&self.inner)
}
pub fn logd_profile_json(&self, ph_start: f64, ph_end: f64, steps: usize) -> String {
let profile = chematic_chem::logd_profile(&self.inner, ph_start, ph_end, steps);
let items: Vec<String> = profile.iter()
.map(|(ph, ld)| format!(r#"{{"ph":{ph:.2},"logd":{ld:.4}}}"#))
.collect();
format!("[{}]", items.join(","))
}
pub fn isotope_distribution_json(&self, resolution: f64) -> String {
let dist = chematic_chem::isotope_distribution(&self.inner, resolution);
let items: Vec<String> = dist.iter()
.map(|(mass, abund)| format!(r#"{{"mass":{mass:.4},"abundance":{abund:.6}}}"#))
.collect();
format!("[{}]", items.join(","))
}
pub fn randic_index(&self) -> f64 {
chematic_chem::randic_index(&self.inner)
}
pub fn zagreb_index_m1(&self) -> u32 {
chematic_chem::zagreb_index_m1(&self.inner)
}
pub fn iupac_name(&self) -> String {
chematic_iupac::name(&self.inner).unwrap_or_default()
}
pub fn assign_cip_json(&self) -> String {
use chematic_chem::assign_cip;
use chematic_core::CipCode;
let assignment = assign_cip(&self.inner);
let centers: Vec<String> = assignment.assignments.iter()
.map(|(idx, code)| {
let code_str = match code {
CipCode::R => "R",
CipCode::S => "S",
CipCode::E => "E",
CipCode::Z => "Z",
};
format!(r#"{{"atom":{},"code":"{}"}}"#, idx.0, code_str)
})
.collect();
format!(r#"{{"centers":[{}]}}"#, centers.join(","))
}
}
#[wasm_bindgen]
pub fn is_valid_smiles(s: &str) -> bool {
chematic_smiles::parse(s).is_ok()
}
#[wasm_bindgen]
pub struct DepictOptions {
inner: chematic_depict::RenderOptions,
highlight_atoms: Vec<u32>,
highlight_bonds: Vec<u32>,
atom_color_entries: Vec<(u32, String)>,
}
impl Default for DepictOptions {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen]
impl DepictOptions {
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
Self {
inner: chematic_depict::RenderOptions::default(),
highlight_atoms: vec![],
highlight_bonds: vec![],
atom_color_entries: vec![],
}
}
pub fn set_width(&mut self, w: u32) {
self.inner.width = Some(w);
}
pub fn set_height(&mut self, h: u32) {
self.inner.height = Some(h);
}
pub fn set_padding(&mut self, p: f64) {
self.inner.padding = p;
}
pub fn set_background(&mut self, bg: String) {
self.inner.background = bg;
}
pub fn set_dark(&mut self, dark: bool) {
self.inner.dark = dark;
}
pub fn set_highlight_atoms(&mut self, atoms: Vec<u32>) {
self.highlight_atoms = atoms;
}
pub fn set_highlight_bonds(&mut self, bonds: Vec<u32>) {
self.highlight_bonds = bonds;
}
pub fn set_highlight_color(&mut self, color: String) {
self.inner.highlight_color = color;
}
pub fn set_atom_color(&mut self, idx: u32, color: String) {
self.atom_color_entries.push((idx, color));
}
pub fn set_atom_ids(&mut self, v: bool) {
self.inner.atom_ids = v;
}
pub fn set_show_atom_indices(&mut self, v: bool) {
self.inner.show_atom_indices = v;
}
pub fn set_kekulize(&mut self, v: bool) {
self.inner.kekulize = v;
}
pub(crate) fn to_render_options(&self) -> chematic_depict::RenderOptions {
let mut ro = self.inner.clone();
ro.highlight_atoms = self
.highlight_atoms
.iter()
.map(|&i| chematic_core::AtomIdx(i))
.collect();
ro.highlight_bonds = self
.highlight_bonds
.iter()
.map(|&i| chematic_core::BondIdx(i))
.collect();
ro.atom_color_map = self
.atom_color_entries
.iter()
.map(|(i, c)| (chematic_core::AtomIdx(*i), c.clone()))
.collect(); ro
}
}
#[wasm_bindgen]
pub fn parse_smiles(s: &str) -> Result<MolHandle, JsValue> {
enforce_wasm_input_len("smiles", s)?;
let mol = chematic_smiles::parse(s).map_err(|e| JsValue::from_str(&e.to_string()))?;
enforce_wasm_molecule_size(&mol)?;
Ok(MolHandle {
inner: std::rc::Rc::new(mol),
})
}
#[wasm_bindgen]
pub fn parse_cxsmiles_json(s: &str) -> Result<String, JsValue> {
enforce_wasm_input_len("cxsmiles", s)?;
let cx = chematic_smiles::parse_cxsmiles(s).map_err(|e| JsValue::from_str(&e.to_string()))?;
enforce_wasm_molecule_size(&cx.mol)?;
let atom_props = cx
.atom_props
.iter()
.map(|p| {
format!(
r#"{{"atom":{},"key":"{}","value":"{}"}}"#,
p.atom.0,
escape_json_string(&p.key),
escape_json_string(&p.value)
)
})
.collect::<Vec<_>>()
.join(",");
let zero_bonds = cx
.mol
.bonds()
.filter_map(|(bidx, bond)| {
(bond.order == chematic_core::BondOrder::Zero).then_some(bidx.0.to_string())
})
.collect::<Vec<_>>()
.join(",");
Ok(format!(
r#"{{"smiles":"{}","cxsmiles":"{}","atomCount":{},"bondCount":{},"atomLabels":{},"atomProps":[{}],"atomRadicals":{},"zeroBonds":[{}]}}"#,
escape_json_string(&chematic_smiles::write(&cx.mol)),
escape_json_string(&chematic_smiles::write_cxsmiles(&cx)),
cx.mol.atom_count(),
cx.mol.bond_count(),
json_option_string_array(&cx.atom_labels),
atom_props,
json_option_u8_array(&cx.atom_radicals),
zero_bonds
))
}
#[wasm_bindgen]
pub fn normalize_cxsmiles(s: &str) -> Result<String, JsValue> {
let cx = chematic_smiles::parse_cxsmiles(s).map_err(|e| JsValue::from_str(&e.to_string()))?;
if cx.mol.atom_count() > WASM_MAX_ATOMS {
return Err(JsValue::from_str(&format!(
"Molecule exceeds maximum atom count ({} > {})",
cx.mol.atom_count(),
WASM_MAX_ATOMS
)));
}
Ok(chematic_smiles::write_cxsmiles(&cx))
}
#[wasm_bindgen]
pub fn parse_cxsmarts_json(s: &str) -> Result<String, JsValue> {
let cx = chematic_smarts::parse_cxsmarts(s).map_err(|e| JsValue::from_str(&e.to_string()))?;
if cx.query.atom_count() > WASM_MAX_ATOMS {
return Err(JsValue::from_str(&format!(
"Query molecule exceeds maximum atom count ({} > {})",
cx.query.atom_count(),
WASM_MAX_ATOMS
)));
}
let atom_props = cx
.atom_props
.iter()
.map(|p| {
format!(
r#"{{"atom":{},"key":"{}","value":"{}"}}"#,
p.atom,
escape_json_string(&p.key),
escape_json_string(&p.value)
)
})
.collect::<Vec<_>>()
.join(",");
Ok(format!(
r#"{{"atomCount":{},"bondCount":{},"atomLabels":{},"atomProps":[{}],"atomRadicals":{}}}"#,
cx.query.atom_count(),
cx.query.bonds.len(),
json_option_string_array(&cx.atom_labels),
atom_props,
json_option_u8_array(&cx.atom_radicals)
))
}
#[wasm_bindgen]
pub fn tanimoto_ecfp4(a: &MolHandle, b: &MolHandle) -> f64 {
chematic_fp::tanimoto_ecfp4(&a.inner, &b.inner)
}
#[wasm_bindgen]
pub fn tanimoto_fcfp4(a: &MolHandle, b: &MolHandle) -> f64 {
chematic_fp::tanimoto_fcfp4(&a.inner, &b.inner)
}
#[wasm_bindgen]
pub fn tanimoto_atom_pair(a: &MolHandle, b: &MolHandle) -> f64 {
let fa = chematic_fp::atom_pair_fp(&a.inner);
let fb = chematic_fp::atom_pair_fp(&b.inner);
fa.tanimoto(&fb)
}
#[wasm_bindgen]
pub fn tanimoto_torsion(a: &MolHandle, b: &MolHandle) -> f64 {
let fa = chematic_fp::torsion_fp(&a.inner);
let fb = chematic_fp::torsion_fp(&b.inner);
fa.tanimoto(&fb)
}
#[wasm_bindgen]
pub fn brics_fragment_count(mol: &MolHandle) -> usize {
chematic_chem::brics_fragments(&mol.inner).len()
}
#[wasm_bindgen]
pub fn run_md_json(mol: &MolHandle, steps: usize, temp_k: f64) -> String {
if mol.inner.atom_count() > WASM_MAX_ATOMS {
return format!(r#"{{"error":"molecule too large (max {} atoms)"}}"#, WASM_MAX_ATOMS);
}
const MIN_TEMP_K: f64 = 1.0;
const MAX_TEMP_K: f64 = 100_000.0;
if !temp_k.is_finite() || !(MIN_TEMP_K..=MAX_TEMP_K).contains(&temp_k) {
return format!(r#"{{"error":"temperature must be between {} and {} K"}}"#, MIN_TEMP_K, MAX_TEMP_K);
}
const MAX_MD_STEPS: usize = 10_000;
let steps = steps.min(MAX_MD_STEPS);
let coords = chematic_3d::generate_coords(&mol.inner);
let config = chematic_3d::MDConfig {
timestep_fs: 1.0,
steps,
temperature_k: temp_k,
thermostat: chematic_3d::Thermostat::Berendsen { tau_fs: 100.0 },
save_every: (steps / 20).max(1),
coulomb: true,
};
let traj = chematic_3d::run_md(&mol.inner, coords, &config);
let mut frames_json = Vec::new();
for frame in &traj.frames {
frames_json.push(format!(
r#"{{"step": {}, "potential": {:.4}, "kinetic": {:.4}, "temp": {:.2}}}"#,
frame.step, frame.potential_energy, frame.kinetic_energy, frame.temperature_k
));
}
format!(r#"{{"frames": [{}]}}"#, frames_json.join(", "))
}
#[wasm_bindgen]
pub fn coulomb_energy_json(mol: &MolHandle) -> String {
let coords = chematic_3d::generate_coords(&mol.inner);
let charges = chematic_chem::gasteiger_charges(&mol.inner);
let energy = chematic_ewald::direct_coulomb(
&(0..coords.atom_count())
.map(|i| {
let p = coords.get(chematic_core::AtomIdx(i as u32));
[p.x, p.y, p.z]
})
.collect::<Vec<_>>(),
&charges,
);
format!(r#"{{"coulomb_energy": {:.4}, "unit": "kcal/mol"}}"#, energy)
}
#[wasm_bindgen]
pub fn minimize_dreiding_json(mol: &MolHandle) -> String {
let coords = chematic_3d::generate_coords(&mol.inner);
let min_coords = chematic_3d::minimize_dreiding(&mol.inner, coords);
chematic_3d::write_pdb(&mol.inner, &min_coords)
}
#[wasm_bindgen]
pub fn add_hydrogens(mol: &MolHandle) -> MolHandle {
MolHandle {
inner: std::rc::Rc::new(chematic_chem::add_hydrogens(&mol.inner)),
}
}
#[wasm_bindgen]
pub fn remove_hydrogens(mol: &MolHandle) -> MolHandle {
MolHandle {
inner: std::rc::Rc::new(chematic_chem::remove_hydrogens(&mol.inner)),
}
}
#[wasm_bindgen]
pub fn depict_svg_grid(smiles_block: &str, cols: usize) -> String {
let mols: Vec<chematic_core::Molecule> = smiles_block
.lines()
.filter(|s| !s.trim().is_empty())
.filter_map(|s| chematic_smiles::parse(s.trim()).ok())
.collect();
let refs: Vec<&chematic_core::Molecule> = mols.iter().collect();
chematic_depict::depict_svg_grid(&refs, cols)
}
#[wasm_bindgen]
pub fn run_reactants(smirks: &str, reactants_smiles: &str) -> Result<String, JsValue> {
let reactant_mols: Result<Vec<chematic_core::Molecule>, _> = reactants_smiles
.split('|')
.map(|s| chematic_smiles::parse(s.trim()).map_err(|e| JsValue::from_str(&e.to_string())))
.collect();
let reactant_mols = reactant_mols?;
let refs: Vec<&chematic_core::Molecule> = reactant_mols.iter().collect();
let products = chematic_rxn::run_reactants(smirks, &refs)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let outer: Vec<String> = products
.iter()
.map(|set| {
let inner: Vec<String> = set
.iter()
.map(|mol| format!("\"{}\"", chematic_smiles::canonical_smiles(mol)))
.collect();
format!("[{}]", inner.join(", "))
})
.collect();
Ok(format!("[{}]", outer.join(", ")))
}
#[wasm_bindgen]
pub fn enumerate_library_2way(
template: &str,
scaffolds_smiles: &str,
building_blocks_smiles: &str,
) -> Result<String, JsValue> {
let scaffolds: Result<Vec<chematic_core::Molecule>, _> = scaffolds_smiles
.split('|')
.map(|s| chematic_smiles::parse(s.trim()).map_err(|e| JsValue::from_str(&e.to_string())))
.collect();
let scaffolds = scaffolds?;
let building_blocks: Result<Vec<chematic_core::Molecule>, _> = building_blocks_smiles
.split('|')
.map(|s| chematic_smiles::parse(s.trim()).map_err(|e| JsValue::from_str(&e.to_string())))
.collect();
let building_blocks = building_blocks?;
let config = chematic_rxn::LibraryConfig {
skip_failures: true,
max_size: Some(WASM_MAX_BATCH_ITEMS), };
let products = chematic_rxn::enumerate_library_2way(template, scaffolds, building_blocks, &config)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let smiles_list: Vec<String> = products
.iter()
.map(|mol| format!("\"{}\"", chematic_smiles::canonical_smiles(mol)))
.collect();
Ok(format!("[{}]", smiles_list.join(", ")))
}
#[wasm_bindgen]
pub fn smarts_match_atoms(smarts: &str, mol: &MolHandle) -> Result<String, JsValue> {
let query =
chematic_smarts::parse_smarts(smarts).map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
const WASM_MAX_SMARTS_VISITS: u64 = 1_000_000;
let config = chematic_smarts::MatchConfig {
max_matches: Some(WASM_MAX_SMARTS_MATCHES), use_chirality: false, use_isotopes: false, uniquify: true, max_visit_budget: Some(WASM_MAX_SMARTS_VISITS),
};
let matches = chematic_smarts::find_matches_with_config(&query, &mol.inner, &config);
let parts: Vec<String> = matches
.into_iter()
.map(|m| {
let mut idxs: Vec<u32> = m.values().map(|a| a.0).collect();
idxs.sort_unstable();
format!(
"[{}]",
idxs.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",")
)
})
.collect();
Ok(format!("[{}]", parts.join(",")))
}
#[wasm_bindgen]
pub fn smarts_match_atoms_with_chirality(
smarts: &str,
mol: &MolHandle,
use_chirality: bool,
) -> Result<String, JsValue> {
let query =
chematic_smarts::parse_smarts(smarts).map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
const WASM_MAX_SMARTS_VISITS: u64 = 1_000_000;
let config = chematic_smarts::MatchConfig {
max_matches: Some(WASM_MAX_SMARTS_MATCHES),
use_chirality,
use_isotopes: false,
uniquify: true,
max_visit_budget: Some(WASM_MAX_SMARTS_VISITS),
};
let matches = chematic_smarts::find_matches_with_config(&query, &mol.inner, &config);
let parts: Vec<String> = matches
.into_iter()
.map(|m| {
let mut idxs: Vec<u32> = m.values().map(|a| a.0).collect();
idxs.sort_unstable();
format!(
"[{}]",
idxs.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",")
)
})
.collect();
Ok(format!("[{}]", parts.join(",")))
}
#[wasm_bindgen]
pub fn generate_3d_pdb(mol: &MolHandle) -> String {
let coords = chematic_3d::generate_coords(&mol.inner);
chematic_3d::write_pdb(&mol.inner, &coords)
}
#[wasm_bindgen]
pub fn generate_3d_minimized_pdb(mol: &MolHandle) -> String {
let coords = chematic_3d::generate_coords(&mol.inner);
let minimized = chematic_3d::minimize(&mol.inner, coords);
chematic_3d::write_pdb(&mol.inner, &minimized)
}
#[wasm_bindgen]
pub fn generate_3d_etkdg_pdb(mol: &MolHandle) -> String {
let coords = chematic_3d::generate_coords_etkdg(&mol.inner);
chematic_3d::write_pdb(&mol.inner, &coords)
}
#[wasm_bindgen]
pub fn generate_3d_etkdg_minimized_pdb(mol: &MolHandle) -> String {
let coords = chematic_3d::generate_coords_etkdg(&mol.inner);
let minimized = chematic_3d::minimize(&mol.inner, coords);
chematic_3d::write_pdb(&mol.inner, &minimized)
}
#[wasm_bindgen]
pub fn whim_descriptors_json(mol: &MolHandle) -> String {
let coords = chematic_3d::generate_coords(&mol.inner);
let desc = chematic_3d::whim_descriptors(&mol.inner, &coords);
let parts: Vec<String> = desc.iter().map(|&v| v.to_string()).collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn getaway_descriptors_json(mol: &MolHandle) -> String {
let coords = chematic_3d::generate_coords(&mol.inner);
let desc = chematic_3d::getaway_descriptors(&mol.inner, &coords);
let parts: Vec<String> = desc.iter().map(|&v| v.to_string()).collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn whim_getaway_combined_json(mol: &MolHandle) -> String {
let coords = chematic_3d::generate_coords(&mol.inner);
let desc = chematic_3d::whim_getaway_combined(&mol.inner, &coords);
let parts: Vec<String> = desc.iter().map(|&v| v.to_string()).collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn ecfp4_bitvec(mol: &MolHandle) -> Vec<u8> {
let fp = chematic_fp::ecfp4(&mol.inner);
(0..256usize)
.map(|byte_idx| {
let mut byte = 0u8;
for bit in 0..8usize {
if fp.get(byte_idx * 8 + bit) {
byte |= 1 << bit;
}
}
byte
})
.collect()
}
#[wasm_bindgen]
pub fn ecfp4_bitvec_with_chirality(mol: &MolHandle, use_chirality: bool) -> Vec<u8> {
let config = chematic_fp::EcfpConfig {
radius: 2,
nbits: 2048,
use_chirality,
use_double_fold: false,
};
let fp = chematic_fp::ecfp(&mol.inner, &config);
(0..256usize)
.map(|byte_idx| {
let mut byte = 0u8;
for bit in 0..8usize {
if fp.get(byte_idx * 8 + bit) {
byte |= 1 << bit;
}
}
byte
})
.collect()
}
#[wasm_bindgen]
pub fn ecfp6_bitvec_with_chirality(mol: &MolHandle, use_chirality: bool) -> Vec<u8> {
let config = chematic_fp::EcfpConfig {
radius: 3,
nbits: 2048,
use_chirality,
use_double_fold: false,
};
let fp = chematic_fp::ecfp(&mol.inner, &config);
(0..256usize)
.map(|byte_idx| {
let mut byte = 0u8;
for bit in 0..8usize {
if fp.get(byte_idx * 8 + bit) {
byte |= 1 << bit;
}
}
byte
})
.collect()
}
#[wasm_bindgen]
pub fn depict_reaction_svg(rxn_smiles: &str) -> Result<String, JsValue> {
let rxn =
chematic_rxn::parse_reaction(rxn_smiles).map_err(|e| JsValue::from_str(&e.to_string()))?;
const MOL_W: u32 = 200;
const MOL_H: u32 = 180;
const SEP_PLUS: u32 = 40;
const SEP_ARROW: u32 = 60;
const TOP_PAD: u32 = 20;
let opts = chematic_depict::RenderOptions {
width: Some(MOL_W),
height: Some(MOL_H),
..Default::default()
};
let mut frags: Vec<(u32, String)> = Vec::new();
let mut seps: Vec<(u32, &'static str)> = Vec::new();
let mut cursor: u32 = 0;
for (i, mol) in rxn.reactants.iter().enumerate() {
if i > 0 {
seps.push((cursor + SEP_PLUS / 2, "+"));
cursor += SEP_PLUS;
}
frags.push((cursor, chematic_depict::depict_svg_opts(mol, &opts)));
cursor += MOL_W;
}
seps.push((cursor + SEP_ARROW / 2, "→"));
cursor += SEP_ARROW;
for (i, mol) in rxn.products.iter().enumerate() {
if i > 0 {
seps.push((cursor + SEP_PLUS / 2, "+"));
cursor += SEP_PLUS;
}
frags.push((cursor, chematic_depict::depict_svg_opts(mol, &opts)));
cursor += MOL_W;
}
let total_w = cursor;
let total_h = MOL_H + TOP_PAD;
let mid_y = MOL_H / 2 + TOP_PAD;
let mut out = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{total_w}" height="{total_h}" viewBox="0 0 {total_w} {total_h}">"#
);
for (x, svg) in &frags {
out.push_str(&svg.replacen("<svg ", &format!(r#"<svg x="{x}" y="{TOP_PAD}" "#), 1));
}
for (cx, sym) in &seps {
out.push_str(&format!(
r##"<text x="{cx}" y="{mid_y}" text-anchor="middle" dominant-baseline="central" font-size="20" font-family="sans-serif" fill="#555">{sym}</text>"##
));
}
out.push_str("</svg>");
Ok(out)
}
#[wasm_bindgen]
pub fn mol_from_v3000_block(block: &str) -> Result<MolHandle, JsValue> {
if block.len() > WASM_MAX_INPUT_BYTES {
return Err(JsValue::from_str("V3000 block too large"));
}
let (mol, _meta) =
chematic_mol::parse_mol_v3000(block).map_err(|e| JsValue::from_str(&e.to_string()))?;
if mol.atom_count() > WASM_MAX_ATOMS {
return Err(JsValue::from_str(&format!("molecule too large (max {} atoms)", WASM_MAX_ATOMS)));
}
Ok(MolHandle {
inner: std::rc::Rc::new(mol),
})
}
#[wasm_bindgen]
pub fn mol_from_sdf_block(block: &str) -> Result<MolHandle, JsValue> {
if block.len() > WASM_MAX_INPUT_BYTES {
return Err(JsValue::from_str("MOL block too large"));
}
let (mol, _meta) =
chematic_mol::parse_mol(block).map_err(|e| JsValue::from_str(&e.to_string()))?;
if mol.atom_count() > WASM_MAX_ATOMS {
return Err(JsValue::from_str(&format!("molecule too large (max {} atoms)", WASM_MAX_ATOMS)));
}
Ok(MolHandle {
inner: std::rc::Rc::new(mol),
})
}
#[wasm_bindgen]
pub fn to_mol_block(mol: &MolHandle) -> String {
let meta = chematic_mol::MolMetadata::default();
let layout = chematic_depict::compute_layout(&mol.inner);
const SCALE: f64 = 1.5 / 40.0;
let coords: Vec<(f64, f64)> = (0..mol.inner.atom_count())
.map(|i| {
let p = layout.get(chematic_core::AtomIdx(i as u32));
(p.x * SCALE, -p.y * SCALE)
})
.collect();
chematic_mol::write_mol_with_coords(&mol.inner, &meta, &coords)
}
#[wasm_bindgen]
pub fn sdf_to_smiles_json(sdf: &str) -> String {
if sdf.len() > WASM_MAX_INPUT_BYTES {
return format!(r#"[{{"error":"SDF input too large ({} bytes)"}}]"#, sdf.len());
}
let entries: Vec<String> = chematic_mol::SdfReader::new(sdf)
.take(WASM_MAX_BATCH_ITEMS)
.map(|r| match r {
Ok((mol, _)) => {
let smi = chematic_smiles::canonical_smiles(&mol);
format!("\"{}\"", smi.replace('"', "\\\""))
}
Err(_) => "null".to_string(),
})
.collect();
format!("[{}]", entries.join(","))
}
#[wasm_bindgen]
pub fn estate_indices_json(mol: &MolHandle) -> String {
let vals = chematic_chem::estate_indices(&mol.inner);
let parts: Vec<String> = vals.iter().map(|v| format!("{v:.4}")).collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn tanimoto_topo_path(a: &MolHandle, b: &MolHandle) -> f64 {
chematic_fp::tanimoto_topo_path(&a.inner, &b.inner)
}
#[wasm_bindgen]
pub fn identify_functional_groups(mol: &MolHandle) -> String {
let groups = chematic_chem::identify_functional_groups(&mol.inner);
let parts: Vec<String> = groups
.iter()
.map(|g| {
let atoms: Vec<String> = g.atom_indices.iter().map(|i| i.to_string()).collect();
format!(
"{{\"atoms\":[{}],\"type\":\"{}\"}}",
atoms.join(","),
g.atom_types
)
})
.collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn pharmacophore_features_json(mol: &MolHandle) -> String {
let features = chematic_perception::detect_features(&mol.inner);
let parts: Vec<String> = features
.iter()
.map(|f| {
let ftype = match f.ftype {
chematic_perception::FeatureType::Donor => "Donor",
chematic_perception::FeatureType::Acceptor => "Acceptor",
chematic_perception::FeatureType::Aromatic => "Aromatic",
chematic_perception::FeatureType::Hydrophobic => "Hydrophobic",
chematic_perception::FeatureType::Positive => "Positive",
chematic_perception::FeatureType::Negative => "Negative",
};
format!(
"{{\"type\":\"{}\",\"atom\":{},\"neighbors\":{}}}",
ftype,
f.atom.0,
f.neighbors.len()
)
})
.collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn gasteiger_charges_json(mol: &MolHandle) -> String {
let q = chematic_chem::gasteiger_charges(&mol.inner);
let parts: Vec<String> = q.iter().map(|v| format!("{v:.4}")).collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn mmff94_charges_json(mol: &MolHandle) -> String {
let q = chematic_chem::mmff94_charges(&mol.inner);
let parts: Vec<String> = q.iter().map(|v| format!("{v:.6}")).collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn mhfp_hashes_json(mol: &MolHandle) -> String {
if mol.inner.atom_count() > WASM_MAX_ATOMS {
return format!(r#"{{"error":"molecule too large (max {} atoms)"}}"#, WASM_MAX_ATOMS);
}
let fp = chematic_fp::mhfp(&mol.inner);
let hs: Vec<String> = fp.hashes.iter().map(|h| h.to_string()).collect();
format!(r#"{{"num_hashes":{},"hashes":[{}]}}"#, fp.num_hashes, hs.join(","))
}
#[wasm_bindgen]
pub fn tanimoto_mhfp_smiles(smi1: &str, smi2: &str) -> Result<f64, JsValue> {
let m1 = chematic_smiles::parse(smi1)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let m2 = chematic_smiles::parse(smi2)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
if m1.atom_count() > WASM_MAX_ATOMS || m2.atom_count() > WASM_MAX_ATOMS {
return Err(JsValue::from_str(&format!("molecule too large (max {} atoms)", WASM_MAX_ATOMS)));
}
Ok(chematic_fp::tanimoto_mhfp(&m1, &m2))
}
#[wasm_bindgen]
pub fn erg_vec_json(mol: &MolHandle) -> String {
if mol.inner.atom_count() > WASM_MAX_ATOMS {
return format!(r#"{{"error":"molecule too large (max {} atoms)"}}"#, WASM_MAX_ATOMS);
}
let v = chematic_fp::erg_vec(&mol.inner);
let vals: Vec<String> = v.iter().map(|x| format!("{x:.6}")).collect();
format!(r#"{{"len":{},"values":[{}]}}"#, chematic_fp::ERG_VEC_LEN, vals.join(","))
}
#[wasm_bindgen]
pub fn mmff94_charges_typed_json(mol: &MolHandle) -> String {
if mol.inner.atom_count() > WASM_MAX_ATOMS {
return format!(r#"{{"error":"molecule too large (max {} atoms)"}}"#, WASM_MAX_ATOMS);
}
let q = chematic_chem::mmff94_charges_typed(&mol.inner);
let vals: Vec<String> = q.iter().map(|x| format!("{x:.6}")).collect();
format!(r#"{{"charges":[{}]}}"#, vals.join(","))
}
#[wasm_bindgen]
pub fn minimize_mmff94_json(mol: &MolHandle, max_iter: u32) -> String {
if mol.inner.atom_count() > WASM_MAX_ATOMS {
return format!(r#"{{"error":"molecule too large (max {} atoms)"}}"#, WASM_MAX_ATOMS);
}
let conf = chematic_3d::generate_coords(&mol.inner);
let n = mol.inner.atom_count();
let mut coords: Vec<[f64; 3]> = (0..n)
.map(|i| { let p = conf.get(chematic_core::AtomIdx(i as u32)); [p.x, p.y, p.z] })
.collect();
match chematic_ff::minimize_mmff94_full(&mol.inner, &mut coords, max_iter as usize) {
Ok(r) => format!(
r#"{{"energy":{:.4},"rmsd":{:.4},"converged":{},"iterations":{}}}"#,
r.energy, r.rmsd, r.converged, r.iterations
),
Err(e) => format!(r#"{{"error":"{}"}}"#, e),
}
}
#[wasm_bindgen]
pub fn minimize_mmff94_lbfgs_json(mol: &MolHandle, max_iter: u32) -> String {
if mol.inner.atom_count() > WASM_MAX_ATOMS {
return format!(r#"{{"error":"molecule too large (max {} atoms)"}}"#, WASM_MAX_ATOMS);
}
let conf = chematic_3d::generate_coords(&mol.inner);
let n = mol.inner.atom_count();
let mut coords: Vec<[f64; 3]> = (0..n)
.map(|i| { let p = conf.get(chematic_core::AtomIdx(i as u32)); [p.x, p.y, p.z] })
.collect();
match chematic_ff::minimize_mmff94_lbfgs(&mol.inner, &mut coords, max_iter as usize) {
Ok(r) => format!(
r#"{{"energy":{:.4},"rmsd":{:.4},"converged":{},"iterations":{}}}"#,
r.energy, r.rmsd, r.converged, r.iterations
),
Err(e) => format!(r#"{{"error":"{}"}}"#, e),
}
}
#[wasm_bindgen]
pub fn mmff94_energy_breakdown_json(mol: &MolHandle) -> String {
if mol.inner.atom_count() > WASM_MAX_ATOMS {
return format!(r#"{{"error":"molecule too large (max {} atoms)"}}"#, WASM_MAX_ATOMS);
}
let conf = chematic_3d::generate_coords(&mol.inner);
let n = mol.inner.atom_count();
let coords: Vec<[f64; 3]> = (0..n)
.map(|i| { let p = conf.get(chematic_core::AtomIdx(i as u32)); [p.x, p.y, p.z] })
.collect();
match chematic_ff::mmff94_energy_breakdown(&mol.inner, &coords) {
Ok(bd) => format!(
r#"{{"bond":{:.4},"angle":{:.4},"torsion":{:.4},"vdw":{:.4},"elec":{:.4},"total":{:.4}}}"#,
bd.bond, bd.angle, bd.torsion, bd.vdw, bd.electrostatic, bd.total
),
Err(e) => format!(r#"{{"error":"{}"}}"#, e),
}
}
#[wasm_bindgen]
pub fn torsion_scan_json(mol: &MolHandle, i: u32, j: u32, k: u32, l: u32, steps: u32) -> String {
if mol.inner.atom_count() > WASM_MAX_ATOMS {
return format!(r#"{{"error":"molecule too large (max {} atoms)"}}"#, WASM_MAX_ATOMS);
}
let n = mol.inner.atom_count();
if i as usize >= n || j as usize >= n || k as usize >= n || l as usize >= n {
return r#"{"error":"atom index out of range"}"#.to_string();
}
let conf = chematic_3d::generate_coords(&mol.inner);
let coords: Vec<[f64; 3]> = (0..n)
.map(|idx| { let p = conf.get(chematic_core::AtomIdx(idx as u32)); [p.x, p.y, p.z] })
.collect();
let steps = (steps as usize).clamp(4, 360);
match chematic_ff::mmff94_torsion_scan(
&mol.inner, &coords,
i as usize, j as usize, k as usize, l as usize,
steps,
) {
Ok(pts) => {
let entries: Vec<String> = pts.iter()
.map(|(a, e)| format!(r#"{{"angle":{:.2},"energy":{:.4}}}"#, a, e))
.collect();
format!("[{}]", entries.join(","))
}
Err(e) => format!(r#"{{"error":"{}"}}"#, e),
}
}
#[wasm_bindgen]
pub fn mmff94_partial_charges_json(mol: &MolHandle) -> String {
if mol.inner.atom_count() > WASM_MAX_ATOMS {
return format!(r#"{{"error":"molecule too large (max {} atoms)"}}"#, WASM_MAX_ATOMS);
}
match chematic_ff::mmff94_charges_numeric(&mol.inner) {
Ok(q) => {
let vals: Vec<String> = q.iter().map(|x| format!("{x:.4}")).collect();
format!(r#"{{"charges":[{}]}}"#, vals.join(","))
}
Err(e) => format!(r#"{{"error":"{}"}}"#, e),
}
}
#[wasm_bindgen]
pub fn pharmacophore_fp_2d_summary(mol: &MolHandle) -> String {
let counts = chematic_fp::pharmacophore_feature_counts(&mol.inner);
format!(
"{{\"Donor\":{},\"Acceptor\":{},\"Aromatic\":{},\"Hydrophobic\":{},\"Positive\":{},\"Negative\":{}}}",
counts[0], counts[1], counts[2], counts[3], counts[4], counts[5]
)
}
#[wasm_bindgen]
pub fn pharmacophore_fp_3d_summary(mol: &MolHandle) -> String {
let _coords = chematic_3d::generate_coords(&mol.inner);
let features = chematic_perception::detect_features(&mol.inner);
let mut counts = [0; 6];
for feat in features {
let idx = match feat.ftype {
chematic_perception::FeatureType::Donor => 0,
chematic_perception::FeatureType::Acceptor => 1,
chematic_perception::FeatureType::Aromatic => 2,
chematic_perception::FeatureType::Hydrophobic => 3,
chematic_perception::FeatureType::Positive => 4,
chematic_perception::FeatureType::Negative => 5,
};
counts[idx] += 1;
}
format!(
"{{\"Donor\":{},\"Acceptor\":{},\"Aromatic\":{},\"Hydrophobic\":{},\"Positive\":{},\"Negative\":{}}}",
counts[0], counts[1], counts[2], counts[3], counts[4], counts[5]
)
}
#[wasm_bindgen]
pub fn mqn_json(mol: &MolHandle) -> String {
let desc = chematic_chem::mqn(&mol.inner);
let values: Vec<String> = desc.iter().map(|&v| v.to_string()).collect();
format!("[{}]", values.join(","))
}
#[wasm_bindgen]
pub fn autocorr_2d_json(mol: &MolHandle) -> String {
let ac = chematic_chem::autocorr_2d(&mol.inner);
format!(
"[{:.4},{:.4},{:.4},{:.4},{:.4},{:.4},{:.4}]",
ac[0], ac[1], ac[2], ac[3], ac[4], ac[5], ac[6]
)
}
#[wasm_bindgen]
pub fn autocorr_3d_json(mol: &MolHandle) -> String {
let coords = chematic_3d::generate_coords(&mol.inner);
let ac = chematic_3d::autocorr_3d(&mol.inner, &coords);
format!(
"[{:.4},{:.4},{:.4},{:.4},{:.4},{:.4},{:.4},{:.4}]",
ac[0], ac[1], ac[2], ac[3], ac[4], ac[5], ac[6], ac[7]
)
}
#[wasm_bindgen]
pub fn sa_score(mol: &MolHandle) -> f64 {
chematic_chem::sa_score(&mol.inner)
}
#[wasm_bindgen]
pub fn slogp_vsa_json(mol: &MolHandle) -> String {
let v = chematic_chem::slogp_vsa(&mol.inner);
let parts: Vec<String> = v.iter().map(|x| format!("{x:.4}")).collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn smr_vsa_json(mol: &MolHandle) -> String {
let v = chematic_chem::smr_vsa(&mol.inner);
let parts: Vec<String> = v.iter().map(|x| format!("{x:.4}")).collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn peoe_vsa_json(mol: &MolHandle) -> String {
let v = chematic_chem::peoe_vsa(&mol.inner);
let parts: Vec<String> = v.iter().map(|x| format!("{x:.4}")).collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn detect_functional_groups(mol: &MolHandle) -> String {
let groups = chematic_chem::detect_named_functional_groups(&mol.inner);
let parts: Vec<String> = groups
.iter()
.map(|g| {
let atoms: Vec<String> = g.atoms.iter().map(|a| a.0.to_string()).collect();
format!(
"{{\"name\":\"{}\",\"atoms\":[{}]}}",
g.name,
atoms.join(",")
)
})
.collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn get_atom_info(mol: &MolHandle, idx: u32) -> String {
use chematic_core::{AtomIdx, BondOrder, implicit_hcount};
let mol = &*mol.inner;
if idx as usize >= mol.atom_count() {
return "null".to_string();
}
let atom_idx = AtomIdx(idx);
let atom = mol.atom(atom_idx);
let symbol = atom.element.symbol();
let charge = atom.charge;
let is_aromatic = atom.aromatic;
let total_h = atom.hydrogen_count.unwrap_or(0) as u32 + implicit_hcount(mol, atom_idx) as u32;
let hybridization = if is_aromatic {
Some("sp2")
} else {
let mut double_count = 0u32;
let mut has_triple = false;
for (_, bond_idx) in mol.neighbors(atom_idx) {
match mol.bond(bond_idx).order {
BondOrder::Double => double_count += 1,
BondOrder::Triple => {
has_triple = true;
break;
}
_ => {}
}
}
if has_triple || double_count >= 2 {
Some("sp")
} else if double_count == 1 {
Some("sp2")
} else {
Some("sp3")
}
};
let hyb_json = match hybridization {
Some(h) => format!("\"{}\"", h),
None => "null".to_string(),
};
format!(
"{{\"element\":\"{}\",\"hybridization\":{},\"charge\":{},\"isAromatic\":{},\"totalHydrogens\":{}}}",
symbol, hyb_json, charge, is_aromatic, total_h
)
}
#[wasm_bindgen]
pub fn smiles_to_svg_highlighted(
smiles: &str,
atoms: Vec<u32>,
bonds: Vec<u32>,
color: &str,
) -> Result<String, JsValue> {
let mol = chematic_smiles::parse(smiles)
.map(|m| MolHandle {
inner: std::rc::Rc::new(m),
})
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let mut opts = DepictOptions::new();
opts.set_highlight_atoms(atoms);
opts.set_highlight_bonds(bonds);
if !color.is_empty() {
opts.set_highlight_color(color.to_string());
}
Ok(mol.depict_svg_opts(&opts))
}
#[wasm_bindgen]
pub fn match_smarts_smiles(smiles: &str, smarts: &str) -> Result<String, JsValue> {
let mol = chematic_smiles::parse(smiles)
.map(|m| MolHandle {
inner: std::rc::Rc::new(m),
})
.map_err(|e| JsValue::from_str(&e.to_string()))?;
smarts_match_atoms(smarts, &mol)
}
#[wasm_bindgen]
pub fn tanimoto_smiles(smiles1: &str, smiles2: &str) -> Result<f64, JsValue> {
let m1 = chematic_smiles::parse(smiles1).map_err(|e| JsValue::from_str(&e.to_string()))?;
let m2 = chematic_smiles::parse(smiles2).map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(chematic_fp::tanimoto_ecfp4(&m1, &m2))
}
#[wasm_bindgen]
pub fn mol_block_from_smiles(smiles: &str) -> Result<String, JsValue> {
let mol = chematic_smiles::parse(smiles)
.map(|m| MolHandle {
inner: std::rc::Rc::new(m),
})
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(to_mol_block(&mol))
}
#[wasm_bindgen]
pub fn get_bond_info(mol: &MolHandle, idx: u32) -> String {
use chematic_core::BondOrder;
let mol_ref = &*mol.inner;
if idx as usize >= mol_ref.bond_count() {
return "null".to_string();
}
let bond_idx = chematic_core::BondIdx(idx);
let bond = mol_ref.bond(bond_idx);
let bond_order: f64 = match bond.order {
BondOrder::Aromatic => 1.5,
other => other.order_value().unwrap_or(1.0) as f64,
};
let is_aromatic = bond.order == BondOrder::Aromatic;
let in_ring = bond_in_ring(mol_ref, bond.atom1, bond.atom2);
format!(
"{{\"bondOrder\":{:.1},\"isAromatic\":{},\"isInRing\":{},\"atomFrom\":{},\"atomTo\":{}}}",
bond_order, is_aromatic, in_ring, bond.atom1.0, bond.atom2.0
)
}
#[wasm_bindgen]
pub fn get_bond_between(mol: &MolHandle, atom1: u32, atom2: u32) -> String {
use chematic_core::{AtomIdx, BondOrder};
let mol_ref = &*mol.inner;
let a = AtomIdx(atom1);
let b = AtomIdx(atom2);
if atom1 as usize >= mol_ref.atom_count() || atom2 as usize >= mol_ref.atom_count() {
return "null".to_string();
}
let Some((bond_idx, bond)) = mol_ref.bond_between(a, b) else {
return "null".to_string();
};
let bond_order: f64 = match bond.order {
BondOrder::Aromatic => 1.5,
other => other.order_value().unwrap_or(1.0) as f64,
};
let is_aromatic = bond.order == BondOrder::Aromatic;
let in_ring = bond_in_ring(mol_ref, a, b);
format!(
"{{\"bondIdx\":{},\"bondOrder\":{:.1},\"isAromatic\":{},\"isInRing\":{},\"atomFrom\":{},\"atomTo\":{}}}",
bond_idx.0, bond_order, is_aromatic, in_ring, atom1, atom2
)
}
fn bond_in_ring(
mol: &chematic_core::Molecule,
a: chematic_core::AtomIdx,
b: chematic_core::AtomIdx,
) -> bool {
let rings = chematic_perception::find_sssr(mol);
for ring in rings.rings() {
let n = ring.len();
for i in 0..n {
if (ring[i] == a && ring[(i + 1) % n] == b) || (ring[i] == b && ring[(i + 1) % n] == a)
{
return true;
}
}
}
false
}
fn molecular_formula(mol: &chematic_core::Molecule) -> String {
use chematic_core::{Element, implicit_hcount};
use std::collections::BTreeMap;
let mut counts: BTreeMap<u8, u32> = BTreeMap::new();
for (idx, atom) in mol.atoms() {
let an = atom.element.atomic_number();
if an != 1 {
*counts.entry(an).or_insert(0) += 1;
let h = implicit_hcount(mol, idx) as u32;
if h > 0 {
*counts.entry(1).or_insert(0) += h;
}
} else {
*counts.entry(1).or_insert(0) += 1;
}
}
let mut result = String::new();
let append = |symbol: &str, count: u32, out: &mut String| {
out.push_str(symbol);
if count > 1 {
out.push_str(&count.to_string());
}
};
if let Some(&c_count) = counts.get(&6) {
append("C", c_count, &mut result);
}
if let Some(&h_count) = counts.get(&1) {
append("H", h_count, &mut result);
}
for (&an, &count) in &counts {
if an == 1 || an == 6 {
continue;
}
let elem = Element::from_atomic_number(an).unwrap();
append(elem.symbol(), count, &mut result);
}
result
}
#[wasm_bindgen]
pub fn murcko_scaffold(mol: &MolHandle) -> MolHandle {
let scaffold = chematic_chem::murcko_scaffold(&mol.inner);
MolHandle {
inner: std::rc::Rc::new(scaffold),
}
}
#[wasm_bindgen]
pub fn generic_murcko_scaffold(mol: &MolHandle) -> MolHandle {
let scaffold = chematic_chem::generic_murcko_scaffold(&mol.inner);
MolHandle {
inner: std::rc::Rc::new(scaffold),
}
}
#[wasm_bindgen]
pub fn canonical_tautomer(mol: &MolHandle) -> MolHandle {
let t = chematic_chem::canonical_tautomer(&mol.inner);
MolHandle {
inner: std::rc::Rc::new(t),
}
}
#[wasm_bindgen]
pub fn canonical_tautomer_with_blocked_atoms_json(
mol: &MolHandle,
blocked_atom_indices_json: &str,
) -> String {
if blocked_atom_indices_json.len() > WASM_MAX_JSON_STRING_BYTES {
return format!(r#"{{"error":"blocked_atom_indices_json too large ({} bytes)"}}"#, blocked_atom_indices_json.len());
}
if mol.inner.atom_count() > WASM_MAX_ATOMS {
return format!(r#"{{"error":"molecule too large (max {} atoms)"}}"#, WASM_MAX_ATOMS);
}
let indices: Vec<u32> = match serde_json::from_str(blocked_atom_indices_json) {
Ok(v) => v,
Err(e) => return format!(r#"{{"error":"invalid JSON: {e}"}}"#),
};
let blocked_atoms: std::collections::HashSet<chematic_core::AtomIdx> =
indices.into_iter().map(chematic_core::AtomIdx).collect();
let config = chematic_chem::TautomerConfig {
blocked_atoms,
..chematic_chem::TautomerConfig::default()
};
let result = chematic_chem::canonical_tautomer_with_config(&mol.inner, &config);
let smi = chematic_smiles::canonical_smiles(&result);
let escaped = smi.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
#[wasm_bindgen]
pub fn enumerate_tautomers_json(mol: &MolHandle) -> String {
if mol.inner.atom_count() > WASM_MAX_ATOMS {
return format!(r#"["{{"error":"molecule too large (max {} atoms)"}}"]"#, WASM_MAX_ATOMS);
}
let tautomers = chematic_chem::enumerate_tautomers(&mol.inner);
let parts: Vec<String> = tautomers
.iter()
.map(|m| {
format!(
"\"{}\"",
chematic_smiles::canonical_smiles(m).replace('"', "\\\"")
)
})
.collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn largest_fragment(mol: &MolHandle) -> MolHandle {
let frag = chematic_chem::largest_fragment(&mol.inner);
MolHandle {
inner: std::rc::Rc::new(frag),
}
}
#[wasm_bindgen]
pub fn neutralize_charges(mol: &MolHandle) -> MolHandle {
let neutral = chematic_chem::neutralize_charges(&mol.inner);
MolHandle {
inner: std::rc::Rc::new(neutral),
}
}
#[wasm_bindgen]
pub fn maccs_bitvec(mol: &MolHandle) -> Vec<u8> {
let fp = chematic_fp::maccs(&mol.inner);
(0..21usize)
.map(|byte_idx| {
let mut byte = 0u8;
for bit in 0..8usize {
let global_bit = byte_idx * 8 + bit;
if global_bit < 166 && fp.get(global_bit) {
byte |= 1 << bit;
}
}
byte
})
.collect()
}
#[wasm_bindgen]
pub fn tanimoto_maccs(a: &MolHandle, b: &MolHandle) -> f64 {
let fa = chematic_fp::maccs(&a.inner);
let fb = chematic_fp::maccs(&b.inner);
fa.tanimoto(&fb)
}
#[wasm_bindgen]
pub fn get_descriptors_json(mol: &MolHandle) -> String {
let m = &mol.inner;
format!(
concat!(
"{{",
"\"mw\":{mw:.4},\"exactMass\":{em:.6},\"tpsa\":{tpsa:.4},",
"\"logP\":{logp:.4},\"molarRefractivity\":{mr:.4},",
"\"hbd\":{hbd},\"hba\":{hba},\"rotatableBonds\":{rb},",
"\"heavyAtomCount\":{hac},\"ringCount\":{rc},",
"\"aromaticRingCount\":{arc},\"numHeteroatoms\":{nh},",
"\"numStereocenters\":{nsc},\"numSpiroAtoms\":{nsp},",
"\"numBridgeheadAtoms\":{nbh},\"fsp3\":{fsp3:.4},",
"\"qed\":{qed:.4},\"saScore\":{sa:.4},",
"\"formalChargeSum\":{fc},\"labuteASA\":{asa:.4},",
"\"bertzCT\":{bertz:.4},\"wienerIndex\":{wi:.4},",
"\"lipinskiPasses\":{lip},\"veberPasses\":{veb},",
"\"eganPasses\":{egan},\"ghosePasses\":{ghose},",
"\"reosPasses\":{reos},\"painsPasses\":{pains},",
"\"bbbScore\":{bbb:.4},\"bbbPasses\":{bbp},",
"\"caco2\":{caco:.4},\"hergRisk\":{herg:.4},",
"\"cyp3a4Risk\":{cyp:.4},",
"\"pkaAcid\":{acid},\"pkaBase\":{base}",
"}}"
),
mw = chematic_chem::molecular_weight(m),
em = chematic_chem::exact_mass(m),
tpsa = chematic_chem::tpsa(m),
logp = chematic_chem::logp_crippen(m),
mr = chematic_chem::molar_refractivity(m),
hbd = chematic_chem::hbd_count(m),
hba = chematic_chem::hba_count(m),
rb = chematic_chem::rotatable_bond_count(m),
hac = chematic_chem::heavy_atom_count(m),
rc = chematic_chem::ring_count(m),
arc = chematic_chem::aromatic_ring_count(m),
nh = chematic_chem::num_heteroatoms(m),
nsc = chematic_chem::num_stereocenters(m),
nsp = chematic_chem::num_spiro_atoms(m),
nbh = chematic_chem::num_bridgehead_atoms(m),
fsp3 = chematic_chem::fsp3(m),
qed = chematic_chem::qed(m),
sa = chematic_chem::sa_score(m),
fc = chematic_chem::formal_charge_sum(m),
asa = chematic_chem::labute_asa(m),
bertz = chematic_chem::bertz_ct(m),
wi = chematic_chem::wiener_index(m),
lip = chematic_chem::lipinski_passes(m),
veb = chematic_chem::veber_passes(m),
egan = chematic_chem::egan_passes(m),
ghose = chematic_chem::ghose_passes(m),
reos = chematic_chem::reos_passes(m),
pains = chematic_chem::pains_passes(m),
bbb = chematic_chem::bbb_score(m),
bbp = chematic_chem::bbb_passes(m),
caco = chematic_chem::caco2_permeability(m),
herg = chematic_chem::herg_risk_score(m),
cyp = chematic_chem::cyp3a4_inhibition_risk(m),
acid = chematic_chem::pka_acid(m).map_or("null".to_string(), |v| format!("{v:.2}")),
base = chematic_chem::pka_base(m).map_or("null".to_string(), |v| format!("{v:.2}")),
)
}
#[wasm_bindgen]
pub fn predict_pka_json(smiles: &str) -> String {
let mol = match chematic_smiles::parse(smiles) {
Ok(m) => m,
Err(e) => return format!(r#"{{"error":"{}"}}"#, escape_json_string(&e.to_string())),
};
let sites = chematic_chem::predict_pka(&mol);
if sites.is_empty() {
return "[]".to_string();
}
let items: Vec<String> = sites
.iter()
.map(|s| {
let type_str = match s.site_type {
chematic_chem::PkaSiteType::Acid => "acid",
chematic_chem::PkaSiteType::Base => "base",
};
format!(
r#"{{"atom_idx":{},"pka":{:.2},"type":"{}","group":"{}"}}"#,
s.atom_idx.0, s.pka, type_str, s.group_name
)
})
.collect();
format!("[{}]", items.join(","))
}
#[wasm_bindgen]
pub fn admet_profile_json(smiles: &str) -> String {
let mol = match chematic_smiles::parse(smiles) {
Ok(m) => m,
Err(e) => return format!(r#"{{"error":"{}"}}"#, escape_json_string(&e.to_string())),
};
let p = chematic_chem::admet_profile(&mol);
let acid_str = p.pka_acid.map_or("null".to_string(), |v| format!("{v:.2}"));
let base_str = p.pka_base.map_or("null".to_string(), |v| format!("{v:.2}"));
format!(
concat!(
"{{",
"\"bbb_score\":{bbb:.4},\"bbb_passes\":{bbp},",
"\"caco2\":{caco:.4},\"herg_risk\":{herg:.4},",
"\"cyp3a4_risk\":{cyp:.4},",
"\"pka_acid\":{acid},\"pka_base\":{base},",
"\"esol\":{esol:.4},\"logd74\":{logd:.4},",
"\"mw\":{mw:.4},\"logp\":{logp:.4},\"tpsa\":{tpsa:.4},",
"\"hbd\":{hbd},\"hba\":{hba},\"rotatable_bonds\":{rb}",
"}}"
),
bbb = p.bbb_score,
bbp = p.bbb_passes,
caco = p.caco2,
herg = p.herg_risk,
cyp = p.cyp3a4_risk,
acid = acid_str,
base = base_str,
esol = p.esol,
logd = p.logd74,
mw = p.mw,
logp = p.logp,
tpsa = p.tpsa,
hbd = p.hbd,
hba = p.hba,
rb = p.rotatable_bonds,
)
}
#[wasm_bindgen]
pub fn sdf_to_records_json(sdf: &str) -> String {
if sdf.len() > WASM_MAX_INPUT_BYTES {
return format!(r#"[{{"error":"SDF input too large ({} bytes)"}}]"#, sdf.len());
}
let entries: Vec<String> = chematic_mol::SdfRecordReader::new(sdf)
.take(WASM_MAX_BATCH_ITEMS)
.map(|r| match r {
Ok(rec) => {
let smi = chematic_smiles::canonical_smiles(&rec.mol);
let name = escape_json_string(&rec.meta.name);
let props: Vec<String> = rec
.properties
.iter()
.map(|(k, v)| {
format!(
"\"{}\":\"{}\"",
escape_json_string(k),
escape_json_string(v)
)
})
.collect();
format!(
"{{\"smiles\":\"{}\",\"name\":\"{}\",\"properties\":{{{}}}}}",
escape_json_string(&smi),
name,
props.join(",")
)
}
Err(_) => "null".to_string(),
})
.collect();
format!("[{}]", entries.join(","))
}
#[wasm_bindgen]
pub fn depict_svg_grid_highlighted(smiles_block: &str, cols: usize, match_smarts: &str) -> String {
let mols: Vec<chematic_core::Molecule> = smiles_block
.lines()
.filter(|s| !s.trim().is_empty())
.filter_map(|s| chematic_smiles::parse(s).ok())
.collect();
if mols.is_empty() || cols == 0 {
return "<svg xmlns=\"http://www.w3.org/2000/svg\" \
width=\"0\" height=\"0\"></svg>"
.to_string();
}
let query = if match_smarts.is_empty() {
None
} else {
chematic_smarts::parse_smarts(match_smarts).ok()
};
let cell_opts: Vec<chematic_depict::svg::RenderOptions> = mols
.iter()
.map(|mol| {
let mut opts = chematic_depict::svg::RenderOptions::default();
if let Some(q) = &query {
let matches = chematic_smarts::find_matches(q, mol);
opts.highlight_atoms = matches.into_iter().flat_map(|m| m.into_values()).collect();
}
opts
})
.collect();
let pairs: Vec<(
&chematic_core::Molecule,
Option<&chematic_depict::svg::RenderOptions>,
)> = mols
.iter()
.zip(cell_opts.iter())
.map(|(m, o)| (m, Some(o)))
.collect();
chematic_depict::depict_svg_grid_with_opts(&pairs, cols)
}
#[wasm_bindgen]
pub fn pains_matches_json(mol: &MolHandle) -> String {
let names = chematic_chem::pains_matches(&mol.inner);
let parts: Vec<String> = names
.iter()
.map(|n| format!("\"{}\"", n.replace('"', "\\\"")))
.collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn cip_assignments_json(mol: &MolHandle) -> String {
let assignment = chematic_chem::assign_cip(&mol.inner);
let parts: Vec<String> = assignment
.assignments
.iter()
.map(|(idx, code)| {
let code_str = match code {
chematic_core::CipCode::R => "R",
chematic_core::CipCode::S => "S",
chematic_core::CipCode::E => "E",
chematic_core::CipCode::Z => "Z",
};
format!("{{\"atomIdx\":{},\"cipCode\":\"{}\"}}", idx.0, code_str)
})
.collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn ecfp6_bitvec(mol: &MolHandle) -> Vec<u8> {
let fp = chematic_fp::ecfp6(&mol.inner);
(0..256usize)
.map(|byte_idx| {
let mut byte = 0u8;
for bit in 0..8usize {
if fp.get(byte_idx * 8 + bit) {
byte |= 1 << bit;
}
}
byte
})
.collect()
}
#[wasm_bindgen]
pub fn tanimoto_ecfp6(a: &MolHandle, b: &MolHandle) -> f64 {
chematic_fp::ecfp6(&a.inner).tanimoto(&chematic_fp::ecfp6(&b.inner))
}
#[wasm_bindgen]
pub fn dice_ecfp4(a: &MolHandle, b: &MolHandle) -> f64 {
chematic_fp::ecfp4(&a.inner).dice(&chematic_fp::ecfp4(&b.inner))
}
#[wasm_bindgen]
pub fn dice_maccs(a: &MolHandle, b: &MolHandle) -> f64 {
chematic_fp::maccs(&a.inner).dice(&chematic_fp::maccs(&b.inner))
}
#[wasm_bindgen]
pub fn shape_descriptors_json(mol: &MolHandle) -> String {
let coords = chematic_3d::generate_coords(&mol.inner);
let m = &*mol.inner;
fn fmt(v: f64) -> String {
if v.is_finite() {
format!("{v:.4}")
} else {
"null".to_string()
}
}
format!(
"{{\"pmi1\":{},\"pmi2\":{},\"pmi3\":{},\"npr1\":{},\"npr2\":{},\
\"asphericity\":{},\"eccentricity\":{},\"radiusOfGyration\":{},\"planeOfBestFit\":{}}}",
fmt(chematic_3d::pmi1(m, &coords)),
fmt(chematic_3d::pmi2(m, &coords)),
fmt(chematic_3d::pmi3(m, &coords)),
fmt(chematic_3d::npr1(m, &coords)),
fmt(chematic_3d::npr2(m, &coords)),
fmt(chematic_3d::asphericity(m, &coords)),
fmt(chematic_3d::eccentricity(m, &coords)),
fmt(chematic_3d::radius_of_gyration(m, &coords)),
fmt(chematic_3d::plane_of_best_fit(m, &coords)),
)
}
#[wasm_bindgen]
pub fn maxmin_picks_ecfp4_json(smiles_json: &str, n: usize) -> Result<String, JsValue> {
let smiles_list = parse_smiles_json_array(smiles_json)?;
let mols: Vec<chematic_core::Molecule> = smiles_list
.iter()
.map(|s| {
let mol = chematic_smiles::parse(s).map_err(|e| JsValue::from_str(&e.to_string()))?;
enforce_wasm_molecule_size(&mol)?;
Ok::<_, JsValue>(mol)
})
.collect::<Result<_, _>>()?;
let picks = chematic_chem::maxmin_picks(&mols, n, |a, b| {
chematic_fp::ecfp4(a).tanimoto(&chematic_fp::ecfp4(b))
});
let parts: Vec<String> = picks.iter().map(|i| i.to_string()).collect();
Ok(format!("[{}]", parts.join(",")))
}
#[wasm_bindgen]
pub fn butina_cluster_ecfp4_json(smiles_json: &str, cutoff: f64) -> Result<String, JsValue> {
let smiles_list = parse_smiles_json_array(smiles_json)?;
let mols: Vec<chematic_core::Molecule> = smiles_list
.iter()
.map(|s| {
let mol = chematic_smiles::parse(s).map_err(|e| JsValue::from_str(&e.to_string()))?;
enforce_wasm_molecule_size(&mol)?;
Ok::<_, JsValue>(mol)
})
.collect::<Result<_, _>>()?;
let clusters = chematic_chem::butina_cluster(&mols, cutoff, |a, b| {
chematic_fp::ecfp4(a).tanimoto(&chematic_fp::ecfp4(b))
});
let outer: Vec<String> = clusters
.iter()
.map(|cluster| {
let inner: Vec<String> = cluster.iter().map(|i| i.to_string()).collect();
format!("[{}]", inner.join(","))
})
.collect();
Ok(format!("[{}]", outer.join(",")))
}
#[wasm_bindgen]
pub fn mcs_smiles_json(smiles_json: &str) -> Result<String, JsValue> {
let smiles_list = parse_smiles_json_array(smiles_json)?;
if smiles_list.len() < 2 {
return Err(JsValue::from_str(
"mcs_smiles_json requires at least 2 SMILES",
));
}
let mols: Vec<chematic_core::Molecule> = smiles_list
.iter()
.map(|s| {
let mol = chematic_smiles::parse(s).map_err(|e| JsValue::from_str(&e.to_string()))?;
enforce_wasm_molecule_size(&mol)?;
Ok::<_, JsValue>(mol)
})
.collect::<Result<_, _>>()?;
let mol_refs: Vec<&chematic_core::Molecule> = mols.iter().collect();
let qmol = chematic_smarts::find_mcs(&mol_refs);
if qmol.atoms.is_empty() {
return Ok("null".to_string());
}
use chematic_core::{Atom, AtomIdx, BondOrder, Element, MoleculeBuilder};
use chematic_smarts::{AtomPrimitive, AtomQuery, BondPrimitive, BondQuery};
let mut builder = MoleculeBuilder::new();
for qa in &qmol.atoms {
let elem = match &qa.query {
AtomQuery::Primitive(AtomPrimitive::AtomicNum(n)) => {
Element::from_atomic_number(*n).unwrap_or(Element::C)
}
_ => Element::C,
};
builder.add_atom(Atom::new(elem));
}
for (atom_idx, neighbors) in qmol.adj.iter().enumerate() {
for (bond_idx, neighbor_idx) in neighbors {
if atom_idx < *neighbor_idx {
let order = match &qmol.bonds[*bond_idx].query {
BondQuery::Primitive(BondPrimitive::Double) => BondOrder::Double,
BondQuery::Primitive(BondPrimitive::Triple) => BondOrder::Triple,
BondQuery::Primitive(BondPrimitive::Aromatic) => BondOrder::Aromatic,
_ => BondOrder::Single,
};
let _ = builder.add_bond(
AtomIdx(atom_idx as u32),
AtomIdx(*neighbor_idx as u32),
order,
);
}
}
}
let mol = builder.build();
Ok(chematic_smiles::canonical_smiles(&mol))
}
fn escape_json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out
}
fn json_option_string_array(values: &[Option<String>]) -> String {
let items = values
.iter()
.map(|value| match value {
Some(s) => format!(r#""{}""#, escape_json_string(s)),
None => "null".to_string(),
})
.collect::<Vec<_>>()
.join(",");
format!("[{items}]")
}
fn json_option_u8_array(values: &[Option<u8>]) -> String {
let items = values
.iter()
.map(|value| value.map_or_else(|| "null".to_string(), |v| v.to_string()))
.collect::<Vec<_>>()
.join(",");
format!("[{items}]")
}
fn enforce_wasm_input_len(label: &str, input: &str) -> Result<(), JsValue> {
if input.len() > WASM_MAX_INPUT_BYTES {
return Err(JsValue::from_str(&format!(
"{label} exceeds maximum input size ({} > {} bytes)",
input.len(),
WASM_MAX_INPUT_BYTES
)));
}
Ok(())
}
fn enforce_wasm_molecule_size(mol: &chematic_core::Molecule) -> Result<(), JsValue> {
if mol.atom_count() > WASM_MAX_ATOMS {
return Err(JsValue::from_str(&format!(
"Molecule exceeds maximum atom count ({} > {})",
mol.atom_count(),
WASM_MAX_ATOMS
)));
}
Ok(())
}
fn parse_wasm_string_json_array(json: &str, label: &str) -> Result<Vec<String>, JsValue> {
enforce_wasm_input_len(label, json)?;
let values: Vec<String> = serde_json::from_str(json)
.map_err(|e| JsValue::from_str(&format!("{label} must be a JSON array of strings: {e}")))?;
if values.len() > WASM_MAX_BATCH_ITEMS {
return Err(JsValue::from_str(&format!(
"{label} exceeds maximum item count ({} > {})",
values.len(),
WASM_MAX_BATCH_ITEMS
)));
}
if let Some((idx, value)) = values
.iter()
.enumerate()
.find(|(_, value)| value.len() > WASM_MAX_JSON_STRING_BYTES)
{
return Err(JsValue::from_str(&format!(
"{label}[{idx}] exceeds maximum string size ({} > {} bytes)",
value.len(),
WASM_MAX_JSON_STRING_BYTES
)));
}
Ok(values)
}
fn parse_smiles_json_array(json: &str) -> Result<Vec<String>, JsValue> {
parse_wasm_string_json_array(json, "smiles_json")
}
#[wasm_bindgen]
pub struct ConformerHandle {
inner: chematic_3d::ConformerEnsemble,
}
#[wasm_bindgen]
impl ConformerHandle {
#[wasm_bindgen(constructor)]
pub fn new(smiles: &str) -> Result<ConformerHandle, JsValue> {
let mol = chematic_smiles::parse(smiles).map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(ConformerHandle {
inner: chematic_3d::ConformerEnsemble::new(mol),
})
}
pub fn conformer_count(&self) -> usize {
self.inner.conformer_count()
}
pub fn mol(&self) -> MolHandle {
let smi = chematic_smiles::canonical_smiles(self.inner.mol());
let mol = chematic_smiles::parse(&smi)
.unwrap_or_else(|_| chematic_core::MoleculeBuilder::new().build());
MolHandle {
inner: std::rc::Rc::new(mol),
}
}
pub fn add_generated_conformer(&mut self) -> usize {
let coords = chematic_3d::generate_coords(self.inner.mol());
self.inner.add_conformer(coords).unwrap_or(usize::MAX)
}
pub fn add_minimized_conformer(&mut self) -> usize {
let coords = chematic_3d::generate_coords(self.inner.mol());
let minimized = chematic_3d::minimize(self.inner.mol(), coords);
self.inner.add_conformer(minimized).unwrap_or(usize::MAX)
}
pub fn get_conformer_pdb(&self, idx: usize) -> Option<String> {
self.inner
.get_conformer(idx)
.map(|coords| chematic_3d::write_pdb(self.inner.mol(), coords))
}
pub fn conformer_rmsd(&self, a: usize, b: usize) -> f64 {
self.inner.conformer_rmsd(a, b).unwrap_or(f64::NAN)
}
pub fn conformer_rmsd_no_align(&self, a: usize, b: usize) -> f64 {
self.inner.conformer_rmsd_no_align(a, b).unwrap_or(f64::NAN)
}
pub fn remove_conformer(&mut self, idx: usize) -> bool {
self.inner.remove_conformer(idx).is_some()
}
pub fn cluster_conformers_json(&self, rms_threshold: f64) -> String {
let kept = self.inner.cluster_conformers_by_rms(rms_threshold);
let total = self.inner.conformer_count();
let removed = total.saturating_sub(kept.len());
let indices_str: Vec<String> = kept.iter().map(|i| i.to_string()).collect();
format!(
r#"{{"kept_indices":[{}],"removed_count":{}}}"#,
indices_str.join(","),
removed
)
}
}
#[wasm_bindgen]
pub fn mol_with_atom_added(mol: &MolHandle, element_symbol: &str) -> Result<MolHandle, JsValue> {
let element = chematic_core::Element::from_symbol(element_symbol)
.ok_or_else(|| JsValue::from_str(&format!("unknown element: {element_symbol}")))?;
let atom = chematic_core::Atom::new(element);
let (new_mol, _) = mol.inner.with_atom_added(atom);
Ok(MolHandle {
inner: std::rc::Rc::new(new_mol),
})
}
#[wasm_bindgen]
pub fn mol_next_atom_idx(mol: &MolHandle) -> u32 {
mol.inner.atom_count() as u32
}
#[wasm_bindgen]
pub fn mol_with_bond_added(
mol: &MolHandle,
a: u32,
b: u32,
order: u32,
) -> Result<MolHandle, JsValue> {
let bond_order = match order {
2 => chematic_core::BondOrder::Double,
3 => chematic_core::BondOrder::Triple,
_ => chematic_core::BondOrder::Single,
};
let (new_mol, _bond_idx) = mol
.inner
.with_bond_added(
chematic_core::AtomIdx(a),
chematic_core::AtomIdx(b),
bond_order,
)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(MolHandle {
inner: std::rc::Rc::new(new_mol),
})
}
#[wasm_bindgen]
pub fn mol_with_atom_charge(mol: &MolHandle, idx: u32, charge: i32) -> Result<MolHandle, JsValue> {
if idx as usize >= mol.inner.atom_count() {
return Err(JsValue::from_str(&format!(
"atom index {idx} out of range (molecule has {} atoms)",
mol.inner.atom_count()
)));
}
let new_mol = mol
.inner
.with_atom_charge(chematic_core::AtomIdx(idx), charge as i8);
Ok(MolHandle {
inner: std::rc::Rc::new(new_mol),
})
}
#[wasm_bindgen]
pub fn mol_with_atom_element(
mol: &MolHandle,
idx: u32,
element_symbol: &str,
) -> Result<MolHandle, JsValue> {
if idx as usize >= mol.inner.atom_count() {
return Err(JsValue::from_str(&format!(
"atom index {idx} out of range (molecule has {} atoms)",
mol.inner.atom_count()
)));
}
let el = chematic_core::Element::from_symbol(element_symbol)
.ok_or_else(|| JsValue::from_str(&format!("unknown element: {element_symbol}")))?;
let new_mol = mol.inner.with_atom_element(chematic_core::AtomIdx(idx), el);
Ok(MolHandle {
inner: std::rc::Rc::new(new_mol),
})
}
#[wasm_bindgen]
pub fn mol_with_atom_removed(mol: &MolHandle, idx: u32) -> Result<MolHandle, JsValue> {
if idx as usize >= mol.inner.atom_count() {
return Err(JsValue::from_str(&format!(
"atom index {idx} out of range (molecule has {} atoms)",
mol.inner.atom_count()
)));
}
let (new_mol, _remap) = mol.inner.with_atom_removed(chematic_core::AtomIdx(idx));
Ok(MolHandle {
inner: std::rc::Rc::new(new_mol),
})
}
#[wasm_bindgen]
pub fn mol_with_bond_removed(mol: &MolHandle, idx: u32) -> Result<MolHandle, JsValue> {
if idx as usize >= mol.inner.bond_count() {
return Err(JsValue::from_str(&format!(
"bond index {idx} out of range (molecule has {} bonds)",
mol.inner.bond_count()
)));
}
let new_mol = mol.inner.with_bond_removed(chematic_core::BondIdx(idx));
Ok(MolHandle {
inner: std::rc::Rc::new(new_mol),
})
}
#[wasm_bindgen]
#[allow(clippy::type_complexity)]
pub fn smiles_array_to_sdf(smiles_json: &str) -> Result<String, JsValue> {
let smiles_list = parse_smiles_json_array(smiles_json)?;
let mut records: Vec<(
chematic_core::Molecule,
chematic_mol::MolMetadata,
Vec<(f64, f64)>,
)> = Vec::new();
for smi in &smiles_list {
let mol = chematic_smiles::parse(smi).map_err(|e| JsValue::from_str(&e.to_string()))?;
enforce_wasm_molecule_size(&mol)?;
let layout = chematic_depict::compute_layout(&mol);
const SCALE: f64 = 1.5 / 40.0;
let coords: Vec<(f64, f64)> = (0..mol.atom_count())
.map(|i| {
let p = layout.get(chematic_core::AtomIdx(i as u32));
(p.x * SCALE, -p.y * SCALE)
})
.collect();
let meta = chematic_mol::MolMetadata::default();
records.push((mol, meta, coords));
}
let refs: Vec<(
&chematic_core::Molecule,
&chematic_mol::MolMetadata,
&[(f64, f64)],
)> = records
.iter()
.map(|(m, meta, c)| (m, meta, c.as_slice()))
.collect();
Ok(chematic_mol::write_sdf(&refs))
}
#[wasm_bindgen]
pub fn to_mol_v3000_block(mol: &MolHandle) -> String {
let layout = chematic_depict::compute_layout(&mol.inner);
const SCALE: f64 = 1.5 / 40.0;
let coords: Vec<(f64, f64)> = (0..mol.inner.atom_count())
.map(|i| {
let p = layout.get(chematic_core::AtomIdx(i as u32));
(p.x * SCALE, -p.y * SCALE)
})
.collect();
let meta = chematic_mol::MolMetadata::default();
chematic_mol::write_mol_v3000(&mol.inner, &meta, &coords)
}
#[wasm_bindgen]
pub fn depict_data_json(mol: &MolHandle) -> String {
let data = chematic_depict::compute_depict_data(&mol.inner);
let atoms: Vec<String> = data
.atoms
.iter()
.map(|a| {
let label = match &a.label {
Some(s) => format!("\"{}\"", escape_json_string(s)),
None => "null".to_string(),
};
let charge_suffix = if a.charge != 0 {
format!(",\"charge\":{}", a.charge)
} else {
String::new()
};
let label_prefix = format!("{label},");
format!(
r#"{{"idx":{},"element":"{}","x":{:.4},"y":{:.4},"label":{}"color":"{}"{}}}"#,
a.idx.0,
a.element.symbol(),
a.pos.x,
a.pos.y,
label_prefix,
escape_json_string(&a.color),
charge_suffix,
)
})
.collect();
let bond_kind = |k: &chematic_depict::DepictBondKind| match k {
chematic_depict::DepictBondKind::Single => "Single",
chematic_depict::DepictBondKind::Double => "Double",
chematic_depict::DepictBondKind::Triple => "Triple",
chematic_depict::DepictBondKind::Aromatic => "Aromatic",
chematic_depict::DepictBondKind::Up => "Up",
chematic_depict::DepictBondKind::Down => "Down",
};
let bonds: Vec<String> = data
.bonds
.iter()
.map(|b| {
format!(
r#"{{"idx":{},"atom1":{},"atom2":{},"kind":"{}"}}"#,
b.idx.0,
b.atom1.0,
b.atom2.0,
bond_kind(&b.kind)
)
})
.collect();
format!(
r#"{{"atoms":[{}],"bonds":[{}]}}"#,
atoms.join(","),
bonds.join(","),
)
}
#[wasm_bindgen]
pub fn cpk_color(element_symbol: &str) -> String {
let an = chematic_core::Element::from_symbol(element_symbol)
.map(|e| e.atomic_number())
.unwrap_or(0);
chematic_depict::atom_color(an).to_string()
}
#[wasm_bindgen]
pub fn mol_from_cml(cml: &str) -> Result<MolHandle, JsValue> {
if cml.len() > WASM_MAX_INPUT_BYTES {
return Err(JsValue::from_str("CML input too large"));
}
let (mol, _coords) =
chematic_mol::parse_cml(cml).map_err(|e| JsValue::from_str(&e.to_string()))?;
if mol.atom_count() > WASM_MAX_ATOMS {
return Err(JsValue::from_str(&format!("molecule too large (max {} atoms)", WASM_MAX_ATOMS)));
}
Ok(MolHandle {
inner: std::rc::Rc::new(mol),
})
}
#[wasm_bindgen]
pub fn to_cml(mol: &MolHandle) -> String {
let layout = chematic_depict::compute_layout(&mol.inner);
const SCALE: f64 = 1.5 / 40.0;
let coords: Vec<(f64, f64)> = (0..mol.inner.atom_count())
.map(|i| {
let p = layout.get(chematic_core::AtomIdx(i as u32));
(p.x * SCALE, -p.y * SCALE)
})
.collect();
chematic_mol::write_cml(&mol.inner, Some(&coords))
}
#[wasm_bindgen]
pub fn mol_from_cdxml(cdxml: &str) -> Result<MolHandle, JsValue> {
if cdxml.len() > WASM_MAX_INPUT_BYTES {
return Err(JsValue::from_str("CDXML input too large"));
}
let (mol, _coords) =
chematic_mol::parse_cdxml(cdxml).map_err(|e| JsValue::from_str(&e.to_string()))?;
if mol.atom_count() > WASM_MAX_ATOMS {
return Err(JsValue::from_str(&format!("molecule too large (max {} atoms)", WASM_MAX_ATOMS)));
}
Ok(MolHandle {
inner: std::rc::Rc::new(mol),
})
}
#[wasm_bindgen]
pub fn cdxml_to_smiles_json(cdxml: &str) -> Result<String, JsValue> {
if cdxml.len() > WASM_MAX_INPUT_BYTES {
return Err(JsValue::from_str("CDXML input too large"));
}
let fragments =
chematic_mol::parse_cdxml_all(cdxml).map_err(|e| JsValue::from_str(&e.to_string()))?;
let parts: Vec<String> = fragments
.iter()
.take(WASM_MAX_BATCH_ITEMS)
.map(|(mol, _)| {
format!(
"\"{}\"",
escape_json_string(&chematic_smiles::canonical_smiles(mol))
)
})
.collect();
Ok(format!("[{}]", parts.join(",")))
}
#[wasm_bindgen]
pub fn mol_block_coords_json(mol_block: &str) -> Result<String, JsValue> {
let (_mol, _meta, coords) = chematic_mol::parse_mol_with_coords(mol_block)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let parts: Vec<String> = coords
.iter()
.map(|(x, y)| {
let xf = if x.is_finite() {
format!("{x:.4}")
} else {
"0".to_string()
};
let yf = if y.is_finite() {
format!("{y:.4}")
} else {
"0".to_string()
};
format!("[{xf},{yf}]")
})
.collect();
Ok(format!("[{}]", parts.join(",")))
}
#[wasm_bindgen]
pub fn depict_data_with_coords_json(mol: &MolHandle, coords_json: &str) -> String {
let coords: Vec<(f64, f64)> = {
let mut result = Vec::new();
let inner = coords_json.trim().trim_matches(|c| c == '[' || c == ']');
for pair in inner.split("],[") {
let clean = pair.trim().trim_matches(|c| c == '[' || c == ']');
let nums: Vec<f64> = clean
.split(',')
.filter_map(|s| s.trim().parse::<f64>().ok())
.collect();
if nums.len() >= 2 {
result.push((nums[0], nums[1]));
}
}
result
};
let data = chematic_depict::depict_data_with_coords(&mol.inner, &coords);
let atoms: Vec<String> = data
.atoms
.iter()
.map(|a| {
let label = match &a.label {
Some(s) => format!("\"{}\"", escape_json_string(s)),
None => "null".to_string(),
};
let charge_suffix = if a.charge != 0 {
format!(",\"charge\":{}", a.charge)
} else {
String::new()
};
let label_prefix = format!("{label},");
format!(
r#"{{"idx":{},"element":"{}","x":{:.4},"y":{:.4},"label":{}"color":"{}"{}}}"#,
a.idx.0,
a.element.symbol(),
a.pos.x,
a.pos.y,
label_prefix,
escape_json_string(&a.color),
charge_suffix,
)
})
.collect();
let bond_kind = |k: &chematic_depict::DepictBondKind| match k {
chematic_depict::DepictBondKind::Single => "Single",
chematic_depict::DepictBondKind::Double => "Double",
chematic_depict::DepictBondKind::Triple => "Triple",
chematic_depict::DepictBondKind::Aromatic => "Aromatic",
chematic_depict::DepictBondKind::Up => "Up",
chematic_depict::DepictBondKind::Down => "Down",
};
let bonds: Vec<String> = data
.bonds
.iter()
.map(|b| {
format!(
r#"{{"idx":{},"atom1":{},"atom2":{},"kind":"{}"}}"#,
b.idx.0,
b.atom1.0,
b.atom2.0,
bond_kind(&b.kind)
)
})
.collect();
format!(
r#"{{"atoms":[{}],"bonds":[{}]}}"#,
atoms.join(","),
bonds.join(",")
)
}
#[wasm_bindgen]
pub fn mmp_pairs_json(smiles_json: &str) -> Result<String, JsValue> {
let smiles_list = parse_smiles_json_array(smiles_json)?;
let mols: Vec<chematic_core::Molecule> = smiles_list
.iter()
.map(|s| {
let mol = chematic_smiles::parse(s).map_err(|e| JsValue::from_str(&e.to_string()))?;
enforce_wasm_molecule_size(&mol)?;
Ok::<_, JsValue>(mol)
})
.collect::<Result<_, _>>()?;
let mol_refs: Vec<&chematic_core::Molecule> = mols.iter().collect();
let pairs = chematic_chem::find_mmp(&mol_refs);
let entries: Vec<String> = pairs
.iter()
.map(|p| {
format!(
r#"{{"mol_a":"{}","mol_b":"{}","core":"{}","fragment_a":"{}","fragment_b":"{}"}}"#,
escape_json_string(&p.mol_a),
escape_json_string(&p.mol_b),
escape_json_string(&p.core),
escape_json_string(&p.fragment_a),
escape_json_string(&p.fragment_b),
)
})
.collect();
Ok(format!("[{}]", entries.join(",")))
}
#[wasm_bindgen]
pub fn rgroup_decompose_json(smiles_json: &str, core_smarts: &str) -> Result<String, JsValue> {
use chematic_core::AtomIdx;
use chematic_smarts::{AtomPrimitive, AtomQuery};
use std::collections::{HashMap, HashSet};
let query = chematic_smarts::parse_smarts(core_smarts)
.map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
let wildcard_indices: Vec<usize> = query
.atoms
.iter()
.enumerate()
.filter(|(_, qa)| matches!(&qa.query, AtomQuery::Primitive(AtomPrimitive::Wildcard)))
.map(|(i, _)| i)
.collect();
let smiles_list = parse_smiles_json_array(smiles_json)?;
let mols: Vec<chematic_core::Molecule> = smiles_list
.iter()
.map(|s| {
let mol = chematic_smiles::parse(s).map_err(|e| JsValue::from_str(&e.to_string()))?;
enforce_wasm_molecule_size(&mol)?;
Ok::<_, JsValue>(mol)
})
.collect::<Result<_, _>>()?;
let mut entries: Vec<String> = Vec::new();
for mol in &mols {
let config = chematic_smarts::MatchConfig {
max_matches: Some(WASM_MAX_SMARTS_MATCHES),
use_chirality: false,
use_isotopes: false,
uniquify: true,
max_visit_budget: None,
};
let matches = chematic_smarts::find_matches_with_config(&query, mol, &config);
if matches.is_empty() {
entries.push("{\"matched\":false}".to_string());
continue;
}
let mapping: &HashMap<usize, AtomIdx> = &matches[0];
let core_atoms: HashSet<AtomIdx> = mapping
.iter()
.filter(|(qi, _)| !wildcard_indices.contains(qi))
.map(|(_, &mol_idx)| mol_idx)
.collect();
let mut rgroup_parts: Vec<String> = Vec::new();
for (rg_num, qi) in wildcard_indices.iter().enumerate() {
let smi = if let Some(&attachment) = mapping.get(qi) {
rgroup_fragment_smiles(mol, attachment, &core_atoms)
} else {
String::new()
};
rgroup_parts.push(format!(
"\"r{}\":\"{}\"",
rg_num + 1,
escape_json_string(&smi)
));
}
let rg_json = rgroup_parts.join(",");
entries.push(format!("{{\"matched\":true,{rg_json}}}"));
}
Ok(format!("[{}]", entries.join(",")))
}
fn rgroup_fragment_smiles(
mol: &chematic_core::Molecule,
start: chematic_core::AtomIdx,
core_atoms: &std::collections::HashSet<chematic_core::AtomIdx>,
) -> String {
use chematic_core::{Atom, AtomIdx, MoleculeBuilder};
use std::collections::{HashMap, HashSet, VecDeque};
let mut fragment: Vec<AtomIdx> = Vec::new();
let mut visited: HashSet<AtomIdx> = HashSet::new();
let mut queue: VecDeque<AtomIdx> = VecDeque::new();
queue.push_back(start);
while let Some(idx) = queue.pop_front() {
if visited.contains(&idx) || core_atoms.contains(&idx) {
continue;
}
visited.insert(idx);
fragment.push(idx);
for (neighbor, _) in mol.neighbors(idx) {
if !visited.contains(&neighbor) {
queue.push_back(neighbor);
}
}
}
if fragment.is_empty() {
return String::new();
}
let fragment_set: HashSet<AtomIdx> = fragment.iter().copied().collect();
let mut builder = MoleculeBuilder::new();
let mut idx_map: HashMap<AtomIdx, AtomIdx> = HashMap::new();
for &orig in &fragment {
let atom = mol.atom(orig);
let mut a = Atom::new(atom.element);
a.charge = atom.charge;
a.isotope = atom.isotope;
a.aromatic = atom.aromatic;
a.chirality = atom.chirality;
a.hydrogen_count = atom.hydrogen_count;
a.atom_map = atom.atom_map;
let new_idx = builder.add_atom(a);
idx_map.insert(orig, new_idx);
}
for (_, bond) in mol.bonds() {
if fragment_set.contains(&bond.atom1) && fragment_set.contains(&bond.atom2) {
if let (Some(&n1), Some(&n2)) = (idx_map.get(&bond.atom1), idx_map.get(&bond.atom2)) {
let _ = builder.add_bond(n1, n2, bond.order);
}
}
}
chematic_smiles::canonical_smiles(&builder.build())
}
#[wasm_bindgen]
pub fn fcfp4_bitvec(mol: &MolHandle) -> Vec<u8> {
let fp = chematic_fp::fcfp4(&mol.inner);
(0..256usize)
.map(|byte_idx| {
let mut byte = 0u8;
for bit in 0..8usize {
if fp.get(byte_idx * 8 + bit) {
byte |= 1 << bit;
}
}
byte
})
.collect()
}
#[wasm_bindgen]
pub fn fcfp6_bitvec(mol: &MolHandle) -> Vec<u8> {
let fp = chematic_fp::fcfp6(&mol.inner);
(0..256usize)
.map(|byte_idx| {
let mut byte = 0u8;
for bit in 0..8usize {
if fp.get(byte_idx * 8 + bit) {
byte |= 1 << bit;
}
}
byte
})
.collect()
}
#[wasm_bindgen]
pub fn dice_ecfp6(a: &MolHandle, b: &MolHandle) -> f64 {
chematic_fp::ecfp6(&a.inner).dice(&chematic_fp::ecfp6(&b.inner))
}
#[wasm_bindgen]
pub fn write_smiles(mol: &MolHandle) -> String {
chematic_smiles::write(&mol.inner)
}
#[wasm_bindgen]
pub fn normalize_reaction_smiles(rxn_smiles: &str) -> Result<String, JsValue> {
let rxn =
chematic_rxn::parse_reaction(rxn_smiles).map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(chematic_rxn::write_reaction(&rxn))
}
#[wasm_bindgen]
pub fn brics_fragments_json(mol: &MolHandle) -> String {
let frags = chematic_chem::brics_fragments(&mol.inner);
let parts: Vec<String> = frags
.iter()
.map(|m| {
format!(
"\"{}\"",
escape_json_string(&chematic_smiles::canonical_smiles(m))
)
})
.collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn atom_pair_bitvec(mol: &MolHandle) -> Vec<u8> {
let fp = chematic_fp::atom_pair_fp(&mol.inner);
(0..256usize)
.map(|byte_idx| {
let mut byte = 0u8;
for bit in 0..8usize {
if fp.get(byte_idx * 8 + bit) {
byte |= 1 << bit;
}
}
byte
})
.collect()
}
#[wasm_bindgen]
pub fn torsion_bitvec(mol: &MolHandle) -> Vec<u8> {
let fp = chematic_fp::torsion_fp(&mol.inner);
(0..256usize)
.map(|byte_idx| {
let mut byte = 0u8;
for bit in 0..8usize {
if fp.get(byte_idx * 8 + bit) {
byte |= 1 << bit;
}
}
byte
})
.collect()
}
#[wasm_bindgen]
pub fn tanimoto_fcfp6(a: &MolHandle, b: &MolHandle) -> f64 {
chematic_fp::fcfp6(&a.inner).tanimoto(&chematic_fp::fcfp6(&b.inner))
}
#[wasm_bindgen]
pub fn sdf_from_records_json(
smiles_json: &str,
names_json: &str,
props_json: &str,
) -> Result<String, JsValue> {
let smiles_list = parse_smiles_json_array(smiles_json)?;
let names_list = parse_wasm_string_json_array(names_json, "names_json")?;
let props_list = parse_wasm_string_json_array(props_json, "props_json")?;
let n = smiles_list.len();
if names_list.len() != n || props_list.len() != n {
return Err(JsValue::from_str(
"sdf_from_records_json: smiles_json, names_json, and props_json must have the same length",
));
}
let mut sdf = String::new();
for i in 0..n {
let mol = chematic_smiles::parse(&smiles_list[i])
.map_err(|e| JsValue::from_str(&e.to_string()))?;
enforce_wasm_molecule_size(&mol)?;
let layout = chematic_depict::compute_layout(&mol);
const SCALE: f64 = 1.5 / 40.0;
let coords: Vec<(f64, f64)> = (0..mol.atom_count())
.map(|j| {
let p = layout.get(chematic_core::AtomIdx(j as u32));
(p.x * SCALE, -p.y * SCALE)
})
.collect();
let meta = chematic_mol::MolMetadata {
name: names_list[i].clone(),
comment: String::new(),
};
sdf.push_str(&chematic_mol::write_mol_with_coords(&mol, &meta, &coords));
for pair in props_list[i].lines() {
if let Some((key, value)) = pair.split_once('\t') {
sdf.push_str(&format!("> <{key}>\n{value}\n\n"));
}
}
sdf.push_str("$$$$\n");
}
Ok(sdf)
}
#[wasm_bindgen]
pub fn mol_from_xyz(xyz: &str) -> Result<MolHandle, JsValue> {
if xyz.len() > WASM_MAX_INPUT_BYTES {
return Err(JsValue::from_str("XYZ input too large"));
}
let (mol, _coords) =
chematic_3d::parse_xyz(xyz).map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
if mol.atom_count() > WASM_MAX_ATOMS {
return Err(JsValue::from_str(&format!("molecule too large (max {} atoms)", WASM_MAX_ATOMS)));
}
Ok(MolHandle {
inner: std::rc::Rc::new(mol),
})
}
#[wasm_bindgen]
pub fn determine_bonds_from_xyz_json(xyz_str: &str) -> String {
if xyz_str.len() > WASM_MAX_JSON_STRING_BYTES {
return format!(r#"{{"error":"XYZ input too large ({} bytes)"}}"#, xyz_str.len());
}
let (mol_topo, coords) = match chematic_3d::parse_xyz(xyz_str) {
Ok(r) => r,
Err(e) => return format!(r#"{{"error":"{}"}}"#, format!("{e:?}").replace('"', "\\\"")),
};
let atom_pairs: Vec<(chematic_core::Element, chematic_3d::Point3)> = mol_topo
.atoms()
.map(|(idx, atom)| (atom.element, coords.get(idx)))
.collect();
let mol_bonded = match chematic_3d::determine_bonds(&atom_pairs, 0.40) {
Ok(m) => m,
Err(e) => return format!(r#"{{"error":"{}"}}"#, e),
};
let mol_heavy = chematic_chem::remove_hydrogens(&mol_bonded);
let smiles = chematic_smiles::canonical_smiles(&mol_heavy);
let smiles_escaped = smiles.replace('\\', "\\\\").replace('"', "\\\"");
format!(
r#"{{"smiles":"{}","atom_count":{},"bond_count":{}}}"#,
smiles_escaped,
mol_heavy.atom_count(),
mol_heavy.bond_count(),
)
}
#[wasm_bindgen]
pub fn to_xyz(mol: &MolHandle) -> String {
let coords = chematic_3d::generate_coords(&mol.inner);
chematic_3d::write_xyz(&mol.inner, &coords, "")
}
#[wasm_bindgen]
pub fn mol_from_pdb(pdb: &str) -> MolHandle {
if pdb.len() > WASM_MAX_JSON_STRING_BYTES {
return MolHandle { inner: std::rc::Rc::new(chematic_core::MoleculeBuilder::new().build()) };
}
let atoms = chematic_3d::parse_pdb_atoms(pdb);
if atoms.len() > WASM_MAX_ATOMS {
return MolHandle { inner: std::rc::Rc::new(chematic_core::MoleculeBuilder::new().build()) };
}
let (mol, _coords) = chematic_3d::pdb_to_molecule(&atoms);
MolHandle {
inner: std::rc::Rc::new(mol),
}
}
#[wasm_bindgen]
pub fn logp_per_atom_json(mol: &MolHandle) -> String {
let vals = chematic_chem::logp_crippen_per_atom(&mol.inner);
let parts: Vec<String> = vals.iter().map(|v| format!("{v:.4}")).collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn mr_per_atom_json(mol: &MolHandle) -> String {
let vals = chematic_chem::mr_per_atom(&mol.inner);
let parts: Vec<String> = vals.iter().map(|v| format!("{v:.4}")).collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn labute_asa_per_atom_json(mol: &MolHandle) -> String {
let vals = chematic_chem::labute_asa_per_atom(&mol.inner);
let parts: Vec<String> = vals
.iter()
.map(|v| {
if v.is_finite() {
format!("{v:.4}")
} else {
"null".to_string()
}
})
.collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn sssr_rings_json(mol: &MolHandle) -> String {
let ring_set = chematic_perception::find_sssr(&mol.inner);
let outer: Vec<String> = ring_set
.rings()
.iter()
.map(|ring| {
let inner: Vec<String> = ring.iter().map(|idx| idx.0.to_string()).collect();
format!("[{}]", inner.join(","))
})
.collect();
format!("[{}]", outer.join(","))
}
#[wasm_bindgen]
pub fn ecfp_bitvec_custom(
mol: &MolHandle,
radius: u32,
nbits: usize,
use_chirality: bool,
) -> Vec<u8> {
let nbits = match nbits {
256 | 512 | 1024 | 2048 => nbits,
_ => 2048,
};
let fp = chematic_fp::ecfp(
&mol.inner,
&chematic_fp::EcfpConfig {
radius,
nbits,
use_chirality,
use_double_fold: false,
},
);
let byte_count = nbits / 8;
(0..byte_count)
.map(|byte_idx| {
let mut byte = 0u8;
for bit in 0..8usize {
if fp.get(byte_idx * 8 + bit) {
byte |= 1 << bit;
}
}
byte
})
.collect()
}
#[wasm_bindgen]
pub fn enumerate_stereo_isomers_json(mol: &MolHandle) -> Result<String, JsValue> {
use chematic_core::{Atom, AtomIdx, BondOrder, Chirality, MoleculeBuilder};
let m = &*mol.inner;
let unspecified: Vec<AtomIdx> = m
.atoms()
.filter(|(idx, atom)| {
if atom.element.atomic_number() != 6 || atom.aromatic {
return false;
}
if atom.chirality != Chirality::None {
return false;
}
let degree = m.neighbors(*idx).count();
if degree < 2 {
return false;
}
let total = degree + chematic_core::implicit_hcount(m, *idx) as usize;
total == 4
&& m.neighbors(*idx).all(|(_, bidx)| {
!matches!(m.bond(bidx).order, BondOrder::Double | BondOrder::Triple)
})
})
.map(|(idx, _)| idx)
.collect();
let n = unspecified.len();
if n > 6 {
return Err(JsValue::from_str(&format!(
"enumerate_stereo_isomers_json: {n} unspecified centers exceeds the 6-center limit (2^{n} = {} combinations)",
1usize << n,
)));
}
if n == 0 {
let smi = chematic_smiles::canonical_smiles(m);
let inchi_str = chematic_inchi::inchi(m);
let inchikey_str = chematic_inchi::inchi_key(&inchi_str);
return Ok(format!(
r#"[{{"smiles":"{}","inchi":"{}","inchikey":"{}"}}]"#,
escape_json_string(&smi),
escape_json_string(&inchi_str),
escape_json_string(&inchikey_str),
));
}
let mut seen = std::collections::HashSet::new();
let mut results: Vec<String> = Vec::new();
for bits in 0u32..(1u32 << n) {
let chirality_overrides: std::collections::HashMap<AtomIdx, Chirality> = unspecified
.iter()
.enumerate()
.map(|(i, &idx)| {
let cw = (bits >> i) & 1 == 1;
let chirality = if cw {
Chirality::Clockwise
} else {
Chirality::CounterClockwise
};
(idx, chirality)
})
.collect();
let mut builder = MoleculeBuilder::new();
for (idx, atom) in m.atoms() {
let mut a = Atom::new(atom.element);
a.charge = atom.charge;
a.isotope = atom.isotope;
a.aromatic = atom.aromatic;
a.atom_map = atom.atom_map;
if let Some(&new_chirality) = chirality_overrides.get(&idx) {
a.chirality = new_chirality;
let implicit_h = chematic_core::implicit_hcount(m, idx);
a.hydrogen_count = Some(atom.hydrogen_count.unwrap_or(implicit_h));
} else {
a.chirality = atom.chirality;
a.hydrogen_count = atom.hydrogen_count;
}
builder.add_atom(a);
}
for (_, bond) in m.bonds() {
let _ = builder.add_bond(bond.atom1, bond.atom2, bond.order);
}
let isomer = builder.build();
let smi = chematic_smiles::canonical_smiles(&isomer);
if seen.insert(smi.clone()) {
let inchi_str = chematic_inchi::inchi(&isomer);
let inchikey_str = chematic_inchi::inchi_key(&inchi_str);
results.push(format!(
r#"{{"smiles":"{}","inchi":"{}","inchikey":"{}"}}"#,
escape_json_string(&smi),
escape_json_string(&inchi_str),
escape_json_string(&inchikey_str),
));
}
}
Ok(format!("[{}]", results.join(",")))
}
#[wasm_bindgen]
pub fn find_reaction_center_json(reaction_smiles: &str) -> String {
let rxn = match chematic_rxn::parse_reaction(reaction_smiles) {
Ok(r) => r,
Err(e) => return format!("error:{e}"),
};
let center = chematic_rxn::find_reaction_center(&rxn);
let broken: Vec<String> = center
.broken_bonds
.iter()
.map(|(a, b)| format!("[{},{}]", a.0, b.0))
.collect();
let formed: Vec<String> = center
.formed_bonds
.iter()
.map(|(a, b)| format!("[{},{}]", a.0, b.0))
.collect();
let changed: Vec<String> = center
.changed_atoms
.iter()
.map(|a| a.0.to_string())
.collect();
format!(
"{{\"broken\":[{}],\"formed\":[{}],\"changed\":[{}]}}",
broken.join(","),
formed.join(","),
changed.join(","),
)
}
#[wasm_bindgen]
pub fn standardize_smiles(smiles: &str) -> String {
let mol = match chematic_smiles::parse(smiles) {
Ok(m) => m,
Err(e) => return format!("error:{e}"),
};
let mol = chematic_chem::largest_fragment(&mol);
let mol = chematic_chem::neutralize_charges(&mol);
chematic_smiles::canonical_smiles(&mol)
}
#[wasm_bindgen]
pub fn standardize_smiles_report_json(
smiles: &str,
largest_fragment_only: bool,
neutralize_charges: bool,
remove_explicit_h: bool,
canonical_tautomer: bool,
) -> String {
let mol = match chematic_smiles::parse(smiles) {
Ok(m) => m,
Err(e) => return format!("error:{e}"),
};
let pipeline = chematic_chem::StandardizationPipeline::new(chematic_chem::StandardizeOptions {
canonical_tautomer,
neutralize_charges,
remove_explicit_h,
largest_fragment_only,
zwitterion_handling: chematic_chem::ZwitterionHandling::Normalize,
});
let (standardized, report) = pipeline.run(&mol);
let report_json = match serde_json::to_string(&report) {
Ok(json) => json,
Err(e) => return format!("error:{e}"),
};
format!(
r#"{{"smiles":"{}","report":{}}}"#,
escape_json_string(&chematic_smiles::canonical_smiles(&standardized)),
report_json
)
}
#[wasm_bindgen]
pub fn balance_check_json(reaction_smiles: &str) -> String {
let rxn = match chematic_rxn::parse_reaction(reaction_smiles) {
Ok(r) => r,
Err(e) => return format!("error:{e}"),
};
let result = chematic_rxn::balance_check(&rxn);
let diff: Vec<String> = result
.diff()
.into_iter()
.map(|s| format!("\"{}\"", s))
.collect();
format!(
"{{\"balanced\":{},\"diff\":[{}]}}",
result.balanced,
diff.join(",")
)
}
#[wasm_bindgen]
pub fn nearest_neighbors_json(query_smiles: &str, db_smiles_json: &str, k: usize) -> String {
if let Err(e) = enforce_wasm_input_len("query_smiles", query_smiles) {
return format!(
"error:{}",
e.as_string()
.unwrap_or_else(|| "input too large".to_string())
);
}
let query = match chematic_smiles::parse(query_smiles) {
Ok(m) => m,
Err(e) => return format!("error:query parse failed: {e}"),
};
if let Err(e) = enforce_wasm_molecule_size(&query) {
return format!(
"error:{}",
e.as_string()
.unwrap_or_else(|| "molecule too large".to_string())
);
}
let smiles_list = match parse_smiles_json_array(db_smiles_json) {
Ok(values) => values,
Err(e) => {
return format!(
"error:{}",
e.as_string()
.unwrap_or_else(|| "db_smiles_json parse failed".to_string())
);
}
};
let mut db = Vec::with_capacity(smiles_list.len());
let mut original_indices = Vec::with_capacity(smiles_list.len());
for (idx, smiles) in smiles_list.iter().enumerate() {
let mol = match chematic_smiles::parse(smiles) {
Ok(mol) => mol,
Err(e) => return format!("error:db parse failed at index {idx}: {e}"),
};
if let Err(e) = enforce_wasm_molecule_size(&mol) {
return format!(
"error:db molecule at index {idx}: {}",
e.as_string()
.unwrap_or_else(|| "molecule too large".to_string())
);
}
db.push(mol);
original_indices.push(idx);
}
let results = chematic_fp::nearest_neighbors(&query, &db, k, chematic_fp::FpType::Ecfp4);
let entries: Vec<String> = results
.iter()
.map(|(i, t)| {
let original_idx = original_indices[*i];
format!("{{\"index\":{original_idx},\"tanimoto\":{t:.6}}}")
})
.collect();
format!("[{}]", entries.join(","))
}
#[wasm_bindgen]
pub fn virtual_screen_ecfp4_json(query_smi: &str, db_smiles_json: &str, k: u32) -> String {
if let Err(e) = enforce_wasm_input_len("query_smi", query_smi) {
return format!("error:{}", e.as_string().unwrap_or_else(|| "input too large".to_string()));
}
let query_mol = match chematic_smiles::parse(query_smi) {
Ok(m) => m,
Err(e) => return format!("error:query parse failed: {e}"),
};
if let Err(e) = enforce_wasm_molecule_size(&query_mol) {
return format!("error:{}", e.as_string().unwrap_or_else(|| "molecule too large".to_string()));
}
let smiles_list = match parse_smiles_json_array(db_smiles_json) {
Ok(v) => v,
Err(e) => return format!("error:{}", e.as_string().unwrap_or_else(|| "db parse failed".to_string())),
};
let mut db_fps = Vec::with_capacity(smiles_list.len());
for (idx, smi) in smiles_list.iter().enumerate() {
let mol = match chematic_smiles::parse(smi) {
Ok(m) => m,
Err(e) => return format!("error:db parse failed at index {idx}: {e}"),
};
if let Err(e) = enforce_wasm_molecule_size(&mol) {
return format!("error:db molecule at index {idx}: {}",
e.as_string().unwrap_or_else(|| "molecule too large".to_string()));
}
db_fps.push(chematic_fp::ecfp4(&mol));
}
let query_fp = chematic_fp::ecfp4(&query_mol);
let k = (k as usize).min(smiles_list.len());
let hits = chematic_fp::top_k_similar(&query_fp, &db_fps, k);
let entries: Vec<String> = hits.iter().enumerate().map(|(rank, (idx, score))| {
let smi_escaped = smiles_list[*idx].replace('\\', "\\\\").replace('"', "\\\"");
format!(r#"{{"rank":{},"score":{:.6},"smiles":"{}","idx":{}}}"#,
rank + 1, score, smi_escaped, idx)
}).collect();
format!(r#"{{"results":[{}]}}"#, entries.join(","))
}
#[wasm_bindgen]
pub fn tanimoto_row_json(query_smi: &str, db_smiles_json: &str) -> String {
if let Err(e) = enforce_wasm_input_len("query_smi", query_smi) {
return format!("error:{}", e.as_string().unwrap_or_else(|| "input too large".to_string()));
}
let query_mol = match chematic_smiles::parse(query_smi) {
Ok(m) => m,
Err(e) => return format!("error:query parse failed: {e}"),
};
if let Err(e) = enforce_wasm_molecule_size(&query_mol) {
return format!("error:{}", e.as_string().unwrap_or_else(|| "molecule too large".to_string()));
}
let smiles_list = match parse_smiles_json_array(db_smiles_json) {
Ok(v) => v,
Err(e) => return format!("error:{}", e.as_string().unwrap_or_else(|| "db parse failed".to_string())),
};
let mut db_fps = Vec::with_capacity(smiles_list.len());
for (idx, smi) in smiles_list.iter().enumerate() {
let mol = match chematic_smiles::parse(smi) {
Ok(m) => m,
Err(e) => return format!("error:db parse failed at index {idx}: {e}"),
};
if let Err(e) = enforce_wasm_molecule_size(&mol) {
return format!("error:db molecule at index {idx}: {}",
e.as_string().unwrap_or_else(|| "molecule too large".to_string()));
}
db_fps.push(chematic_fp::ecfp4(&mol));
}
let query_fp = chematic_fp::ecfp4(&query_mol);
let scores = chematic_fp::tanimoto_slice(&query_fp, &db_fps);
let parts: Vec<String> = scores.iter().map(|s| format!("{s:.6}")).collect();
format!("[{}]", parts.join(","))
}
#[wasm_bindgen]
pub fn mol2_to_smiles(mol2_str: &str) -> String {
match chematic_mol::parse_mol2(mol2_str) {
Ok((mol, _)) => chematic_smiles::write(&mol),
Err(e) => format!("error:{e}"),
}
}
#[wasm_bindgen]
pub fn smiles_to_mol2(smiles: &str) -> String {
match chematic_smiles::parse(smiles) {
Ok(mol) => chematic_mol::write_mol2(&mol, &[]),
Err(e) => format!("error:{e}"),
}
}
#[wasm_bindgen]
pub fn inchi_from_smiles(smiles: &str) -> String {
match chematic_smiles::parse(smiles) {
Ok(mol) => chematic_inchi::inchi(&mol),
Err(e) => format!("error:{e}"),
}
}
#[wasm_bindgen]
pub fn inchikey_from_smiles(smiles: &str) -> String {
match chematic_smiles::parse(smiles) {
Ok(mol) => {
let inchi_str = chematic_inchi::inchi(&mol);
chematic_inchi::inchi_key(&inchi_str)
}
Err(e) => format!("error:{e}"),
}
}
#[wasm_bindgen]
pub fn invert_stereocenter_at(mol: &MolHandle, atom_idx: u32) -> Result<MolHandle, JsValue> {
let idx = chematic_core::AtomIdx(atom_idx);
if atom_idx as usize >= mol.inner.atom_count() {
return Err(JsValue::from_str(&format!("atom_idx {} out of range", atom_idx)));
}
let new_mol = chematic_chem::invert_stereocenter(&mol.inner, idx);
Ok(MolHandle {
inner: std::rc::Rc::new(new_mol),
})
}
#[wasm_bindgen]
pub fn get_bond_length_json(smiles: &str, a: u32, b: u32) -> f64 {
if smiles.len() > WASM_MAX_INPUT_BYTES {
return -1.0;
}
let mol = match chematic_smiles::parse(smiles) {
Ok(m) => m,
Err(_) => return -1.0,
};
let coords = chematic_3d::generate_coords(&mol);
let a_idx = chematic_core::AtomIdx(a);
let b_idx = chematic_core::AtomIdx(b);
if a >= mol.atom_count() as u32 || b >= mol.atom_count() as u32 {
return -1.0;
}
chematic_3d::get_bond_length(&coords, a_idx, b_idx)
}
#[wasm_bindgen]
pub fn get_dihedral_json(smiles: &str, a: u32, b: u32, c: u32, d: u32) -> JsValue {
if smiles.len() > WASM_MAX_INPUT_BYTES {
return JsValue::NULL;
}
let mol = match chematic_smiles::parse(smiles) {
Ok(m) => m,
Err(_) => return JsValue::NULL,
};
if a >= mol.atom_count() as u32
|| b >= mol.atom_count() as u32
|| c >= mol.atom_count() as u32
|| d >= mol.atom_count() as u32
{
return JsValue::NULL;
}
let coords = chematic_3d::generate_coords(&mol);
let a_idx = chematic_core::AtomIdx(a);
let b_idx = chematic_core::AtomIdx(b);
let c_idx = chematic_core::AtomIdx(c);
let d_idx = chematic_core::AtomIdx(d);
match chematic_3d::get_dihedral_deg(&coords, a_idx, b_idx, c_idx, d_idx) {
Some(angle) => JsValue::from_f64(angle),
None => JsValue::NULL,
}
}
#[wasm_bindgen]
pub fn set_dihedral_json(smiles: &str, a: u32, b: u32, c: u32, d: u32, angle_deg: f64) -> Result<String, String> {
if smiles.len() > WASM_MAX_INPUT_BYTES {
return Err("Input SMILES too long".to_string());
}
let mol = chematic_smiles::parse(smiles).map_err(|e| format!("Parse error: {}", e))?;
if a >= mol.atom_count() as u32
|| b >= mol.atom_count() as u32
|| c >= mol.atom_count() as u32
|| d >= mol.atom_count() as u32
{
return Err("Atom index out of range".to_string());
}
let coords = chematic_3d::generate_coords(&mol);
let a_idx = chematic_core::AtomIdx(a);
let b_idx = chematic_core::AtomIdx(b);
let c_idx = chematic_core::AtomIdx(c);
let d_idx = chematic_core::AtomIdx(d);
let angle_rad = angle_deg.to_radians();
let new_coords = chematic_3d::set_dihedral(&coords, &mol, a_idx, b_idx, c_idx, d_idx, angle_rad);
Ok(chematic_3d::write_pdb(&mol, &new_coords))
}
#[wasm_bindgen]
pub fn random_smiles_json(smiles: &str, count: usize, seed: u64) -> Result<String, String> {
if smiles.len() > WASM_MAX_INPUT_BYTES {
return Err("Input SMILES too long".to_string());
}
let mol = chematic_smiles::parse(smiles).map_err(|e| format!("Parse error: {}", e))?;
let count = count.min(100); let variants = chematic_smiles::random_smiles_vect(&mol, count, seed);
let json = format!(
"[{}]",
variants
.iter()
.map(|s| format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")))
.collect::<Vec<_>>()
.join(",")
);
Ok(json)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(s: &str) -> MolHandle {
MolHandle {
inner: std::rc::Rc::new(chematic_smiles::parse(s).unwrap()),
}
}
#[test]
fn parse_benzene_atom_count() {
assert_eq!(parse("c1ccccc1").atom_count(), 6);
}
#[test]
fn logd_at_ph7_benzene() {
let h = parse("c1ccccc1");
let logd = h.logd_at_ph(7.0);
assert!(logd.is_finite(), "logD should be finite, got {logd}");
}
#[test]
fn logd_profile_json_has_ph_fields() {
let h = parse("c1ccccc1");
let json = h.logd_profile_json(0.0, 14.0, 15);
assert!(json.starts_with('[') && json.ends_with(']'),
"should be JSON array: {json}");
assert!(json.contains(r#""ph":"#), "should contain ph field: {json}");
assert!(json.contains(r#""logd":"#), "should contain logd field: {json}");
}
#[test]
fn isotope_distribution_json_nonempty() {
let h = parse("C");
let json = h.isotope_distribution_json(0.1);
assert!(json.starts_with('['), "should be JSON array: {json}");
assert!(json.contains(r#""mass":"#), "should contain mass entries: {json}");
}
#[test]
fn randic_index_ethane() {
let h = parse("CC");
let r = h.randic_index();
assert!((r - 1.0).abs() < 1e-6, "ethane Randic index = 1.0, got {r}");
}
#[test]
fn zagreb_m1_ethane() {
let h = parse("CC");
let z = h.zagreb_index_m1();
assert_eq!(z, 2, "ethane Zagreb M1 = 2, got {z}");
}
#[test]
fn iupac_name_benzene() {
assert_eq!(parse("c1ccccc1").iupac_name(), "benzene");
}
#[test]
fn iupac_name_ethanol() {
assert_eq!(parse("CCO").iupac_name(), "ethanol");
}
#[test]
fn iupac_name_branched_acid() {
assert_eq!(parse("CC(C)C(=O)O").iupac_name(), "2-methylpropanoic acid");
}
#[test]
fn iupac_name_unsupported_returns_empty() {
let h = parse("C.C");
assert!(h.iupac_name().is_empty(), "disconnected should return empty");
}
#[test]
fn assign_cip_json_no_centers() {
let json = parse("CC").assign_cip_json();
assert_eq!(json, r#"{"centers":[]}"#);
}
#[test]
fn assign_cip_json_chiral_center() {
let json = parse("N[C@@H](C)C(=O)O").assign_cip_json();
assert!(json.contains("\"code\":"), "should contain code field: {json}");
assert!(json.contains("\"atom\":"), "should contain atom field: {json}");
}
#[test]
fn canonical_smiles_benzene() {
let mol = parse("c1ccccc1");
let cs = mol.canonical_smiles();
assert!(!cs.is_empty());
}
#[test]
fn parse_cxsmiles_json_preserves_metadata() {
let json = parse_cxsmiles_json("C~O |$C1;O2$,atomProp:1.role.acceptor,^2:0,Z:0|").unwrap();
assert!(json.contains(r#""atomLabels":["C1","O2"]"#), "{json}");
assert!(json.contains(r#""key":"role""#), "{json}");
assert!(json.contains(r#""atomRadicals":[2,null]"#), "{json}");
assert!(json.contains(r#""zeroBonds":[0]"#), "{json}");
}
#[test]
fn parse_cxsmarts_json_preserves_metadata() {
let json =
parse_cxsmarts_json("[#6]~[#8] |$C1;O2$,atomProp:1.role.acceptor,^2:0|").unwrap();
assert!(json.contains(r#""atomCount":2"#), "{json}");
assert!(json.contains(r#""atomLabels":["C1","O2"]"#), "{json}");
assert!(json.contains(r#""key":"role""#), "{json}");
assert!(json.contains(r#""atomRadicals":[2,null]"#), "{json}");
}
#[test]
fn molecular_weight_aspirin() {
let mw = parse("CC(=O)Oc1ccccc1C(=O)O").molecular_weight();
assert!((mw - 180.16).abs() < 1.0);
}
#[test]
fn lipinski_aspirin() {
assert!(parse("CC(=O)Oc1ccccc1C(=O)O").lipinski_passes());
}
#[test]
fn tanimoto_same_mol() {
let a = parse("c1ccccc1");
let b = parse("c1ccccc1");
let sim = tanimoto_ecfp4(&a, &b);
assert!((sim - 1.0).abs() < 1e-6);
}
#[test]
fn tanimoto_different() {
let a = parse("c1ccccc1");
let b = parse("CC(=O)Oc1ccccc1C(=O)O");
assert!(tanimoto_ecfp4(&a, &b) < 1.0);
}
#[test]
fn heavy_atom_count_ethanol() {
assert_eq!(parse("CCO").heavy_atom_count(), 3);
}
#[test]
fn logp_crippen_aspirin_range() {
let lp = parse("CC(=O)Oc1ccccc1C(=O)O").logp_crippen();
assert!(lp > 0.5 && lp < 3.5, "aspirin LogP = {lp:.3}");
}
#[test]
fn fsp3_benzene_zero() {
assert_eq!(parse("c1ccccc1").fsp3(), 0.0, "benzene Fsp3 = 0");
}
#[test]
fn fsp3_cyclohexane_one() {
assert_eq!(parse("C1CCCCC1").fsp3(), 1.0, "cyclohexane Fsp3 = 1");
}
#[test]
fn aromatic_ring_count_benzene() {
assert_eq!(parse("c1ccccc1").aromatic_ring_count(), 1);
}
#[test]
fn qed_aspirin_range() {
let q = parse("CC(=O)Oc1ccccc1C(=O)O").qed();
assert!(q > 0.0 && q <= 1.0, "aspirin QED = {q:.3}");
}
#[test]
fn exact_mass_aspirin() {
let em = parse("CC(=O)Oc1ccccc1C(=O)O").exact_mass();
assert!((em - 180.042).abs() < 0.01, "aspirin exact mass = {em:.4}");
}
#[test]
fn rotatable_bond_count_aspirin() {
let rb = parse("CC(=O)Oc1ccccc1C(=O)O").rotatable_bond_count();
assert!((2..=5).contains(&rb), "aspirin rotbonds = {rb}");
}
#[test]
fn tanimoto_atom_pair_same_mol() {
let a = parse("c1ccccc1");
let b = parse("c1ccccc1");
assert!((tanimoto_atom_pair(&a, &b) - 1.0).abs() < 1e-6);
}
#[test]
fn tanimoto_torsion_same_mol() {
let a = parse("CCCC");
let b = parse("CCCC");
assert!((tanimoto_torsion(&a, &b) - 1.0).abs() < 1e-6);
}
#[test]
fn brics_fragment_count_benzene() {
assert_eq!(brics_fragment_count(&parse("c1ccccc1")), 1);
}
#[test]
fn brics_fragment_count_aspirin() {
assert!(brics_fragment_count(&parse("CC(=O)Oc1ccccc1C(=O)O")) >= 2);
}
#[test]
fn wiener_index_ethane() {
assert_eq!(parse("CC").wiener_index(), 1.0);
}
#[test]
fn kappa1_propane_range() {
let k = parse("CCC").kappa1();
assert!(k > 0.0, "kappa1 should be positive");
}
#[test]
fn chi0_benzene_positive() {
assert!(parse("c1ccccc1").chi0() > 0.0);
}
#[test]
fn labute_asa_aspirin_range() {
let asa = parse("CC(=O)Oc1ccccc1C(=O)O").labute_asa();
assert!(asa > 50.0 && asa < 200.0, "aspirin LabuteASA = {asa:.2}");
}
#[test]
fn bertz_ct_benzene_positive() {
assert!(parse("c1ccccc1").bertz_ct() > 0.0);
}
#[test]
fn morgan_fp_counts_json_benzene() {
let json = parse("c1ccccc1").morgan_fp_counts_json(2);
assert!(json.starts_with('{') && json.ends_with('}'));
}
#[test]
fn add_remove_hydrogens_roundtrip() {
let mol = parse("CC");
let with_h = add_hydrogens(&mol);
assert!(
with_h.atom_count() > mol.atom_count(),
"H atoms should be added"
);
let back = remove_hydrogens(&with_h);
assert_eq!(back.atom_count(), mol.atom_count());
}
#[test]
fn depict_svg_grid_two_mols() {
let svg = depict_svg_grid("CC\nCCC", 2);
assert!(svg.contains("<svg"), "expected SVG output");
}
#[test]
fn depict_svg_grid_invalid_smiles_skipped() {
let svg = depict_svg_grid("CC\nNOT_A_SMILES\nCCC", 2);
assert!(
svg.contains("<svg"),
"invalid SMILES should be silently skipped"
);
}
#[test]
fn run_reactants_esterification() {
let result = run_reactants(
"[C:1](=O)[OH:2].[O:3][C:4]>>[C:1](=O)[O:3][C:4]",
"CC(=O)O|CCO",
);
assert!(result.is_ok(), "run_reactants should succeed");
let json = result.unwrap();
assert!(json.contains('['), "expected JSON array");
}
#[test]
fn is_valid_smiles_valid() {
assert!(is_valid_smiles("CCO"), "ethanol is valid");
assert!(is_valid_smiles("c1ccccc1"), "benzene is valid");
assert!(is_valid_smiles("O"), "water is valid");
assert!(is_valid_smiles("C"), "methane is valid");
}
#[test]
fn is_valid_smiles_invalid() {
assert!(!is_valid_smiles(""), "empty string is invalid");
assert!(
!is_valid_smiles("[NOSUCHELEMENT]"),
"unknown bracket atom is invalid"
);
}
#[test]
fn depict_svg_opts_transparent_background() {
let h = parse("CCO");
let mut opts = DepictOptions::new();
opts.set_background("transparent".to_string());
let svg = h.depict_svg_opts(&opts);
assert!(svg.contains("<svg"), "must produce SVG");
assert!(
!svg.contains("fill=\"transparent\""),
"no bg rect for transparent"
);
}
#[test]
fn depict_svg_opts_custom_size() {
let h = parse("CCO");
let mut opts = DepictOptions::new();
opts.set_width(300);
opts.set_height(200);
let svg = h.depict_svg_opts(&opts);
assert!(svg.contains("width=\"300\""), "SVG width should be 300");
assert!(svg.contains("height=\"200\""), "SVG height should be 200");
}
#[test]
fn depict_svg_opts_dark_theme() {
let h = parse("CC");
let mut opts = DepictOptions::new();
opts.set_dark(true);
opts.set_background("#0f172a".to_string());
let svg = h.depict_svg_opts(&opts);
assert!(
svg.contains("stroke=\"white\""),
"dark theme bonds should be white"
);
}
#[test]
fn depict_svg_single_atom_water_shows_h2o() {
let svg = parse("O").depict_svg();
assert!(svg.contains("H2O"), "water 'O' should render as H2O");
}
#[test]
fn depict_svg_single_atom_methane_shows_ch4() {
let svg = parse("C").depict_svg();
assert!(svg.contains("CH4"), "methane 'C' should render as CH4");
}
#[test]
fn depict_svg_disconnected_nacl() {
let svg = parse("[Na+].[Cl-]").depict_svg();
assert!(
svg.contains("Na"),
"Na should appear in disconnected SMILES SVG"
);
assert!(
svg.contains("Cl"),
"Cl should appear in disconnected SMILES SVG"
);
assert!(!svg.is_empty());
}
#[test]
fn depict_svg_disconnected_water_dimer() {
let svg = parse("O.O").depict_svg();
assert!(
svg.matches("H2O").count() >= 2,
"both water O atoms should render as H2O"
);
assert!(!svg.is_empty());
}
#[test]
fn depict_svg_opts_atom_ids_contains_data_attrs() {
let h = parse("CC(=O)O"); let mut opts = DepictOptions::new();
opts.set_atom_ids(true);
let svg = h.depict_svg_opts(&opts);
assert!(
svg.contains("data-atom-idx="),
"atom_ids should add data-atom-idx"
);
assert!(
svg.contains("data-element="),
"atom_ids should add data-element"
);
assert!(
svg.contains("data-charge="),
"atom_ids should add data-charge"
);
assert_eq!(
svg.matches("data-atom-idx=").count(),
4,
"all atoms should have data-atom-idx"
);
}
#[test]
fn depict_svg_opts_atom_ids_false_no_data_attrs() {
let h = parse("CC(=O)O");
let svg = h.depict_svg_opts(&DepictOptions::new());
assert!(
!svg.contains("data-atom-idx="),
"default opts should not have data-atom-idx"
);
}
#[test]
fn depict_svg_opts_atom_ids_charge_correct() {
let h = parse("[NH4+]");
let mut opts = DepictOptions::new();
opts.set_atom_ids(true);
let svg = h.depict_svg_opts(&opts);
assert!(
svg.contains("data-charge=\"1\""),
"NH4+ should have charge=1"
);
}
#[test]
fn depict_svg_opts_show_atom_indices() {
let h = parse("c1ccccc1"); let mut opts = DepictOptions::new();
opts.set_show_atom_indices(true);
let svg = h.depict_svg_opts(&opts);
assert!(svg.contains(">0<"), "index 0 should appear");
assert!(svg.contains(">5<"), "index 5 should appear");
}
#[test]
fn depict_svg_opts_show_atom_indices_false_no_indices() {
let h = parse("CCO");
let svg = h.depict_svg_opts(&DepictOptions::new());
assert!(
!svg.contains("fill=\"#8b92a9\""),
"default should not show grey index labels"
);
}
#[test]
fn depict_svg_opts_kekulize_removes_aromatic_bonds() {
let h = parse("c1ccccc1"); let mut opts = DepictOptions::new();
opts.set_kekulize(true);
let svg = h.depict_svg_opts(&opts);
assert!(!svg.is_empty());
assert!(
svg.contains("<line"),
"kekulé benzene should have line elements"
);
assert!(
!svg.contains("stroke-dasharray"),
"kekulé benzene must not use aromatic dashed style"
); }
#[test]
fn depict_svg_opts_kekulize_false_uses_aromatic() {
let h = parse("c1ccccc1");
let svg = h.depict_svg_opts(&DepictOptions::new());
assert!(
svg.contains("stroke-dasharray"),
"default benzene should use aromatic dashed style"
);
}
#[test]
fn smarts_match_benzene_ring_returns_json() {
let mol = parse("c1ccccc1");
let result = smarts_match_atoms("c1ccccc1", &mol);
assert!(result.is_ok(), "valid SMARTS should not error");
let json = result.unwrap();
assert!(
!json.is_empty() && json != "[]",
"benzene ring SMARTS should find a match"
);
assert!(json.starts_with("[["), "result should be array of arrays");
}
#[test]
fn smarts_match_no_match_returns_empty_array() {
let mol = parse("CC"); let result = smarts_match_atoms("c1ccccc1", &mol);
assert!(
result.is_ok(),
"valid SMARTS on non-matching mol should not error"
);
assert_eq!(
result.unwrap(),
"[]",
"no match should return empty JSON array"
);
}
#[test]
fn generate_3d_pdb_benzene_has_6_hetatm_lines() {
let mol = parse("c1ccccc1");
let pdb = generate_3d_pdb(&mol);
let hetatm_count = pdb.lines().filter(|l| l.starts_with("HETATM")).count();
assert_eq!(hetatm_count, 6, "benzene should produce 6 HETATM lines");
}
#[test]
fn generate_3d_pdb_aspirin_no_nan() {
let mol = parse("CC(=O)Oc1ccccc1C(=O)O");
let pdb = generate_3d_pdb(&mol);
assert!(
!pdb.contains("nan") && !pdb.contains("inf"),
"PDB must have no NaN/Inf coords"
);
assert!(pdb.contains("HETATM"), "must produce HETATM records");
}
#[test]
fn smarts_parse_invalid_is_err() {
assert!(
chematic_smarts::parse_smarts("[invalid").is_err(),
"invalid SMARTS should return Err from parse_smarts"
);
}
const ETHANE_MOL_BLOCK: &str = "\
ethane
chematic
2 1 0 0 0 0 0 0 0 0 0 V2000
0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
1.5000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
1 2 1 0
M END
";
#[test]
fn mol_from_sdf_block_ethane_atom_count() {
let h = mol_from_sdf_block(ETHANE_MOL_BLOCK).expect("ethane parse");
assert_eq!(h.atom_count(), 2);
}
#[test]
fn sdf_to_smiles_json_two_records() {
let sdf = format!("{ETHANE_MOL_BLOCK}$$$$\n{ETHANE_MOL_BLOCK}$$$$\n");
let json = sdf_to_smiles_json(&sdf);
assert!(json.starts_with('[') && json.ends_with(']'));
let count = json.matches("CC").count();
assert_eq!(count, 2, "expected 2 ethane SMILES in JSON, got: {json}");
}
#[test]
fn estate_indices_json_acetic_acid_nonempty() {
let h = parse("CC(=O)O");
let json = estate_indices_json(&h);
assert!(json.starts_with('[') && json.ends_with(']'));
assert!(!json.is_empty() && json != "[]");
}
#[test]
fn tanimoto_topo_path_same_mol_is_one() {
let a = parse("c1ccccc1");
let b = parse("c1ccccc1");
assert!((tanimoto_topo_path(&a, &b) - 1.0).abs() < 1e-9);
}
#[test]
fn tanimoto_topo_path_different_mols_lt_one() {
let a = parse("c1ccccc1");
let b = parse("CC(=O)Oc1ccccc1C(=O)O");
assert!(tanimoto_topo_path(&a, &b) < 1.0);
}
#[test]
fn sum_estate_aspirin_positive() {
let h = parse("CC(=O)Oc1ccccc1C(=O)O");
assert!(h.sum_estate() > 0.0);
}
#[test]
fn max_min_estate_ordering() {
let h = parse("CC(=O)O");
assert!(h.max_estate() >= h.min_estate());
}
#[test]
fn depict_reaction_svg_esterification() {
let svg = depict_reaction_svg("CC(=O)O.CCO>>CC(=O)OCC.O").unwrap();
assert!(svg.contains("→"), "must contain arrow character");
assert!(svg.contains("<svg"), "must be valid SVG");
}
#[test]
fn depict_reaction_svg_single_step() {
let svg = depict_reaction_svg("C>>CC").unwrap();
assert!(svg.contains("→"));
assert!(svg.contains("<svg"));
}
#[test]
fn rxn_parse_missing_arrow_is_err() {
assert!(chematic_rxn::parse_reaction("not_a_reaction").is_err());
}
#[test]
fn identify_functional_groups_pyridine_has_n() {
let h = parse("c1ccncc1");
let json = identify_functional_groups(&h);
assert!(
json.contains('N'),
"pyridine FG JSON should contain N: {json}"
);
assert!(json.starts_with('['), "should be JSON array");
}
#[test]
fn identify_functional_groups_hexane_empty() {
let h = parse("CCCCCC");
let json = identify_functional_groups(&h);
assert_eq!(json, "[]", "hexane should have no functional groups");
}
#[test]
fn gasteiger_charges_json_oxygen_negative() {
let h = parse("CC(=O)O"); let json = gasteiger_charges_json(&h);
assert!(json.starts_with('[') && json.ends_with(']'));
let has_negative = json
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.any(|s| s.trim().parse::<f64>().map(|v| v < 0.0).unwrap_or(false));
assert!(
has_negative,
"acetic acid should have at least one negative charge: {json}"
);
}
#[test]
fn test_mmff94_charges_json_length() {
let h = parse("CC(=O)O");
let json = mmff94_charges_json(&h);
assert!(json.starts_with('[') && json.ends_with(']'));
let count = json
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.count();
assert_eq!(count, 4, "acetic acid should have 4 charges, got {count}");
}
#[test]
fn test_mmff94_charges_json_acetate_negative() {
let h = parse("CC(=O)[O-]");
let json = mmff94_charges_json(&h);
let total: f64 = json
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.filter_map(|s| s.trim().parse::<f64>().ok())
.sum();
assert!(
(total + 1.0).abs() < 0.01,
"acetate total charge should be -1, got {total}"
);
}
#[test]
fn test_mhfp_hashes_json_format() {
let h = parse("c1ccccc1");
let json = mhfp_hashes_json(&h);
assert!(json.contains("\"num_hashes\":128"), "should have num_hashes:128");
assert!(json.contains("\"hashes\":"), "should have hashes key");
}
#[test]
fn test_tanimoto_mhfp_smiles_self() {
let result = tanimoto_mhfp_smiles("c1ccccc1", "c1ccccc1").unwrap();
assert!((result - 1.0).abs() < 1e-9, "self-similarity should be 1.0, got {result}");
}
#[test]
fn test_mhfp_lsh_handle_query() {
let mut idx = MhfpLshHandle::new(128);
idx.add_smiles("c1ccccc1").unwrap(); idx.add_smiles("Cc1ccccc1").unwrap(); idx.add_smiles("CC").unwrap();
let json = idx.query_json("c1ccccc1", 0.99).unwrap();
assert!(json.contains("\"index\":0"), "benzene should find itself: {json}");
assert_eq!(idx.len(), 3);
}
#[test]
fn to_mol_block_has_nonzero_coords() {
let h = parse("c1ccccc1"); let block = to_mol_block(&h);
let nonzero = block.lines().skip(4).take(6).any(|line| {
if line.len() < 30 {
return false;
}
let x: f64 = line[0..10].trim().parse().unwrap_or(0.0);
let y: f64 = line[10..20].trim().parse().unwrap_or(0.0);
x.abs() > 0.01 || y.abs() > 0.01
});
assert!(
nonzero,
"to_mol_block should write real 2D coordinates, got:\n{block}"
);
}
#[test]
fn sa_score_range() {
let h = parse("CC(=O)Oc1ccccc1C(=O)O"); let score = sa_score(&h);
assert!(
(1.0..=10.0).contains(&score),
"SA score out of [1,10]: {score:.2}"
);
}
#[test]
fn mol_with_atom_charge_changes_charge() {
let mol = parse("N"); let mol2 = mol_with_atom_charge(&mol, 0, 1).unwrap();
let atom = mol2.inner.atom(chematic_core::AtomIdx(0));
assert_eq!(atom.charge, 1, "charge should be 1");
}
#[test]
fn mol_with_atom_element_changes_element() {
let mol = parse("CC");
let mol2 = mol_with_atom_element(&mol, 0, "N").unwrap();
let atom = mol2.inner.atom(chematic_core::AtomIdx(0));
assert_eq!(atom.element.symbol(), "N", "element should be N");
}
#[test]
fn mol_block_coords_json_ethanol() {
let mol_block = "\nethanol\n\n 3 2 0 0 0 0 0 0 0 0 0 V2000\n 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 1.5000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 3.0000 0.0000 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 1 2 1 0\n 2 3 1 0\nM END\n";
let json = mol_block_coords_json(mol_block).unwrap();
assert!(
json.contains("[0.0000,0.0000]"),
"first atom at origin: {json}"
);
assert!(
json.contains("[1.5000,0.0000]"),
"second atom at x=1.5: {json}"
);
}
#[test]
fn cdxml_to_smiles_json_two_fragments() {
let cdxml = r#"<?xml version="1.0"?>
<CDXML>
<fragment>
<n id="1" Element="6" p="0 0"/>
<n id="2" Element="6" p="10 0"/>
<b B="1" E="2" Order="1"/>
</fragment>
<fragment>
<n id="3" Element="8" p="30 0"/>
<n id="4" Element="6" p="40 0"/>
<b B="3" E="4" Order="1"/>
</fragment>
</CDXML>"#;
let json = cdxml_to_smiles_json(cdxml).unwrap();
let count = json.matches(',').count() + 1;
assert_eq!(count, 2, "should have 2 fragments: {json}");
}
#[test]
fn cdxml_wedge_bond_becomes_up() {
let cdxml = r#"<?xml version="1.0"?>
<CDXML>
<fragment>
<n id="1" Element="6" p="0 0"/>
<n id="2" Element="6" p="10 0"/>
<b B="1" E="2" Order="1" Display="WedgeBegin"/>
</fragment>
</CDXML>"#;
let (mol, _) = chematic_mol::parse_cdxml(cdxml).unwrap();
let bond = mol.bond(chematic_core::BondIdx(0));
assert_eq!(bond.order, chematic_core::BondOrder::Up, "WedgeBegin → Up");
}
#[test]
fn depict_data_with_coords_uses_provided_coords() {
let mol = parse("CC");
let json = depict_data_with_coords_json(&mol, "[[100.0,200.0],[300.0,400.0]]");
assert!(json.contains("100."), "x coord should be 100: {json}");
assert!(json.contains("200."), "y coord should be 200: {json}");
}
#[test]
fn mol_with_atom_added_increases_count() {
let mol = parse("CC");
let mol2 = mol_with_atom_added(&mol, "O").unwrap();
assert_eq!(mol2.atom_count(), mol.atom_count() + 1);
}
#[test]
fn mol_with_bond_added_increases_count() {
let mol = parse("CC.O"); let mol2 = mol_with_bond_added(&mol, 1, 2, 1).unwrap();
assert_eq!(mol2.bond_count(), mol.bond_count() + 1);
}
#[test]
fn mol_with_atom_removed_decreases_count() {
let mol = parse("CCO");
let mol2 = mol_with_atom_removed(&mol, 2).unwrap();
assert_eq!(mol2.atom_count(), mol.atom_count() - 1);
assert_eq!(mol2.bond_count(), mol.bond_count() - 1);
}
#[test]
fn mol_with_bond_removed_decreases_count() {
let mol = parse("CC");
let mol2 = mol_with_bond_removed(&mol, 0).unwrap();
assert_eq!(mol2.atom_count(), 2, "atoms preserved");
assert_eq!(mol2.bond_count(), 0, "bond removed");
}
#[test]
fn mol_next_atom_idx_equals_atom_count() {
let mol = parse("CCO");
assert_eq!(mol_next_atom_idx(&mol), 3);
}
#[test]
fn smiles_array_to_sdf_contains_dollar_delimiters() {
let sdf = smiles_array_to_sdf(r#"["CC","CCO"]"#).unwrap();
assert_eq!(sdf.matches("$$$$").count(), 2, "2 delimiters: {sdf}");
}
#[test]
fn to_mol_v3000_block_contains_v3000() {
let mol = parse("CC");
let v3k = to_mol_v3000_block(&mol);
assert!(v3k.contains("V3000"), "should contain V3000 tag");
assert!(v3k.contains("M V30 BEGIN ATOM"), "should have atom block");
}
#[test]
fn depict_data_json_benzene_atoms_bonds() {
let mol = parse("c1ccccc1");
let json = depict_data_json(&mol);
assert!(json.contains("\"atoms\":"), "should have atoms key");
assert!(json.contains("\"bonds\":"), "should have bonds key");
assert!(json.contains("\"element\":\"C\""), "benzene has C atoms");
}
#[test]
fn depict_data_json_pyridine_nitrogen_has_label() {
let mol = parse("c1ccncc1");
let json = depict_data_json(&mol);
assert!(
json.contains("\"label\":\"N\""),
"pyridine N should have label: {json}"
);
}
#[test]
fn cpk_color_nitrogen_is_blue() {
assert_eq!(cpk_color("N"), "#3050F8", "N is blue in CPK");
}
#[test]
fn cpk_color_carbon_is_black() {
assert_eq!(cpk_color("C"), "#000000", "C is black in CPK");
}
#[test]
fn mol_from_cml_ethanol() {
let cml = r#"<molecule>
<atomArray>
<atom id="a1" elementType="C" x2="0.0" y2="0.0"/>
<atom id="a2" elementType="C" x2="1.5" y2="0.0"/>
<atom id="a3" elementType="O" x2="3.0" y2="0.0"/>
</atomArray>
<bondArray>
<bond atomRefs2="a1 a2" order="1"/>
<bond atomRefs2="a2 a3" order="1"/>
</bondArray>
</molecule>"#;
let mol = mol_from_cml(cml).unwrap();
assert_eq!(mol.atom_count(), 3, "ethanol: 3 heavy atoms");
assert_eq!(mol.bond_count(), 2, "ethanol: 2 bonds");
}
#[test]
fn to_cml_contains_molecule_tag() {
let mol = parse("CC(=O)O");
let cml = to_cml(&mol);
assert!(cml.contains("<molecule"), "should contain <molecule");
assert!(cml.contains("elementType="), "should contain elementType");
assert!(cml.contains("atomRefs2="), "should contain atomRefs2");
}
#[test]
fn to_cml_roundtrip_atom_count() {
let mol = parse("CC(=O)O");
let cml = to_cml(&mol);
let mol2 = mol_from_cml(&cml).unwrap();
assert_eq!(
mol.atom_count(),
mol2.atom_count(),
"CML round-trip preserves atom count"
);
assert_eq!(
mol.bond_count(),
mol2.bond_count(),
"CML round-trip preserves bond count"
);
}
#[test]
fn mol_from_cdxml_ethanol() {
let cdxml = r#"<?xml version="1.0"?>
<CDXML>
<fragment>
<n id="1" p="10.0 20.0" Element="6"/>
<n id="2" p="25.0 20.0" Element="6"/>
<n id="3" p="40.0 20.0" Element="8"/>
<b B="1" E="2" Order="1"/>
<b B="2" E="3" Order="1"/>
</fragment>
</CDXML>"#;
let mol = mol_from_cdxml(cdxml).unwrap();
assert_eq!(mol.atom_count(), 3, "CDXML ethanol: 3 heavy atoms");
assert_eq!(mol.bond_count(), 2, "CDXML ethanol: 2 bonds");
}
#[test]
fn mol_from_cml_unknown_element_returns_err() {
let cml = r#"<molecule><atomArray>
<atom id="a1" elementType="Xx"/>
</atomArray></molecule>"#;
let result = chematic_mol::parse_cml(cml);
assert!(result.is_err(), "unknown element should return Err");
}
#[test]
fn mmp_pairs_json_ethylbenzene_propylbenzene() {
let smiles_json = r#"["CCc1ccccc1","CCCc1ccccc1"]"#;
let result = mmp_pairs_json(smiles_json).unwrap();
assert!(
result.contains("\"core\":"),
"should have core key: {result}"
);
assert!(
result.contains("c1(ccccc1)[*]"),
"core should be benzene: {result}"
);
assert!(
result.contains("\"fragment_a\":"),
"should have fragment_a: {result}"
);
assert!(
result.contains("\"fragment_b\":"),
"should have fragment_b: {result}"
);
}
#[test]
fn mmp_pairs_json_no_pairs_for_single_molecule() {
let smiles_json = r#"["CCc1ccccc1"]"#;
let result = mmp_pairs_json(smiles_json).unwrap();
assert_eq!(result, "[]", "single molecule should have no pairs");
}
#[test]
fn mmp_pairs_json_three_molecules() {
let smiles_json = r#"["CCc1ccccc1","CCCc1ccccc1","CCCCc1ccccc1"]"#;
let result = mmp_pairs_json(smiles_json).unwrap();
assert!(
result.contains("\"mol_a\":"),
"should have mol_a key: {result}"
);
assert!(
result.contains("\"core\":"),
"should have core key: {result}"
);
let pair_count = result.matches("\"mol_a\":").count();
assert!(
pair_count >= 1,
"should have >= 1 pair, got {pair_count}: {result}"
);
}
#[test]
fn conformer_handle_new_and_add() {
let ens = ConformerHandle::new("CCCC").unwrap(); assert_eq!(ens.conformer_count(), 0, "new ensemble has 0 conformers");
}
#[test]
fn conformer_handle_add_generated() {
let mut ens = ConformerHandle::new("CCCC").unwrap();
let idx = ens.add_generated_conformer();
assert_eq!(idx, 0, "first conformer has index 0");
assert_eq!(ens.conformer_count(), 1);
}
#[test]
fn conformer_handle_add_minimized() {
let mut ens = ConformerHandle::new("CCCC").unwrap();
let idx = ens.add_minimized_conformer();
assert_eq!(idx, 0);
assert_eq!(ens.conformer_count(), 1);
}
#[test]
fn conformer_handle_get_pdb_returns_hetatm() {
let mut ens = ConformerHandle::new("CC").unwrap();
ens.add_generated_conformer();
let pdb = ens.get_conformer_pdb(0).expect("conformer 0 exists");
assert!(pdb.contains("HETATM"), "PDB should contain HETATM records");
}
#[test]
fn conformer_handle_rmsd_same_conformer() {
let mut ens = ConformerHandle::new("CCCC").unwrap();
ens.add_generated_conformer();
ens.add_generated_conformer();
let rmsd = ens.conformer_rmsd(0, 0);
assert!(rmsd.is_finite(), "RMSD should be finite");
assert!(rmsd >= 0.0, "RMSD must be non-negative");
}
#[test]
fn conformer_handle_rmsd_out_of_range_is_nan() {
let mut ens = ConformerHandle::new("CC").unwrap();
ens.add_generated_conformer();
assert!(ens.conformer_rmsd(0, 99).is_nan(), "out-of-range gives NaN");
}
#[test]
fn conformer_handle_remove_conformer() {
let mut ens = ConformerHandle::new("CC").unwrap();
ens.add_generated_conformer();
assert!(ens.remove_conformer(0));
assert_eq!(ens.conformer_count(), 0);
assert!(!ens.remove_conformer(0), "already removed");
}
#[test]
fn conformer_handle_mol_returns_correct_atom_count() {
let ens = ConformerHandle::new("CC(=O)O").unwrap(); let mol = ens.mol();
assert_eq!(mol.atom_count(), 4);
}
#[test]
fn rgroup_toluene_ethylbenzene_para_substituted() {
let smiles_json = r#"["Cc1ccccc1","CCc1ccccc1"]"#;
let json = rgroup_decompose_json(smiles_json, "c1ccc(*)cc1").unwrap();
assert!(
json.contains("\"matched\":true"),
"both should match: {json}"
);
assert!(json.contains("\"r1\":"), "should have r1 key: {json}");
}
#[test]
fn rgroup_no_match_returns_false() {
let smiles_json = r#"["c1ccccc1"]"#;
let json = rgroup_decompose_json(smiles_json, "c1ccc(*)cc1").unwrap();
assert!(
json.contains("\"matched\":false"),
"unsubstituted benzene should not match: {json}"
);
}
#[test]
fn rgroup_two_attachment_points() {
let smiles_json = r#"["Cc1cccc(C)c1"]"#;
let json = rgroup_decompose_json(smiles_json, "c1cc(*)cc(*)c1").unwrap();
assert!(
json.contains("\"matched\":true"),
"m-xylene should match: {json}"
);
assert!(json.contains("\"r1\":"), "should have r1: {json}");
assert!(json.contains("\"r2\":"), "should have r2: {json}");
}
#[test]
fn rgroup_invalid_smarts_returns_err() {
let result = chematic_smarts::parse_smarts("~~~invalid~~~");
assert!(result.is_err(), "invalid SMARTS should not parse");
}
#[test]
fn fcfp4_bitvec_length_256() {
assert_eq!(fcfp4_bitvec(&parse("c1ccccc1")).len(), 256);
}
#[test]
fn fcfp6_bitvec_length_256() {
assert_eq!(fcfp6_bitvec(&parse("c1ccccc1")).len(), 256);
}
#[test]
fn dice_ecfp6_identical_is_one() {
let mol = parse("c1ccccc1");
assert!((dice_ecfp6(&mol, &mol) - 1.0).abs() < 1e-9);
}
#[test]
fn write_smiles_contains_same_atoms() {
let mol = parse("CC(=O)O");
let smi = write_smiles(&mol);
let mol2 = parse_smiles(&smi).unwrap();
assert_eq!(mol.atom_count(), mol2.atom_count());
}
#[test]
fn normalize_reaction_smiles_roundtrip() {
let result = chematic_rxn::parse_reaction("CC>>CO");
assert!(result.is_ok(), "simple reaction should parse");
let rxn = result.unwrap();
let out = chematic_rxn::write_reaction(&rxn);
assert!(
out.contains(">>"),
"written reaction should contain >>: {out}"
);
}
#[test]
fn brics_fragments_json_aspirin() {
let mol = parse("CC(=O)Oc1ccccc1C(=O)O"); let json = brics_fragments_json(&mol);
let count_from_json = json
.split('"')
.filter(|s| s.contains('C') || s.contains('c'))
.count();
assert!(
json.starts_with('[') && json.ends_with(']'),
"not a JSON array: {json}"
);
assert_eq!(
count_from_json,
brics_fragment_count(&mol),
"fragment count mismatch: json={json}"
);
}
#[test]
fn brics_fragments_json_benzene_self() {
let mol = parse("c1ccccc1");
let json = brics_fragments_json(&mol);
assert!(json.starts_with('[') && json.ends_with(']'));
assert_eq!(
brics_fragment_count(&mol),
1,
"benzene has 1 fragment (itself): {json}"
);
}
#[test]
fn atom_pair_bitvec_length_256() {
let mol = parse("c1ccccc1");
assert_eq!(atom_pair_bitvec(&mol).len(), 256);
}
#[test]
fn torsion_bitvec_length_256() {
let mol = parse("CCCCC");
assert_eq!(torsion_bitvec(&mol).len(), 256);
}
#[test]
fn tanimoto_fcfp6_identical_is_one() {
let mol = parse("c1ccccc1");
assert!((tanimoto_fcfp6(&mol, &mol) - 1.0).abs() < 1e-9);
}
#[test]
fn sdf_from_records_json_round_trip() {
let smiles_json = r#"["CC"]"#;
let names_json = r#"["ethane"]"#;
let props_json = r#"["MW\t30.07\nSource\ttest"]"#;
let sdf = sdf_from_records_json(smiles_json, names_json, props_json).unwrap();
assert!(sdf.contains("ethane"), "name missing: {sdf}");
assert!(sdf.contains("> <MW>"), "MW field missing: {sdf}");
assert!(sdf.contains("30.07"), "MW value missing: {sdf}");
assert!(sdf.contains("$$$$"), "SDF delimiter missing: {sdf}");
}
#[test]
fn sdf_from_records_json_no_props() {
let sdf = sdf_from_records_json(r#"["C"]"#, r#"["methane"]"#, r#"[""]"#).unwrap();
assert!(sdf.contains("methane"));
assert!(sdf.contains("$$$$"));
}
#[test]
fn sdf_from_records_json_length_mismatch_returns_err() {
let smiles = parse_smiles_json_array(r#"["CC","CCC"]"#).unwrap();
let names = parse_smiles_json_array(r#"["ethane"]"#).unwrap();
assert_ne!(smiles.len(), names.len(), "lengths should differ");
}
#[test]
fn parse_smiles_json_array_handles_escaped_strings() {
let smiles = parse_smiles_json_array(r#"["C\\C=C\\O","CC"]"#).unwrap();
assert_eq!(smiles[0], r#"C\C=C\O"#);
assert_eq!(smiles[1], "CC");
}
#[test]
fn mol_from_xyz_roundtrip_atom_count() {
let xyz = "2\nethane\nC 0.0 0.0 0.0\nC 1.5 0.0 0.0\n";
let result = chematic_3d::parse_xyz(xyz);
assert!(result.is_ok(), "valid XYZ should parse");
let (mol, _) = result.unwrap();
assert_eq!(mol.atom_count(), 2);
}
#[test]
fn to_xyz_contains_atom_lines() {
let mol = parse("CC"); let xyz = to_xyz(&mol);
assert!(xyz.contains('C'), "XYZ should contain C atom lines");
let count: usize = xyz.lines().next().unwrap().trim().parse().unwrap();
assert_eq!(count, mol.atom_count(), "XYZ header atom count matches mol");
}
#[test]
fn mol_from_pdb_returns_handle() {
let h = mol_from_pdb("");
assert_eq!(h.atom_count(), 0);
}
#[test]
fn logp_per_atom_json_length() {
let mol = parse("CC(=O)O"); let json = logp_per_atom_json(&mol);
let count = json
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.count();
assert_eq!(
count,
mol.atom_count(),
"per-atom logP array length mismatch"
);
}
#[test]
fn mr_per_atom_json_length() {
let mol = parse("CC(=O)O");
let json = mr_per_atom_json(&mol);
let count = json
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.count();
assert_eq!(count, mol.atom_count());
}
#[test]
fn labute_asa_per_atom_json_no_nan() {
let mol = parse("c1ccccc1");
let json = labute_asa_per_atom_json(&mol);
assert!(!json.contains("NaN"), "no NaN in asa per-atom: {json}");
}
#[test]
fn sssr_rings_json_benzene_one_ring() {
let mol = parse("c1ccccc1");
let json = sssr_rings_json(&mol);
assert!(
json.starts_with("[[") && json.ends_with("]]"),
"expected one ring: {json}"
);
let ring: Vec<usize> = json
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.filter_map(|s| s.trim_matches(|c| c == '[' || c == ']').parse().ok())
.collect();
assert_eq!(ring.len(), 6, "benzene ring has 6 atoms");
}
#[test]
fn sssr_rings_json_naphthalene_two_rings() {
let mol = parse("c1ccc2ccccc2c1"); let json = sssr_rings_json(&mol);
let ring_count = json.matches("],[").count() + 1;
assert_eq!(ring_count, 2, "naphthalene has 2 rings: {json}");
}
#[test]
fn ecfp_bitvec_custom_256_length() {
let mol = parse("c1ccccc1");
let bv = ecfp_bitvec_custom(&mol, 2, 256, false);
assert_eq!(bv.len(), 32, "256-bit FP = 32 bytes");
}
#[test]
fn ecfp_bitvec_custom_identical_tanimoto() {
let mol = parse("c1ccccc1");
let a = ecfp_bitvec_custom(&mol, 2, 512, false);
let b = ecfp_bitvec_custom(&mol, 2, 512, false);
assert_eq!(a, b, "same mol same FP");
}
#[test]
fn ecfp4_bitvec_with_chirality_l_vs_d_alanine_different() {
let l_ala = parse("C[C@H](N)C(=O)O");
let d_ala = parse("C[C@@H](N)C(=O)O");
let l_fp = ecfp4_bitvec_with_chirality(&l_ala, true);
let d_fp = ecfp4_bitvec_with_chirality(&d_ala, true);
assert_ne!(
l_fp, d_fp,
"L-alanine and D-alanine should have different ECFP4 with use_chirality=true"
);
}
#[test]
fn ecfp4_bitvec_with_chirality_l_vs_d_alanine_same_without_chirality() {
let l_ala = parse("C[C@H](N)C(=O)O");
let d_ala = parse("C[C@@H](N)C(=O)O");
let l_fp = ecfp4_bitvec_with_chirality(&l_ala, false);
let d_fp = ecfp4_bitvec_with_chirality(&d_ala, false);
assert_eq!(
l_fp, d_fp,
"L-alanine and D-alanine should have identical ECFP4 with use_chirality=false"
);
}
#[test]
fn ecfp6_bitvec_with_chirality_l_vs_d_alanine_different() {
let l_ala = parse("C[C@H](N)C(=O)O");
let d_ala = parse("C[C@@H](N)C(=O)O");
let l_fp = ecfp6_bitvec_with_chirality(&l_ala, true);
let d_fp = ecfp6_bitvec_with_chirality(&d_ala, true);
assert_ne!(
l_fp, d_fp,
"L-alanine and D-alanine should have different ECFP6 with use_chirality=true"
);
}
#[test]
fn smarts_match_atoms_with_chirality_chiral_atom_matches_only_with_flag() {
let l_ala = parse("C[C@H](N)C(=O)O");
let d_ala = parse("C[C@@H](N)C(=O)O");
let l_matches_with = smarts_match_atoms_with_chirality("[C@H]", &l_ala, true).unwrap();
let l_matches_without = smarts_match_atoms_with_chirality("[C@H]", &l_ala, false).unwrap();
let d_matches_with = smarts_match_atoms_with_chirality("[C@H]", &d_ala, true).unwrap();
let d_matches_without = smarts_match_atoms_with_chirality("[C@H]", &d_ala, false).unwrap();
assert!(
!l_matches_with.contains("[]"),
"L-ala should match [C@H] with use_chirality=true"
);
assert!(
d_matches_with.contains("[]"),
"D-ala should NOT match [C@H] with use_chirality=true"
);
assert!(
!l_matches_without.contains("[]"),
"L-ala should match [C@H] with use_chirality=false"
);
assert!(
!d_matches_without.contains("[]"),
"D-ala should match [C@H] with use_chirality=false (chirality ignored)"
);
}
#[test]
fn enumerate_stereo_isomers_one_center_gives_two() {
let mol = parse("C(F)(Cl)Br");
let result = enumerate_stereo_isomers_json(&mol);
assert!(result.is_ok(), "should return Ok for valid mol");
let json = result.unwrap();
let count = json.matches("\"smiles\"").count();
assert_eq!(count, 2, "expected 2 stereoisomers: {json}");
}
#[test]
fn enumerate_stereo_isomers_already_specified_gives_one() {
let mol = parse("C[C@H](F)Cl");
let result = enumerate_stereo_isomers_json(&mol);
assert!(result.is_ok());
let json = result.unwrap();
let count = json.matches("\"smiles\"").count();
assert_eq!(count, 1, "fully specified mol should give 1 isomer: {json}");
}
#[test]
fn enumerate_stereo_isomers_no_center_gives_one() {
let mol = parse("CC");
let result = enumerate_stereo_isomers_json(&mol);
assert!(result.is_ok());
let json = result.unwrap();
assert!(json.starts_with('[') && json.ends_with(']'));
}
#[test]
fn mol_from_v3000_block_parses_atom_count() {
assert!(
chematic_mol::parse_mol_v3000(
"\n \n\n 0 0 0 0 0 0 0 0999 V3000\nM V30 BEGIN CTAB\n\
M V30 COUNTS 2 1 0 0 0\nM V30 BEGIN ATOM\n\
M V30 1 C 0 0 0 0\nM V30 2 C 1.5 0 0 0\n\
M V30 END ATOM\nM V30 BEGIN BOND\n\
M V30 1 1 1 2\nM V30 END BOND\n\
M V30 END CTAB\nM END"
)
.is_ok()
);
}
#[test]
fn generate_3d_minimized_pdb_nonzero_coords() {
let mol = parse("CCCC"); let pdb = generate_3d_minimized_pdb(&mol);
assert!(pdb.contains("HETATM"), "expected HETATM records");
let has_nonzero = pdb.lines().filter(|l| l.starts_with("HETATM")).any(|l| {
if l.len() < 54 {
return false;
}
let x: f64 = l[30..38].trim().parse().unwrap_or(0.0);
let y: f64 = l[38..46].trim().parse().unwrap_or(0.0);
x.abs() > 0.01 || y.abs() > 0.01
});
assert!(
has_nonzero,
"minimized PDB should have non-zero coords:\n{pdb}"
);
}
#[test]
fn sdf_to_records_json_parses_properties() {
let sdf = concat!(
"aspirin\n",
" chematic\n",
"\n",
" 2 1 0 0 0 0 0 0 0 0 0 V2000\n",
" 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n",
" 1.5000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n",
" 1 2 1 0\n",
"M END\n",
"> <MW>\n",
"180.2\n",
"\n",
"> <Source>\n",
"ChEMBL\n",
"\n",
"$$$$\n",
);
let json = sdf_to_records_json(sdf);
assert!(
json.contains("\"name\":\"aspirin\""),
"name missing: {json}"
);
assert!(json.contains("\"MW\":\"180.2\""), "MW missing: {json}");
assert!(
json.contains("\"Source\":\"ChEMBL\""),
"Source missing: {json}"
);
}
#[test]
fn sdf_to_records_json_escapes_special_chars() {
let sdf = concat!(
"mol\n",
" chematic\n",
"\n",
" 1 0 0 0 0 0 0 0 0 0 0 V2000\n",
" 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n",
"M END\n",
"> <Notes>\n",
"line1\n",
"line2\n",
"\n",
"$$$$\n",
);
let json = sdf_to_records_json(sdf);
assert!(
json.contains("\\n"),
"newline in multi-line value should be escaped: {json}"
);
}
#[test]
fn depict_svg_grid_highlighted_no_smarts_returns_grid() {
let smiles = "c1ccccc1\nCCO";
let svg = depict_svg_grid_highlighted(smiles, 2, "");
assert!(svg.contains("mol-0"), "expected mol-0: {svg}");
assert!(svg.contains("mol-1"), "expected mol-1: {svg}");
}
#[test]
fn depict_svg_grid_highlighted_with_smarts_adds_circles() {
let smiles = "c1ccccc1\nc1ccncc1"; let svg = depict_svg_grid_highlighted(smiles, 2, "c1ccccn1"); assert!(svg.contains("<circle"), "expected highlight circles: {svg}");
}
#[test]
fn pains_matches_json_empty_for_clean_mol() {
let mol = parse("c1ccccc1"); let json = pains_matches_json(&mol);
assert_eq!(json, "[]");
}
#[test]
fn pains_matches_json_returns_alert_names() {
let mol = parse("O=C1CSC(=S)N1"); let json = pains_matches_json(&mol);
assert!(json.starts_with('[') && json.ends_with(']'));
let _ = json; }
#[test]
fn cip_assignments_json_aspirin_no_stereo() {
let mol = parse("CC(=O)Oc1ccccc1C(=O)O"); let json = cip_assignments_json(&mol);
assert_eq!(json, "[]");
}
#[test]
fn cip_assignments_json_chiral_center() {
let mol = parse("[C@@H](F)(Cl)Br"); let json = cip_assignments_json(&mol);
assert!(json.contains("cipCode"), "expected cipCode in: {json}");
assert!(json.contains('R') || json.contains('S'));
}
#[test]
fn ecfp6_bitvec_length_256() {
let mol = parse("c1ccccc1");
assert_eq!(ecfp6_bitvec(&mol).len(), 256);
}
#[test]
fn tanimoto_ecfp6_identical_is_one() {
let mol = parse("c1ccccc1");
assert!((tanimoto_ecfp6(&mol, &mol) - 1.0).abs() < 1e-9);
}
#[test]
fn dice_ecfp4_identical_is_one() {
let mol = parse("c1ccccc1");
assert!((dice_ecfp4(&mol, &mol) - 1.0).abs() < 1e-9);
}
#[test]
fn dice_maccs_identical_is_one() {
let mol = parse("c1ccccc1");
assert!((dice_maccs(&mol, &mol) - 1.0).abs() < 1e-9);
}
#[test]
fn num_aliphatic_rings_cyclohexane() {
let mol = parse("C1CCCCC1"); assert_eq!(mol.num_aliphatic_rings(), 1);
assert_eq!(mol.num_saturated_rings(), 1);
assert_eq!(mol.aromatic_ring_count(), 0);
}
#[test]
fn num_unspecified_stereocenters_unspec() {
let mol = parse("C(F)(Cl)Br"); assert_eq!(mol.num_unspecified_stereocenters(), 1);
}
#[test]
fn shape_descriptors_json_benzene_keys() {
let mol = parse("c1ccccc1");
let json = shape_descriptors_json(&mol);
assert!(json.contains("\"pmi1\""), "missing pmi1: {json}");
assert!(json.contains("\"npr1\""), "missing npr1: {json}");
assert!(
json.contains("\"asphericity\""),
"missing asphericity: {json}"
);
}
#[test]
fn shape_descriptors_json_single_atom_no_nan() {
let mol = parse("[Na+]"); let json = shape_descriptors_json(&mol);
assert!(!json.contains("NaN"), "NaN in output: {json}");
assert!(!json.contains("inf"), "inf in output: {json}");
}
#[test]
fn maxmin_picks_returns_correct_count() {
let json = r#"["CC","c1ccccc1","CCO","CCCC","c1cccnc1"]"#;
let result = maxmin_picks_ecfp4_json(json, 3).unwrap();
let count = result
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.count();
assert_eq!(count, 3, "expected 3 picks: {result}");
}
#[test]
fn smiles_parse_fails_for_unclosed_ring() {
assert!(
chematic_smiles::parse("C1CC").is_err(),
"unclosed ring should fail"
);
}
#[test]
fn butina_cluster_all_similar_one_cluster() {
let json = r#"["c1ccccc1","c1ccccc1","c1ccccc1"]"#;
let result = butina_cluster_ecfp4_json(json, 0.0).unwrap();
assert!(result.starts_with('[') && result.ends_with(']'));
}
#[test]
fn mcs_smiles_two_acetyl_compounds() {
let json = r#"["CC(=O)O","CC(=O)N"]"#; let result = mcs_smiles_json(json).unwrap();
assert_ne!(result, "null", "expected a non-null MCS");
assert!(!result.is_empty());
}
#[test]
fn mcs_smiles_no_overlap_returns_null() {
let json = r#"["[Na+]","[Cl-]"]"#; let result = mcs_smiles_json(json).unwrap();
assert!(result == "null" || !result.is_empty());
}
#[test]
fn mcs_smiles_single_input_returns_err() {
let json = r#"["CC"]"#;
let smiles_list: Vec<String> = json
.split('"')
.enumerate()
.filter(|(i, _)| i % 2 == 1)
.map(|(_, s)| s.to_string())
.collect();
assert_eq!(smiles_list.len(), 1, "helper extracted wrong count");
assert!(smiles_list.len() < 2, "should be fewer than 2 inputs");
}
#[test]
fn murcko_scaffold_benzene_ring() {
let mol = parse("c1ccccc1CC(=O)O"); let scaffold = murcko_scaffold(&mol);
let smi = scaffold.canonical_smiles();
assert!(!smi.is_empty(), "murcko_scaffold returned empty SMILES");
}
#[test]
fn canonical_tautomer_returns_handle() {
let mol = parse("Oc1cccc2ccccc12"); let t = canonical_tautomer(&mol);
assert!(t.atom_count() > 0);
}
#[test]
fn enumerate_tautomers_json_is_array() {
let mol = parse("Oc1cccc2ccccc12");
let json = enumerate_tautomers_json(&mol);
assert!(json.starts_with('[') && json.ends_with(']'));
assert!(json.len() > 2, "expected at least one tautomer");
}
#[test]
fn largest_fragment_strips_salt() {
let mol = parse("CC(=O)[O-].[Na+]");
let frag = largest_fragment(&mol);
assert!(frag.atom_count() > 1, "expected the larger fragment");
assert!(
frag.atom_count() < mol.atom_count(),
"fragment should be smaller than the salt"
);
}
#[test]
fn neutralize_charges_removes_charges() {
let mol = parse("CC(=O)[O-]");
let neutral = neutralize_charges(&mol);
assert_eq!(neutral.formal_charge_sum(), 0);
}
#[test]
fn standardize_smiles_report_json_includes_audit_report() {
let json = standardize_smiles_report_json("CC.CCC", true, false, false, false);
assert!(json.contains("\"smiles\""), "missing smiles: {json}");
assert!(json.contains("\"report\""), "missing report: {json}");
assert!(
json.contains("\"status\":\"Modified\""),
"missing status: {json}"
);
assert!(
json.contains("\"step\":\"LargestFragment\""),
"missing largest-fragment step: {json}"
);
}
#[test]
fn maccs_bitvec_length_21() {
let mol = parse("c1ccccc1");
let bv = maccs_bitvec(&mol);
assert_eq!(bv.len(), 21, "MACCS 166 bits should fit in 21 bytes");
}
#[test]
fn tanimoto_maccs_identical_is_one() {
let mol = parse("c1ccccc1");
assert!((tanimoto_maccs(&mol, &mol) - 1.0).abs() < 1e-9);
}
#[test]
fn get_descriptors_json_keys() {
let mol = parse("CC(=O)Oc1ccccc1C(=O)O"); let json = get_descriptors_json(&mol);
assert!(json.contains("\"mw\""), "missing mw: {json}");
assert!(json.contains("\"tpsa\""), "missing tpsa: {json}");
assert!(json.contains("\"qed\""), "missing qed: {json}");
}
#[test]
fn slogp_vsa_json_length_12() {
let h = parse("c1ccccc1");
let json = slogp_vsa_json(&h);
let count = json
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.count();
assert_eq!(count, 12, "SlogP_VSA should have 12 values");
}
#[test]
fn smr_vsa_json_length_10() {
let h = parse("c1ccccc1");
let json = smr_vsa_json(&h);
let count = json
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.count();
assert_eq!(count, 10, "SMR_VSA should have 10 values");
}
#[test]
fn peoe_vsa_json_length_14() {
let h = parse("c1ccccc1");
let json = peoe_vsa_json(&h);
let count = json
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.count();
assert_eq!(count, 14, "PEOE_VSA should have 14 values");
}
#[test]
fn test_pka_acid_value_acetic_acid() {
let h = parse("CC(=O)O");
let pka = h.pka_acid_value();
assert!(pka.is_finite(), "acetic acid pKa should be finite");
assert!((pka - 4.0).abs() < 1.0, "acetic acid pKa ~4.0, got {pka:.2}");
}
#[test]
fn test_pka_base_value_aniline() {
let h = parse("Nc1ccccc1");
let pka = h.pka_base_value();
assert!(pka.is_finite(), "aniline pKa_base should be finite");
assert!((pka - 4.6).abs() < 1.0, "aniline pKa_base ~4.6, got {pka:.2}");
}
#[test]
fn test_pka_acid_value_benzene_nan() {
let h = parse("c1ccccc1");
let pka = h.pka_acid_value();
assert!(pka.is_nan(), "benzene has no acidic site → NaN");
}
#[test]
fn test_pka_base_value_benzene_nan() {
let h = parse("c1ccccc1");
let pka = h.pka_base_value();
assert!(pka.is_nan(), "benzene has no basic site → NaN");
}
#[test]
fn test_bbb_score_benzene_positive() {
let h = parse("c1ccccc1");
let score = h.bbb_score();
assert!(score > -1.0, "benzene should be CNS penetrant (logBB > -1), got {score:.3}");
}
#[test]
fn test_bbb_passes_benzene() {
let h = parse("c1ccccc1");
assert!(h.bbb_passes(), "benzene (TPSA=0, MW=78) should pass BBB rules");
}
#[test]
fn test_caco2_hexane_high() {
let h = parse("CCCCCC");
let c = h.caco2_permeability();
assert!(c > -5.5, "hexane should have high Caco-2 permeability, got {c:.3}");
}
#[test]
fn test_herg_risk_range() {
let h = parse("c1ccccc1");
let r = h.herg_risk_score();
assert!((0.0..=1.0).contains(&r), "hERG risk must be in [0,1], got {r}");
}
#[test]
fn test_cyp3a4_risk_range() {
let h = parse("c1ccccc1");
let r = h.cyp3a4_inhibition_risk();
assert!((0.0..=1.0).contains(&r), "CYP3A4 risk must be in [0,1], got {r}");
}
#[test]
fn test_predict_pka_json_acetic_acid() {
let json = predict_pka_json("CC(=O)O");
assert!(json.starts_with('[') && json.ends_with(']'), "should be JSON array");
assert!(json.contains("\"type\":\"acid\""), "should contain acid site");
assert!(!json.contains("\"error\""), "should not contain error");
}
#[test]
fn test_predict_pka_json_benzene_empty() {
let json = predict_pka_json("c1ccccc1");
assert_eq!(json, "[]", "benzene has no ionizable sites");
}
#[test]
fn test_predict_pka_json_invalid_smiles() {
let json = predict_pka_json("C1CC");
assert!(json.contains("\"error\""), "invalid SMILES should return error JSON");
}
#[test]
fn test_predict_pka_json_glycine_both() {
let json = predict_pka_json("NCC(=O)O");
assert!(json.contains("\"type\":\"acid\""), "glycine should have acid site");
assert!(json.contains("\"type\":\"base\""), "glycine should have base site");
}
#[test]
fn test_admet_profile_json_aspirin_valid() {
let json = admet_profile_json("CC(=O)Oc1ccccc1C(=O)O");
assert!(!json.contains("\"error\""), "aspirin should parse without error");
assert!(json.contains("\"bbb_score\""), "should have bbb_score field");
assert!(json.contains("\"caco2\""), "should have caco2 field");
assert!(json.contains("\"herg_risk\""), "should have herg_risk field");
assert!(json.contains("\"pka_acid\""), "should have pka_acid field");
assert!(json.contains("\"bbb_passes\":true"), "aspirin should pass BBB");
}
#[test]
fn test_admet_profile_json_benzene_null_pka() {
let json = admet_profile_json("c1ccccc1");
assert!(json.contains("\"pka_acid\":null"), "benzene pka_acid should be null");
assert!(json.contains("\"pka_base\":null"), "benzene pka_base should be null");
}
#[test]
fn test_admet_profile_json_invalid_smiles() {
let json = admet_profile_json("C1CC");
assert!(json.contains("\"error\""), "invalid SMILES should return error JSON");
}
#[test]
fn test_get_descriptors_json_has_admet_fields() {
let h = parse("c1ccccc1");
let json = get_descriptors_json(&h);
assert!(json.contains("\"bbbScore\""), "should have bbbScore field");
assert!(json.contains("\"caco2\""), "should have caco2 field");
assert!(json.contains("\"hergRisk\""), "should have hergRisk field");
assert!(json.contains("\"cyp3a4Risk\""), "should have cyp3a4Risk field");
assert!(json.contains("\"pkaAcid\""), "should have pkaAcid field");
assert!(json.contains("\"pkaBase\""), "should have pkaBase field");
}
#[test]
fn test_get_descriptors_json_benzene_null_pka() {
let h = parse("c1ccccc1");
let json = get_descriptors_json(&h);
assert!(json.contains("\"pkaAcid\":null"), "benzene pkaAcid should be null");
assert!(json.contains("\"pkaBase\":null"), "benzene pkaBase should be null");
}
}
#[wasm_bindgen]
pub fn ring_families_json(mol: &MolHandle) -> Result<String, JsValue> {
use chematic_perception::{find_sssr, find_ring_families, RingSystemKind};
if mol.inner.atom_count() > WASM_MAX_ATOMS {
return Err(JsValue::from_str(&format!(
"Molecule exceeds maximum atom count ({} > {})",
mol.inner.atom_count(),
WASM_MAX_ATOMS
)));
}
let sssr = find_sssr(&mol.inner);
let families = find_ring_families(&mol.inner, &sssr);
let json_families: Vec<serde_json::Value> = families
.iter()
.map(|fam| {
let kind_str = match fam.kind {
RingSystemKind::Simple => "Simple",
RingSystemKind::Fused => "Fused",
RingSystemKind::Spiro => "Spiro",
RingSystemKind::Bridged => "Bridged",
};
serde_json::json!({
"kind": kind_str,
"atoms": fam.atoms.iter().map(|a| a.0).collect::<Vec<_>>(),
"ringIndices": fam.ring_indices.clone(),
"atomCount": fam.atoms.len(),
"ringCount": fam.ring_indices.len(),
})
})
.collect();
serde_json::to_string(&json_families)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
#[wasm_bindgen]
pub struct MhfpLshHandle {
inner: chematic_fp::MhfpLshIndex,
}
#[wasm_bindgen]
impl MhfpLshHandle {
#[wasm_bindgen(constructor)]
pub fn new(num_hashes: usize) -> MhfpLshHandle {
MhfpLshHandle {
inner: chematic_fp::MhfpLshIndex::new(num_hashes),
}
}
pub fn add_smiles(&mut self, smiles: &str) -> Result<usize, JsValue> {
let mol = chematic_smiles::parse(smiles)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let fp = chematic_fp::mhfp(&mol);
Ok(self.inner.add(fp))
}
pub fn query_json(&self, query_smiles: &str, threshold: f64) -> Result<String, JsValue> {
let mol = chematic_smiles::parse(query_smiles)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let fp = chematic_fp::mhfp(&mol);
let results = self.inner.query(&fp, threshold);
let items: Vec<String> = results
.iter()
.map(|(i, s)| format!(r#"{{"index":{},"similarity":{:.6}}}"#, i, s))
.collect();
Ok(format!("[{}]", items.join(",")))
}
pub fn len(&self) -> usize {
self.inner.len()
}
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
}