diff --git a/Local.testsettings b/Local.testsettings index 9654e6a..8ff3125 100644 --- a/Local.testsettings +++ b/Local.testsettings @@ -1,10 +1,26 @@  These are default test settings for a local test run. - - - + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mustache-sharp.test/FormatCompilerTester.cs b/mustache-sharp.test/FormatCompilerTester.cs new file mode 100644 index 0000000..4514303 --- /dev/null +++ b/mustache-sharp.test/FormatCompilerTester.cs @@ -0,0 +1,921 @@ +using System; +using System.Globalization; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Collections.Generic; + +namespace mustache.test +{ + /// + /// Tests the FormatParser class. + /// + [TestClass] + public class FormatCompilerTester + { + #region Tagless Formats + + /// + /// If the given format is null, an exception should be thrown. + /// + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void TestCompile_NullFormat_Throws() + { + FormatCompiler compiler = new FormatCompiler(); + compiler.Compile(null); + } + + /// + /// If the format string contains no tag, then the given format string + /// should be printed. + /// + [TestMethod] + public void TestCompile_NoTags_PrintsFormatString() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = "This is an ordinary string."; + Generator generator = compiler.Compile(format); + string result = generator.Render(null); + Assert.AreEqual(format, result, "The generated text was wrong."); + } + + /// + /// If a line is just whitespace, it should be printed out as is. + /// + [TestMethod] + public void TestCompile_LineAllWhitespace_PrintsWhitespace() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = "\t \t"; + Generator generator = compiler.Compile(format); + string result = generator.Render(null); + Assert.AreEqual(format, result, "The generated text was wrong."); + } + + /// + /// If a line has output, then the next line is blank, then both lines + /// should be printed. + /// + [TestMethod] + public void TestCompile_OutputNewLineBlank_PrintsBothLines() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"Hello + "; + Generator generator = compiler.Compile(format); + string result = generator.Render(null); + Assert.AreEqual(format, result, "The wrong text was generated."); + } + + #endregion + + #region Key + + /// + /// Replaces placeholds with the actual value. + /// + [TestMethod] + public void TestCompile_Key_ReplacesWithValue() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"Hello, {{Name}}!!!"; + Generator generator = compiler.Compile(format); + string result = generator.Render(new { Name = "Bob" }); + Assert.AreEqual("Hello, Bob!!!", result, "The wrong text was generated."); + } + + /// + /// If we pass null as the source object and the format string contains "this", + /// then nothing should be printed. + /// + [TestMethod] + public void TestCompile_ThisIsNull_PrintsNothing() + { + FormatCompiler compiler = new FormatCompiler(); + Generator generator = compiler.Compile("{{this}}"); + string result = generator.Render(null); + Assert.AreEqual(String.Empty, result, "The wrong text was generated."); + } + + /// + /// If we try to print a key that doesn't exist, an exception should be thrown. + /// + [TestMethod] + [ExpectedException(typeof(KeyNotFoundException))] + public void TestCompile_MissingKey_Throws() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"Hello, {{Name}}!!!"; + Generator generator = compiler.Compile(format); + generator.Render(new object()); + } + + /// + /// If we specify an alignment with a key, the alignment should + /// be used when rending the value. + /// + [TestMethod] + public void TestCompile_KeyWithNegativeAlignment_AppliesAlignment() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"Hello, {{Name,-10}}!!!"; + Generator generator = compiler.Compile(format); + string result = generator.Render(new { Name = "Bob" }); + Assert.AreEqual("Hello, Bob !!!", result, "The wrong text was generated."); + } + + /// + /// If we specify an alignment with a key, the alignment should + /// be used when rending the value. + /// + [TestMethod] + public void TestCompile_KeyWithPositiveAlignment_AppliesAlignment() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"Hello, {{Name,10}}!!!"; + Generator generator = compiler.Compile(format); + string result = generator.Render(new { Name = "Bob" }); + Assert.AreEqual("Hello, Bob!!!", result, "The wrong text was generated."); + } + + /// + /// If we specify a positive alignment with a key with an optional + character, + /// the alignment should be used when rending the value. + /// + [TestMethod] + public void TestCompile_KeyWithPositiveAlignment_OptionalPlus_AppliesAlignment() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"Hello, {{Name,+10}}!!!"; + Generator generator = compiler.Compile(format); + string result = generator.Render(new { Name = "Bob" }); + Assert.AreEqual("Hello, Bob!!!", result, "The wrong text was generated."); + } + + /// + /// If we specify an format with a key, the format should + /// be used when rending the value. + /// + [TestMethod] + public void TestCompile_KeyWithFormat_AppliesFormatting() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"Hello, {{When:yyyyMMdd}}!!!"; + Generator generator = compiler.Compile(format); + string result = generator.Render(new { When = new DateTime(2012, 01, 31) }); + Assert.AreEqual("Hello, 20120131!!!", result, "The wrong text was generated."); + } + + /// + /// If we specify an alignment with a key, the alignment should + /// be used when rending the value. + /// + [TestMethod] + public void TestCompile_KeyWithAlignmentAndFormat_AppliesBoth() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"Hello, {{When,10:yyyyMMdd}}!!!"; + Generator generator = compiler.Compile(format); + string result = generator.Render(new { When = new DateTime(2012, 01, 31) }); + Assert.AreEqual("Hello, 20120131!!!", result, "The wrong text was generated."); + } + + /// + /// If we dot separate keys, the value will be found by searching + /// through the properties. + /// + [TestMethod] + public void TestCompile_NestedKeys_NestedProperties() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"Hello, {{Top.Middle.Bottom}}!!!"; + Generator generator = compiler.Compile(format); + string result = generator.Render(new { Top = new { Middle = new { Bottom = "Bob" } } }); + Assert.AreEqual("Hello, Bob!!!", result, "The wrong text was generated."); + } + + /// + /// If a line has output, then the next line is blank, then both lines + /// should be printed. + /// + [TestMethod] + public void TestCompile_OutputNewLineOutput_PrintsBothLines() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"{{this}} +After"; + Generator generator = compiler.Compile(format); + string result = generator.Render("Content"); + const string expected = @"Content +After"; + Assert.AreEqual(expected, result, "The wrong text was generated."); + } + + /// + /// If there is a line followed by a line with a key, both lines should be + /// printed. + /// + [TestMethod] + public void TestCompile_EmptyNewLineKey_PrintsBothLines() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @" +{{this}}"; + Generator generator = compiler.Compile(format); + string result = generator.Render("Content"); + const string expected = @" +Content"; + Assert.AreEqual(expected, result, "The wrong text was generated."); + } + + /// + /// If there is a no-output line followed by a line with a key, the first line + /// should be removed. + /// + [TestMethod] + public void TestCompile_NoOutputNewLineKey_PrintsBothLines() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"{{#! comment }} +{{this}}"; + Generator generator = compiler.Compile(format); + string result = generator.Render("Content"); + const string expected = @"Content"; + Assert.AreEqual(expected, result, "The wrong text was generated."); + } + + /// + /// If there is a comment on one line followed by a line with a key, the first line + /// should be removed. + /// + [TestMethod] + public void TestCompile_KeyKey_PrintsBothLines() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"{{this}} +{{this}}"; + Generator generator = compiler.Compile(format); + string result = generator.Render("Content"); + const string expected = @"Content +Content"; + Assert.AreEqual(expected, result, "The wrong text was generated."); + } + + #endregion + + #region Comment + + /// + /// Removes comments from the middle of text. + /// + [TestMethod] + public void TestCompile_Comment_RemovesComment() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = "Before{{#! This is a comment }}After"; + Generator generator = compiler.Compile(format); + string result = generator.Render(new object()); + Assert.AreEqual("BeforeAfter", result, "The wrong text was generated."); + } + + /// + /// Removes comments surrounding text. + /// + [TestMethod] + public void TestCompile_CommentContentComment_RemovesComment() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = "{{#! comment }}Middle{{#! comment }}"; + Generator generator = compiler.Compile(format); + string result = generator.Render(new object()); + Assert.AreEqual("Middle", result, "The wrong text was generated."); + } + + /// + /// If blank space is surrounded by comments, the line should be removed. + /// + [TestMethod] + public void TestCompile_CommentBlankComment_RemovesLine() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = "{{#! comment }} {{#! comment }}"; + Generator generator = compiler.Compile(format); + string result = generator.Render(new object()); + Assert.AreEqual(String.Empty, result, "The wrong text was generated."); + } + + /// + /// If a comment follows text, the comment should be removed. + /// + [TestMethod] + public void TestCompile_ContentComment_RemovesComment() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = "Front{{#! comment }}"; + Generator generator = compiler.Compile(format); + string result = generator.Render(new object()); + Assert.AreEqual("Front", result, "The wrong text was generated."); + } + + /// + /// If a comment follows text, the comment should be removed. + /// + [TestMethod] + public void TestCompile_ContentCommentContentComment_RemovesComments() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = "Front{{#! comment }}Middle{{#! comment }}"; + Generator generator = compiler.Compile(format); + string result = generator.Render(new object()); + Assert.AreEqual("FrontMiddle", result, "The wrong text was generated."); + } + + /// + /// If a comment makes up the entire format string, the nothing should be printed out. + /// + [TestMethod] + public void TestCompile_CommentAloneOnlyLine__PrintsEmpty() + { + FormatCompiler compiler = new FormatCompiler(); + Generator generator = compiler.Compile(" {{#! comment }} "); + string result = generator.Render(null); + Assert.AreEqual(String.Empty, result, "The wrong text was generated."); + } + + /// + /// If a comment is on a line by itself, irrespective of leading or trailing whitespace, + /// the line should be removed from output. + /// + [TestMethod] + public void TestCompile_ContentNewLineCommentNewLineContent_RemovesLine() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"Before + {{#! This is a comment }} +After"; + Generator generator = compiler.Compile(format); + string result = generator.Render(new object()); + const string expected = @"Before +After"; + Assert.AreEqual(expected, result, "The wrong text was generated."); + } + + /// + /// If multiple comments are on a line by themselves, irrespective of whitespace, + /// the line should be removed from output. + /// + [TestMethod] + public void TestCompile_ContentNewLineCommentCommentNewLineContent_RemovesLine() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"Before + {{#! This is a comment }} {{#! This is another comment }} +After"; + Generator generator = compiler.Compile(format); + string result = generator.Render(new object()); + const string expected = @"Before +After"; + Assert.AreEqual(expected, result, "The wrong text was generated."); + } + + /// + /// If comments are on a multiple lines by themselves, irrespective of whitespace, + /// the lines should be removed from output. + /// + [TestMethod] + public void TestCompile_CommentsOnMultipleLines_RemovesLines() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"Before + {{#! This is a comment }} + {{#! This is another comment }} + + {{#! This is the final comment }} +After"; + Generator generator = compiler.Compile(format); + string result = generator.Render(new object()); + const string expected = @"Before + +After"; + Assert.AreEqual(expected, result, "The wrong text was generated."); + } + + /// + /// If a comment is followed by text, the line should be printed. + /// + [TestMethod] + public void TestCompile_ContentNewLineCommentContentNewLineContent_PrintsLine() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"Before + {{#! This is a comment }}Extra +After"; + Generator generator = compiler.Compile(format); + string result = generator.Render(new object()); + const string expected = @"Before + Extra +After"; + Assert.AreEqual(expected, result, "The wrong text was generated."); + } + + /// + /// If a comment is followed by the last line in a format string, + /// the comment line should be eliminated and the last line printed. + /// + [TestMethod] + public void TestCompile_CommentNewLineBlank_PrintsBlank() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @" {{#! comment }} + "; + Generator generator = compiler.Compile(format); + string result = generator.Render(null); + Assert.AreEqual(" ", result, "The wrong text was generated."); + } + + /// + /// If a comment is followed by the last line in a format string, + /// the comment line should be eliminated and the last line printed. + /// + [TestMethod] + public void TestCompile_CommentNewLineContent_PrintsContent() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @" {{#! comment }} +After"; + Generator generator = compiler.Compile(format); + string result = generator.Render(null); + Assert.AreEqual("After", result, "The wrong text was generated."); + } + + /// + /// If a line with content is followed by a line with a comment, the first line should + /// be printed. + /// + [TestMethod] + public void TestCompile_ContentNewLineComment_PrintsContent() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"First +{{#! comment }}"; + Generator generator = compiler.Compile(format); + string result = generator.Render(null); + Assert.AreEqual("First", result, "The wrong text was generated."); + } + + /// + /// If a line has a comment, followed by line with content, followed by a line with a comment, only + /// the content should be printed. + /// + [TestMethod] + public void TestCompile_CommentNewLineContentNewLineComment_PrintsContent() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"{{#! comment }} +First +{{#! comment }}"; + Generator generator = compiler.Compile(format); + string result = generator.Render(null); + Assert.AreEqual("First", result, "The wrong text was generated."); + } + + /// + /// If there are lines with content, then a comment, then content, then a comment, only + /// the content should be printed. + /// + [TestMethod] + public void TestCompile_ContentNewLineCommentNewLineContentNewLineComment_PrintsContent() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"First + {{#! comment }} +Middle +{{#! comment }}"; + Generator generator = compiler.Compile(format); + string result = generator.Render(null); + const string expected = @"First +Middle"; + Assert.AreEqual(expected, result, "The wrong text was generated."); + } + + /// + /// If there is content and a comment on a line, followed by a comment, + /// only the content should be printed. + /// + [TestMethod] + public void TestCompile_ContentCommentNewLineComment_PrintsContent() + { + FormatCompiler compiler = new FormatCompiler(); + const string format = @"First{{#! comment }} +{{#! comment }}"; + Generator generator = compiler.Compile(format); + string result = generator.Render(null); + Assert.AreEqual("First", result, "The wrong text was generated."); + } + + #endregion + + #region If + + /// + /// If the condition evaluates to false, the content of an if statement should not be printed. + /// + [TestMethod] + public void TestCompile_If_EvaluatesToFalse_SkipsContent() + { + FormatCompiler parser = new FormatCompiler(); + const string format = "Before{{#if this}}Content{{/if}}After"; + Generator generator = parser.Compile(format); + string result = generator.Render(false); + Assert.AreEqual("BeforeAfter", result, "The wrong text was generated."); + } + + /// + /// If the condition evaluates to false, the content of an if statement should not be printed. + /// + [TestMethod] + public void TestCompile_If_EvaluatesToTrue_PrintsContent() + { + FormatCompiler parser = new FormatCompiler(); + const string format = "Before{{#if this}}Content{{/if}}After"; + Generator generator = parser.Compile(format); + string result = generator.Render(true); + Assert.AreEqual("BeforeContentAfter", result, "The wrong text was generated."); + } + + /// + /// If the header and footer appear on lines by themselves, they should not generate new lines. + /// + [TestMethod] + public void TestCompile_IfNewLineContentNewLineEndIf_PrintsContent() + { + FormatCompiler parser = new FormatCompiler(); + const string format = @"{{#if this}} +Content +{{/if}}"; + Generator generator = parser.Compile(format); + string result = generator.Render(true); + Assert.AreEqual("Content", result, "The wrong text was generated."); + } + + /// + /// If the header and footer appear on lines by themselves, they should not generate new lines. + /// + [TestMethod] + public void TestCompile_IfNewLineEndIf_PrintsNothing() + { + FormatCompiler parser = new FormatCompiler(); + const string format = @"{{#if this}} +{{/if}}"; + Generator generator = parser.Compile(format); + string result = generator.Render(true); + Assert.AreEqual(String.Empty, result, "The wrong text was generated."); + } + + /// + /// If the footer has content in front of it, the content should be printed. + /// + [TestMethod] + public void TestCompile_IfNewLineContentEndIf_PrintsContent() + { + FormatCompiler parser = new FormatCompiler(); + const string format = @"{{#if this}} +Content{{/if}}"; + Generator generator = parser.Compile(format); + string result = generator.Render(true); + Assert.AreEqual("Content", result, "The wrong text was generated."); + } + + /// + /// If the header has content after it, the content should be printed. + /// + [TestMethod] + public void TestCompile_IfContentNewLineEndIf_PrintsContent() + { + FormatCompiler parser = new FormatCompiler(); + const string format = @"{{#if this}}Content +{{/if}}"; + Generator generator = parser.Compile(format); + string result = generator.Render(true); + Assert.AreEqual("Content", result, "The wrong text was generated."); + } + + /// + /// If the header has content after it, the content should be printed. + /// + [TestMethod] + public void TestCompile_ContentIfNewLineEndIf_PrintsContent() + { + FormatCompiler parser = new FormatCompiler(); + const string format = @"Content{{#if this}} +{{/if}}"; + Generator generator = parser.Compile(format); + string result = generator.Render(true); + Assert.AreEqual("Content", result, "The wrong text was generated."); + } + + /// + /// If the header and footer are adjacent, then there is no content. + /// + [TestMethod] + public void TestCompile_IfEndIf_PrintsNothing() + { + FormatCompiler parser = new FormatCompiler(); + const string format = @"{{#if this}}{{/if}}"; + Generator generator = parser.Compile(format); + string result = generator.Render(true); + Assert.AreEqual(String.Empty, result, "The wrong text was generated."); + } + + /// + /// If the header and footer are adjacent, then there is no inner content. + /// + [TestMethod] + public void TestCompile_ContentIfEndIf_PrintsNothing() + { + FormatCompiler parser = new FormatCompiler(); + const string format = @"Content{{#if this}}{{/if}}"; + Generator generator = parser.Compile(format); + string result = generator.Render(true); + Assert.AreEqual("Content", result, "The wrong text was generated."); + } + + /// + /// If the header and footer are adjacent, then there is no inner content. + /// + [TestMethod] + public void TestCompile_IfNewLineCommentEndIf_PrintsNothing() + { + FormatCompiler parser = new FormatCompiler(); + const string format = @"{{#if this}} +{{#! comment}}{{/if}}"; + Generator generator = parser.Compile(format); + string result = generator.Render(true); + Assert.AreEqual(String.Empty, result, "The wrong text was generated."); + } + + /// + /// If the a header follows a footer, it shouldn't generate a new line. + /// + [TestMethod] + public void TestCompile_IfNewLineContentNewLineEndIfIfNewLineContenNewLineEndIf_PrintsContent() + { + FormatCompiler parser = new FormatCompiler(); + const string format = @"{{#if this}} +First +{{/if}}{{#if this}} +Last +{{/if}}"; + Generator generator = parser.Compile(format); + string result = generator.Render(true); + const string expected = @"First +Last"; + Assert.AreEqual(expected, result, "The wrong text was generated."); + } + + /// + /// If the content separates two if statements, it should be unaffected. + /// + [TestMethod] + public void TestCompile_IfNewLineEndIfNewLineContentNewLineIfNewLineEndIf_PrintsContent() + { + FormatCompiler parser = new FormatCompiler(); + const string format = @"{{#if this}} +{{/if}} +Content +{{#if this}} +{{/if}}"; + Generator generator = parser.Compile(format); + string result = generator.Render(true); + const string expected = @"Content"; + Assert.AreEqual(expected, result, "The wrong text was generated."); + } + + /// + /// If there is trailing text of any kind, the newline after content should be preserved. + /// + [TestMethod] + public void TestCompile_IfNewLineEndIfNewLineContentNewLineIfNewLineEndIfContent_PrintsContent() + { + FormatCompiler parser = new FormatCompiler(); + const string format = @"{{#if this}} +{{/if}} +First +{{#if this}} +{{/if}} +Last"; + Generator generator = parser.Compile(format); + string result = generator.Render(true); + const string expected = @"First +Last"; + Assert.AreEqual(expected, result, "The wrong text was generated."); + } + + #endregion + + #region If/Else + + /// + /// If the condition evaluates to false, the content of an else statement should be printed. + /// + [TestMethod] + public void TestCompile_IfElse_EvaluatesToFalse_PrintsElse() + { + FormatCompiler parser = new FormatCompiler(); + const string format = "Before{{#if this}}Yay{{#else}}Nay{{/if}}After"; + Generator generator = parser.Compile(format); + string result = generator.Render(false); + Assert.AreEqual("BeforeNayAfter", result, "The wrong text was generated."); + } + + /// + /// If the condition evaluates to true, the content of an if statement should be printed. + /// + [TestMethod] + public void TestCompile_IfElse_EvaluatesToTrue_PrintsIf() + { + FormatCompiler parser = new FormatCompiler(); + const string format = "Before{{#if this}}Yay{{#else}}Nay{{/if}}After"; + Generator generator = parser.Compile(format); + string result = generator.Render(true); + Assert.AreEqual("BeforeYayAfter", result, "The wrong text was generated."); + } + + /// + /// Second else blocks will be interpreted as just another piece of text. + /// + [TestMethod] + public void TestCompile_IfElse_TwoElses_IncludesSecondElseInElse() + { + FormatCompiler parser = new FormatCompiler(); + const string format = "Before{{#if this}}Yay{{#else}}Nay{{#else}}Bad{{/if}}After"; + Generator generator = parser.Compile(format); + string result = generator.Render(false); + Assert.AreEqual("BeforeNay{{#else}}BadAfter", result, "The wrong text was generated."); + } + + #endregion + + #region If/Elif/Else + + /// + /// If the if statement evaluates to true, its block should be printed. + /// + [TestMethod] + public void TestCompile_IfElifElse_IfTrue_PrintsIf() + { + FormatCompiler parser = new FormatCompiler(); + const string format = "Before{{#if First}}First{{#elif Second}}Second{{#else}}Third{{/if}}After"; + Generator generator = parser.Compile(format); + string result = generator.Render(new { First = true, Second = true }); + Assert.AreEqual("BeforeFirstAfter", result, "The wrong text was generated."); + } + + /// + /// If the elif statement evaluates to true, its block should be printed. + /// + [TestMethod] + public void TestCompile_IfElifElse_ElifTrue_PrintsIf() + { + FormatCompiler parser = new FormatCompiler(); + const string format = "Before{{#if First}}First{{#elif Second}}Second{{#else}}Third{{/if}}After"; + Generator generator = parser.Compile(format); + string result = generator.Render(new { First = false, Second = true }); + Assert.AreEqual("BeforeSecondAfter", result, "The wrong text was generated."); + } + + /// + /// If the elif statement evaluates to false, the else block should be printed. + /// + [TestMethod] + public void TestCompile_IfElifElse_ElifFalse_PrintsElse() + { + FormatCompiler parser = new FormatCompiler(); + const string format = "Before{{#if First}}First{{#elif Second}}Second{{#else}}Third{{/if}}After"; + Generator generator = parser.Compile(format); + string result = generator.Render(new { First = false, Second = false }); + Assert.AreEqual("BeforeThirdAfter", result, "The wrong text was generated."); + } + + #endregion + + #region If/Elif + + /// + /// If the elif statement evaluates to false and there is no else statement, nothing should be printed. + /// + [TestMethod] + public void TestCompile_IfElif_ElifFalse_PrintsNothing() + { + FormatCompiler parser = new FormatCompiler(); + const string format = "Before{{#if First}}First{{#elif Second}}Second{{/if}}After"; + Generator generator = parser.Compile(format); + string result = generator.Render(new { First = false, Second = false }); + Assert.AreEqual("BeforeAfter", result, "The wrong text was generated."); + } + + /// + /// If there are two elif statements and the first is false, the second elif block should be printed. + /// + [TestMethod] + public void TestCompile_IfElifElif_ElifFalse_PrintsSecondElif() + { + FormatCompiler parser = new FormatCompiler(); + const string format = "Before{{#if First}}First{{#elif Second}}Second{{#elif Third}}Third{{/if}}After"; + Generator generator = parser.Compile(format); + string result = generator.Render(new { First = false, Second = false, Third = true }); + Assert.AreEqual("BeforeThirdAfter", result, "The wrong text was generated."); + } + + #endregion + + #region Each + + /// + /// If we pass an empty collection to an each statement, the content should not be printed. + /// + [TestMethod] + public void TestCompile_Each_EmptyCollection_SkipsContent() + { + FormatCompiler parser = new FormatCompiler(); + const string format = "Before{{#each this}}{{this}}{{/each}}After"; + Generator generator = parser.Compile(format); + string result = generator.Render(new int[0]); + Assert.AreEqual("BeforeAfter", result, "The wrong text was generated."); + } + + /// + /// If we pass a populated collection to an each statement, the content should be printed + /// for each item in the collection, using that item as the new scope context. + /// + [TestMethod] + public void TestCompile_Each_PopulatedCollection_PrintsContentForEach() + { + FormatCompiler parser = new FormatCompiler(); + const string format = "Before{{#each this}}{{this}}{{/each}}After"; + Generator generator = parser.Compile(format); + string result = generator.Render(new int[] { 1, 2, 3 }); + Assert.AreEqual("Before123After", result, "The wrong text was generated."); + } + + #endregion + + #region With + + /// + /// The object replacing the placeholder should be used as the context of a with statement. + /// + [TestMethod] + public void TestCompile_With_AddsScope() + { + FormatCompiler parser = new FormatCompiler(); + const string format = "Before{{#with Nested}}{{this}}{{/with}}After"; + Generator generator = parser.Compile(format); + string result = generator.Render(new { Nested = "Hello" }); + Assert.AreEqual("BeforeHelloAfter", result, "The wrong text was generated."); + } + + #endregion + + #region Default Parameter + + /// + /// If a tag is defined with a default parameter, the default value + /// should be returned if an argument is not provided. + /// + [TestMethod] + public void TestCompile_MissingDefaultParameter_ProvidesDefault() + { + FormatCompiler compiler = new FormatCompiler(); + compiler.RegisterTag(new DefaultTagDefinition(), true); + const string format = @"{{#default}}"; + Generator generator = compiler.Compile(format); + string result = generator.Render(null); + Assert.AreEqual("123", result, "The wrong text was generated."); + } + + private sealed class DefaultTagDefinition : InlineTagDefinition + { + public DefaultTagDefinition() + : base("default") + { + } + + protected override bool GetIsContextSensitive() + { + return false; + } + + protected override IEnumerable GetParameters() + { + return new TagParameter[] { new TagParameter("param") { IsRequired = false, DefaultValue = 123 } }; + } + + public override string Decorate(IFormatProvider provider, Dictionary arguments) + { + return arguments["param"].ToString(); + } + } + + #endregion + } +} diff --git a/mustache-sharp.test/FormatParserTester.cs b/mustache-sharp.test/FormatParserTester.cs deleted file mode 100644 index 47dbbbb..0000000 --- a/mustache-sharp.test/FormatParserTester.cs +++ /dev/null @@ -1,209 +0,0 @@ -using System; -using System.Globalization; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace mustache.test -{ - /// - /// Tests the FormatParser class. - /// - [TestClass] - public class FormatParserTester - { - /// - /// Replaces placeholds with the actual value. - /// - [TestMethod] - public void TestBuild_Key_ReplacesWithValue() - { - FormatCompiler parser = new FormatCompiler(); - const string format = @"Hello, {{Name}}!!!"; - Generator generator = parser.Compile(format); - string result = generator.Render(new { Name = "Bob" }); - Assert.AreEqual("Hello, Bob!!!", result, "The wrong text was generated."); - } - - /// - /// Removes comments from the output. - /// - [TestMethod] - public void TestBuild_Comment_RemovesComment() - { - FormatCompiler parser = new FormatCompiler(); - const string format = "Before{{#! This is a comment }}After"; - Generator generator = parser.Compile(format); - string result = generator.Render(new object()); - Assert.AreEqual("BeforeAfter", result, "The wrong text was generated."); - } - - /// - /// If the condition evaluates to false, the content of an if statement should not be printed. - /// - [TestMethod] - public void TestBuild_If_EvaluatesToFalse_SkipsContent() - { - FormatCompiler parser = new FormatCompiler(); - const string format = "Before{{#if this}}Content{{/if}}After"; - Generator generator = parser.Compile(format); - string result = generator.Render(false); - Assert.AreEqual("BeforeAfter", result, "The wrong text was generated."); - } - - /// - /// If the condition evaluates to false, the content of an if statement should not be printed. - /// - [TestMethod] - public void TestBuild_If_EvaluatesToTrue_PrintsContent() - { - FormatCompiler parser = new FormatCompiler(); - const string format = "Before{{#if this}}Content{{/if}}After"; - Generator generator = parser.Compile(format); - string result = generator.Render(true); - Assert.AreEqual("BeforeContentAfter", result, "The wrong text was generated."); - } - - /// - /// If the condition evaluates to false, the content of an else statement should be printed. - /// - [TestMethod] - public void TestBuild_IfElse_EvaluatesToFalse_PrintsElse() - { - FormatCompiler parser = new FormatCompiler(); - const string format = "Before{{#if this}}Yay{{#else}}Nay{{/if}}After"; - Generator generator = parser.Compile(format); - string result = generator.Render(false); - Assert.AreEqual("BeforeNayAfter", result, "The wrong text was generated."); - } - - /// - /// If the condition evaluates to true, the content of an if statement should be printed. - /// - [TestMethod] - public void TestBuild_IfElse_EvaluatesToTrue_PrintsIf() - { - FormatCompiler parser = new FormatCompiler(); - const string format = "Before{{#if this}}Yay{{#else}}Nay{{/if}}After"; - Generator generator = parser.Compile(format); - string result = generator.Render(true); - Assert.AreEqual("BeforeYayAfter", result, "The wrong text was generated."); - } - - /// - /// Second else blocks will be interpreted as just another piece of text. - /// - [TestMethod] - public void TestBuild_IfElse_TwoElses_IncludesSecondElseInElse() - { - FormatCompiler parser = new FormatCompiler(); - const string format = "Before{{#if this}}Yay{{#else}}Nay{{#else}}Bad{{/if}}After"; - Generator generator = parser.Compile(format); - string result = generator.Render(false); - Assert.AreEqual("BeforeNay{{#else}}BadAfter", result, "The wrong text was generated."); - } - - /// - /// If the if statement evaluates to true, its block should be printed. - /// - [TestMethod] - public void TestBuild_IfElifElse_IfTrue_PrintsIf() - { - FormatCompiler parser = new FormatCompiler(); - const string format = "Before{{#if First}}First{{#elif Second}}Second{{#else}}Third{{/if}}After"; - Generator generator = parser.Compile(format); - string result = generator.Render(new { First = true, Second = true }); - Assert.AreEqual("BeforeFirstAfter", result, "The wrong text was generated."); - } - - /// - /// If the elif statement evaluates to true, its block should be printed. - /// - [TestMethod] - public void TestBuild_IfElifElse_ElifTrue_PrintsIf() - { - FormatCompiler parser = new FormatCompiler(); - const string format = "Before{{#if First}}First{{#elif Second}}Second{{#else}}Third{{/if}}After"; - Generator generator = parser.Compile(format); - string result = generator.Render(new { First = false, Second = true }); - Assert.AreEqual("BeforeSecondAfter", result, "The wrong text was generated."); - } - - /// - /// If the elif statement evaluates to false, the else block should be printed. - /// - [TestMethod] - public void TestBuild_IfElifElse_ElifFalse_PrintsElse() - { - FormatCompiler parser = new FormatCompiler(); - const string format = "Before{{#if First}}First{{#elif Second}}Second{{#else}}Third{{/if}}After"; - Generator generator = parser.Compile(format); - string result = generator.Render(new { First = false, Second = false }); - Assert.AreEqual("BeforeThirdAfter", result, "The wrong text was generated."); - } - - /// - /// If the elif statement evaluates to false and there is no else statement, nothing should be printed. - /// - [TestMethod] - public void TestBuild_IfElif_ElifFalse_PrintsNothing() - { - FormatCompiler parser = new FormatCompiler(); - const string format = "Before{{#if First}}First{{#elif Second}}Second{{/if}}After"; - Generator generator = parser.Compile(format); - string result = generator.Render(new { First = false, Second = false }); - Assert.AreEqual("BeforeAfter", result, "The wrong text was generated."); - } - - /// - /// If there are two elif statements and the first is false, the second elif block should be printed. - /// - [TestMethod] - public void TestBuild_IfElifElif_ElifFalse_PrintsSecondElif() - { - FormatCompiler parser = new FormatCompiler(); - const string format = "Before{{#if First}}First{{#elif Second}}Second{{#elif Third}}Third{{/if}}After"; - Generator generator = parser.Compile(format); - string result = generator.Render(new { First = false, Second = false, Third = true }); - Assert.AreEqual("BeforeThirdAfter", result, "The wrong text was generated."); - } - - /// - /// If we pass an empty collection to an each statement, the content should not be printed. - /// - [TestMethod] - public void TestBuild_Each_EmptyCollection_SkipsContent() - { - FormatCompiler parser = new FormatCompiler(); - const string format = "Before{{#each this}}{{this}}{{/each}}After"; - Generator generator = parser.Compile(format); - string result = generator.Render(new int[0]); - Assert.AreEqual("BeforeAfter", result, "The wrong text was generated."); - } - - /// - /// If we pass a populated collection to an each statement, the content should be printed - /// for each item in the collection, using that item as the new scope context. - /// - [TestMethod] - public void TestBuild_Each_PopulatedCollection_PrintsContentForEach() - { - FormatCompiler parser = new FormatCompiler(); - const string format = "Before{{#each this}}{{this}}{{/each}}After"; - Generator generator = parser.Compile(format); - string result = generator.Render(new int[] { 1, 2, 3 }); - Assert.AreEqual("Before123After", result, "The wrong text was generated."); - } - - /// - /// The object replacing the placeholder should be used as the context of a with statement. - /// - [TestMethod] - public void TestBuild_With_AddsScope() - { - FormatCompiler parser = new FormatCompiler(); - const string format = "Before{{#with Nested}}{{this}}{{/with}}After"; - Generator generator = parser.Compile(format); - string result = generator.Render(new { Nested = "Hello" }); - Assert.AreEqual("BeforeHelloAfter", result, "The wrong text was generated."); - } - } -} diff --git a/mustache-sharp.test/mustache-sharp.test.csproj b/mustache-sharp.test/mustache-sharp.test.csproj index 66435c1..81de5b3 100644 --- a/mustache-sharp.test/mustache-sharp.test.csproj +++ b/mustache-sharp.test/mustache-sharp.test.csproj @@ -43,7 +43,7 @@ - + diff --git a/mustache-sharp/CompoundGenerator.cs b/mustache-sharp/CompoundGenerator.cs index 7614ceb..c374f2b 100644 --- a/mustache-sharp/CompoundGenerator.cs +++ b/mustache-sharp/CompoundGenerator.cs @@ -11,7 +11,7 @@ namespace mustache { private readonly TagDefinition _definition; private readonly ArgumentCollection _arguments; - private readonly List _primaryGenerators; + private readonly LinkedList _primaryGenerators; private IGenerator _subGenerator; /// @@ -23,7 +23,7 @@ namespace mustache { _definition = definition; _arguments = arguments; - _primaryGenerators = new List(); + _primaryGenerators = new LinkedList(); } /// @@ -47,6 +47,19 @@ namespace mustache addGenerator(generator, isSubGenerator); } + /// + /// Creates a StaticGenerator from the given value and adds it. + /// + /// The static generators to add. + public void AddStaticGenerators(IEnumerable generators) + { + foreach (StaticGenerator generator in generators) + { + LinkedListNode node = _primaryGenerators.AddLast(generator); + generator.Node = node; + } + } + private void addGenerator(IGenerator generator, bool isSubGenerator) { if (isSubGenerator) @@ -55,7 +68,7 @@ namespace mustache } else { - _primaryGenerators.Add(generator); + _primaryGenerators.AddLast(generator); } } @@ -64,17 +77,17 @@ namespace mustache StringBuilder builder = new StringBuilder(); Dictionary arguments = _arguments.GetArguments(scope); IEnumerable scopes = _definition.GetChildScopes(scope, arguments); - List generators; + LinkedList generators; if (_definition.ShouldGeneratePrimaryGroup(arguments)) { generators = _primaryGenerators; } else { - generators = new List(); + generators = new LinkedList(); if (_subGenerator != null) { - generators.Add(_subGenerator); + generators.AddLast(_subGenerator); } } foreach (KeyScope childScope in scopes) diff --git a/mustache-sharp/ConditionTagDefinition.cs b/mustache-sharp/ConditionTagDefinition.cs index f45e42d..8eae974 100644 --- a/mustache-sharp/ConditionTagDefinition.cs +++ b/mustache-sharp/ConditionTagDefinition.cs @@ -8,7 +8,7 @@ namespace mustache /// /// Defines a tag that conditionally prints its content. /// - internal abstract class ConditionTagDefinition : TagDefinition + internal abstract class ConditionTagDefinition : ContentTagDefinition { private const string conditionParameter = "condition"; @@ -25,30 +25,18 @@ namespace mustache /// Gets the parameters that can be passed to the tag. /// /// The parameters. - protected override TagParameter[] GetParameters() + protected override IEnumerable GetParameters() { return new TagParameter[] { new TagParameter(conditionParameter) { IsRequired = true } }; } - /// - /// Gets whether the tag will contain content. - /// - public override bool HasBody - { - get { return true; } - } - /// /// Gets the tags that come into scope within the context of the current tag. /// /// The child tag definitions. - protected override TagDefinition[] GetChildTags() + protected override IEnumerable GetChildTags() { - return new TagDefinition[] - { - new ElifTagDefinition(), - new ElseTagDefinition(), - }; + return new string[] { "elif", "else" }; } /// @@ -58,7 +46,7 @@ namespace mustache /// True if the tag's generator should be used as a secondary generator. public override bool ShouldCreateSecondaryGroup(TagDefinition definition) { - return (definition is ElifTagDefinition) || (definition is ElseTagDefinition); + return new string[] { "elif", "else" }.Contains(definition.Name); } /// diff --git a/mustache-sharp/ContentTagDefinition.cs b/mustache-sharp/ContentTagDefinition.cs new file mode 100644 index 0000000..2fe16df --- /dev/null +++ b/mustache-sharp/ContentTagDefinition.cs @@ -0,0 +1,38 @@ +using System; + +namespace mustache +{ + /// + /// Defines a tag that can contain inner text. + /// + public abstract class ContentTagDefinition : TagDefinition + { + /// + /// Initializes a new instance of a ContentTagDefinition. + /// + /// The name of the tag being defined. + protected ContentTagDefinition(string tagName) + : base(tagName) + { + } + + /// + /// Initializes a new instance of a ContentTagDefinition. + /// + /// The name of the tag being defined. + /// Specifies whether the tag is a built-in tag. + internal ContentTagDefinition(string tagName, bool isBuiltin) + : base(tagName, isBuiltin) + { + } + + /// + /// Gets or sets whether the tag can have content. + /// + /// True if the tag can have a body; otherwise, false. + protected override bool GetHasContent() + { + return true; + } + } +} diff --git a/mustache-sharp/EachTagDefinition.cs b/mustache-sharp/EachTagDefinition.cs index 1d259a2..70d5257 100644 --- a/mustache-sharp/EachTagDefinition.cs +++ b/mustache-sharp/EachTagDefinition.cs @@ -8,7 +8,7 @@ namespace mustache /// Defines a tag that can iterate over a collection of items and render /// the content using each item as the context. /// - internal sealed class EachTagDefinition : TagDefinition + internal sealed class EachTagDefinition : ContentTagDefinition { private const string collectionParameter = "collection"; @@ -20,32 +20,23 @@ namespace mustache { } + /// + /// Gets whether the tag only exists within the scope of its parent. + /// + protected override bool GetIsContextSensitive() + { + return false; + } + /// /// Gets the parameters that can be passed to the tag. /// /// The parameters. - protected override TagParameter[] GetParameters() + protected override IEnumerable GetParameters() { return new TagParameter[] { new TagParameter(collectionParameter) { IsRequired = true } }; } - /// - /// Gets whether the tag has content. - /// - public override bool HasBody - { - get { return true; } - } - - /// - /// Gets the tags that come into scope within the context of the tag. - /// - /// The tag definitions. - protected override TagDefinition[] GetChildTags() - { - return new TagDefinition[0]; - } - /// /// Gets the scopes for each of the items found in the argument. /// @@ -65,5 +56,14 @@ namespace mustache yield return scope.CreateChildScope(item); } } + + /// + /// Gets the tags that are in scope under this tag. + /// + /// The name of the tags that are in scope. + protected override IEnumerable GetChildTags() + { + return new string[] { }; + } } } diff --git a/mustache-sharp/ElifTagDefinition.cs b/mustache-sharp/ElifTagDefinition.cs index f9aafa6..38b5601 100644 --- a/mustache-sharp/ElifTagDefinition.cs +++ b/mustache-sharp/ElifTagDefinition.cs @@ -15,13 +15,21 @@ namespace mustache : base("elif") { } + + /// + /// Gets whether the tag only exists within the scope of its parent. + /// + protected override bool GetIsContextSensitive() + { + return true; + } /// /// Gets the tags that indicate the end of the current tags context. /// - public override IEnumerable ClosingTags + protected override IEnumerable GetClosingTags() { - get { return new TagDefinition[] { new IfTagDefinition() }; } + return new string[] { "if" }; } } } diff --git a/mustache-sharp/ElseTagDefinition.cs b/mustache-sharp/ElseTagDefinition.cs index 6cfaff9..f3b1d1e 100644 --- a/mustache-sharp/ElseTagDefinition.cs +++ b/mustache-sharp/ElseTagDefinition.cs @@ -6,7 +6,7 @@ namespace mustache /// /// Defines a tag that renders its content if all preceding if and elif tags. /// - internal sealed class ElseTagDefinition : TagDefinition + internal sealed class ElseTagDefinition : ContentTagDefinition { /// /// Initializes a new instance of a ElseTagDefinition. @@ -17,37 +17,19 @@ namespace mustache } /// - /// Gets the parameters that can be passed to the tag. + /// Gets whether the tag only exists within the scope of its parent. /// - /// The parameters. - protected override TagParameter[] GetParameters() + protected override bool GetIsContextSensitive() { - return new TagParameter[0]; - } - - /// - /// Gets whether the tag contains content. - /// - public override bool HasBody - { - get { return true; } + return true; } /// /// Gets the tags that indicate the end of the current tag's content. /// - public override IEnumerable ClosingTags + protected override IEnumerable GetClosingTags() { - get { return new TagDefinition[] { new IfTagDefinition() }; } - } - - /// - /// Gets the tags that come into scope within the context of the tag. - /// - /// The tag definitions. - protected override TagDefinition[] GetChildTags() - { - return new TagDefinition[0]; + return new string[] { "if" }; } } } diff --git a/mustache-sharp/FormatCompiler.cs b/mustache-sharp/FormatCompiler.cs index 73154ec..1f78aa9 100644 --- a/mustache-sharp/FormatCompiler.cs +++ b/mustache-sharp/FormatCompiler.cs @@ -12,33 +12,48 @@ namespace mustache /// public sealed class FormatCompiler { - private const string key = @"[_\w][_\w\d]*"; - private const string compoundKey = key + @"(\." + key + ")*"; - - private readonly MasterTagDefinition _master; - private readonly TagScope _tagScope; + private readonly Dictionary _tagLookup; + private readonly Dictionary _regexLookup; + private readonly MasterTagDefinition _masterDefinition; /// /// Initializes a new instance of a FormatCompiler. /// public FormatCompiler() { - _master = new MasterTagDefinition(); - _tagScope = new TagScope(); - registerTags(_master, _tagScope); + _tagLookup = new Dictionary(); + _regexLookup = new Dictionary(); + _masterDefinition = new MasterTagDefinition(); + + IfTagDefinition ifDefinition = new IfTagDefinition(); + _tagLookup.Add(ifDefinition.Name, ifDefinition); + ElifTagDefinition elifDefinition = new ElifTagDefinition(); + _tagLookup.Add(elifDefinition.Name, elifDefinition); + ElseTagDefinition elseDefinition = new ElseTagDefinition(); + _tagLookup.Add(elseDefinition.Name, elseDefinition); + EachTagDefinition eachDefinition = new EachTagDefinition(); + _tagLookup.Add(eachDefinition.Name, eachDefinition); + WithTagDefinition withDefinition = new WithTagDefinition(); + _tagLookup.Add(withDefinition.Name, withDefinition); } /// /// Registers the given tag definition with the parser. /// /// The tag definition to register. - public void RegisterTag(TagDefinition definition) + /// Specifies whether the tag is immediately in scope. + public void RegisterTag(TagDefinition definition, bool isTopLevel) { if (definition == null) { throw new ArgumentNullException("definition"); } - _tagScope.AddTag(definition); + if (_tagLookup.ContainsKey(definition.Name)) + { + string message = String.Format(Resources.DuplicateTagDefinition, definition.Name); + throw new ArgumentException(message, "definition"); + } + _tagLookup.Add(definition.Name, definition); } /// @@ -48,46 +63,61 @@ namespace mustache /// The text generator. public Generator Compile(string format) { - CompoundGenerator generator = new CompoundGenerator(_master, new ArgumentCollection()); + if (format == null) + { + throw new ArgumentNullException("format"); + } + CompoundGenerator generator = new CompoundGenerator(_masterDefinition, new ArgumentCollection()); Trimmer trimmer = new Trimmer(); - int formatIndex = buildCompoundGenerator(_master, _tagScope, generator, trimmer, format, 0); + int formatIndex = buildCompoundGenerator(_masterDefinition, generator, trimmer, format, 0); string trailing = format.Substring(formatIndex); - StaticGenerator staticGenerator = new StaticGenerator(trailing); - generator.AddGenerator(staticGenerator); + generator.AddStaticGenerators(trimmer.RecordText(trailing, false, false)); + trimmer.Trim(); return new Generator(generator); } - private static void registerTags(TagDefinition definition, TagScope scope) + private Match findNextTag(TagDefinition definition, string format, int formatIndex) { - foreach (TagDefinition childTag in definition.ChildTags) - { - scope.TryAddTag(childTag); - } - } - - private static Match findNextTag(TagDefinition definition, string format, int formatIndex) - { - List matches = new List(); - matches.Add(getKeyRegex()); - matches.Add(getCommentTagRegex()); - foreach (TagDefinition closingTag in definition.ClosingTags) - { - matches.Add(getClosingTagRegex(closingTag)); - } - foreach (TagDefinition childTag in definition.ChildTags) - { - matches.Add(getTagRegex(childTag)); - } - string match = "{{(" + String.Join("|", matches) + ")}}"; - Regex regex = new Regex(match); + Regex regex = prepareRegex(definition); return regex.Match(format, formatIndex); } - private static string getClosingTagRegex(TagDefinition definition) + private Regex prepareRegex(TagDefinition definition) + { + Regex regex; + if (!_regexLookup.TryGetValue(definition.Name, out regex)) + { + List matches = new List(); + matches.Add(getKeyRegex()); + matches.Add(getCommentTagRegex()); + foreach (string closingTag in definition.ClosingTags) + { + matches.Add(getClosingTagRegex(closingTag)); + } + foreach (TagDefinition globalDefinition in _tagLookup.Values) + { + if (!globalDefinition.IsContextSensitive) + { + matches.Add(getTagRegex(globalDefinition)); + } + } + foreach (string childTag in definition.ChildTags) + { + TagDefinition childDefinition = _tagLookup[childTag]; + matches.Add(getTagRegex(childDefinition)); + } + string match = "{{(" + String.Join("|", matches) + ")}}"; + regex = new Regex(match, RegexOptions.Compiled); + _regexLookup.Add(definition.Name, regex); + } + return regex; + } + + private static string getClosingTagRegex(string tagName) { StringBuilder regexBuilder = new StringBuilder(); regexBuilder.Append(@"(?(/(?"); - regexBuilder.Append(definition.Name); + regexBuilder.Append(tagName); regexBuilder.Append(@")\s*?))"); return regexBuilder.ToString(); } @@ -99,7 +129,7 @@ namespace mustache private static string getKeyRegex() { - return @"((?" + compoundKey + @")(,(?(-)?[\d]+))?(:(?.*?))?)"; + return @"((?" + RegexHelper.CompoundKey + @")(,(?(\+|-)?[\d]+))?(:(?.*?))?)"; } private static string getTagRegex(TagDefinition definition) @@ -110,18 +140,21 @@ namespace mustache regexBuilder.Append(@")"); foreach (TagParameter parameter in definition.Parameters) { - regexBuilder.Append(@"\s+?"); + regexBuilder.Append(@"(\s+?"); regexBuilder.Append(@"(?"); - regexBuilder.Append(compoundKey); - regexBuilder.Append(@")"); + regexBuilder.Append(RegexHelper.CompoundKey); + regexBuilder.Append(@"))"); + if (!parameter.IsRequired) + { + regexBuilder.Append("?"); + } } regexBuilder.Append(@"\s*?))"); return regexBuilder.ToString(); } - private static int buildCompoundGenerator( + private int buildCompoundGenerator( TagDefinition tagDefinition, - TagScope scope, CompoundGenerator generator, Trimmer trimmer, string format, int formatIndex) @@ -144,7 +177,7 @@ namespace mustache if (match.Groups["key"].Success) { - trimmer.AddStaticGenerator(generator, true, leading); + generator.AddStaticGenerators(trimmer.RecordText(leading, true, true)); formatIndex = match.Index + match.Length; string key = match.Groups["key"].Value; string alignment = match.Groups["alignment"].Value; @@ -156,24 +189,23 @@ namespace mustache { formatIndex = match.Index + match.Length; string tagName = match.Groups["name"].Value; - TagDefinition nextDefinition = scope.Find(tagName); + TagDefinition nextDefinition = _tagLookup[tagName]; if (nextDefinition == null) { string message = String.Format(Resources.UnknownTag, tagName); throw new FormatException(message); } - trimmer.AddStaticGeneratorBeforeTag(generator, true, leading); - if (nextDefinition.HasBody) + if (nextDefinition.HasContent) { + generator.AddStaticGenerators(trimmer.RecordText(leading, true, false)); ArgumentCollection arguments = getArguments(nextDefinition, match); CompoundGenerator compoundGenerator = new CompoundGenerator(nextDefinition, arguments); - TagScope nextScope = new TagScope(scope); - registerTags(nextDefinition, nextScope); - formatIndex = buildCompoundGenerator(nextDefinition, nextScope, compoundGenerator, trimmer, format, formatIndex); + formatIndex = buildCompoundGenerator(nextDefinition, compoundGenerator, trimmer, format, formatIndex); generator.AddGenerator(nextDefinition, compoundGenerator); } else { + generator.AddStaticGenerators(trimmer.RecordText(leading, true, true)); Match nextMatch = findNextTag(nextDefinition, format, formatIndex); ArgumentCollection arguments = getArguments(nextDefinition, nextMatch); InlineGenerator inlineGenerator = new InlineGenerator(nextDefinition, arguments); @@ -182,9 +214,9 @@ namespace mustache } else if (match.Groups["close"].Success) { + generator.AddStaticGenerators(trimmer.RecordText(leading, true, false)); string tagName = match.Groups["name"].Value; - TagDefinition nextDefinition = scope.Find(tagName); - trimmer.AddStaticGeneratorBeforeTag(generator, false, leading); + TagDefinition nextDefinition = _tagLookup[tagName]; formatIndex = match.Index; if (tagName == tagDefinition.Name) { @@ -194,7 +226,7 @@ namespace mustache } else if (match.Groups["comment"].Success) { - trimmer.AddStaticGenerator(generator, false, leading); + generator.AddStaticGenerators(trimmer.RecordText(leading, true, false)); formatIndex = match.Index + match.Length; } } diff --git a/mustache-sharp/IfTagDefinition.cs b/mustache-sharp/IfTagDefinition.cs index 6e56397..1287f09 100644 --- a/mustache-sharp/IfTagDefinition.cs +++ b/mustache-sharp/IfTagDefinition.cs @@ -15,5 +15,13 @@ namespace mustache : base("if") { } + + /// + /// Gets whether the tag only exists within the scope of its parent. + /// + protected override bool GetIsContextSensitive() + { + return false; + } } } diff --git a/mustache-sharp/InlineTagDefinition.cs b/mustache-sharp/InlineTagDefinition.cs new file mode 100644 index 0000000..49626b2 --- /dev/null +++ b/mustache-sharp/InlineTagDefinition.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; + +namespace mustache +{ + /// + /// Defines a tag that cannot contain inner text. + /// + public abstract class InlineTagDefinition : TagDefinition + { + /// + /// Initializes a new instance of an InlineTagDefinition. + /// + /// The name of the tag being defined. + protected InlineTagDefinition(string tagName) + : base(tagName) + { + } + + /// + /// Initializes a new instance of an InlineTagDefinition. + /// + /// The name of the tag being defined. + /// Specifies whether the tag is a built-in tag. + internal InlineTagDefinition(string tagName, bool isBuiltin) + : base(tagName, isBuiltin) + { + } + + /// + /// Gets or sets whether the tag can have content. + /// + /// True if the tag can have a body; otherwise, false. + protected override bool GetHasContent() + { + return false; + } + + public sealed override string Decorate(IFormatProvider provider, string innerText, Dictionary arguments) + { + return Decorate(provider, arguments); + } + + public abstract string Decorate(IFormatProvider provider, Dictionary arguments); + } +} diff --git a/mustache-sharp/KeyGenerator.cs b/mustache-sharp/KeyGenerator.cs index 09b8a9c..10f97b3 100644 --- a/mustache-sharp/KeyGenerator.cs +++ b/mustache-sharp/KeyGenerator.cs @@ -30,7 +30,7 @@ namespace mustache if (!String.IsNullOrWhiteSpace(alignment)) { formatBuilder.Append(","); - formatBuilder.Append(alignment); + formatBuilder.Append(alignment.TrimStart('+')); } if (!String.IsNullOrWhiteSpace(formatting)) { diff --git a/mustache-sharp/MasterTagDefinition.cs b/mustache-sharp/MasterTagDefinition.cs index b9d5ad7..cd45f00 100644 --- a/mustache-sharp/MasterTagDefinition.cs +++ b/mustache-sharp/MasterTagDefinition.cs @@ -6,7 +6,7 @@ namespace mustache /// /// Defines a pseudo tag that wraps the entire content of a format string. /// - internal sealed class MasterTagDefinition : TagDefinition + internal sealed class MasterTagDefinition : ContentTagDefinition { /// /// Initializes a new instance of a MasterTagDefinition. @@ -17,42 +17,20 @@ namespace mustache } /// - /// Gets the parameters that can be passed to the tag. + /// Gets whether the tag only exists within the scope of its parent. /// - /// The parameters. - protected override TagParameter[] GetParameters() + protected override bool GetIsContextSensitive() { - return new TagParameter[0]; + return true; } /// - /// Gets whether the tag has content. + /// Gets the name of the tags that indicate that the tag's context is closed. /// - public override bool HasBody + /// The tag names. + protected override IEnumerable GetClosingTags() { - get { return true; } - } - - /// - /// Gets the tags that indicate the end of the tags context. - /// - public override IEnumerable ClosingTags - { - get { return new TagDefinition[0]; } - } - - /// - /// Gets the tags that come into scope within the context of the tag. - /// - /// The tags. - protected override TagDefinition[] GetChildTags() - { - return new TagDefinition[] - { - new IfTagDefinition(), - new EachTagDefinition(), - new WithTagDefinition(), - }; + return new string[] { }; } } } diff --git a/mustache-sharp/RegexHelper.cs b/mustache-sharp/RegexHelper.cs index a267910..38548c6 100644 --- a/mustache-sharp/RegexHelper.cs +++ b/mustache-sharp/RegexHelper.cs @@ -8,6 +8,9 @@ namespace mustache /// public static class RegexHelper { + private const string Key = @"[_\w][_\w\d]*"; + internal const string CompoundKey = Key + @"(\." + Key + ")*"; + /// /// Determines whether the given name is a legal identifier. /// @@ -15,7 +18,7 @@ namespace mustache /// True if the name is a legal identifier; otherwise, false. public static bool IsValidIdentifier(string name) { - Regex regex = new Regex(@"^[_\w][_\w\d]*$"); + Regex regex = new Regex("^" + Key + "$"); return regex.IsMatch(name); } } diff --git a/mustache-sharp/StaticGenerator.cs b/mustache-sharp/StaticGenerator.cs index 0d16e75..e83aab5 100644 --- a/mustache-sharp/StaticGenerator.cs +++ b/mustache-sharp/StaticGenerator.cs @@ -8,20 +8,46 @@ namespace mustache /// internal sealed class StaticGenerator : IGenerator { - private readonly string _value; - /// /// Initializes a new instance of a StaticGenerator. /// - /// The string to return. - public StaticGenerator(string value) + public StaticGenerator() { - _value = value; + } + + /// + /// Gets or sets the linked list node containing the current generator. + /// + public LinkedListNode Node + { + get; + set; + } + + /// + /// Gets or sets the static text. + /// + public string Value + { + get; + set; + } + + /// + /// Removes the static text from the final output. + /// + public void Prune() + { + if (Node != null) + { + Node.List.Remove(Node); + Node = null; + } } string IGenerator.GetText(IFormatProvider provider, KeyScope scope) { - return _value; + return Value; } } } diff --git a/mustache-sharp/TagDefinition.cs b/mustache-sharp/TagDefinition.cs index f6538b5..aba8cd8 100644 --- a/mustache-sharp/TagDefinition.cs +++ b/mustache-sharp/TagDefinition.cs @@ -44,60 +44,87 @@ namespace mustache get { return _tagName; } } + /// + /// Gets whether the tag is limited to the parent tag's context. + /// + internal bool IsContextSensitive + { + get { return GetIsContextSensitive(); } + } + + /// + /// Gets whether a tag is limited to the parent tag's context. + /// + protected abstract bool GetIsContextSensitive(); + /// /// Gets the parameters that are defined for the tag. /// - public IEnumerable Parameters + internal IEnumerable Parameters { - get { return new ReadOnlyCollection(GetParameters()); } + get { return GetParameters(); } } /// /// Specifies which parameters are passed to the tag. /// /// The tag parameters. - protected abstract TagParameter[] GetParameters(); + protected virtual IEnumerable GetParameters() + { + return new TagParameter[] { }; + } /// /// Gets whether the tag contains content. /// - public abstract bool HasBody + internal bool HasContent { - get; + get { return GetHasContent(); } } + /// + /// Gets whether tag has content. + /// + /// True if the tag has content; otherwise, false. + protected abstract bool GetHasContent(); + /// /// Gets the tags that can indicate that the tag has closed. /// This field is only used if no closing tag is expected. /// - public virtual IEnumerable ClosingTags + internal IEnumerable ClosingTags { - get + get { return GetClosingTags(); } + } + + protected virtual IEnumerable GetClosingTags() + { + if (HasContent) { - if (HasBody) - { - return new TagDefinition[] { this }; - } - else - { - return new TagDefinition[0]; - } + return new string[] { Name }; + } + else + { + return new string[] { }; } } /// /// Gets the tags that are in scope within the current tag. /// - public IEnumerable ChildTags + internal IEnumerable ChildTags { - get { return new ReadOnlyCollection(GetChildTags()); } + get { return GetChildTags(); } } /// /// Specifies which tags are scoped under the current tag. /// /// The child tag definitions. - protected abstract TagDefinition[] GetChildTags(); + protected virtual IEnumerable GetChildTags() + { + return new string[] { }; + } /// /// Gets the scope to use when building the inner text of the tag. diff --git a/mustache-sharp/TagScope.cs b/mustache-sharp/TagScope.cs deleted file mode 100644 index 40684b9..0000000 --- a/mustache-sharp/TagScope.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System; -using System.Collections.Generic; -using mustache.Properties; - -namespace mustache -{ - /// - /// Represents a scope of tags. - /// - internal sealed class TagScope - { - private readonly TagScope _parent; - private readonly Dictionary _tagLookup; - - /// - /// Initializes a new instance of a TagScope. - /// - public TagScope() - : this(null) - { - } - - /// - /// Initializes a new instance of a TagScope. - /// - /// The parent scope to search for tag definitions. - public TagScope(TagScope parent) - { - _parent = parent; - _tagLookup = new Dictionary(); - } - - /// - /// Registers the tag in the current scope. - /// - /// The tag to add to the current scope. - /// The tag already exists at the current scope. - public void AddTag(TagDefinition definition) - { - if (Find(definition.Name) != null) - { - string message = String.Format(Resources.DuplicateTagDefinition, definition.Name); - throw new ArgumentException(Resources.DuplicateTagDefinition, "definition"); - } - _tagLookup.Add(definition.Name, definition); - } - - /// - /// Trys to register the tag in the current scope. - /// - /// The tag to add to the current scope. - public void TryAddTag(TagDefinition definition) - { - if (Find(definition.Name) == null) - { - _tagLookup.Add(definition.Name, definition); - } - } - - /// - /// Finds the tag definition with the given name. - /// - /// The name of the tag definition to search for. - /// The tag definition with the name -or- null if it does not exist. - public TagDefinition Find(string tagName) - { - TagDefinition definition; - if (_tagLookup.TryGetValue(tagName, out definition)) - { - return definition; - } - if (_parent == null) - { - return null; - } - return _parent.Find(tagName); - } - } -} diff --git a/mustache-sharp/Trimmer.cs b/mustache-sharp/Trimmer.cs index 0f078ec..0b17abe 100644 --- a/mustache-sharp/Trimmer.cs +++ b/mustache-sharp/Trimmer.cs @@ -1,90 +1,147 @@ using System; +using System.Collections.Generic; +using System.Linq; namespace mustache { /// - /// Removes unnecessary whitespace from static text. + /// Removes unnecessary lines from the final output. /// internal sealed class Trimmer { - private bool hasHeader; - private bool hasFooter; - private bool hasTag; - private bool canTrim; + private readonly LinkedList _lines; + private LinkedListNode _currentLine; /// /// Initializes a new instance of a Trimmer. /// public Trimmer() { - hasTag = false; - canTrim = true; + _lines = new LinkedList(); + _currentLine = _lines.AddLast(new LineDetails()); } /// - /// Processes the given text, creating a StaticGenerator and adding it to the current compound generator. + /// Updates the state of the trimmer, indicating that the given text was encountered before an inline tag. /// - /// The compound generator to add the static generator to. - /// Gets whether we're encountered the header tag. - /// The static text to trim. - public void AddStaticGeneratorBeforeTag(CompoundGenerator generator, bool isHeader, string value) + /// The text at the end of the format string. + /// The generator created for the inline tag. + /// A static generator containing the passed text. + public IEnumerable RecordText(string value, bool isTag, bool isOutput) { - string trimmed = processLines(value); - hasHeader |= isHeader; - hasFooter |= hasHeader && !isHeader; - addStaticGenerator(generator, trimmed); - } - - /// - /// Processes the given text, creating a StaticGenerator and adding it to the current compound generator. - /// - /// The compound generator to add the static generator to. - /// Specifies whether the tag results in output. - /// The static text to trim. - public void AddStaticGenerator(CompoundGenerator generator, bool isOutput, string value) - { - string trimmed = processLines(value); - canTrim &= !isOutput; - addStaticGenerator(generator, trimmed); - } - - private string processLines(string value) - { - string trimmed = value; - int newline = value.IndexOf(Environment.NewLine); - if (newline == -1) + int newLineIndex = value.IndexOf(Environment.NewLine); + if (newLineIndex == -1) { - canTrim &= String.IsNullOrWhiteSpace(value); + StaticGenerator generator = new StaticGenerator() { Value = value }; + _currentLine.Value.Generators.Add(generator); + _currentLine.Value.HasTag |= isTag; + _currentLine.Value.HasOutput |= !String.IsNullOrWhiteSpace(value); + yield return generator; } else { - // finish processing the previous line - if (canTrim && hasTag && (!hasHeader || !hasFooter)) + string[] lines = value.Split(new string[] { Environment.NewLine }, StringSplitOptions.None); + + // get the trailing generator + string trailing = lines[0]; + StaticGenerator trailingGenerator = new StaticGenerator() { Value = trailing }; + _currentLine.Value.Generators.Add(trailingGenerator); + _currentLine.Value.HasOutput |= !String.IsNullOrWhiteSpace(trailing); + yield return trailingGenerator; + + // get the middle generators + for (int lineIndex = 1; lineIndex < lines.Length - 1; ++lineIndex) { - string lineEnd = trimmed.Substring(0, newline); - if (String.IsNullOrWhiteSpace(lineEnd)) - { - trimmed = trimmed.Substring(newline + Environment.NewLine.Length); - } + string middle = lines[lineIndex]; + StaticGenerator middleGenerator = new StaticGenerator() { Value = middle }; + LineDetails middleDetails = new LineDetails() { HasTag = false }; + _currentLine = _lines.AddLast(middleDetails); + _currentLine.Value.Generators.Add(middleGenerator); + _currentLine.Value.HasOutput = true; + yield return middleGenerator; } - // start processing the next line - hasTag = false; - hasHeader = false; - hasFooter = false; - int lastNewline = value.LastIndexOf(Environment.NewLine); - string lineStart = value.Substring(lastNewline + Environment.NewLine.Length); - canTrim = String.IsNullOrWhiteSpace(lineStart); + + // get the leading generator + string leading = lines[lines.Length - 1]; + StaticGenerator leadingGenerator = new StaticGenerator() { Value = leading }; + LineDetails details = new LineDetails() { HasTag = isTag }; + _currentLine = _lines.AddLast(details); + _currentLine.Value.Generators.Add(leadingGenerator); + _currentLine.Value.HasOutput = !String.IsNullOrWhiteSpace(leading); + yield return leadingGenerator; + } + if (isOutput) + { + _currentLine.Value.HasOutput = true; } - return trimmed; } - private static void addStaticGenerator(CompoundGenerator generator, string trimmed) + public void Trim() { - if (trimmed.Length > 0) + removeBlankLines(); + separateLines(); + removeEmptyGenerators(); + } + + private void removeBlankLines() + { + LinkedListNode current = _lines.First; + while (current != null) { - StaticGenerator leading = new StaticGenerator(trimmed); - generator.AddGenerator(leading); + LineDetails details = current.Value; + LinkedListNode temp = current; + current = current.Next; + if (details.HasTag && !details.HasOutput) + { + foreach (StaticGenerator generator in temp.Value.Generators) + { + generator.Prune(); + } + temp.List.Remove(temp); + } } } + + private void separateLines() + { + LinkedListNode current = _lines.First; + while (current != _lines.Last) + { + List generators = current.Value.Generators; + StaticGenerator lastGenerator = generators[generators.Count - 1]; + lastGenerator.Value += Environment.NewLine; + current = current.Next; + } + } + + private void removeEmptyGenerators() + { + LinkedListNode current = _lines.First; + while (current != null) + { + foreach (StaticGenerator generator in current.Value.Generators) + { + if (generator.Value.Length == 0) + { + generator.Prune(); + } + } + current = current.Next; + } + } + + private sealed class LineDetails + { + public LineDetails() + { + Generators = new List(); + } + + public bool HasTag { get; set; } + + public List Generators { get; set; } + + public bool HasOutput { get; set; } + } } -} \ No newline at end of file +} diff --git a/mustache-sharp/WithGenerator.cs b/mustache-sharp/WithGenerator.cs index ea1d8ac..80bb8d8 100644 --- a/mustache-sharp/WithGenerator.cs +++ b/mustache-sharp/WithGenerator.cs @@ -6,7 +6,7 @@ namespace mustache /// /// Defines a tag that changes the scope to the object passed as an argument. /// - internal sealed class WithTagDefinition : TagDefinition + internal sealed class WithTagDefinition : ContentTagDefinition { private const string contextParameter = "context"; @@ -18,32 +18,23 @@ namespace mustache { } + /// + /// Gets whether the tag only exists within the scope of its parent. + /// + protected override bool GetIsContextSensitive() + { + return false; + } + /// /// Gets the parameters that can be passed to the tag. /// /// The parameters. - protected override TagParameter[] GetParameters() + protected override IEnumerable GetParameters() { return new TagParameter[] { new TagParameter(contextParameter) { IsRequired = true } }; } - /// - /// Gets whether the tag has content. - /// - public override bool HasBody - { - get { return true; } - } - - /// - /// Gets the tags that come into scope within the tag. - /// - /// The child tag. - protected override TagDefinition[] GetChildTags() - { - return new TagDefinition[0]; - } - /// /// Gets the scopes to use for generating the tag's content. /// diff --git a/mustache-sharp/mustache-sharp.csproj b/mustache-sharp/mustache-sharp.csproj index bc30af7..262b540 100644 --- a/mustache-sharp/mustache-sharp.csproj +++ b/mustache-sharp/mustache-sharp.csproj @@ -37,6 +37,8 @@ + + @@ -59,7 +61,6 @@ -