isospec 0.1.0+2.6.0

Fine-structure isotopic distributions of molecules: the IsoSpec C++ library, built from source, behind a safe Rust API
Documentation
/*
 *   Copyright (C) 2015-2026 Mateusz Łącki and Michał Startek.
 *
 *   This file is part of IsoSpec.
 *
 *   IsoSpec is free software: you can redistribute it and/or modify
 *   it under the terms of the Simplified ("2-clause") BSD licence.
 *
 *   IsoSpec is distributed in the hope that it will be useful,
 *   but WITHOUT ANY WARRANTY; without even the implied warranty of
 *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 *
 *   You should have received a copy of the Simplified BSD Licence
 *   along with IsoSpec.  If not, see <https://opensource.org/licenses/BSD-2-Clause>.
 */

#include "unimod.h"

#include <cstdint>
#include <fstream>
#include <map>
#include <memory>
#include <mutex>
#include <sstream>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>

#include "isoSpec++.h"  // parse_formula_tokens -- reused for reading a composition column
#include "unimod_table_data.h"

namespace IsoSpec {

namespace {

// Upper bound on an id this dense, id-indexed table will accept from a CSV.
// Generous by three orders of magnitude against Unimod's real id space (the
// shipped table's largest id is 2147) precisely so it never has to be
// revisited when Unimod grows; tight enough that a typo can't turn into a
// gigabyte-scale vector.
constexpr uint64_t kMaxUnimodId = 1000000;

// Splits a CSV line into exactly 4 fields (id, name, mono_mass, composition).
// Unimod names never contain a comma in the shipped table, but this stays
// defensive for a hand-edited override CSV: if splitting on ',' yields more
// than 4 pieces, the extra commas are assumed to belong to the name field
// (the only free-text one) and are rejoined into it.
std::vector<std::string> split_csv_line(const std::string& line) {
    std::vector<std::string> fields;
    std::stringstream ss(line);
    std::string field;
    while (std::getline(ss, field, ','))
        fields.push_back(field);
    if (fields.size() < 4)
        throw std::invalid_argument("Invalid unimod CSV row (expected 4 fields): " + line);
    if (fields.size() > 4) {
        std::string composition = fields.back();
        fields.pop_back();
        std::string mono_mass = fields.back();
        fields.pop_back();
        std::string id = fields.front();
        std::string name;
        for (size_t i = 1; i < fields.size(); i++) {
            if (i > 1)
                name += ",";
            name += fields[i];
        }
        fields = {id, name, mono_mass, composition};
    }
    return fields;
}

}  // namespace

UnimodTable parse_unimod_csv(const std::string& csv_text) {
    UnimodTable table;

    std::stringstream stream(csv_text);
    std::string line;
    bool first_line = true;
    while (std::getline(stream, line)) {
        if (!line.empty() && line.back() == '\r')
            line.pop_back();
        if (line.empty())
            continue;
        if (first_line) {
            // header row ("id,name,mono_mass,composition")
            first_line = false;
            continue;
        }

        std::vector<std::string> fields = split_csv_line(line);

        uint64_t id;
        try {
            id = std::stoull(fields[0]);
        } catch (const std::exception&) {
            throw std::invalid_argument("Invalid unimod CSV row (bad id): " + line);
        }
        // entries_ is dense, indexed straight by id, so a single mistyped id
        // in a hand-edited override CSV would ask the allocator for
        // id * sizeof(UnimodEntry) bytes and mean it. Unimod's real id space
        // is four digits (the shipped table tops out at 2147); anything past
        // this cap is a typo, not a modification, and saying so beats
        // discovering it as a multi-gigabyte allocation.
        if (id > kMaxUnimodId)
            throw std::invalid_argument("Invalid unimod CSV row (id implausibly large): " + line);

        double mono_mass;
        try {
            mono_mass = std::stod(fields[2]);
        } catch (const std::exception&) {
            throw std::invalid_argument("Invalid unimod CSV row (bad mono_mass): " + line);
        }

        UnimodEntry entry;
        entry.present = true;
        entry.name = fields[1];
        entry.mono_mass = mono_mass;
        // Reuses the exact same formula-string parser as everything else in
        // IsoSpec (isoSpec++.cpp) -- the composition column is written in
        // that native grammar by scripts/build_unimod_table.py precisely so
        // this call works, rather than a second, bespoke tokenizer for
        // Unimod's own "H(3) C(2) N O" notation.
        parse_formula_tokens(std::string_view(fields[3]), entry.element_first_index, entry.element_delta_count);

        if (id >= table.entries_.size())
            table.entries_.resize(id + 1);
        table.entries_[id] = std::move(entry);
    }

    return table;
}

const UnimodTable& embedded_unimod_table() {
    // kEmbeddedUnimodCsvChunks is split into pieces small enough for MSVC's
    // string-literal size limit (see unimod_table_data.h's header comment);
    // reassemble into one string once, here, before parsing.
    static const UnimodTable instance = [] {
        std::string csv;
        for (std::size_t i = 0; i < kEmbeddedUnimodCsvChunkCount; i++)
            csv += kEmbeddedUnimodCsvChunks[i];
        return parse_unimod_csv(csv);
    }();
    return instance;
}

const UnimodTable& unimod_table_for_path(const std::string& path) {
    // Path-keyed, never evicted, behind a mutex: the returned reference must
    // stay valid for as long as any caller (on any thread) may still be
    // parsing against it, and a cache that overwrote or dropped a table on a
    // different path's load could not promise that. Never evicting costs one
    // table per distinct path ever passed -- realistically 0 or 1, set once at
    // startup. The table is built while the lock is held, so concurrent first
    // loads queue up rather than parse the same file twice; the lock is held
    // only for lookup thereafter.
    static std::mutex mutex;
    static std::map<std::string, std::unique_ptr<const UnimodTable>> cache;

    const std::lock_guard<std::mutex> lock(mutex);

    auto it = cache.find(path);
    if (it != cache.end())
        return *it->second;

    std::ifstream file(path);
    if (!file)
        throw std::invalid_argument("Failed to open unimod db: " + path);
    std::stringstream buffer;
    buffer << file.rdbuf();

    auto table = std::make_unique<const UnimodTable>(parse_unimod_csv(buffer.str()));
    const UnimodTable& ret = *table;
    cache.emplace(path, std::move(table));
    return ret;
}

}  // namespace IsoSpec