feat: implement filename parser with hyphen rules

This commit is contained in:
yyc12345 committed 2026-10-22 20:19:26 +08:00
1 parent bb040ef89b
commit d6677b323b
3 files changed
+503 -1

No files matched your search

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