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
35 changes: 28 additions & 7 deletions src/TopDownProteomics/ProForma/ProFormaWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,18 @@ void WriteTagOrGroup(object obj, StringBuilder sb, bool displayValue, double wei

bool hasAmbiguousSequence = obj is ProFormaTag tag2 && tag2.HasAmbiguousSequence;

// Additional tags that share this exact range are extra modifications on the same
// range (e.g. "(SEQ)[mod1][mod2]"); they are written after the range closes and are
// not nested ranges.
List<(object, int, int, bool, double)>? sameRangeTags = null;

if (startIndex == endIndex && !hasAmbiguousSequence)
{
// Write sequence up to tag
sb.Append(term.Sequence.Substring(currentIndex, startIndex - currentIndex + 1));
currentIndex = startIndex + 1;
}
else // Handle ambiguity range
else // Handle a range (ambiguity range, or a range bearing one or more modifications)
{
// Write sequence up to range (checking for internal tags)
sb.Append(term.Sequence[currentIndex..startIndex]);
Expand All @@ -121,19 +126,29 @@ void WriteTagOrGroup(object obj, StringBuilder sb, bool displayValue, double wei
if (hasAmbiguousSequence)
sb.Append('?');

// Check for other tags that might be inside this range
// Check for other tags that fall within this range
int j = i + 1;
while (j < tagsAndGroups.Count && tagsAndGroups[j].Item2 <= endIndex)
{
(object, int, int, bool, double) internalTag = tagsAndGroups[j];

if (internalTag.Item2 != internalTag.Item3)
if (internalTag.Item2 == startIndex && internalTag.Item3 == endIndex)
{
// Another modification on the same range; emit it after the ')'.
(sameRangeTags ??= new List<(object, int, int, bool, double)>()).Add(internalTag);
}
else if (internalTag.Item2 != internalTag.Item3)
{
throw new ProFormaParseException("Can't nest ranges within each other.");
}
else
{
// A single-residue tag located inside the range.
sb.Append(term.Sequence[currentIndex..(internalTag.Item2 + 1)]);
currentIndex = internalTag.Item2 + 1;

sb.Append(term.Sequence[currentIndex..(internalTag.Item2 + 1)]);
currentIndex = internalTag.Item2 + 1;

WriteTagOrGroup(internalTag.Item1, sb, internalTag.Item4, internalTag.Item5);
WriteTagOrGroup(internalTag.Item1, sb, internalTag.Item4, internalTag.Item5);
}

j++;
i++;
Expand All @@ -144,6 +159,12 @@ void WriteTagOrGroup(object obj, StringBuilder sb, bool displayValue, double wei
}

WriteTagOrGroup(obj, sb, displayValue, weight);

if (sameRangeTags != null)
{
foreach (var extra in sameRangeTags)
WriteTagOrGroup(extra.Item1, sb, extra.Item4, extra.Item5);
}
}

// Write the rest of the sequence
Expand Down
59 changes: 59 additions & 0 deletions tests/TopDownProteomics.Tests/ProForma/ProFormaWriterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -365,5 +365,64 @@ public void WriteSequenceAmbiguities()

Assert.AreEqual("SE(?Q)UENCE", result);
}

[Test]
public void WriteMultipleModificationsOnSameRange()
{
// A range bearing more than one modification: "(SEQ)[mod1][mod2]" (ProForma 2.0 section 4.5).
// Each modification is a separate tag spanning the same range; they must be emitted as
// consecutive descriptors after the range, not treated as nested ranges.
var term = new ProFormaTerm("SEQUENCE", tags: new[]
{
new ProFormaTag(2, 5, new[] { new ProFormaDescriptor(ProFormaKey.Mass, "+14.05") }),
new ProFormaTag(2, 5, new[] { new ProFormaDescriptor(ProFormaKey.Name, "Oxidation") })
});
var result = _writer.WriteString(term);

Assert.AreEqual("SE(QUEN)[+14.05][Oxidation]CE", result);
}

[Test]
public void RoundTripMultipleModificationsOnRange()
{
// Regression for the "Can't nest ranges within each other" writer bug on a range that
// carries several modifications (ProForma 2.0 section 4.5).
var parser = new ProFormaParser();
string proForma = "PRT(ESFRMS)[Oxidation][Oxidation][half cystine][half cystine]ISK";

string written = _writer.WriteString(parser.ParseString(proForma));

Assert.AreEqual(proForma, written);
}

[Test]
public void WriteSingleResidueModificationInsideRange()
{
// A point modification localized to one residue that sits inside a range:
// the inner residue tag is written within the range parentheses, the range
// descriptor after the closing ')'.
var term = new ProFormaTerm("SEQUENCE", tags: new[]
{
new ProFormaTag(1, 5, new[] { new ProFormaDescriptor(ProFormaKey.Mass, "+14.05") }),
new ProFormaTag(3, new[] { new ProFormaDescriptor(ProFormaKey.Name, "Oxidation") })
});
var result = _writer.WriteString(term);

Assert.AreEqual("S(EQU[Oxidation]EN)[+14.05]CE", result);
}

[Test]
public void WriteNestedRangesThrows()
{
// A genuine range nested inside another range (distinct start/end pairs) is invalid
// ProForma and must still be rejected.
var term = new ProFormaTerm("SEQUENCE", tags: new[]
{
new ProFormaTag(1, 5, new[] { new ProFormaDescriptor(ProFormaKey.Mass, "+14.05") }),
new ProFormaTag(2, 4, new[] { new ProFormaDescriptor(ProFormaKey.Name, "Oxidation") })
});

Assert.Throws<ProFormaParseException>(() => _writer.WriteString(term));
}
}
}
Loading