diff --git a/src/TopDownProteomics/ProForma/ProFormaParser.cs b/src/TopDownProteomics/ProForma/ProFormaParser.cs
index 7943c50..d1e2209 100644
--- a/src/TopDownProteomics/ProForma/ProFormaParser.cs
+++ b/src/TopDownProteomics/ProForma/ProFormaParser.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Text;
namespace TopDownProteomics.ProForma
@@ -451,5 +452,142 @@ private void ProcessTag(string tag, int? startIndex, int index, bool inSequenceA
_ => Tuple.Create(ProFormaKey.Name, ProFormaEvidenceType.None, text, groupName, weight)
};
}
+
+ ///
+ /// Parses a ProForma 2.0 string that may contain constructs above a single term: chimeric
+ /// peptidoforms (+, section 7.2), charge states (/z[adducts], section 7.1), and
+ /// multi-chain / branch chains (//, sections 4.2.3.2 and 4.2.4). The string is split on
+ /// these top-level operators (respecting bracket nesting) and each chain is parsed by the
+ /// single-term parser. A plain single-term string yields a group of one peptidoform of one chain.
+ ///
+ /// The ProForma string.
+ /// The parsed .
+ /// proFormaString
+ /// If a chain or charge state is not valid.
+ public ProFormaProteoformGroup ParseProteoformGroupString(string proFormaString)
+ {
+ if (proFormaString == null)
+ throw new ArgumentNullException(nameof(proFormaString));
+
+ var peptidoforms = new List();
+ foreach (string peptidoform in SplitTopLevel(proFormaString, '+'))
+ peptidoforms.Add(this.ParsePeptidoform(peptidoform));
+
+ return new ProFormaProteoformGroup(peptidoforms);
+ }
+
+ private ProFormaPeptidoform ParsePeptidoform(string peptidoform)
+ {
+ int chargeSlash = FindChargeSlash(peptidoform);
+ string chainsPart = chargeSlash < 0 ? peptidoform : peptidoform.Substring(0, chargeSlash);
+
+ int? charge = null;
+ string? ionAdducts = null;
+ if (chargeSlash >= 0)
+ (charge, ionAdducts) = ParseChargeState(peptidoform.Substring(chargeSlash + 1));
+
+ var chains = new List();
+ foreach (string chain in SplitDoubleSlash(chainsPart))
+ chains.Add(this.ParseString(chain));
+
+ return new ProFormaPeptidoform(chains, charge, ionAdducts);
+ }
+
+ private static (int charge, string? adducts) ParseChargeState(string text)
+ {
+ int bracket = text.IndexOf('[');
+ string number = bracket < 0 ? text : text.Substring(0, bracket);
+ string? adducts = null;
+
+ if (bracket >= 0)
+ {
+ int close = text.LastIndexOf(']');
+ if (close <= bracket)
+ throw new ProFormaParseException($"Unterminated charge adduct bracket in '{text}'.");
+ adducts = text.Substring(bracket + 1, close - bracket - 1);
+ }
+
+ if (!int.TryParse(number, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out int charge))
+ throw new ProFormaParseException($"Invalid charge value '{number}'.");
+
+ return (charge, adducts);
+ }
+
+ private static bool IsBracketOpen(char c) => c == '[' || c == '(' || c == '{' || c == '<';
+
+ private static bool IsBracketClose(char c) => c == ']' || c == ')' || c == '}' || c == '>';
+
+ /// Splits on a single delimiter that occurs at bracket depth 0.
+ private static IEnumerable SplitTopLevel(string text, char delimiter)
+ {
+ var parts = new List();
+ var sb = new StringBuilder();
+ int depth = 0;
+
+ foreach (char c in text)
+ {
+ if (IsBracketOpen(c)) depth++;
+ else if (IsBracketClose(c)) depth--;
+
+ if (depth == 0 && c == delimiter)
+ {
+ parts.Add(sb.ToString());
+ sb.Clear();
+ }
+ else sb.Append(c);
+ }
+
+ parts.Add(sb.ToString());
+ return parts;
+ }
+
+ /// Splits on // occurring at bracket depth 0 (the inter-chain / branch separator).
+ private static IEnumerable SplitDoubleSlash(string text)
+ {
+ var parts = new List();
+ var sb = new StringBuilder();
+ int depth = 0;
+
+ for (int i = 0; i < text.Length; i++)
+ {
+ char c = text[i];
+ if (IsBracketOpen(c)) depth++;
+ else if (IsBracketClose(c)) depth--;
+
+ if (depth == 0 && c == '/' && i + 1 < text.Length && text[i + 1] == '/')
+ {
+ parts.Add(sb.ToString());
+ sb.Clear();
+ i++; // consume the second '/'
+ }
+ else sb.Append(c);
+ }
+
+ parts.Add(sb.ToString());
+ return parts;
+ }
+
+ ///
+ /// Returns the index of the depth-0 charge slash — a single / not part of a // —
+ /// or -1 when the peptidoform carries no charge. Chain-separator // pairs are skipped.
+ ///
+ private static int FindChargeSlash(string text)
+ {
+ int depth = 0;
+
+ for (int i = 0; i < text.Length; i++)
+ {
+ char c = text[i];
+ if (IsBracketOpen(c)) depth++;
+ else if (IsBracketClose(c)) depth--;
+ else if (depth == 0 && c == '/')
+ {
+ if (i + 1 < text.Length && text[i + 1] == '/') { i++; continue; } // chain separator
+ return i;
+ }
+ }
+
+ return -1;
+ }
}
}
\ No newline at end of file
diff --git a/src/TopDownProteomics/ProForma/ProFormaPeptidoform.cs b/src/TopDownProteomics/ProForma/ProFormaPeptidoform.cs
new file mode 100644
index 0000000..fe119c4
--- /dev/null
+++ b/src/TopDownProteomics/ProForma/ProFormaPeptidoform.cs
@@ -0,0 +1,35 @@
+using System.Collections.Generic;
+
+namespace TopDownProteomics.ProForma
+{
+ ///
+ /// A single peptidoform above the level of one : one or more chains
+ /// joined by // (inter-chain crosslinks and branches, ProForma 2.0 sections 4.2.3.2 and
+ /// 4.2.4), together with an optional charge state and ion adducts (section 7.1).
+ ///
+ public class ProFormaPeptidoform
+ {
+ /// Initializes a new instance of the class.
+ /// The chains, in order; joined by // when more than one.
+ /// The charge state, or null when none is specified.
+ /// The raw ion-adduct text (e.g. +2Na+,+H+), or null.
+ public ProFormaPeptidoform(IList chains, int? charge = null, string? ionAdducts = null)
+ {
+ this.Chains = chains;
+ this.Charge = charge;
+ this.IonAdducts = ionAdducts;
+ }
+
+ /// The chains of this peptidoform, in order.
+ public IList Chains { get; }
+
+ /// The charge state, or null when the peptidoform carries no /z.
+ public int? Charge { get; }
+
+ ///
+ /// The raw ion-adduct text between the brackets following the charge (e.g. +2Na+,+H+
+ /// in /2[+2Na+,+H+]), or null when none is present.
+ ///
+ public string? IonAdducts { get; }
+ }
+}
diff --git a/src/TopDownProteomics/ProForma/ProFormaProteoformGroup.cs b/src/TopDownProteomics/ProForma/ProFormaProteoformGroup.cs
new file mode 100644
index 0000000..67f7056
--- /dev/null
+++ b/src/TopDownProteomics/ProForma/ProFormaProteoformGroup.cs
@@ -0,0 +1,21 @@
+using System.Collections.Generic;
+
+namespace TopDownProteomics.ProForma
+{
+ ///
+ /// A group of chimeric peptidoforms encoded in a single ProForma 2.0 string and joined by +
+ /// (ProForma 2.0 section 7.2). A non-chimeric string yields a group containing one peptidoform.
+ ///
+ public class ProFormaProteoformGroup
+ {
+ /// Initializes a new instance of the class.
+ /// The chimeric peptidoforms, in order.
+ public ProFormaProteoformGroup(IList peptidoforms)
+ {
+ this.Peptidoforms = peptidoforms;
+ }
+
+ /// The chimeric peptidoforms, in order; joined by + when more than one.
+ public IList Peptidoforms { get; }
+ }
+}
diff --git a/src/TopDownProteomics/ProForma/ProFormaWriter.cs b/src/TopDownProteomics/ProForma/ProFormaWriter.cs
index 06f2241..5d5e951 100644
--- a/src/TopDownProteomics/ProForma/ProFormaWriter.cs
+++ b/src/TopDownProteomics/ProForma/ProFormaWriter.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using System.Text;
@@ -163,6 +164,34 @@ void WriteTagOrGroup(object obj, StringBuilder sb, bool displayValue, double wei
return sb.ToString();
}
+ ///
+ /// Writes a to its canonical ProForma 2.0 string,
+ /// rejoining chains with //, the charge with /z[adducts], and chimeric
+ /// peptidoforms with +. Each chain is serialized by .
+ ///
+ /// The proteoform group.
+ /// The canonical ProForma string.
+ public string WriteString(ProFormaProteoformGroup proteoformGroup)
+ {
+ return string.Join("+", proteoformGroup.Peptidoforms.Select(this.WritePeptidoform));
+ }
+
+ private string WritePeptidoform(ProFormaPeptidoform peptidoform)
+ {
+ var sb = new StringBuilder();
+ sb.Append(string.Join("//", peptidoform.Chains.Select(chain => this.WriteString(chain))));
+
+ if (peptidoform.Charge.HasValue)
+ {
+ sb.Append('/').Append(peptidoform.Charge.Value.ToString(CultureInfo.InvariantCulture));
+
+ if (peptidoform.IonAdducts != null)
+ sb.Append('[').Append(peptidoform.IonAdducts).Append(']');
+ }
+
+ return sb.ToString();
+ }
+
private string CreateDescriptorsText(IList descriptors)
{
var sb = new StringBuilder();
diff --git a/tests/TopDownProteomics.Tests/ProForma/ProFormaProteoformGroupTests.cs b/tests/TopDownProteomics.Tests/ProForma/ProFormaProteoformGroupTests.cs
new file mode 100644
index 0000000..e73d628
--- /dev/null
+++ b/tests/TopDownProteomics.Tests/ProForma/ProFormaProteoformGroupTests.cs
@@ -0,0 +1,93 @@
+using NUnit.Framework;
+using TopDownProteomics.ProForma;
+
+namespace TopDownProteomics.Tests.ProForma
+{
+ [TestFixture]
+ public class ProFormaProteoformGroupTests
+ {
+ public static ProFormaParser _parser = new ProFormaParser();
+ public static ProFormaWriter _writer = new ProFormaWriter();
+
+ [Test]
+ public void Charge()
+ {
+ var group = _parser.ParseProteoformGroupString("EMEVEESPEK/2");
+
+ Assert.AreEqual(1, group.Peptidoforms.Count);
+ Assert.AreEqual(1, group.Peptidoforms[0].Chains.Count);
+ Assert.AreEqual(2, group.Peptidoforms[0].Charge);
+ Assert.AreEqual("EMEVEESPEK/2", _writer.WriteString(group));
+ }
+
+ [Test]
+ public void ChargeWithAdducts()
+ {
+ var group = _parser.ParseProteoformGroupString("EMEVEESPEK/2[+2Na+,+H+]");
+
+ Assert.AreEqual(2, group.Peptidoforms[0].Charge);
+ Assert.AreEqual("+2Na+,+H+", group.Peptidoforms[0].IonAdducts);
+ Assert.AreEqual("EMEVEESPEK/2[+2Na+,+H+]", _writer.WriteString(group));
+ }
+
+ [Test]
+ public void NegativeCharge()
+ {
+ var group = _parser.ParseProteoformGroupString("EMEVEESPEK/-2[2I-]");
+
+ Assert.AreEqual(-2, group.Peptidoforms[0].Charge);
+ Assert.AreEqual("EMEVEESPEK/-2[2I-]", _writer.WriteString(group));
+ }
+
+ [Test]
+ public void Chimeric()
+ {
+ var group = _parser.ParseProteoformGroupString("EMEVEESPEK/2+ELVISLIVER/3");
+
+ Assert.AreEqual(2, group.Peptidoforms.Count);
+ Assert.AreEqual(2, group.Peptidoforms[0].Charge);
+ Assert.AreEqual(3, group.Peptidoforms[1].Charge);
+ Assert.AreEqual("EMEVEESPEK/2+ELVISLIVER/3", _writer.WriteString(group));
+ }
+
+ [Test]
+ public void MultiChainSplitsOnDoubleSlash()
+ {
+ var group = _parser.ParseProteoformGroupString("SEK[XLMOD:02001#XL1]UENCE//EMEVTK[XLMOD:02001#XL1]SESPEK");
+
+ Assert.AreEqual(1, group.Peptidoforms.Count);
+ Assert.AreEqual(2, group.Peptidoforms[0].Chains.Count);
+ Assert.IsNull(group.Peptidoforms[0].Charge);
+ }
+
+ [Test]
+ public void SingleTermYieldsOnePeptidoformOneChain()
+ {
+ var group = _parser.ParseProteoformGroupString("EM[Oxidation]EVEES[Phospho]PEK");
+
+ Assert.AreEqual(1, group.Peptidoforms.Count);
+ Assert.AreEqual(1, group.Peptidoforms[0].Chains.Count);
+ Assert.IsNull(group.Peptidoforms[0].Charge);
+ Assert.AreEqual("EM[Oxidation]EVEES[Phospho]PEK", _writer.WriteString(group));
+ }
+
+ // The ProForma 2.0 specification examples above a single term (sections 4.2.3.2, 4.2.4, 7.1, 7.2).
+ [TestCase("EMEVEESPEK/2")]
+ [TestCase("EM[U:Oxidation]EVEES[U:Phospho]PEK/3")]
+ [TestCase("EMEVEESPEK/2[+2Na+,+H+]")]
+ [TestCase("EMEVEESPEK/1[+2Na+,-H+]")]
+ [TestCase("EMEVEESPEK/-2[2I-]")]
+ [TestCase("EMEVEESPEK/-1[+e-]")]
+ [TestCase("EMEVEESPEK/2+ELVISLIVER/3")]
+ [TestCase("SEK[XLMOD:02001#XL1]UENCE//EMEVTK[XLMOD:02001#XL1]SESPEK")]
+ [TestCase("SEK[XLMOD:02001#XL1]UENCE//EMEVTK[#XL1]SESPEK")]
+ [TestCase("ETFGD[MOD:00093#BRANCH]//R[#BRANCH]ATER")]
+ public void RoundTripIsCanonicallyIdempotent(string proForma)
+ {
+ string first = _writer.WriteString(_parser.ParseProteoformGroupString(proForma));
+ string second = _writer.WriteString(_parser.ParseProteoformGroupString(first));
+
+ Assert.AreEqual(first, second);
+ }
+ }
+}