From 15ff7cf7ad19d735c1bcb5e67851ac848a721557 Mon Sep 17 00:00:00 2001 From: paladine Date: Thu, 28 Apr 2022 11:34:52 -0600 Subject: [PATCH] Fix MSG parse issue with CPTDTXT.MSG (#557) * Perform flushing of large line buffers if necessary to prevent overflows in LineBreaker * Fix MSG parse issue with CPTDTXT.MSG MSG parsing code would enter a junk state when encountering a \r\n immediately after an identifier, but it should allow the transition to the SPACE state instead. Technically \r\n are space characters after all. * Msg Parser Refactor * Handle Help Text Before MSG Key * Ignore CR in MSG File Values * First Swag at Update Values * Unit Tests passing locally * Added XML Comments on Methods * Clarification * Unit Tests for MsgFile Parser Methods * Use Char Values (Easier to Read) * Specific Unit Test for Weird MajorMUD Formatting * Removed Debug * Update MSG File Integration Tests for Escape Characters * Fix Issue of too many characters being truncated when escaping a curly bracket * Fix Issue of no space between KEY and VALUE curly Bracket - Properly parse if format is `KEY{VALUE}` - Unit + Integration Test Updates * Rename KEY to IDENTIFIER Co-authored-by: Eric P. Nusbaum --- MBBSEmu.Tests/Assets/IntegrationTest.msg | 21 ++ MBBSEmu.Tests/MBBSEmu.Tests.csproj | 2 + MBBSEmu.Tests/Module/MsgFile_Tests.cs | 272 +++++++++---- MBBSEmu/Module/McvFile.cs | 2 +- MBBSEmu/Module/MsgFile.cs | 462 +++++++++++++++++------ 5 files changed, 570 insertions(+), 189 deletions(-) create mode 100644 MBBSEmu.Tests/Assets/IntegrationTest.msg diff --git a/MBBSEmu.Tests/Assets/IntegrationTest.msg b/MBBSEmu.Tests/Assets/IntegrationTest.msg new file mode 100644 index 00000000..4584efb2 --- /dev/null +++ b/MBBSEmu.Tests/Assets/IntegrationTest.msg @@ -0,0 +1,21 @@ +LEVEL0 {MBBSEmu Test MSG File for MajorMUD Scenarios} + +LEVEL1 {Hardware Setup Options} + +LEVEL3 {Security and Accounting Options} + +LEVEL4 {Configuration Options} + +TEST1{This is topic 1: value} S 30 Help Topic String + +LEVEL6 {Text Editable Blocks} + +TEST2 {This is topic 2: value} S 30 Help Topic 2 + +String + +TEST3 {This is topic 3: value} S 40 Help Topic 3 String + +TEST4 {Escaped ~~ Values ~} Test} + +LEVEL8 {FSE Help Messages} \ No newline at end of file diff --git a/MBBSEmu.Tests/MBBSEmu.Tests.csproj b/MBBSEmu.Tests/MBBSEmu.Tests.csproj index 6f56100f..f736ef96 100644 --- a/MBBSEmu.Tests/MBBSEmu.Tests.csproj +++ b/MBBSEmu.Tests/MBBSEmu.Tests.csproj @@ -7,6 +7,7 @@ + @@ -46,6 +47,7 @@ + diff --git a/MBBSEmu.Tests/Module/MsgFile_Tests.cs b/MBBSEmu.Tests/Module/MsgFile_Tests.cs index e8184565..1a3ebb59 100644 --- a/MBBSEmu.Tests/Module/MsgFile_Tests.cs +++ b/MBBSEmu.Tests/Module/MsgFile_Tests.cs @@ -10,102 +10,218 @@ namespace MBBSEmu.Tests.Module { - public class MsgFile_Tests : TestBase, IDisposable - { - private readonly string _modulePath; - - private MemoryStream Load(string resourceFile) + public class MsgFile_Tests : TestBase, IDisposable { - var resource = ResourceManager.GetTestResourceManager().GetResource($"MBBSEmu.Tests.Assets.{resourceFile}"); - return new MemoryStream(resource.ToArray()); - } + private readonly string _modulePath; - public MsgFile_Tests() - { - _modulePath = GetModulePath(); - } + private MemoryStream Load(string resourceFile) + { + var resource = ResourceManager.GetTestResourceManager().GetResource($"MBBSEmu.Tests.Assets.{resourceFile}"); + return new MemoryStream(resource.ToArray()); + } - public void Dispose() - { - if (Directory.Exists(_modulePath)) + public MsgFile_Tests() { - Directory.Delete(_modulePath, recursive: true); + _modulePath = GetModulePath(); } - } - [Fact] - public void ReplaceWithEmptyDictionary() - { - var sourceMessage = Load("MBBSEMU.MSG"); - var outputRawStream = new MemoryStream(); - using var sourceStream = new StreamStream(sourceMessage); - using var outputStream = new StreamStream(outputRawStream); + public void Dispose() + { + if (Directory.Exists(_modulePath)) + { + Directory.Delete(_modulePath, recursive: true); + } + } - MsgFile.UpdateValues(sourceStream, outputStream, new Dictionary()); + [Fact] + public void ReplaceWithEmptyDictionary() + { + var sourceMessage = Load("MBBSEMU.MSG"); + var outputRawStream = new MemoryStream(); + using var sourceStream = new StreamStream(sourceMessage); + using var outputStream = new StreamStream(outputRawStream); - outputRawStream.Flush(); - outputRawStream.Seek(0, SeekOrigin.Begin); - var result = outputRawStream.ToArray(); + MsgFile.UpdateValues(sourceStream, outputStream, new Dictionary()); - sourceMessage.Seek(0, SeekOrigin.Begin); - var expected = sourceMessage.ToArray(); + outputRawStream.Flush(); + outputRawStream.Seek(0, SeekOrigin.Begin); + var result = outputRawStream.ToArray(); - result.Should().BeEquivalentTo(expected); - } - - [Fact] - public void ReplaceWithActualValues() - { - var sourceMessage = Load("MBBSEMU.MSG"); - var outputRawStream = new MemoryStream(); - using var sourceStream = new StreamStream(sourceMessage); - using var outputStream = new StreamStream(outputRawStream); - - MsgFile.UpdateValues(sourceStream, outputStream, new Dictionary() {{"SOCCCR", "128"}, {"SLOWTICS", "Whatever"}, {"MAXITEM", "45"}}); - - outputRawStream.Flush(); - outputRawStream.Seek(0, SeekOrigin.Begin); - var result = Encoding.ASCII.GetString(outputRawStream.ToArray()); - - // expected should have the mods applied - var expected = Encoding.ASCII.GetString(Load("MBBSEMU.MSG").ToArray()); - expected = expected.Replace("SOCCCR {SoC credit consumption rate adjustment, per min: 0}", "SOCCCR {SoC credit consumption rate adjustment, per min: 128}"); - expected = expected.Replace("SLOWTICS {Slow system factor: 10000}", "SLOWTICS {Slow system factor: Whatever}"); - expected = expected.Replace("MAXITEM {Maximum number of items: 954}", "MAXITEM {Maximum number of items: 45}"); - - result.Should().Be(expected); - } + sourceMessage.Seek(0, SeekOrigin.Begin); + var expected = sourceMessage.ToArray(); - [Fact] - public void ReplaceFileEmptyDictionary() - { - var fileName = Path.Combine(_modulePath, "MBBSEMU.MSG"); + result.Should().BeEquivalentTo(expected); + } - Directory.CreateDirectory(_modulePath); - File.WriteAllBytes(fileName, Load("MBBSEMU.MSG").ToArray()); + [Fact] + public void ReplaceWithActualValues() + { + var sourceMessage = Load("MBBSEMU.MSG"); + var outputRawStream = new MemoryStream(); + using var sourceStream = new StreamStream(sourceMessage); + using var outputStream = new StreamStream(outputRawStream); - MsgFile.UpdateValues(fileName, new Dictionary()); + MsgFile.UpdateValues(sourceStream, outputStream, new Dictionary() { { "SOCCCR", "128" }, { "SLOWTICS", "Whatever" }, { "MAXITEM", "45" } }); - File.ReadAllBytes(fileName).Should().BeEquivalentTo(Load("MBBSEMU.MSG").ToArray()); - } + outputRawStream.Flush(); + outputRawStream.Seek(0, SeekOrigin.Begin); + var result = Encoding.ASCII.GetString(outputRawStream.ToArray()); - [Fact] - public void ReplaceFileWithActualValues() - { - var fileName = Path.Combine(_modulePath, "MBBSEMU.MSG"); + // expected should have the mods applied + var expected = Encoding.ASCII.GetString(Load("MBBSEMU.MSG").ToArray()); + expected = expected.Replace("SOCCCR {SoC credit consumption rate adjustment, per min: 0}", "SOCCCR {SoC credit consumption rate adjustment, per min: 128}"); + expected = expected.Replace("SLOWTICS {Slow system factor: 10000}", "SLOWTICS {Slow system factor: Whatever}"); + expected = expected.Replace("MAXITEM {Maximum number of items: 954}", "MAXITEM {Maximum number of items: 45}"); + + result.Should().Be(expected); + } + + [Fact] + public void ReplaceFileEmptyDictionary() + { + var fileName = Path.Combine(_modulePath, "MBBSEMU.MSG"); + + Directory.CreateDirectory(_modulePath); + File.WriteAllBytes(fileName, Load("MBBSEMU.MSG").ToArray()); + + MsgFile.UpdateValues(fileName, new Dictionary()); + + File.ReadAllBytes(fileName).Should().BeEquivalentTo(Load("MBBSEMU.MSG").ToArray()); + } + + [Fact] + public void ReplaceFileWithActualValues() + { + var fileName = Path.Combine(_modulePath, "MBBSEMU.MSG"); + + Directory.CreateDirectory(_modulePath); + File.WriteAllBytes(fileName, Load("MBBSEMU.MSG").ToArray()); + + MsgFile.UpdateValues(fileName, new Dictionary() { { "SOCCCR", "128" }, { "SLOWTICS", "Whatever" }, { "MAXITEM", "45" } }); - Directory.CreateDirectory(_modulePath); - File.WriteAllBytes(fileName, Load("MBBSEMU.MSG").ToArray()); + // expected should have the mods applied + var expected = Encoding.ASCII.GetString(Load("MBBSEMU.MSG").ToArray()); + expected = expected.Replace("SOCCCR {SoC credit consumption rate adjustment, per min: 0}", "SOCCCR {SoC credit consumption rate adjustment, per min: 128}"); + expected = expected.Replace("SLOWTICS {Slow system factor: 10000}", "SLOWTICS {Slow system factor: Whatever}"); + expected = expected.Replace("MAXITEM {Maximum number of items: 954}", "MAXITEM {Maximum number of items: 45}"); - MsgFile.UpdateValues(fileName, new Dictionary() {{"SOCCCR", "128"}, {"SLOWTICS", "Whatever"}, {"MAXITEM", "45"}}); + File.ReadAllBytes(fileName).Should().BeEquivalentTo(Encoding.ASCII.GetBytes(expected)); + } + + [Theory] + [InlineData('\r', ' ')] + [InlineData('~', '~')] + public void ProcessValue_IgnoredValues(char currentCharacter, char previousCharacter) + { + var resultCharacter = MsgFile.ProcessValue(currentCharacter, previousCharacter, out var resultState); + + Assert.Equal(0, resultCharacter); + Assert.Equal(MsgFile.MsgParseState.VALUE, resultState); + } + + [Fact] + public void ProcessValue_EscapedBracket() + { + var resultCharacter = MsgFile.ProcessValue('}', '~', out var resultState); + + Assert.Equal(0, resultCharacter); + Assert.Equal(MsgFile.MsgParseState.ESCAPEBRACKET, resultState); + } + + [Fact] + public void ProcessValue_ClosingBracket() + { + var resultCharacter = MsgFile.ProcessValue('}', ' ', out var resultState); + + Assert.Equal(0, resultCharacter); + Assert.Equal(MsgFile.MsgParseState.POSTVALUE, resultState); + } + + [Theory] + [InlineData('A', MsgFile.MsgParseState.IDENTIFIER)] + [InlineData('Z', MsgFile.MsgParseState.IDENTIFIER)] + [InlineData('1', MsgFile.MsgParseState.IDENTIFIER)] + [InlineData('0', MsgFile.MsgParseState.IDENTIFIER)] + [InlineData(' ', MsgFile.MsgParseState.PREKEY)] + [InlineData('\r', MsgFile.MsgParseState.PREKEY)] + [InlineData('\n', MsgFile.MsgParseState.PREKEY)] + [InlineData('!', MsgFile.MsgParseState.PREKEY)] + [InlineData('{', MsgFile.MsgParseState.PREKEY)] + [InlineData('}', MsgFile.MsgParseState.PREKEY)] + public void ProcessPreKey_Tests(char currentCharacter, MsgFile.MsgParseState expectedState) + { + var resultCharacter = MsgFile.ProcessPreKey(currentCharacter, out var resultState); + + Assert.Equal(currentCharacter, resultCharacter); + Assert.Equal(expectedState, resultState); + } + + [Theory] + [InlineData('A', MsgFile.MsgParseState.IDENTIFIER)] + [InlineData('Z', MsgFile.MsgParseState.IDENTIFIER)] + [InlineData('1', MsgFile.MsgParseState.IDENTIFIER)] + [InlineData('0', MsgFile.MsgParseState.IDENTIFIER)] + [InlineData(' ', MsgFile.MsgParseState.POSTKEY)] + [InlineData('\r', MsgFile.MsgParseState.POSTKEY)] + [InlineData('\n', MsgFile.MsgParseState.POSTKEY)] + [InlineData('!', MsgFile.MsgParseState.POSTKEY)] + [InlineData('{', MsgFile.MsgParseState.VALUE)] + [InlineData('}', MsgFile.MsgParseState.POSTKEY)] + public void ProcessKey_Tests(char currentCharacter, MsgFile.MsgParseState expectedState) + { + var resultCharacter = MsgFile.ProcessKey(currentCharacter, out var resultState); + + Assert.Equal(currentCharacter, resultCharacter); + Assert.Equal(expectedState, resultState); + } + + [Theory] + [InlineData('{', MsgFile.MsgParseState.VALUE)] + [InlineData('\r', MsgFile.MsgParseState.POSTKEY)] //MajorMUD puts the key on its own line + [InlineData('\n', MsgFile.MsgParseState.POSTKEY)] //MajorMUD puts the key on its own line + [InlineData('A', MsgFile.MsgParseState.IDENTIFIER)] + [InlineData('Z', MsgFile.MsgParseState.IDENTIFIER)] + [InlineData('1', MsgFile.MsgParseState.IDENTIFIER)] + [InlineData('0', MsgFile.MsgParseState.IDENTIFIER)] + [InlineData(' ', MsgFile.MsgParseState.POSTKEY)] + public void ProcessPostKey_Tests(char currentCharacter, MsgFile.MsgParseState expectedState) + { + var resultCharacter = MsgFile.ProcessPostKey(currentCharacter, out var resultState); + + Assert.Equal(currentCharacter, resultCharacter); + Assert.Equal(expectedState, resultState); + } + + [Theory] + [InlineData('\n', MsgFile.MsgParseState.PREKEY)] + [InlineData('A', MsgFile.MsgParseState.POSTVALUE)] + [InlineData('Z', MsgFile.MsgParseState.POSTVALUE)] + [InlineData('1', MsgFile.MsgParseState.POSTVALUE)] + [InlineData('0', MsgFile.MsgParseState.POSTVALUE)] + [InlineData(' ', MsgFile.MsgParseState.POSTVALUE)] + [InlineData('\r', MsgFile.MsgParseState.POSTVALUE)] + [InlineData('!', MsgFile.MsgParseState.POSTVALUE)] + [InlineData('{', MsgFile.MsgParseState.POSTVALUE)] + [InlineData('}', MsgFile.MsgParseState.POSTVALUE)] + public void ProcessPostValue_Tests(char currentCharacter, MsgFile.MsgParseState expectedState) + { + var resultCharacter = MsgFile.ProcessPostValue(currentCharacter, out var resultState); + + Assert.Equal(currentCharacter, resultCharacter); + Assert.Equal(expectedState, resultState); + } + + [Fact] + public void LoadMsg_IntegrationTest() + { + var sourceMessage = Load("IntegrationTest.msg"); - // expected should have the mods applied - var expected = Encoding.ASCII.GetString(Load("MBBSEMU.MSG").ToArray()); - expected = expected.Replace("SOCCCR {SoC credit consumption rate adjustment, per min: 0}", "SOCCCR {SoC credit consumption rate adjustment, per min: 128}"); - expected = expected.Replace("SLOWTICS {Slow system factor: 10000}", "SLOWTICS {Slow system factor: Whatever}"); - expected = expected.Replace("MAXITEM {Maximum number of items: 954}", "MAXITEM {Maximum number of items: 45}"); + var msgValues = MsgFile.ExtractMsgValues(sourceMessage.ToArray()); - File.ReadAllBytes(fileName).Should().BeEquivalentTo(Encoding.ASCII.GetBytes(expected)); + Assert.Equal("This is topic 1: value\0", Encoding.ASCII.GetString(msgValues[4])); + Assert.Equal("This is topic 2: value\0", Encoding.ASCII.GetString(msgValues[6])); + Assert.Equal("This is topic 3: value\0", Encoding.ASCII.GetString(msgValues[7])); + Assert.Equal("Escaped ~ Values } Test\0", Encoding.ASCII.GetString(msgValues[8])); + } } - } } diff --git a/MBBSEmu/Module/McvFile.cs b/MBBSEmu/Module/McvFile.cs index 25d5dcda..b68c98c9 100644 --- a/MBBSEmu/Module/McvFile.cs +++ b/MBBSEmu/Module/McvFile.cs @@ -202,7 +202,7 @@ private ReadOnlySpan GetMessageValue(int ordinal) for (var i = 1; i <= message.Length; i++) { - if (message[^i] == 0x3A || message[^i] == 0x20) + if (message[^i] == ':' || message[^i] == ' ') return message.Slice(message.Length - i, i); } diff --git a/MBBSEmu/Module/MsgFile.cs b/MBBSEmu/Module/MsgFile.cs index 29c881ae..1cc6acbc 100644 --- a/MBBSEmu/Module/MsgFile.cs +++ b/MBBSEmu/Module/MsgFile.cs @@ -40,13 +40,37 @@ public MsgFile(IFileUtility fileUtility, string modulePath, string msgName) BuildMCV(); } - private enum MsgParseState { - NEWLINE, + public enum MsgParseState + { + /// + /// Data Before Identifier Characters + /// + PREKEY, + + /// + /// Data for Identifier + /// IDENTIFIER, - SPACE, - BRACKET, - BRACKET_REPLACE, - JUNK + + /// + /// Data Post Identifier + /// + POSTKEY, + + /// + /// Data In Value + /// + VALUE, + + /// + /// Post Value (Type, Length, etc.) + /// + POSTVALUE, + + /// + /// If an escaped bracket is encountered as part of a message + /// + ESCAPEBRACKET, }; private static bool IsIdentifier(char c) => @@ -57,7 +81,6 @@ private static bool IsAlnum(char c) => private static MemoryStream FixLineEndings(MemoryStream input) { - var rawMessage = input.ToArray(); input.SetLength(0); @@ -85,83 +108,222 @@ private static MemoryStream FixLineEndings(MemoryStream input) private void BuildMCV() { var path = _fileUtility.FindFile(_modulePath, Path.ChangeExtension(_moduleName, ".MSG")); - var fileToRead = File.ReadAllBytes(Path.Combine(_modulePath, path)); - var state = MsgParseState.NEWLINE; - var identifier = new StringBuilder(); - using var msgValue = new MemoryStream(); + var msgFileData = File.ReadAllBytes(Path.Combine(_modulePath, path)); var language = Encoding.ASCII.GetBytes("English/ANSI\0"); - var messages = new List(); - var last = (char)0; - foreach (var b in fileToRead) + var messages = ExtractMsgValues(msgFileData); + + WriteMCV(language, messages); + } + + /// + /// Takes the specified raw MSG File Contents and extracts the values to an array, where + /// the ordinals in the array correspond with the ordinals of the MSG value as referenced + /// in the .H file generated by MajorBBS/Worldgroup + /// + /// + /// + public static IList ExtractMsgValues(ReadOnlySpan msgData) + { + var result = new List(); + + var state = MsgParseState.PREKEY; + var msgKey = new StringBuilder(); + using var msgValue = new MemoryStream(); + var previousCharacter = (char)0; + + foreach (var b in msgData) { var c = (char)b; switch (state) { - case MsgParseState.NEWLINE when IsIdentifier(c): - state = MsgParseState.IDENTIFIER; - identifier.Clear(); - identifier.Append(c); - break; - case MsgParseState.NEWLINE when c != '\n': - state = MsgParseState.JUNK; - break; - case MsgParseState.IDENTIFIER when IsIdentifier(c): - identifier.Append(c); - break; - case MsgParseState.IDENTIFIER when c is '\r' or '\n': - state = MsgParseState.JUNK; - break; - case MsgParseState.IDENTIFIER when char.IsWhiteSpace(c): - state = MsgParseState.SPACE; - break; - case MsgParseState.IDENTIFIER when c == '{': - state = MsgParseState.BRACKET; - msgValue.SetLength(0); - break; - case MsgParseState.SPACE when c == '{': - state = MsgParseState.BRACKET; - msgValue.SetLength(0); - break; - case MsgParseState.SPACE when !char.IsWhiteSpace(c): - state = MsgParseState.JUNK; - break; - case MsgParseState.BRACKET when c == '~' && last == '~': - // double tilde, only output one tilde, so skip second output - break; - case MsgParseState.BRACKET when c == '}' && last == '~': - // escaped ~}, change '~' we've already collected to '}' - EscapeBracket(msgValue); - break; - case MsgParseState.BRACKET when c == '}': - var value = FixLineEndings(msgValue); + case MsgParseState.PREKEY: + { + c = ProcessPreKey(c, out state); - if (identifier.ToString().Equals("LANGUAGE")) - language = value.ToArray(); - else - messages.Add(value.ToArray()); + if (state == MsgParseState.IDENTIFIER) + msgKey.Append(c); - state = MsgParseState.JUNK; - msgValue.SetLength(0); - break; - case MsgParseState.BRACKET when c != '\r': - msgValue.WriteByte(b); - break; - case MsgParseState.JUNK when c == '\n': - state = MsgParseState.NEWLINE; + break; + } + case MsgParseState.IDENTIFIER: + { + c = ProcessKey(c, out state); + + if (state == MsgParseState.IDENTIFIER) + msgKey.Append(c); + + break; + } + case MsgParseState.POSTKEY: + { + ProcessPostKey(c, out state); + + //Reset Key + if (state == MsgParseState.IDENTIFIER) + { + msgKey.Clear(); + msgKey.Append(c); + } + + break; + } + case MsgParseState.VALUE: + { + c = ProcessValue(c, previousCharacter, out state); + + if (state == MsgParseState.ESCAPEBRACKET) + { + EscapeBracket(msgValue); + state = MsgParseState.VALUE; + break; + } + + //End of Value, Write to Output + if (c == 0 && state == MsgParseState.POSTVALUE) + { + if (msgKey.ToString().ToUpper() == "LANGUAGE") + { + //Ignore for now, it's always "English/ANSI" + } + else + { + result.Add(FixLineEndings(msgValue).ToArray()); + } + + //Reset Buffers + msgValue.SetLength(0); + msgKey.Clear(); + break; + } + + if (c > 0) + msgValue.WriteByte((byte)c); + + break; + } + case MsgParseState.POSTVALUE: + { + ProcessPostValue(c, out state); + break; + } + default: break; } - last = c; + + previousCharacter = c; } - WriteMCV(language, messages); + return result; + } + + /// + /// Handles processing of the characters prior to a Key in an entry in a MSG file + /// + /// + /// + /// + public static char ProcessPreKey(char inputCharacter, out MsgParseState resultState) + { + resultState = IsIdentifier(inputCharacter) ? MsgParseState.IDENTIFIER : MsgParseState.PREKEY; + return inputCharacter; + } + + /// + /// Handles processing of the characters in a Key of an entry in a MSG File + /// + /// + /// + /// + public static char ProcessKey(char inputCharacter, out MsgParseState resultState) + { + resultState = inputCharacter switch + { + var s when IsIdentifier(s) => MsgParseState.IDENTIFIER, + var s when s == '{' => MsgParseState.VALUE, //If there is no white space between the KEY name and the curly bracket for value + _ => MsgParseState.POSTKEY + }; + + return inputCharacter; + } + + /// + /// Handles processing the characters after the Key of an entry in a MSG File + /// + /// + /// + public static char ProcessPostKey(char inputCharacter, out MsgParseState resultState) + { + if (inputCharacter == '{') + { + resultState = MsgParseState.VALUE; + return inputCharacter; + } + + //If we find a character that's an key value in Post Key, we're probably processing a text block so reset + if (IsIdentifier(inputCharacter)) + { + resultState = MsgParseState.IDENTIFIER; + return inputCharacter; + } + + resultState = MsgParseState.POSTKEY; + return inputCharacter; + } + + /// + /// Handles processing the values between the curly brackets in an MSG File + /// + /// + /// + /// + /// + public static char ProcessValue(char inputCharacter, char previousCharacter, out MsgParseState resultState) + { + if (inputCharacter == '}') + { + //Escaped Bracket + if (previousCharacter == '~') + { + resultState = MsgParseState.ESCAPEBRACKET; + return (char)0; + } + + //Valid Ending Bracket + resultState = MsgParseState.POSTVALUE; + return (char)0; + } + + resultState = MsgParseState.VALUE; + + //Escaped Tilde + if (inputCharacter == '~' && previousCharacter == '~') + return (char)0; + + //Ignore Newline + if (inputCharacter == '\r') + return (char)0; + + return inputCharacter; + } + + /// + /// Handles processing the characters after the final value curly bracket in an MSG file + /// + /// + /// + public static char ProcessPostValue(char inputCharacter, out MsgParseState resultState) + { + resultState = inputCharacter == '\n' ? MsgParseState.PREKEY : MsgParseState.POSTVALUE; + + return inputCharacter; } private static void EscapeBracket(MemoryStream input) { var arrayToParse = input.ToArray(); input.SetLength(0); - input.Write(arrayToParse[..^2]); + input.Write(arrayToParse[..^1]); input.WriteByte((byte)'}'); } @@ -238,6 +400,15 @@ private void WriteMCV(byte[] language, IList messages, bool writeStringL WriteUInt16(writer, (short)messages.Count); } + /// + /// Used to programatically update values in an MSG file + /// + /// There are several APIs within MajorBBS where Modules can update a given config value + /// that is saved within an MSG file, allowing that configuration option to be persisted + /// in the MSG after the module exits. + /// + /// + /// public static void UpdateValues(string filename, Dictionary values) { var tmpPath = Path.GetTempFileName(); @@ -252,10 +423,24 @@ public static void UpdateValues(string filename, Dictionary valu File.Move(tmpPath, filename, overwrite: true); } + /// + /// Takes an input MSG File (Stream) and writes an output MSG File (Stream), replacing + /// the values for the specified KEYs with the specified VALUEs in dictionary parameter + /// + /// While similar to the above method for extracting values, we need to recreate the file + /// byte for byte with only the values replaced. There are differences in how each state is + /// handled between key extraction and value updates, hence needing its own method. + /// + /// + /// + /// + /// public static void UpdateValues(IStream input, IStream output, Dictionary values) { - var state = MsgParseState.NEWLINE; - var identifier = new StringBuilder(); + var state = MsgParseState.PREKEY; + var msgKey = new StringBuilder(); + using var msgValue = new MemoryStream(); + var previousCharacter = (char)0; int b; while ((b = input.ReadByte()) != -1) @@ -263,54 +448,111 @@ public static void UpdateValues(IStream input, IStream output, Dictionary 2) + throw new ArgumentOutOfRangeException("Unable to locate Value in MSG File Entry to Update"); + + output.Write(Encoding.ASCII.GetBytes(msgValueComponents[0])); + output.Write((byte)':'); + + //Fill in any white space + foreach (var valueCharacter in msgValueComponents[1]) + { + if (valueCharacter != ' ') + break; + + output.Write((byte)' '); + } + + output.Write(Encoding.ASCII.GetBytes(values[msgKey.ToString()])); + output.Write((byte)'}'); + } + else + { + output.Write(msgValue.ToArray()); + output.Write((byte)'}'); + } + + + //Reset Buffers + msgValue.SetLength(0); + msgKey.Clear(); + break; + } + + msgValue.WriteByte((byte)originalCharacter); + + + break; + } + case MsgParseState.POSTVALUE: + { + c = ProcessPostValue(c, out state); + output.Write((byte)c); + break; + } + default: break; - case MsgParseState.JUNK when c == '\n': - state = MsgParseState.NEWLINE; - break; - case MsgParseState.BRACKET_REPLACE: - continue; // skip the output.Write } - output.Write((byte)b); + previousCharacter = c; } } }