Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions src/TopDownProteomics/ProForma/ProFormaParser.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;

namespace TopDownProteomics.ProForma
Expand Down Expand Up @@ -451,5 +452,142 @@ private void ProcessTag(string tag, int? startIndex, int index, bool inSequenceA
_ => Tuple.Create(ProFormaKey.Name, ProFormaEvidenceType.None, text, groupName, weight)
};
}

/// <summary>
/// Parses a ProForma 2.0 string that may contain constructs above a single term: chimeric
/// peptidoforms (<c>+</c>, section 7.2), charge states (<c>/z[adducts]</c>, section 7.1), and
/// multi-chain / branch chains (<c>//</c>, 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.
/// </summary>
/// <param name="proFormaString">The ProForma string.</param>
/// <returns>The parsed <see cref="ProFormaProteoformGroup"/>.</returns>
/// <exception cref="ArgumentNullException">proFormaString</exception>
/// <exception cref="ProFormaParseException">If a chain or charge state is not valid.</exception>
public ProFormaProteoformGroup ParseProteoformGroupString(string proFormaString)
{
if (proFormaString == null)
throw new ArgumentNullException(nameof(proFormaString));

var peptidoforms = new List<ProFormaPeptidoform>();
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<ProFormaTerm>();
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 == '>';

/// <summary>Splits on a single delimiter that occurs at bracket depth 0.</summary>
private static IEnumerable<string> SplitTopLevel(string text, char delimiter)
{
var parts = new List<string>();
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;
}

/// <summary>Splits on <c>//</c> occurring at bracket depth 0 (the inter-chain / branch separator).</summary>
private static IEnumerable<string> SplitDoubleSlash(string text)
{
var parts = new List<string>();
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;
}

/// <summary>
/// Returns the index of the depth-0 charge slash — a single <c>/</c> not part of a <c>//</c> —
/// or -1 when the peptidoform carries no charge. Chain-separator <c>//</c> pairs are skipped.
/// </summary>
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;
}
}
}
35 changes: 35 additions & 0 deletions src/TopDownProteomics/ProForma/ProFormaPeptidoform.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Collections.Generic;

namespace TopDownProteomics.ProForma
{
/// <summary>
/// A single peptidoform above the level of one <see cref="ProFormaTerm"/>: one or more chains
/// joined by <c>//</c> (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).
/// </summary>
public class ProFormaPeptidoform
{
/// <summary>Initializes a new instance of the <see cref="ProFormaPeptidoform"/> class.</summary>
/// <param name="chains">The chains, in order; joined by <c>//</c> when more than one.</param>
/// <param name="charge">The charge state, or <c>null</c> when none is specified.</param>
/// <param name="ionAdducts">The raw ion-adduct text (e.g. <c>+2Na+,+H+</c>), or <c>null</c>.</param>
public ProFormaPeptidoform(IList<ProFormaTerm> chains, int? charge = null, string? ionAdducts = null)
{
this.Chains = chains;
this.Charge = charge;
this.IonAdducts = ionAdducts;
}

/// <summary>The chains of this peptidoform, in order.</summary>
public IList<ProFormaTerm> Chains { get; }

/// <summary>The charge state, or <c>null</c> when the peptidoform carries no <c>/z</c>.</summary>
public int? Charge { get; }

/// <summary>
/// The raw ion-adduct text between the brackets following the charge (e.g. <c>+2Na+,+H+</c>
/// in <c>/2[+2Na+,+H+]</c>), or <c>null</c> when none is present.
/// </summary>
public string? IonAdducts { get; }
}
}
21 changes: 21 additions & 0 deletions src/TopDownProteomics/ProForma/ProFormaProteoformGroup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.Collections.Generic;

namespace TopDownProteomics.ProForma
{
/// <summary>
/// A group of chimeric peptidoforms encoded in a single ProForma 2.0 string and joined by <c>+</c>
/// (ProForma 2.0 section 7.2). A non-chimeric string yields a group containing one peptidoform.
/// </summary>
public class ProFormaProteoformGroup
{
/// <summary>Initializes a new instance of the <see cref="ProFormaProteoformGroup"/> class.</summary>
/// <param name="peptidoforms">The chimeric peptidoforms, in order.</param>
public ProFormaProteoformGroup(IList<ProFormaPeptidoform> peptidoforms)
{
this.Peptidoforms = peptidoforms;
}

/// <summary>The chimeric peptidoforms, in order; joined by <c>+</c> when more than one.</summary>
public IList<ProFormaPeptidoform> Peptidoforms { get; }
}
}
29 changes: 29 additions & 0 deletions src/TopDownProteomics/ProForma/ProFormaWriter.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;

Expand Down Expand Up @@ -163,6 +164,34 @@ void WriteTagOrGroup(object obj, StringBuilder sb, bool displayValue, double wei
return sb.ToString();
}

/// <summary>
/// Writes a <see cref="ProFormaProteoformGroup"/> to its canonical ProForma 2.0 string,
/// rejoining chains with <c>//</c>, the charge with <c>/z[adducts]</c>, and chimeric
/// peptidoforms with <c>+</c>. Each chain is serialized by <see cref="WriteString(ProFormaTerm)"/>.
/// </summary>
/// <param name="proteoformGroup">The proteoform group.</param>
/// <returns>The canonical ProForma string.</returns>
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<ProFormaDescriptor> descriptors)
{
var sb = new StringBuilder();
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading