diff --git a/PLAN.md b/PLAN.md index 867e325..0fe864b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -43,7 +43,8 @@ - 仅认半角 `-`(全角 `-`、破折号 `—`/`–` 等不认) - 无 `-`:歌手为空,整串 `Trim()` 为歌曲名 -- 有 `-`(多个时**以最后一个分割**):前段归歌手、后段归歌曲名(粗暴分配,交换按钮兜底),各自 `Trim()` +- 恰好一个 `-`:前段归歌手、后段归歌曲名(粗暴分配,交换按钮兜底),各自 `Trim()` +- 多个 `-`:歌手为空,步骤二输入的整串 `Trim()` 为歌曲名 **示例**:`A - B (Live - Tokyo)(修正版).mp3` → 歌手 `A`,歌曲名 `B`,注释 `Live - Tokyo 修正版` diff --git a/SongTidy.Tests/FilenameParserTests.cs b/SongTidy.Tests/FilenameParserTests.cs new file mode 100644 index 0000000..b264033 --- /dev/null +++ b/SongTidy.Tests/FilenameParserTests.cs @@ -0,0 +1,264 @@ +using SongTidy.Services; + +namespace SongTidy.Tests; + +/// +/// Contains tests for . +/// +[TestClass] +public sealed class FilenameParserTests { + #region Successful parsing + + /// + /// Verifies the documented example with one English and one Chinese bracket group. + /// + [TestMethod] + public void ParsesSpecificationExample() { + AssertParse("A - B (Live - Tokyo)(修正版)", "A", "B", "Live - Tokyo 修正版"); + } + + /// + /// Verifies that multiple comment groups are joined with a single half-width space. + /// + [TestMethod] + public void ParsesMultipleCommentGroups() { + AssertParse("A - B (Live)(Remix)", "A", "B", "Live Remix"); + } + + /// + /// Verifies that Chinese bracket groups are extracted like English bracket groups. + /// + [TestMethod] + public void ParsesChineseBracketComment() { + AssertParse("歌手 - 歌名(现场)", "歌手", "歌名", "现场"); + } + + /// + /// Verifies that all comment groups are removed from the text used for artist and title. + /// + [TestMethod] + public void RemovesAllCommentGroupsFromSource() { + AssertParse("A - B (Live) (Tokyo)", "A", "B", "Live Tokyo"); + } + + /// + /// Verifies that nested brackets stay as literal text inside the comment. + /// + [TestMethod] + public void KeepsNestedBracketsInsideComment() { + AssertParse("A - B (Live (Tokyo) Mix)", "A", "B", "Live (Tokyo) Mix"); + } + + /// + /// Verifies that nested full-width brackets stay as literal text inside an English comment. + /// + [TestMethod] + public void PreservesFullWidthNestedBrackets() { + AssertParse("A - B (Live(东京))", "A", "B", "Live(东京)"); + } + + /// + /// Verifies that only the outermost pair of a nested group is treated as the comment. + /// + [TestMethod] + public void TreatsOnlyOutermostBracketGroupAsComment() { + AssertParse("A - B ((Live))", "A", "B", "(Live)"); + } + + /// + /// Verifies that a comment can be extracted from a name without a hyphen. + /// + [TestMethod] + public void ParsesCommentWithoutArtistSeparator() { + AssertParse("Song (Live)", string.Empty, "Song", "Live"); + } + + #endregion + + #region Invalid bracket structures + + /// + /// Verifies that an unmatched opening bracket disables comment extraction entirely. + /// + [TestMethod] + public void AbandonsExtractionForUnmatchedOpeningBracket() { + AssertParse("A - B (Live", "A", "B (Live", string.Empty); + } + + /// + /// Verifies that an unmatched closing bracket disables comment extraction entirely. + /// + [TestMethod] + public void AbandonsExtractionForUnmatchedClosingBracket() { + AssertParse("A - B Live)", "A", "B Live)", string.Empty); + } + + /// + /// Verifies that opening and closing brackets of different types cannot pair. + /// + [TestMethod] + public void AbandonsExtractionForMismatchedBracketTypes() { + AssertParse("A - B (Live)", "A", "B (Live)", string.Empty); + } + + /// + /// Verifies that an extra closing bracket after a valid pair disables extraction entirely. + /// + [TestMethod] + public void AbandonsExtractionForExtraClosingBracket() { + AssertParse("A - B (Live))", "A", "B (Live))", string.Empty); + } + + /// + /// Verifies that an extra opening bracket after a valid pair disables extraction entirely. + /// + [TestMethod] + public void AbandonsExtractionForExtraOpeningBracket() { + AssertParse("A - B (Live) (", "A", "B (Live) (", string.Empty); + } + + /// + /// Verifies that an unclosed outer bracket invalidates otherwise complete nested brackets. + /// + [TestMethod] + public void AbandonsExtractionForUnclosedOuterBracket() { + AssertParse("A - B (Live (Tokyo)", "A", "B (Live (Tokyo)", string.Empty); + } + + /// + /// Verifies that crossing bracket groups are treated as an invalid structure. + /// + [TestMethod] + public void AbandonsExtractionForCrossingBracketGroups() { + AssertParse("(a(b))", string.Empty, "(a(b))", string.Empty); + } + + #endregion + + #region Hyphen handling + + /// + /// Verifies that a single half-width hyphen separates artist from title. + /// + [TestMethod] + public void SplitsSingleHalfWidthHyphen() { + AssertParse("A - B", "A", "B", string.Empty); + } + + /// + /// Verifies that multiple half-width hyphens make the whole remaining text the title. + /// + [TestMethod] + public void TreatsMultipleHyphensAsTitleOnly() { + AssertParse("A - B - C", string.Empty, "A - B - C", string.Empty); + } + + /// + /// Verifies that hyphens inside a removed comment do not count when applying the multiple-hyphen rule. + /// + [TestMethod] + public void IgnoresHyphensInsideCommentsWhenSplitting() { + AssertParse("A - B (Live - Tokyo - Mix)", "A", "B", "Live - Tokyo - Mix"); + } + + /// + /// Verifies that a full-width hyphen is not treated as a separator. + /// + [TestMethod] + public void DoesNotSplitOnFullWidthHyphen() { + AssertParse("A - B", string.Empty, "A - B", string.Empty); + } + + /// + /// Verifies that an em dash is not treated as a separator. + /// + [TestMethod] + public void DoesNotSplitOnEmDash() { + AssertParse("A — B", string.Empty, "A — B", string.Empty); + } + + /// + /// Verifies that a name without a hyphen produces an empty artist and a full title. + /// + [TestMethod] + public void ReturnsEmptyArtistWhenNoHyphenExists() { + AssertParse("Song Title", string.Empty, "Song Title", string.Empty); + } + + /// + /// Verifies that an empty input produces three empty fields. + /// + [TestMethod] + public void ReturnsEmptyFieldsForEmptyInput() { + AssertParse(string.Empty, string.Empty, string.Empty, string.Empty); + } + + #endregion + + #region Whitespace handling + + /// + /// Verifies that standard whitespace around the artist and title is removed. + /// + [TestMethod] + public void TrimsStandardWhitespaceAroundArtistAndTitle() { + AssertParse(" A - B ", "A", "B", string.Empty); + } + + /// + /// Verifies that standard whitespace around extracted comment text is removed. + /// + [TestMethod] + public void TrimsStandardWhitespaceInsideComments() { + AssertParse("A - B ( Live )", "A", "B", "Live"); + } + + /// + /// Verifies that full-width spaces inside comment text are preserved. + /// + [TestMethod] + public void PreservesFullWidthSpacesInsideComments() { + AssertParse("A - B (\u3000Live\u3000)", "A", "B", "\u3000Live\u3000"); + } + + /// + /// Verifies that full-width spaces around artist and title are preserved. + /// + [TestMethod] + public void PreservesFullWidthSpacesAroundFields() { + AssertParse("A\u3000-\u3000B", "A\u3000", "\u3000B", string.Empty); + } + + #endregion + + #region Argument validation + + /// + /// Verifies that a null input is rejected. + /// + [TestMethod] + public void ThrowsForNullInput() { + Assert.Throws(() => FilenameParser.Parse(null!)); + } + + #endregion + + /// + /// Parses the input and asserts all three resulting fields. + /// + /// The file name to parse. + /// The expected artist value. + /// The expected title value. + /// The expected comment value. + private static void AssertParse( + string input, + string expectedArtist, + string expectedTitle, + string expectedComment) { + FilenameParseResult result = FilenameParser.Parse(input); + + Assert.AreEqual(expectedArtist, result.Artist, "Artist mismatch."); + Assert.AreEqual(expectedTitle, result.Title, "Title mismatch."); + Assert.AreEqual(expectedComment, result.Comment, "Comment mismatch."); + } +} diff --git a/SongTidy/Services/FilenameParser.cs b/SongTidy/Services/FilenameParser.cs new file mode 100644 index 0000000..36a1869 --- /dev/null +++ b/SongTidy/Services/FilenameParser.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SongTidy.Services; + +/// +/// Parses a file name into artist, title, and comment fields using plain string operations. +/// +public static class FilenameParser { + #region Public API + + /// + /// Parses a file name that does not include its extension. + /// + /// The file name to parse. + /// + /// The extracted artist, title, and comment. Missing fields are returned as empty strings. + /// + /// + /// After comment extraction, a single half-width hyphen separates artist from title. + /// When no hyphen or more than one hyphen remains, the artist is empty and the whole text is the title. + /// + public static FilenameParseResult Parse(string fileNameWithoutExtension) { + ArgumentNullException.ThrowIfNull(fileNameWithoutExtension); + + string source = fileNameWithoutExtension; + string comment = string.Empty; + + // Extract comments only when every bracket in the name is paired correctly. + if (TryExtractTopLevelPairs(fileNameWithoutExtension, out List pairs)) { + List commentParts = new(pairs.Count); + + // Each outermost bracket pair contributes one trimmed comment fragment. + foreach (BracketPair pair in pairs) { + commentParts.Add(TrimStandardWhitespace(fileNameWithoutExtension[(pair.Start + 1)..pair.End])); + } + + comment = string.Join(' ', commentParts); + source = RemoveRanges(fileNameWithoutExtension, pairs); + } + + // Split only when exactly one half-width hyphen remains after comment extraction. + int hyphenIndex = source.IndexOf('-'); + + if (hyphenIndex < 0) { + return new FilenameParseResult(string.Empty, TrimStandardWhitespace(source), comment); + } + + // Multiple hyphens make the whole remaining text the title. + if (hyphenIndex != source.LastIndexOf('-')) { + return new FilenameParseResult(string.Empty, TrimStandardWhitespace(source), comment); + } + + string artist = TrimStandardWhitespace(source[..hyphenIndex]); + string title = TrimStandardWhitespace(source[(hyphenIndex + 1)..]); + + return new FilenameParseResult(artist, title, comment); + } + + #endregion + + #region Bracket extraction + + /// + /// Finds all outermost bracket pairs and verifies that the bracket structure is valid. + /// + /// The text to inspect. + /// + /// Receives the outermost bracket pairs in source order. The list is empty when validation fails. + /// + /// + /// when every bracket is paired with a bracket of the same type; + /// otherwise, . + /// + private static bool TryExtractTopLevelPairs(string source, out List pairs) { + pairs = new List(); + Stack openBrackets = new(); + + for (int index = 0; index < source.Length; index++) { + char character = source[index]; + + // Record every opening bracket; its matching close is resolved by the stack. + if (IsOpeningBracket(character)) { + openBrackets.Push(new OpenBracket(index, character)); + continue; + } + + if (!IsClosingBracket(character)) { + continue; + } + + // A closing bracket without a same-type opening bracket invalidates the whole extraction. + if (openBrackets.Count == 0) { + pairs.Clear(); + return false; + } + + OpenBracket opening = openBrackets.Pop(); + + if (!IsMatchingPair(opening.Character, character)) { + pairs.Clear(); + return false; + } + + // Keep only outermost pairs; nested brackets remain literal text inside the comment. + if (openBrackets.Count == 0) { + pairs.Add(new BracketPair(opening.Index, index)); + } + } + + // Any opening bracket left on the stack has no matching close. + if (openBrackets.Count > 0) { + pairs.Clear(); + return false; + } + + return true; + } + + /// + /// Determines whether a character opens an English or Chinese bracket pair. + /// + /// The character to inspect. + /// for an opening bracket. + private static bool IsOpeningBracket(char character) { + return character is '(' or '('; + } + + /// + /// Determines whether a character closes an English or Chinese bracket pair. + /// + /// The character to inspect. + /// for a closing bracket. + private static bool IsClosingBracket(char character) { + return character is ')' or ')'; + } + + /// + /// Determines whether an opening bracket and a closing bracket belong to the same bracket type. + /// + /// The opening bracket character. + /// The closing bracket character. + /// when both characters form a valid pair. + private static bool IsMatchingPair(char opening, char closing) { + return (opening == '(' && closing == ')') + || (opening == '(' && closing == ')'); + } + + /// + /// Builds a new string with the supplied ranges removed. + /// + /// The text to copy. + /// The non-overlapping ranges to omit, ordered by start position. + /// The text that remains after all ranges are removed. + private static string RemoveRanges(string source, List ranges) { + StringBuilder builder = new(source.Length); + int currentIndex = 0; + + foreach (BracketPair range in ranges) { + if (range.Start > currentIndex) { + builder.Append(source, currentIndex, range.Start - currentIndex); + } + + currentIndex = range.End + 1; + } + + if (currentIndex < source.Length) { + builder.Append(source, currentIndex, source.Length - currentIndex); + } + + return builder.ToString(); + } + + #endregion + + #region Text helpers + + /// + /// Trims ASCII whitespace from both ends while preserving full-width spaces. + /// + /// The text to trim. + /// The trimmed text. + private static string TrimStandardWhitespace(string value) { + int start = 0; + int end = value.Length - 1; + + // Move the start index past leading standard whitespace. + while (start <= end && IsStandardWhitespace(value[start])) { + start++; + } + + // Move the end index back past trailing standard whitespace. + while (end >= start && IsStandardWhitespace(value[end])) { + end--; + } + + return value[start..(end + 1)]; + } + + /// + /// Determines whether a character is one of the standard ASCII whitespace characters. + /// + /// The character to inspect. + /// for standard whitespace. + private static bool IsStandardWhitespace(char character) { + return character is ' ' or '\t' or '\r' or '\n' or '\v' or '\f'; + } + + #endregion + + #region Nested types + + /// + /// Represents the character offsets of a matched bracket pair. + /// + /// The zero-based index of the opening bracket. + /// The zero-based index of the closing bracket. + private sealed record BracketPair(int Start, int End); + + /// + /// Represents an opening bracket waiting for its matching closing bracket. + /// + /// The zero-based index of the opening bracket. + /// The opening bracket character. + private sealed record OpenBracket(int Index, char Character); + + #endregion +} + +/// +/// Represents the fields extracted from a file name. +/// +/// The extracted artist, or an empty string when no artist was found. +/// The extracted title, or an empty string when no title text remains. +/// The combined comment text, or an empty string when no comment was extracted. +public sealed record FilenameParseResult(string Artist, string Title, string Comment);