From 09dad6c6fadfc552934b1cd796df30537b9e0c2c Mon Sep 17 00:00:00 2001 From: "Fellers, Ryan" Date: Tue, 8 Sep 2026 16:50:58 -0500 Subject: [PATCH 1/2] Add configurable ProForma residue validation --- .../TopDownProteomics.Benchmarks.csproj | 2 +- .../ProForma/ProFormaParser.cs | 18 +++++++++++++++--- .../ProForma/ProFormaParserTests.cs | 16 +++++++++++++--- .../TopDownProteomics.Tests.csproj | 2 +- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/TopDownProteomics.Benchmarks/TopDownProteomics.Benchmarks.csproj b/src/TopDownProteomics.Benchmarks/TopDownProteomics.Benchmarks.csproj index d1873b5..cadb98a 100644 --- a/src/TopDownProteomics.Benchmarks/TopDownProteomics.Benchmarks.csproj +++ b/src/TopDownProteomics.Benchmarks/TopDownProteomics.Benchmarks.csproj @@ -3,7 +3,7 @@ Exe TopDownProteomics.Benchmarks TopDownProteomics.Benchmarks - net6.0 + net8.0 Consortium for Top-Down Proteomics diff --git a/src/TopDownProteomics/ProForma/ProFormaParser.cs b/src/TopDownProteomics/ProForma/ProFormaParser.cs index d1e2209..7b35234 100644 --- a/src/TopDownProteomics/ProForma/ProFormaParser.cs +++ b/src/TopDownProteomics/ProForma/ProFormaParser.cs @@ -10,10 +10,22 @@ namespace TopDownProteomics.ProForma /// public class ProFormaParser { + private readonly Func _residueValidator; + /// /// Initializes a new instance of the class. /// - public ProFormaParser() { } + public ProFormaParser() : this(char.IsUpper) { } + + /// + /// Initializes a new instance of the class with a residue validator. + /// + /// Determines whether a character is a valid residue. + /// is . + public ProFormaParser(Func residueValidator) + { + _residueValidator = residueValidator ?? throw new ArgumentNullException(nameof(residueValidator)); + } #if !NETSTANDARD2_1 /// @@ -218,7 +230,7 @@ public ProFormaTerm ParseString(ReadOnlySpan proFormaString) else { // Validate amino acid character - if (!char.IsUpper(current)) + if (!_residueValidator(current)) throw new ProFormaParseException($"{current} is not an upper case letter."); // Reset the range as soon as we see an amino acid @@ -263,7 +275,7 @@ private void HandleGlobalModification(ref IDictionary? targets = new List(); for (int k = atSymbolIndex + 1; k < tagText.Length; k++) { - if (char.IsUpper(tagText[k])) + if (_residueValidator(tagText[k])) targets.Add(tagText[k]); else if (tagText[k] != ',') throw new ProFormaParseException($"Unexpected character {tagText[k]} in global modification target list."); diff --git a/tests/TopDownProteomics.Tests/ProForma/ProFormaParserTests.cs b/tests/TopDownProteomics.Tests/ProForma/ProFormaParserTests.cs index ffddd5c..bb8e877 100644 --- a/tests/TopDownProteomics.Tests/ProForma/ProFormaParserTests.cs +++ b/tests/TopDownProteomics.Tests/ProForma/ProFormaParserTests.cs @@ -27,6 +27,19 @@ public void NoModifications() Assert.IsNull(term.Tags); } + [Test] + public void ResidueValidatorControlsSequenceAndGlobalModificationTargets() + { + var parser = new ProFormaParser(residue => residue == 'A' || residue == '*'); + + var term = parser.ParseString("<[Oxidation]@*,A>A*"); + + Assert.AreEqual("A*", term.Sequence); + CollectionAssert.AreEquivalent(new[] { '*', 'A' }, term.GlobalModifications.Single().TargetAminoAcids); + Assert.Throws(() => parser.ParseString("AB")); + Assert.Throws(() => _parser.ParseString("A*")); + } + [Test] [TestCase("PRO[info:test]TEOFORM", "PROTEOFORM", "test")] [TestCase("PRO[info:test[nested]]TEOFORM", "PROTEOFORM", "test[nested]")] @@ -781,7 +794,6 @@ public void Ambiguity_UnknownPosition_4_4_1() Assert.AreEqual(ProFormaKey.Name, unlocal.Key); Assert.AreEqual(ProFormaEvidenceType.None, unlocal.EvidenceType); - // Check multiple unlocalized mods with a terminal mod term = _parser.ParseString("[Phospho][Phospho2]?[Acetyl]-EM[Hydroxylation]EVTSESPEK"); Assert.AreEqual(1, term.Tags?.Count); @@ -799,7 +811,6 @@ public void Ambiguity_UnknownPosition_4_4_1() Assert.AreEqual(1, term.UnlocalizedTags.Last().Count); Assert.AreEqual("Phospho2", unlocal2.Value); - // Check ^{count} format term = _parser.ParseString("[Phospho]^2?[Acetyl]-EM[Oxidation]EVTSESPEK"); Assert.AreEqual(1, term.Tags?.Count); @@ -823,7 +834,6 @@ public void Ambiguity_UnknownPosition_4_4_1() Assert.AreEqual(1, term.UnlocalizedTags.Last().Count); Assert.AreEqual("Methyl", unlocal2.Value); - // INVALID to have terminal mod before unlocalized mods Assert.Throws(() => _parser.ParseString("[Acetyl]-[Phospho]^2?EM[Hydroxylation]EVTSESPEK")); } diff --git a/tests/TopDownProteomics.Tests/TopDownProteomics.Tests.csproj b/tests/TopDownProteomics.Tests/TopDownProteomics.Tests.csproj index 10b857b..a1d941d 100644 --- a/tests/TopDownProteomics.Tests/TopDownProteomics.Tests.csproj +++ b/tests/TopDownProteomics.Tests/TopDownProteomics.Tests.csproj @@ -1,6 +1,6 @@  - net6.0;net472 + net8.0;net472 TopDownProteomics.Tests Consortium for Top-Down Proteomics latest From 1ccfd6808e66bbcf92d50bc30393908900f3516f Mon Sep 17 00:00:00 2001 From: "Fellers, Ryan" Date: Wed, 9 Sep 2026 13:05:56 -0500 Subject: [PATCH 2/2] Address residue validator review feedback --- .github/workflows/dotnet.yml | 2 +- .github/workflows/release.yml | 2 +- src/TopDownProteomics/ProForma/ProFormaParser.cs | 11 +++++++++-- .../ProForma/ProFormaParserTests.cs | 11 ++++++++++- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 97e2716..ec4d367 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -17,7 +17,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 6.x + dotnet-version: 8.x - name: Restore dependencies run: dotnet restore - name: Build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4733b3..c9cbb6c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: 6.x + dotnet-version: 8.x - name: Verify commit exists in origin/master run: | diff --git a/src/TopDownProteomics/ProForma/ProFormaParser.cs b/src/TopDownProteomics/ProForma/ProFormaParser.cs index 7b35234..c8594b8 100644 --- a/src/TopDownProteomics/ProForma/ProFormaParser.cs +++ b/src/TopDownProteomics/ProForma/ProFormaParser.cs @@ -22,6 +22,10 @@ public ProFormaParser() : this(char.IsUpper) { } /// /// Determines whether a character is a valid residue. /// is . + /// + /// For workflows that create a proteoform group or chemical hash, use a matching + /// with the custom residue validator. + /// public ProFormaParser(Func residueValidator) { _residueValidator = residueValidator ?? throw new ArgumentNullException(nameof(residueValidator)); @@ -231,7 +235,7 @@ public ProFormaTerm ParseString(ReadOnlySpan proFormaString) { // Validate amino acid character if (!_residueValidator(current)) - throw new ProFormaParseException($"{current} is not an upper case letter."); + throw new ProFormaParseException($"{current} is not a valid residue."); // Reset the range as soon as we see an amino acid if (endRange.HasValue) @@ -275,9 +279,12 @@ private void HandleGlobalModification(ref IDictionary? targets = new List(); for (int k = atSymbolIndex + 1; k < tagText.Length; k++) { + if (tagText[k] == ',') + continue; + if (_residueValidator(tagText[k])) targets.Add(tagText[k]); - else if (tagText[k] != ',') + else throw new ProFormaParseException($"Unexpected character {tagText[k]} in global modification target list."); } } diff --git a/tests/TopDownProteomics.Tests/ProForma/ProFormaParserTests.cs b/tests/TopDownProteomics.Tests/ProForma/ProFormaParserTests.cs index bb8e877..5939d52 100644 --- a/tests/TopDownProteomics.Tests/ProForma/ProFormaParserTests.cs +++ b/tests/TopDownProteomics.Tests/ProForma/ProFormaParserTests.cs @@ -15,6 +15,7 @@ public void InvalidProFormaStrings() { Assert.Throws(() => _parser.ParseString((Span)null)); Assert.Throws(() => _parser.ParseString(string.Empty)); + Assert.Throws(() => new ProFormaParser(null!)); } [Test] @@ -36,8 +37,13 @@ public void ResidueValidatorControlsSequenceAndGlobalModificationTargets() Assert.AreEqual("A*", term.Sequence); CollectionAssert.AreEquivalent(new[] { '*', 'A' }, term.GlobalModifications.Single().TargetAminoAcids); - Assert.Throws(() => parser.ParseString("AB")); + var exception = Assert.Throws(() => parser.ParseString("AB")); + Assert.AreEqual("B is not a valid residue.", exception.Message); Assert.Throws(() => _parser.ParseString("A*")); + + var permissiveParser = new ProFormaParser(_ => true); + term = permissiveParser.ParseString("<[Oxidation]@A,C>A"); + CollectionAssert.AreEquivalent(new[] { 'A', 'C' }, term.GlobalModifications.Single().TargetAminoAcids); } [Test] @@ -794,6 +800,7 @@ public void Ambiguity_UnknownPosition_4_4_1() Assert.AreEqual(ProFormaKey.Name, unlocal.Key); Assert.AreEqual(ProFormaEvidenceType.None, unlocal.EvidenceType); + // Check multiple unlocalized mods with a terminal mod term = _parser.ParseString("[Phospho][Phospho2]?[Acetyl]-EM[Hydroxylation]EVTSESPEK"); Assert.AreEqual(1, term.Tags?.Count); @@ -811,6 +818,7 @@ public void Ambiguity_UnknownPosition_4_4_1() Assert.AreEqual(1, term.UnlocalizedTags.Last().Count); Assert.AreEqual("Phospho2", unlocal2.Value); + // Check ^{count} format term = _parser.ParseString("[Phospho]^2?[Acetyl]-EM[Oxidation]EVTSESPEK"); Assert.AreEqual(1, term.Tags?.Count); @@ -834,6 +842,7 @@ public void Ambiguity_UnknownPosition_4_4_1() Assert.AreEqual(1, term.UnlocalizedTags.Last().Count); Assert.AreEqual("Methyl", unlocal2.Value); + // INVALID to have terminal mod before unlocalized mods Assert.Throws(() => _parser.ParseString("[Acetyl]-[Phospho]^2?EM[Hydroxylation]EVTSESPEK")); }