diff --git a/Keybinding.Test/SeparatorKeyParsingTests.cs b/Keybinding.Test/SeparatorKeyParsingTests.cs new file mode 100644 index 0000000..fcd6da5 --- /dev/null +++ b/Keybinding.Test/SeparatorKeyParsingTests.cs @@ -0,0 +1,129 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Keybinding.Test; + +using ktsu.Keybinding.Core; +using ktsu.Keybinding.Core.Contracts; +using ktsu.Keybinding.Core.Models; + +/// +/// Tests that the '+' and ',' keys can be bound, and that input with a missing key is rejected rather than dropped. +/// +[TestClass] +public class SeparatorKeyParsingTests +{ + private static KeybindingManager CreateManager() => + new(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())); + + [TestMethod] + public void ChordParse_CtrlPlus_IsCtrlAndPlusKey() + { + Chord chord = Chord.Parse("Ctrl++"); + + Assert.AreNotEqual(Chord.Parse("Ctrl"), chord); + Assert.HasCount(2, chord.Notes); + Assert.Contains(n => n.Key.ToString() == "+", chord.Notes); + } + + [TestMethod] + public void ChordParse_CtrlComma_IsCtrlAndCommaKey() + { + Chord chord = Chord.Parse("Ctrl+,"); + + Assert.HasCount(2, chord.Notes); + Assert.Contains(n => n.Key.ToString() == ",", chord.Notes); + } + + [TestMethod] + public void ChordParse_PlusAlone_IsPlusKey() + { + Chord chord = Chord.Parse("+"); + + Assert.HasCount(1, chord.Notes); + Assert.AreEqual("+", chord.Notes.Single().Key.ToString()); + } + + [TestMethod] + public void PhraseParse_CtrlComma_IsSingleChordWithCommaKey() + { + Phrase phrase = Phrase.Parse("Ctrl+,"); + + Assert.HasCount(1, phrase.Sequence); + Assert.AreEqual(Chord.Parse("Ctrl+,"), phrase.Sequence[0]); + Assert.AreNotEqual(Phrase.Parse("Ctrl"), phrase); + } + + [TestMethod] + public void PhraseParse_CommaKeyFollowedByChord_SplitsCorrectly() + { + Phrase phrase = Phrase.Parse("Ctrl+,, R"); + + Assert.HasCount(2, phrase.Sequence); + Assert.AreEqual(Chord.Parse("Ctrl+,"), phrase.Sequence[0]); + Assert.AreEqual(Chord.Parse("R"), phrase.Sequence[1]); + } + + [TestMethod] + public void PhraseParse_PlusKeyInSequence_SplitsCorrectly() + { + Phrase phrase = Phrase.Parse("Ctrl++, Ctrl+R"); + + Assert.HasCount(2, phrase.Sequence); + Assert.AreEqual(Chord.Parse("Ctrl++"), phrase.Sequence[0]); + Assert.AreEqual(Chord.Parse("Ctrl+R"), phrase.Sequence[1]); + } + + [TestMethod] + public void Parse_RoundTripsThroughToString() + { + Phrase phrase = Phrase.Parse("Ctrl+,, Ctrl++"); + + Assert.AreEqual(phrase, Phrase.Parse(phrase.ToString())); + } + + [TestMethod] + [DataRow("Ctrl+")] + [DataRow("A++B")] + [DataRow("Ctrl++A+")] + public void ChordParse_MissingKey_Throws(string value) => + Assert.ThrowsExactly(() => Chord.Parse(value)); + + [TestMethod] + [DataRow("Ctrl+R,")] + [DataRow("Ctrl+R,,R")] + [DataRow(", R")] + public void PhraseParse_MissingChord_Throws(string value) => + Assert.ThrowsExactly(() => Phrase.Parse(value)); + + [TestMethod] + public void ServiceParseChord_CtrlPlus_MatchesChordParse() + { + using KeybindingManager manager = CreateManager(); + IKeybindingService service = manager.Keybindings; + + Chord chord = service.ParseChord("Ctrl++"); + + Assert.HasCount(2, chord.Notes); + Assert.AreEqual(Chord.Parse("Ctrl++"), chord); + } + + [TestMethod] + public void ServiceParsePhrase_CtrlComma_MatchesPhraseParse() + { + using KeybindingManager manager = CreateManager(); + IKeybindingService service = manager.Keybindings; + + Phrase phrase = service.ParsePhrase("Ctrl+,"); + + Assert.AreEqual(Phrase.Parse("Ctrl+,"), phrase); + } + + [TestMethod] + public void ServiceParseChord_MissingKey_Throws() + { + using KeybindingManager manager = CreateManager(); + IKeybindingService service = manager.Keybindings; + + Assert.ThrowsExactly(() => service.ParseChord("Ctrl+")); + } +} diff --git a/Keybinding/Models/KeyStringTokenizer.cs b/Keybinding/Models/KeyStringTokenizer.cs new file mode 100644 index 0000000..a349fa4 --- /dev/null +++ b/Keybinding/Models/KeyStringTokenizer.cs @@ -0,0 +1,106 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Keybinding.Core.Models; + +using System.Text; + +/// +/// Splits chord and phrase strings into tokens, treating a separator character that appears where a key is +/// expected as the literal key, so "Ctrl++" is Ctrl and Plus and "Ctrl+," is Ctrl and Comma. +/// +internal static class KeyStringTokenizer +{ + private const char NoteSeparator = '+'; + private const char ChordSeparator = ','; + private const string ChordSeparators = "+"; + private const string PhraseSeparators = "+,"; + + /// + /// Splits a chord string such as "Ctrl+Alt+S" or "Ctrl++" into note strings. + /// + /// The chord string. + /// The trimmed note strings, or an empty array for whitespace input. + /// Thrown when a key is missing, as in "Ctrl+" or "A++B". + internal static string[] SplitChord(string value) => Split(value, NoteSeparator, ChordSeparators); + + /// + /// Splits a phrase string such as "Ctrl+R, R" or "Ctrl+,, R" into chord strings. + /// + /// The phrase string. + /// The trimmed chord strings, or an empty array for whitespace input. + /// Thrown when a key or chord is missing, as in "Ctrl+" or "A,,B". + internal static string[] SplitPhrase(string value) => Split(value, ChordSeparator, PhraseSeparators); + + private static string[] Split(string value, char splitOn, string separators) + { + List tokens = []; + StringBuilder current = new(); + bool expectKey = true; + bool sawContent = false; + + for (int i = 0; i < value.Length; i++) + { + char c = value[i]; + + if (separators.Contains(c) && !expectKey) + { + // A separator after a key ends the note, and ends the token when it is the one being split on + if (c == splitOn) + { + tokens.Add(current.ToString().Trim()); + current.Clear(); + } + else + { + current.Append(c); + } + + expectKey = true; + continue; + } + + if (separators.Contains(c)) + { + EnsureSeparatorIsKey(value, i, separators); + } + + current.Append(c); + if (!char.IsWhiteSpace(c)) + { + expectKey = false; + sawContent = true; + } + } + + if (!sawContent) + { + return []; + } + + if (expectKey) + { + throw new ArgumentException($"Missing key at the end of \"{value}\"", nameof(value)); + } + + tokens.Add(current.ToString().Trim()); + return [.. tokens]; + } + + /// + /// A separator where a key is expected is the key itself, but only when nothing else follows it before the next + /// separator. Otherwise the input has an empty key, which must not be dropped. + /// + private static void EnsureSeparatorIsKey(string value, int index, string separators) + { + int next = index + 1; + while (next < value.Length && char.IsWhiteSpace(value[next])) + { + next++; + } + + if (next < value.Length && !separators.Contains(value[next])) + { + throw new ArgumentException($"Missing key before '{value[index]}' at position {index} in \"{value}\"", nameof(value)); + } + } +} diff --git a/Keybinding/Models/MusicalTypes.cs b/Keybinding/Models/MusicalTypes.cs index 88c6502..8549fb9 100644 --- a/Keybinding/Models/MusicalTypes.cs +++ b/Keybinding/Models/MusicalTypes.cs @@ -262,7 +262,7 @@ public static Chord Parse(string value) throw new ArgumentException("Value cannot be null or whitespace", nameof(value)); } - string[] parts = [.. value.Split('+', StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim())]; + string[] parts = KeyStringTokenizer.SplitChord(value); if (parts.Length == 0) { @@ -396,8 +396,7 @@ public static Phrase Parse(string value) throw new ArgumentException("Value cannot be null or whitespace", nameof(value)); } - string[] chordStrings = [.. value.Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim())]; + string[] chordStrings = KeyStringTokenizer.SplitPhrase(value); return chordStrings.Length == 0 ? throw new ArgumentException("Invalid phrase format", nameof(value)) diff --git a/Keybinding/Services/KeybindingService.cs b/Keybinding/Services/KeybindingService.cs index 6f4fcaa..c0f9a14 100644 --- a/Keybinding/Services/KeybindingService.cs +++ b/Keybinding/Services/KeybindingService.cs @@ -183,7 +183,7 @@ public Phrase ParsePhrase(string phraseString) } // Split by comma to get individual chord strings - string[] chordStrings = phraseString.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + string[] chordStrings = KeyStringTokenizer.SplitPhrase(phraseString); List chords = []; foreach (string chordString in chordStrings) @@ -204,7 +204,7 @@ public Chord ParseChord(string chordString) } // Split by + to get individual note strings - string[] noteStrings = chordString.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + string[] noteStrings = KeyStringTokenizer.SplitChord(chordString); List notes = []; foreach (string noteString in noteStrings)