From da6ce926b48b765a5d6e4ee4d332b11f55a54dfb Mon Sep 17 00:00:00 2001 From: "Clarence \"Sparr\" Risher" Date: Mon, 5 Oct 2026 09:38:26 -0400 Subject: [PATCH] Match tags and uppercase mod names independent of culture Root node tag names and the nested :HAS[ search were compared with CurrentCultureIgnoreCase. Under a Turkish or Azeri culture, i and I are not a case pair, so :first and :final in any casing other than all uppercase were not recognized. Use OrdinalIgnoreCase instead. Pass specifier descriptors uppercased the mod name with the current culture, turning i into U+0130. Use ToUpperInvariant, as PatchList already does for pass names. Co-Authored-By: Claude Opus 5.5 --- ModuleManager/Extensions/StringExtensions.cs | 2 +- .../PassSpecifiers/AfterPassSpecifier.cs | 2 +- .../PassSpecifiers/BeforePassSpecifier.cs | 2 +- .../PassSpecifiers/ForPassSpecifier.cs | 2 +- .../PassSpecifiers/LastPassSpecifier.cs | 2 +- ModuleManager/Patches/ProtoPatchBuilder.cs | 16 +++--- .../Extensions/StringExtensionsTest.cs | 25 +++++++++ ModuleManagerTests/ModuleManagerTests.csproj | 1 + .../PassSpecifiers/AfterPassSpecifierTest.cs | 9 ++++ .../PassSpecifiers/BeforePassSpecifierTest.cs | 9 ++++ .../PassSpecifiers/ForPassSpecifierTest.cs | 9 ++++ .../PassSpecifiers/LastPassSpecifierTest.cs | 9 ++++ .../Patches/ProtoPatchBuilderTest.cs | 52 +++++++++++++++++++ ModuleManagerTests/TemporaryCulture.cs | 25 +++++++++ 14 files changed, 152 insertions(+), 13 deletions(-) create mode 100644 ModuleManagerTests/TemporaryCulture.cs diff --git a/ModuleManager/Extensions/StringExtensions.cs b/ModuleManager/Extensions/StringExtensions.cs index 634fab7a..13ce804c 100644 --- a/ModuleManager/Extensions/StringExtensions.cs +++ b/ModuleManager/Extensions/StringExtensions.cs @@ -30,7 +30,7 @@ public static bool Contains(this string str, string value, out int index) if (str == null) throw new ArgumentNullException(nameof(str)); if (value == null) throw new ArgumentNullException(nameof(value)); - index = str.IndexOf(value, StringComparison.CurrentCultureIgnoreCase); + index = str.IndexOf(value, StringComparison.OrdinalIgnoreCase); return index != -1; } } diff --git a/ModuleManager/Patches/PassSpecifiers/AfterPassSpecifier.cs b/ModuleManager/Patches/PassSpecifiers/AfterPassSpecifier.cs index a50b99c5..02605d70 100644 --- a/ModuleManager/Patches/PassSpecifiers/AfterPassSpecifier.cs +++ b/ModuleManager/Patches/PassSpecifiers/AfterPassSpecifier.cs @@ -24,6 +24,6 @@ public bool CheckNeeds(INeedsChecker needsChecker, IPatchProgress progress) return result; } - public string Descriptor => $":AFTER[{mod.ToUpper()}]"; + public string Descriptor => $":AFTER[{mod.ToUpperInvariant()}]"; } } diff --git a/ModuleManager/Patches/PassSpecifiers/BeforePassSpecifier.cs b/ModuleManager/Patches/PassSpecifiers/BeforePassSpecifier.cs index 9f509f43..6bbf9eb2 100644 --- a/ModuleManager/Patches/PassSpecifiers/BeforePassSpecifier.cs +++ b/ModuleManager/Patches/PassSpecifiers/BeforePassSpecifier.cs @@ -24,6 +24,6 @@ public bool CheckNeeds(INeedsChecker needsChecker, IPatchProgress progress) return result; } - public string Descriptor => $":BEFORE[{mod.ToUpper()}]"; + public string Descriptor => $":BEFORE[{mod.ToUpperInvariant()}]"; } } diff --git a/ModuleManager/Patches/PassSpecifiers/ForPassSpecifier.cs b/ModuleManager/Patches/PassSpecifiers/ForPassSpecifier.cs index 3256a072..b170aaaa 100644 --- a/ModuleManager/Patches/PassSpecifiers/ForPassSpecifier.cs +++ b/ModuleManager/Patches/PassSpecifiers/ForPassSpecifier.cs @@ -24,6 +24,6 @@ public bool CheckNeeds(INeedsChecker needsChecker, IPatchProgress progress) return result; } - public string Descriptor => $":FOR[{mod.ToUpper()}]"; + public string Descriptor => $":FOR[{mod.ToUpperInvariant()}]"; } } diff --git a/ModuleManager/Patches/PassSpecifiers/LastPassSpecifier.cs b/ModuleManager/Patches/PassSpecifiers/LastPassSpecifier.cs index e0d4fcc4..2fb29832 100644 --- a/ModuleManager/Patches/PassSpecifiers/LastPassSpecifier.cs +++ b/ModuleManager/Patches/PassSpecifiers/LastPassSpecifier.cs @@ -14,6 +14,6 @@ public LastPassSpecifier(string mod) } public bool CheckNeeds(INeedsChecker needsChecker, IPatchProgress progress) => true; - public string Descriptor => $":LAST[{mod.ToUpper()}]"; + public string Descriptor => $":LAST[{mod.ToUpperInvariant()}]"; } } diff --git a/ModuleManager/Patches/ProtoPatchBuilder.cs b/ModuleManager/Patches/ProtoPatchBuilder.cs index 377855fa..15889ebe 100644 --- a/ModuleManager/Patches/ProtoPatchBuilder.cs +++ b/ModuleManager/Patches/ProtoPatchBuilder.cs @@ -55,7 +55,7 @@ public ProtoPatch Build(UrlDir.UrlConfig urlConfig, Command command, ITagList ta if (tag.trailer != null) progress.Warning(urlConfig, "unrecognized trailer: '" + tag.trailer + "' on: " + urlConfig.SafeUrl()); - if (tag.key.Equals("NEEDS", StringComparison.CurrentCultureIgnoreCase)) + if (tag.key.Equals("NEEDS", StringComparison.OrdinalIgnoreCase)) { if (needs != null) { @@ -71,7 +71,7 @@ public ProtoPatch Build(UrlDir.UrlConfig urlConfig, Command command, ITagList ta needs = tag.value; } - else if (tag.key.Equals("HAS", StringComparison.CurrentCultureIgnoreCase)) + else if (tag.key.Equals("HAS", StringComparison.OrdinalIgnoreCase)) { if (command == Command.Insert) { @@ -93,7 +93,7 @@ public ProtoPatch Build(UrlDir.UrlConfig urlConfig, Command command, ITagList ta has = tag.value; } - else if (tag.key.Equals("FIRST", StringComparison.CurrentCultureIgnoreCase)) + else if (tag.key.Equals("FIRST", StringComparison.OrdinalIgnoreCase)) { if (tag.value != null) { @@ -114,7 +114,7 @@ public ProtoPatch Build(UrlDir.UrlConfig urlConfig, Command command, ITagList ta passSpecifier = new FirstPassSpecifier(); } - else if (tag.key.Equals("BEFORE", StringComparison.CurrentCultureIgnoreCase)) + else if (tag.key.Equals("BEFORE", StringComparison.OrdinalIgnoreCase)) { if (string.IsNullOrEmpty(tag.value)) { @@ -137,7 +137,7 @@ public ProtoPatch Build(UrlDir.UrlConfig urlConfig, Command command, ITagList ta passSpecifier = new BeforePassSpecifier(tag.value, urlConfig); } - else if (tag.key.Equals("FOR", StringComparison.CurrentCultureIgnoreCase)) + else if (tag.key.Equals("FOR", StringComparison.OrdinalIgnoreCase)) { if (string.IsNullOrEmpty(tag.value)) { @@ -160,7 +160,7 @@ public ProtoPatch Build(UrlDir.UrlConfig urlConfig, Command command, ITagList ta passSpecifier = new ForPassSpecifier(tag.value, urlConfig); } - else if (tag.key.Equals("AFTER", StringComparison.CurrentCultureIgnoreCase)) + else if (tag.key.Equals("AFTER", StringComparison.OrdinalIgnoreCase)) { if (string.IsNullOrEmpty(tag.value)) { @@ -183,7 +183,7 @@ public ProtoPatch Build(UrlDir.UrlConfig urlConfig, Command command, ITagList ta passSpecifier = new AfterPassSpecifier(tag.value, urlConfig); } - else if (tag.key.Equals("LAST", StringComparison.CurrentCultureIgnoreCase)) + else if (tag.key.Equals("LAST", StringComparison.OrdinalIgnoreCase)) { if (string.IsNullOrEmpty(tag.value)) { @@ -206,7 +206,7 @@ public ProtoPatch Build(UrlDir.UrlConfig urlConfig, Command command, ITagList ta passSpecifier = new LastPassSpecifier(tag.value); } - else if (tag.key.Equals("FINAL", StringComparison.CurrentCultureIgnoreCase)) + else if (tag.key.Equals("FINAL", StringComparison.OrdinalIgnoreCase)) { if (tag.value != null) { diff --git a/ModuleManagerTests/Extensions/StringExtensionsTest.cs b/ModuleManagerTests/Extensions/StringExtensionsTest.cs index acf9274e..7fc22891 100644 --- a/ModuleManagerTests/Extensions/StringExtensionsTest.cs +++ b/ModuleManagerTests/Extensions/StringExtensionsTest.cs @@ -51,6 +51,31 @@ public void TestContains(string str, string test, bool expectedResult, int expec Assert.Equal(expectedIndex, index); } + [InlineData("abc:first", ":FIRST", 3)] + [InlineData("abc:FINAL", ":final", 3)] + [InlineData("abc:Has[def]", ":HAS[", 3)] + [Theory] + public void TestContains__CaseInsensitive(string str, string test, int expectedIndex) + { + bool result = str.Contains(test, out int index); + Assert.True(result); + Assert.Equal(expectedIndex, index); + } + + [InlineData("abc:first", ":FIRST", 3)] + [InlineData("abc:FINAL", ":final", 3)] + [InlineData("abc:Has[def]", ":HAS[", 3)] + [Theory] + public void TestContains__CaseInsensitive__TurkishCulture(string str, string test, int expectedIndex) + { + using (new TemporaryCulture(TemporaryCulture.TURKISH)) + { + bool result = str.Contains(test, out int index); + Assert.True(result); + Assert.Equal(expectedIndex, index); + } + } + [Fact] public void TestContains__NullStr() { diff --git a/ModuleManagerTests/ModuleManagerTests.csproj b/ModuleManagerTests/ModuleManagerTests.csproj index f3afbbf5..2ebab704 100644 --- a/ModuleManagerTests/ModuleManagerTests.csproj +++ b/ModuleManagerTests/ModuleManagerTests.csproj @@ -95,6 +95,7 @@ + diff --git a/ModuleManagerTests/Patches/PassSpecifiers/AfterPassSpecifierTest.cs b/ModuleManagerTests/Patches/PassSpecifiers/AfterPassSpecifierTest.cs index 6e086b57..1b8016d0 100644 --- a/ModuleManagerTests/Patches/PassSpecifiers/AfterPassSpecifierTest.cs +++ b/ModuleManagerTests/Patches/PassSpecifiers/AfterPassSpecifierTest.cs @@ -101,5 +101,14 @@ public void TestDescriptor() { Assert.Equal(":AFTER[MOD1]", passSpecifier.Descriptor); } + + [Fact] + public void TestDescriptor__TurkishCulture() + { + using (new TemporaryCulture(TemporaryCulture.TURKISH)) + { + Assert.Equal(":AFTER[MINI]", new AfterPassSpecifier("mini", urlConfig).Descriptor); + } + } } } diff --git a/ModuleManagerTests/Patches/PassSpecifiers/BeforePassSpecifierTest.cs b/ModuleManagerTests/Patches/PassSpecifiers/BeforePassSpecifierTest.cs index 937da7c2..87ac547d 100644 --- a/ModuleManagerTests/Patches/PassSpecifiers/BeforePassSpecifierTest.cs +++ b/ModuleManagerTests/Patches/PassSpecifiers/BeforePassSpecifierTest.cs @@ -101,5 +101,14 @@ public void TestDescriptor() { Assert.Equal(":BEFORE[MOD1]", passSpecifier.Descriptor); } + + [Fact] + public void TestDescriptor__TurkishCulture() + { + using (new TemporaryCulture(TemporaryCulture.TURKISH)) + { + Assert.Equal(":BEFORE[MINI]", new BeforePassSpecifier("mini", urlConfig).Descriptor); + } + } } } diff --git a/ModuleManagerTests/Patches/PassSpecifiers/ForPassSpecifierTest.cs b/ModuleManagerTests/Patches/PassSpecifiers/ForPassSpecifierTest.cs index cec25628..c3c6f041 100644 --- a/ModuleManagerTests/Patches/PassSpecifiers/ForPassSpecifierTest.cs +++ b/ModuleManagerTests/Patches/PassSpecifiers/ForPassSpecifierTest.cs @@ -101,5 +101,14 @@ public void TestDescriptor() { Assert.Equal(":FOR[MOD1]", passSpecifier.Descriptor); } + + [Fact] + public void TestDescriptor__TurkishCulture() + { + using (new TemporaryCulture(TemporaryCulture.TURKISH)) + { + Assert.Equal(":FOR[MINI]", new ForPassSpecifier("mini", urlConfig).Descriptor); + } + } } } diff --git a/ModuleManagerTests/Patches/PassSpecifiers/LastPassSpecifierTest.cs b/ModuleManagerTests/Patches/PassSpecifiers/LastPassSpecifierTest.cs index bdf6bee5..6f0b6b8b 100644 --- a/ModuleManagerTests/Patches/PassSpecifiers/LastPassSpecifierTest.cs +++ b/ModuleManagerTests/Patches/PassSpecifiers/LastPassSpecifierTest.cs @@ -54,5 +54,14 @@ public void TestDescriptor() { Assert.Equal(":LAST[MOD1]", passSpecifier.Descriptor); } + + [Fact] + public void TestDescriptor__TurkishCulture() + { + using (new TemporaryCulture(TemporaryCulture.TURKISH)) + { + Assert.Equal(":LAST[MINI]", new LastPassSpecifier("mini").Descriptor); + } + } } } diff --git a/ModuleManagerTests/Patches/ProtoPatchBuilderTest.cs b/ModuleManagerTests/Patches/ProtoPatchBuilderTest.cs index fd2ff4ab..dad317dd 100644 --- a/ModuleManagerTests/Patches/ProtoPatchBuilderTest.cs +++ b/ModuleManagerTests/Patches/ProtoPatchBuilderTest.cs @@ -259,6 +259,32 @@ public void TestBuild__First__Case2() Assert.IsType(protoPatch.passSpecifier); } + [Fact] + public void TestBuild__First__TurkishCulture() + { + ITagList tagList = Substitute.For(); + tagList.PrimaryTag.Returns(new Tag("NODE", null, null)); + tagList.GetEnumerator().Returns(new ArrayEnumerator( + new Tag("first", null, null) + )); + + ProtoPatch protoPatch; + using (new TemporaryCulture(TemporaryCulture.TURKISH)) + { + protoPatch = builder.Build(urlConfig, Command.Copy, tagList); + } + + EnsureNoErrors(); + + Assert.Same(urlConfig, protoPatch.urlConfig); + Assert.Equal(Command.Copy, protoPatch.command); + Assert.Equal("NODE", protoPatch.nodeType); + Assert.Null(protoPatch.nodeName); + Assert.Null(protoPatch.needs); + Assert.Null(protoPatch.has); + Assert.IsType(protoPatch.passSpecifier); + } + [Fact] public void TestBuild__Before() { @@ -610,6 +636,32 @@ public void TestBuild__Final__Case2() Assert.IsType(protoPatch.passSpecifier); } + [Fact] + public void TestBuild__Final__TurkishCulture() + { + ITagList tagList = Substitute.For(); + tagList.PrimaryTag.Returns(new Tag("NODE", null, null)); + tagList.GetEnumerator().Returns(new ArrayEnumerator( + new Tag("final", null, null) + )); + + ProtoPatch protoPatch; + using (new TemporaryCulture(TemporaryCulture.TURKISH)) + { + protoPatch = builder.Build(urlConfig, Command.Copy, tagList); + } + + EnsureNoErrors(); + + Assert.Same(urlConfig, protoPatch.urlConfig); + Assert.Equal(Command.Copy, protoPatch.command); + Assert.Equal("NODE", protoPatch.nodeType); + Assert.Null(protoPatch.nodeName); + Assert.Null(protoPatch.needs); + Assert.Null(protoPatch.has); + Assert.IsType(protoPatch.passSpecifier); + } + [Fact] public void TestBuild__Insert__InsertPass() { diff --git a/ModuleManagerTests/TemporaryCulture.cs b/ModuleManagerTests/TemporaryCulture.cs new file mode 100644 index 00000000..2fd7791c --- /dev/null +++ b/ModuleManagerTests/TemporaryCulture.cs @@ -0,0 +1,25 @@ +using System; +using System.Globalization; +using System.Threading; + +namespace ModuleManagerTests +{ + public sealed class TemporaryCulture : IDisposable + { + // Lowercase i and uppercase I are not a case pair in this culture + public const string TURKISH = "tr-TR"; + + private readonly CultureInfo originalCulture; + + public TemporaryCulture(string name) + { + originalCulture = Thread.CurrentThread.CurrentCulture; + Thread.CurrentThread.CurrentCulture = new CultureInfo(name); + } + + public void Dispose() + { + Thread.CurrentThread.CurrentCulture = originalCulture; + } + } +}