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); + } + } +}