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);