diff --git a/ModuleManager/ModuleManager.csproj b/ModuleManager/ModuleManager.csproj
index 812217d7..dbe672d1 100644
--- a/ModuleManager/ModuleManager.csproj
+++ b/ModuleManager/ModuleManager.csproj
@@ -47,6 +47,7 @@
+
diff --git a/ModuleManager/PatchExtractor.cs b/ModuleManager/PatchExtractor.cs
new file mode 100644
index 00000000..3f4be072
--- /dev/null
+++ b/ModuleManager/PatchExtractor.cs
@@ -0,0 +1,153 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+using ModuleManager.Extensions;
+
+namespace ModuleManager
+{
+ public static class PatchExtractor
+ {
+ private static readonly Regex firstRegex = new Regex(@":FIRST", RegexOptions.IgnoreCase);
+ private static readonly Regex finalRegex = new Regex(@":FINAL", RegexOptions.IgnoreCase);
+ private static readonly Regex beforeRegex = new Regex(@":BEFORE\[([^\[\]]+)\]", RegexOptions.IgnoreCase);
+ private static readonly Regex forRegex = new Regex(@":FOR\[([^\[\]]+)\]", RegexOptions.IgnoreCase);
+ private static readonly Regex afterRegex = new Regex(@":AFTER\[([^\[\]]+)\]", RegexOptions.IgnoreCase);
+
+ public static PatchList SortAndExtractPatches(UrlDir databaseRoot, IEnumerable modList, IPatchProgress progress)
+ {
+ PatchList list = new PatchList(modList);
+
+ foreach (UrlDir.UrlConfig url in databaseRoot.AllConfigs.ToArray())
+ {
+ try
+ {
+ Command command = CommandParser.Parse(url.type, out _);;
+
+ Match firstMatch = firstRegex.Match(url.type);
+ Match finalMatch = finalRegex.Match(url.type);
+ Match beforeMatch = beforeRegex.Match(url.type);
+ Match forMatch = forRegex.Match(url.type);
+ Match afterMatch = afterRegex.Match(url.type);
+
+ int matchCount = 0;
+
+ if (firstMatch.Success) matchCount++;
+ if (finalMatch.Success) matchCount++;
+ if (beforeMatch.Success) matchCount++;
+ if (forMatch.Success) matchCount++;
+ if (afterMatch.Success) matchCount++;
+
+ if (firstMatch.NextMatch().Success) matchCount++;
+ if (finalMatch.NextMatch().Success) matchCount++;
+ if (beforeMatch.NextMatch().Success) matchCount++;
+ if (forMatch.NextMatch().Success) matchCount++;
+ if (afterMatch.NextMatch().Success) matchCount++;
+
+ bool error = false;
+
+ if (command == Command.Insert && matchCount > 0)
+ {
+ progress.Error(url, $"Error - pass specifier detected on an insert node (not a patch): {url.parent.url}/{url.type}");
+ error = true;
+ }
+ if (matchCount > 1)
+ {
+ progress.Error(url, $"Error - more than one pass specifier on a node: {url.parent.url}/{url.type}");
+ error = true;
+ }
+ if (error)
+ {
+ url.parent.configs.Remove(url);
+ continue;
+ }
+
+ if (command == Command.Insert) continue;
+
+ url.parent.configs.Remove(url);
+
+ Match theMatch = null;
+ List thePass = null;
+ bool modNotFound = false;
+
+ if (firstMatch.Success)
+ {
+ theMatch = firstMatch;
+ thePass = list.firstPatches;
+ }
+ else if (finalMatch.Success)
+ {
+ theMatch = finalMatch;
+ thePass = list.finalPatches;
+ }
+ else if (beforeMatch.Success)
+ {
+ if (CheckMod(beforeMatch, list.modPasses, out string theMod))
+ {
+ theMatch = beforeMatch;
+ thePass = list.modPasses[theMod].beforePatches;
+ }
+ else
+ {
+ modNotFound = true;
+ }
+ }
+ else if (forMatch.Success)
+ {
+ if (CheckMod(forMatch, list.modPasses, out string theMod))
+ {
+ theMatch = forMatch;
+ thePass = list.modPasses[theMod].forPatches;
+ }
+ else
+ {
+ modNotFound = true;
+ }
+ }
+ else if (afterMatch.Success)
+ {
+ if (CheckMod(afterMatch, list.modPasses, out string theMod))
+ {
+ theMatch = afterMatch;
+ thePass = list.modPasses[theMod].afterPatches;
+ }
+ else
+ {
+ modNotFound = true;
+ }
+ }
+ else
+ {
+ thePass = list.legacyPatches;
+ }
+
+ if (modNotFound) continue;
+
+ UrlDir.UrlConfig newUrl = url;
+ if (theMatch != null)
+ {
+ string newName = url.type.Remove(theMatch.Index, theMatch.Length);
+ ConfigNode newNode = new ConfigNode(newName) { id = url.config.id };
+ newNode.ShallowCopyFrom(url.config);
+ newUrl = new UrlDir.UrlConfig(url.parent, newNode);
+ }
+
+ thePass.Add(newUrl);
+ }
+ catch(Exception e)
+ {
+ progress.Exception(url, $"Exception while parsing pass for config: {url.parent.url}/{url.type}", e);
+ }
+ }
+
+ return list;
+ }
+
+ private static bool CheckMod(Match match, PatchList.ModPassCollection modPasses, out string theMod)
+ {
+ theMod = match.Groups[1].Value.Trim().ToLower();
+ return modPasses.HasMod(theMod);
+ }
+ }
+}
diff --git a/ModuleManagerTests/ModuleManagerTests.csproj b/ModuleManagerTests/ModuleManagerTests.csproj
index b8f47f39..6d67d5de 100644
--- a/ModuleManagerTests/ModuleManagerTests.csproj
+++ b/ModuleManagerTests/ModuleManagerTests.csproj
@@ -57,6 +57,7 @@
+
diff --git a/ModuleManagerTests/PatchExtractorTest.cs b/ModuleManagerTests/PatchExtractorTest.cs
new file mode 100644
index 00000000..35453e9a
--- /dev/null
+++ b/ModuleManagerTests/PatchExtractorTest.cs
@@ -0,0 +1,315 @@
+using System;
+using System.Collections.Generic;
+using Xunit;
+using NSubstitute;
+using TestUtils;
+using ModuleManager;
+
+namespace ModuleManagerTests
+{
+ public class PatchExtractorTest
+ {
+ private UrlDir root;
+ private UrlDir.UrlFile file;
+
+ private IPatchProgress progress;
+
+ public PatchExtractorTest()
+ {
+ root = UrlBuilder.CreateRoot();
+ file = UrlBuilder.CreateFile("abc/def.cfg", root);
+
+ progress = Substitute.For();
+ }
+
+ [Fact]
+ public void TestSortAndExtractPatches()
+ {
+ UrlDir.UrlConfig[] insertConfigs =
+ {
+ CreateConfig("NODE"),
+ CreateConfig("NADE"),
+ };
+
+ UrlDir.UrlConfig[] legacyConfigs =
+ {
+ CreateConfig("@NODE"),
+ CreateConfig("@NADE[foo]:HAS[#bar]"),
+ };
+
+ UrlDir.UrlConfig[] firstConfigs =
+ {
+ CreateConfig("@NODE:FIRST"),
+ CreateConfig("@NODE[foo]:HAS[#bar]:FIRST"),
+ CreateConfig("@NADE:First"),
+ CreateConfig("@NADE:first"),
+ };
+
+ UrlDir.UrlConfig[] finalConfigs =
+ {
+ CreateConfig("@NODE:FINAL"),
+ CreateConfig("@NODE[foo]:HAS[#bar]:FINAL"),
+ CreateConfig("@NADE:Final"),
+ CreateConfig("@NADE:final"),
+ };
+
+ UrlDir.UrlConfig[] beforeMod1Configs =
+ {
+ CreateConfig("@NODE:BEFORE[mod1]"),
+ CreateConfig("@NODE[foo]:HAS[#bar]:BEFORE[mod1]"),
+ CreateConfig("@NADE:before[mod1]"),
+ CreateConfig("@NADE:BEFORE[MOD1]"),
+ };
+
+ UrlDir.UrlConfig[] forMod1Configs =
+ {
+ CreateConfig("@NODE:FOR[mod1]"),
+ CreateConfig("@NODE[foo]:HAS[#bar]:FOR[mod1]"),
+ CreateConfig("@NADE:for[mod1]"),
+ CreateConfig("@NADE:FOR[MOD1]"),
+ };
+
+ UrlDir.UrlConfig[] afterMod1Configs =
+ {
+ CreateConfig("@NODE:AFTER[mod1]"),
+ CreateConfig("@NODE[foo]:HAS[#bar]:AFTER[mod1]"),
+ CreateConfig("@NADE:after[mod1]"),
+ CreateConfig("@NADE:AFTER[MOD1]"),
+ };
+
+ UrlDir.UrlConfig[] beforeMod2Configs =
+ {
+ CreateConfig("@NODE:BEFORE[mod2]"),
+ CreateConfig("@NODE[foo]:HAS[#bar]:BEFORE[mod2]"),
+ CreateConfig("@NADE:before[mod2]"),
+ CreateConfig("@NADE:BEFORE[MOD2]"),
+ };
+
+ UrlDir.UrlConfig[] forMod2Configs =
+ {
+ CreateConfig("@NODE:FOR[mod2]"),
+ CreateConfig("@NODE[foo]:HAS[#bar]:FOR[mod2]"),
+ CreateConfig("@NADE:for[mod2]"),
+ CreateConfig("@NADE:FOR[MOD2]"),
+ };
+
+ UrlDir.UrlConfig[] afterMod2Configs =
+ {
+ CreateConfig("@NODE:AFTER[mod2]"),
+ CreateConfig("@NODE[foo]:HAS[#bar]:AFTER[mod2]"),
+ CreateConfig("@NADE:after[mod2]"),
+ CreateConfig("@NADE:AFTER[MOD2]"),
+ };
+
+ UrlDir.UrlConfig[] beforeMod3Configs =
+ {
+ CreateConfig("@NODE:BEFORE[mod3]"),
+ CreateConfig("@NODE[foo]:HAS[#bar]:BEFORE[mod3]"),
+ CreateConfig("@NADE:before[mod3]"),
+ CreateConfig("@NADE:BEFORE[MOD3]"),
+ };
+
+ UrlDir.UrlConfig[] forMod3Configs =
+ {
+ CreateConfig("@NODE:FOR[mod3]"),
+ CreateConfig("@NODE[foo]:HAS[#bar]:FOR[mod3]"),
+ CreateConfig("@NADE:for[mod3]"),
+ CreateConfig("@NADE:FOR[MOD3]"),
+ };
+
+ UrlDir.UrlConfig[] afterMod3Configs =
+ {
+ CreateConfig("@NODE:AFTER[mod3]"),
+ CreateConfig("@NODE[foo]:HAS[#bar]:AFTER[mod3]"),
+ CreateConfig("@NADE:after[mod3]"),
+ CreateConfig("@NADE:AFTER[MOD3]"),
+ };
+
+ string[] modList = { "mod1", "mod2" };
+ PatchList list = PatchExtractor.SortAndExtractPatches(root, modList, progress);
+
+ progress.DidNotReceiveWithAnyArgs().Error(null, null);
+ progress.DidNotReceiveWithAnyArgs().Exception(null, null);
+ progress.DidNotReceiveWithAnyArgs().Exception(null, null, null);
+
+ Assert.True(list.modPasses.HasMod("mod1"));
+ Assert.True(list.modPasses.HasMod("mod2"));
+ Assert.False(list.modPasses.HasMod("mod3"));
+
+ Assert.Equal(insertConfigs, root.AllConfigs);
+
+ Assert.Equal(legacyConfigs, list.legacyPatches);
+
+ List currentPatches;
+
+ currentPatches = list.firstPatches;
+ Assert.Equal(firstConfigs.Length, currentPatches.Count);
+ AssertUrlCorrect("@NODE", firstConfigs[0], currentPatches[0]);
+ AssertUrlCorrect("@NODE[foo]:HAS[#bar]", firstConfigs[1], currentPatches[1]);
+ AssertUrlCorrect("@NADE", firstConfigs[2], currentPatches[2]);
+ AssertUrlCorrect("@NADE", firstConfigs[3], currentPatches[3]);
+
+ currentPatches = list.finalPatches;
+ Assert.Equal(finalConfigs.Length, currentPatches.Count);
+ AssertUrlCorrect("@NODE", finalConfigs[0], currentPatches[0]);
+ AssertUrlCorrect("@NODE[foo]:HAS[#bar]", finalConfigs[1], currentPatches[1]);
+ AssertUrlCorrect("@NADE", finalConfigs[2], currentPatches[2]);
+ AssertUrlCorrect("@NADE", finalConfigs[3], currentPatches[3]);
+
+ currentPatches = list.modPasses["mod1"].beforePatches;
+ Assert.Equal(beforeMod1Configs.Length, currentPatches.Count);
+ AssertUrlCorrect("@NODE", beforeMod1Configs[0], currentPatches[0]);
+ AssertUrlCorrect("@NODE[foo]:HAS[#bar]", beforeMod1Configs[1], currentPatches[1]);
+ AssertUrlCorrect("@NADE", beforeMod1Configs[2], currentPatches[2]);
+ AssertUrlCorrect("@NADE", beforeMod1Configs[3], currentPatches[3]);
+
+ currentPatches = list.modPasses["mod1"].forPatches;
+ Assert.Equal(forMod1Configs.Length, currentPatches.Count);
+ AssertUrlCorrect("@NODE", forMod1Configs[0], currentPatches[0]);
+ AssertUrlCorrect("@NODE[foo]:HAS[#bar]", forMod1Configs[1], currentPatches[1]);
+ AssertUrlCorrect("@NADE", forMod1Configs[2], currentPatches[2]);
+ AssertUrlCorrect("@NADE", forMod1Configs[3], currentPatches[3]);
+
+ currentPatches = list.modPasses["mod1"].afterPatches;
+ Assert.Equal(afterMod1Configs.Length, currentPatches.Count);
+ AssertUrlCorrect("@NODE", afterMod1Configs[0], currentPatches[0]);
+ AssertUrlCorrect("@NODE[foo]:HAS[#bar]", afterMod1Configs[1], currentPatches[1]);
+ AssertUrlCorrect("@NADE", afterMod1Configs[2], currentPatches[2]);
+ AssertUrlCorrect("@NADE", afterMod1Configs[3], currentPatches[3]);
+
+ currentPatches = list.modPasses["mod2"].beforePatches;
+ Assert.Equal(beforeMod2Configs.Length, currentPatches.Count);
+ AssertUrlCorrect("@NODE", beforeMod2Configs[0], currentPatches[0]);
+ AssertUrlCorrect("@NODE[foo]:HAS[#bar]", beforeMod2Configs[1], currentPatches[1]);
+ AssertUrlCorrect("@NADE", beforeMod2Configs[2], currentPatches[2]);
+ AssertUrlCorrect("@NADE", beforeMod2Configs[3], currentPatches[3]);
+
+ currentPatches = list.modPasses["mod2"].forPatches;
+ Assert.Equal(forMod1Configs.Length, currentPatches.Count);
+ AssertUrlCorrect("@NODE", forMod2Configs[0], currentPatches[0]);
+ AssertUrlCorrect("@NODE[foo]:HAS[#bar]", forMod2Configs[1], currentPatches[1]);
+ AssertUrlCorrect("@NADE", forMod2Configs[2], currentPatches[2]);
+ AssertUrlCorrect("@NADE", forMod2Configs[3], currentPatches[3]);
+
+ currentPatches = list.modPasses["mod2"].afterPatches;
+ Assert.Equal(afterMod1Configs.Length, currentPatches.Count);
+ AssertUrlCorrect("@NODE", afterMod2Configs[0], currentPatches[0]);
+ AssertUrlCorrect("@NODE[foo]:HAS[#bar]", afterMod2Configs[1], currentPatches[1]);
+ AssertUrlCorrect("@NADE", afterMod2Configs[2], currentPatches[2]);
+ AssertUrlCorrect("@NADE", afterMod2Configs[3], currentPatches[3]);
+ }
+
+ [Fact]
+ public void TestSortAndExtractPatches__InsertWithPass()
+ {
+ UrlDir.UrlConfig config1 = CreateConfig("NODE");
+ UrlDir.UrlConfig config2 = CreateConfig("NODE:FOR[mod1]");
+ UrlDir.UrlConfig config3 = CreateConfig("NODE:FOR[mod2]");
+ UrlDir.UrlConfig config4 = CreateConfig("NODE:FINAL");
+
+ string[] modList = { "mod1" };
+ PatchList list = PatchExtractor.SortAndExtractPatches(root, modList, progress);
+
+ Assert.Equal(new[] { config1 }, root.AllConfigs);
+
+ progress.Received().Error(config2, "Error - pass specifier detected on an insert node (not a patch): abc/def/NODE:FOR[mod1]");
+ progress.Received().Error(config3, "Error - pass specifier detected on an insert node (not a patch): abc/def/NODE:FOR[mod2]");
+ progress.Received().Error(config4, "Error - pass specifier detected on an insert node (not a patch): abc/def/NODE:FINAL");
+
+ Assert.Empty(list.firstPatches);
+ Assert.Empty(list.legacyPatches);
+ Assert.Empty(list.finalPatches);
+ Assert.Empty(list.modPasses["mod1"].beforePatches);
+ Assert.Empty(list.modPasses["mod1"].forPatches);
+ Assert.Empty(list.modPasses["mod1"].afterPatches);
+ }
+
+ [Fact]
+ public void TestSortAndExtractPatches__MoreThanOnePass()
+ {
+ UrlDir.UrlConfig config1 = CreateConfig("@NODE:FIRST");
+ UrlDir.UrlConfig config2 = CreateConfig("@NODE:FIRST:FIRST");
+ UrlDir.UrlConfig config3 = CreateConfig("@NODE:FIRST:FOR[mod1]");
+
+ string[] modList = { "mod1" };
+ PatchList list = PatchExtractor.SortAndExtractPatches(root, modList, progress);
+
+ Assert.Empty(root.AllConfigs);
+
+ progress.Received().Error(config2, "Error - more than one pass specifier on a node: abc/def/@NODE:FIRST:FIRST");
+ progress.Received().Error(config3, "Error - more than one pass specifier on a node: abc/def/@NODE:FIRST:FOR[mod1]");
+
+ Assert.Equal(1, list.firstPatches.Count);
+ AssertUrlCorrect("@NODE", config1, list.firstPatches[0]);
+ Assert.Empty(list.legacyPatches);
+ Assert.Empty(list.finalPatches);
+ Assert.Empty(list.modPasses["mod1"].beforePatches);
+ Assert.Empty(list.modPasses["mod1"].forPatches);
+ Assert.Empty(list.modPasses["mod1"].afterPatches);
+ }
+
+ [Fact]
+ public void TestSortAndExtractPatches__Exception()
+ {
+ Exception e = new Exception("an exception was thrown");
+ progress.WhenForAnyArgs(p => p.Error(null, null)).Throw(e);
+
+ UrlDir.UrlConfig config1 = CreateConfig("@NODE");
+ UrlDir.UrlConfig config2 = CreateConfig("@NODE:FIRST:FIRST");
+ UrlDir.UrlConfig config3 = CreateConfig("@NADE:FIRST");
+
+ string[] modList = { "mod1" };
+ PatchList list = PatchExtractor.SortAndExtractPatches(root, modList, progress);
+
+ progress.Received().Exception(config2, "Exception while parsing pass for config: abc/def/@NODE:FIRST:FIRST", e);
+
+ Assert.Equal(new[] { config1 }, list.legacyPatches);
+ Assert.Equal(1, list.firstPatches.Count);
+ AssertUrlCorrect("@NADE", config3, list.firstPatches[0]);
+ }
+
+ private UrlDir.UrlConfig CreateConfig(string name)
+ {
+ ConfigNode node = new TestConfigNode(name)
+ {
+ { "name", "snack" },
+ { "cheese", "gouda" },
+ { "bread", "sourdough" },
+ new ConfigNode("wine"),
+ new ConfigNode("fruit"),
+ };
+
+ node.id = "hungry?";
+
+ return UrlBuilder.CreateConfig(node, file);
+ }
+
+ private void AssertUrlCorrect(string expectedNodeName, UrlDir.UrlConfig originalUrl, UrlDir.UrlConfig observedUrl)
+ {
+ Assert.Equal(expectedNodeName, observedUrl.type);
+
+ ConfigNode originalNode = originalUrl.config;
+ ConfigNode observedNode = observedUrl.config;
+
+ Assert.Equal(expectedNodeName, observedNode.name);
+
+ if (originalNode.HasValue("name")) Assert.Equal(originalNode.GetValue("name"), observedUrl.name);
+
+ Assert.Same(originalUrl.parent, observedUrl.parent);
+
+ Assert.Equal(originalNode.id, observedNode.id);
+ Assert.Equal(originalNode.values.Count, observedNode.values.Count);
+ Assert.Equal(originalNode.nodes.Count, observedNode.nodes.Count);
+
+ for (int i = 0; i < originalNode.values.Count; i++)
+ {
+ Assert.Same(originalNode.values[i], observedNode.values[i]);
+ }
+
+ for (int i = 0; i < originalNode.nodes.Count; i++)
+ {
+ Assert.Same(originalNode.nodes[i], observedNode.nodes[i]);
+ }
+ }
+ }
+}