diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..f28471b1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Force LF line endings for all text files, regardless of the checking-out platform's git +# config (e.g. Windows runners/clients that default core.autocrlf=true). Several tests embed +# multi-line template literals (verbatim/raw C# strings) and compare rendered output against +# an explicit \n-based expectation; without this, those literals check out as \r\n on Windows +# and the comparison fails, since Handlebars.Net no longer rewrites line endings at render time +# (see issue #661) — line endings in template source are the caller's content now, not something +# the library silently normalizes. +* text=auto eol=lf diff --git a/source/Handlebars.Test/BasicIntegrationTests.cs b/source/Handlebars.Test/BasicIntegrationTests.cs index 0c64e9b3..d5211f82 100644 --- a/source/Handlebars.Test/BasicIntegrationTests.cs +++ b/source/Handlebars.Test/BasicIntegrationTests.cs @@ -482,7 +482,7 @@ public void PathRelativeBinding(IHandlebars handlebars) }; var result = handlebarsTemplate(data); - var actual = string.Join(" ", result.Split(new[] { "\n" }, StringSplitOptions.RemoveEmptyEntries).Select(o => o.Trim())); + var actual = string.Join(" ", result.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries).Select(o => o.Trim(' '))); Assert.Equal("Garry Finch gazraa Karen Finch photobasics", actual); } @@ -546,7 +546,7 @@ public void PathRelativeBinding_WithDefaultValue(IHandlebars handlebars) }; var result = handlebarsTemplate(data); - var actual = string.Join(" ", result.Split(new[] { "\n" }, StringSplitOptions.RemoveEmptyEntries).Select(o => o.Trim())); + var actual = string.Join(" ", result.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries).Select(o => o.Trim(' '))); Assert.Equal("Garry Finch N/A Karen Finch photobasics", actual); } diff --git a/source/Handlebars.Test/ComplexIntegrationTests.cs b/source/Handlebars.Test/ComplexIntegrationTests.cs index da1c26d0..0332c57a 100644 --- a/source/Handlebars.Test/ComplexIntegrationTests.cs +++ b/source/Handlebars.Test/ComplexIntegrationTests.cs @@ -56,10 +56,14 @@ public void DeepIf() var resultTrueFalse = template(trueFalse); var resultFalseTrue = template(falseTrue); var resultFalseFalse = template(falseFalse); - Assert.Equal("a is true\n", resultTrueTrue); - Assert.Equal("a is false\n", resultTrueFalse); - Assert.Equal("b is true\n", resultFalseTrue); - Assert.Equal("b is false\n", resultFalseFalse); + Assert.Equal(@"a is true +", resultTrueTrue); + Assert.Equal(@"a is false +", resultTrueFalse); + Assert.Equal(@"b is true +", resultFalseTrue); + Assert.Equal(@"b is false +", resultFalseFalse); } [Fact] diff --git a/source/Handlebars.Test/IssueTests.cs b/source/Handlebars.Test/IssueTests.cs index 5396af96..130efdc0 100644 --- a/source/Handlebars.Test/IssueTests.cs +++ b/source/Handlebars.Test/IssueTests.cs @@ -225,7 +225,9 @@ public void RenderingWithUnusedPartial() var transformed = navTemplate(context).Trim(); - Assert.Equal("
\n
Menu Item: Getting Started
\n
", transformed); + Assert.Equal(@"
+
Menu Item: Getting Started
+
", transformed); } // issue: https://github.com/Handlebars-Net/Handlebars.Net/issues/394 @@ -1270,6 +1272,72 @@ public void Issue601_DefaultInterfaceMemberPropertyIsEnumerated() Assert.Contains($"OtherStr={data.OtherStr};", result); } + // Issue: https://github.com/Handlebars-Net/Handlebars.Net/issues/661 + // Static template text must round-trip verbatim: the compiler must not rewrite + // line endings the caller put in the template string. + // + // This mirrors the reporter's exact repro shape: #each over a helper subexpression + // (String.Split isn't in this repo, so a local "Split" helper stands in for it) and + // the @Key/@Index casing they used — confirmed case-insensitive via ChainSegment's + // OrdinalIgnoreCase lookup, so this is equivalent to @key/@index, not a looser stand-in. + [Fact] + public void Issue661_ExplicitCrLfInTemplateIsPreservedVerbatim() + { + var handlebars = Handlebars.Create(); + handlebars.RegisterHelper("Split", (context, arguments) => + ((string) arguments[0]).Split(((string) arguments[1])[0])); + + var template = handlebars.Compile("{{#each (Split \"a;b;c\" ';')}}\r\n{{@Key}}:{{@Index}}:{{this}}\r\n{{/each}}"); + + var result = template(new { }); + + Assert.Equal("0:0:a\r\n1:1:b\r\n2:2:c\r\n", result); + } + + // A bare \n in the template must not be turned into \r\n either — the fix must not + // trade under-preservation for over-preservation. + [Fact] + public void Issue661_ExplicitLfInTemplateIsPreservedVerbatim() + { + var handlebars = Handlebars.Create(); + var template = handlebars.Compile("{{#each this}}\n{{@key}}:{{@index}}:{{this}}\n{{/each}}"); + + var result = template(new[] { "a", "b", "c" }); + + Assert.Equal("0:0:a\n1:1:b\n2:2:c\n", result); + } + + // Whatever the platform's native newline is, it must pass through unchanged. On the + // CI matrix (macOS/Ubuntu/Windows) this exercises \n and \r\n as separate runs without + // any OS-conditional test code. + [Fact] + public void Issue661_PlatformNewlineInTemplateIsPreservedVerbatim() + { + var handlebars = Handlebars.Create(); + var template = handlebars.Compile("line one" + Environment.NewLine + "{{value}}" + Environment.NewLine + "line three"); + + var result = template(new { value = "middle" }); + + Assert.Equal("line one" + Environment.NewLine + "middle" + Environment.NewLine + "line three", result); + } + + // Indented partials must preserve \r\n in the partial's own content too — WriteWithIndent + // used to normalise to \n while everything else passed content through verbatim. + [Fact] + public void Issue661_IndentedPartialPreservesCrLf() + { + var handlebars = Handlebars.Create(); + using (var reader = new StringReader("one\r\ntwo\r\nthree")) + { + handlebars.RegisterTemplate("myPartial", handlebars.Compile(reader)); + } + var template = handlebars.Compile(" {{> myPartial}}"); + + var result = template(new { }); + + Assert.Equal(" one\r\n two\r\n three", result); + } + // Issue: https://github.com/Handlebars-Net/Handlebars.Net/issues/660 // A writer-based helper used as a subexpression must hand its captured output to the // outer helper as a plain System.String, not an opaque internal wrapper type — otherwise diff --git a/source/Handlebars.Test/PartialTests.cs b/source/Handlebars.Test/PartialTests.cs index 00f7a868..41848ae6 100644 --- a/source/Handlebars.Test/PartialTests.cs +++ b/source/Handlebars.Test/PartialTests.cs @@ -950,7 +950,7 @@ public void PartialIndentationIsAppliedInsideBlock() } [Fact] - public void PartialWithCrLfLineEndingsNormalisedToLf() + public void PartialWithCrLfLineEndingsPreservedVerbatim() { var handlebars = Handlebars.Create(); var source = " {{> p}}"; @@ -963,8 +963,8 @@ public void PartialWithCrLfLineEndingsNormalisedToLf() var result = handlebars.Compile(source)(new { }); - // \r\n in the partial source is normalised to \n; every line gets the indent. - Assert.Equal(" line1\n line2\n line3", result); + // \r\n in the partial source is preserved verbatim; every line gets the indent (issue #661). + Assert.Equal(" line1\r\n line2\r\n line3", result); } [Fact] diff --git a/source/Handlebars.Test/ReadmeTests.cs b/source/Handlebars.Test/ReadmeTests.cs index 8966be8e..52589baa 100644 --- a/source/Handlebars.Test/ReadmeTests.cs +++ b/source/Handlebars.Test/ReadmeTests.cs @@ -29,14 +29,14 @@ public void RegisterBlockHelper() {"Chewy", "hamster" } }; - var template = "{{#each this}}The animal, {{@key}}, {{#StringEqualityBlockHelper @value 'dog'}}is a dog{{else}}is not a dog{{/StringEqualityBlockHelper}}.\n{{/each}}"; + var template = "{{#each this}}The animal, {{@key}}, {{#StringEqualityBlockHelper @value 'dog'}}is a dog{{else}}is not a dog{{/StringEqualityBlockHelper}}.\r\n{{/each}}"; var compiledTemplate = handlebars.Compile(template); string templateOutput = compiledTemplate(animals); Assert.Equal( - "The animal, Fluffy, is not a dog.\n" + - "The animal, Fido, is a dog.\n" + - "The animal, Chewy, is not a dog.\n", + "The animal, Fluffy, is not a dog.\r\n" + + "The animal, Fido, is a dog.\r\n" + + "The animal, Chewy, is not a dog.\r\n", templateOutput ); } diff --git a/source/Handlebars.Test/ViewEngine/ViewEngineTests.cs b/source/Handlebars.Test/ViewEngine/ViewEngineTests.cs index fb3b21c0..71bfe339 100644 --- a/source/Handlebars.Test/ViewEngine/ViewEngineTests.cs +++ b/source/Handlebars.Test/ViewEngine/ViewEngineTests.cs @@ -17,7 +17,7 @@ public void CanLoadAViewWithALayout() //Given a layout in a subfolder var files = new FakeFileSystem() { - {"views\\somelayout.hbs", "layout start\n{{{body}}}\nlayout end"}, + {"views\\somelayout.hbs", "layout start\r\n{{{body}}}\r\nlayout end"}, //And a view in the same folder which uses that layout { "views\\someview.hbs", "{{!< somelayout}}This is the body"} }; @@ -28,7 +28,7 @@ public void CanLoadAViewWithALayout() var output = renderView(null); //Then the correct output should be rendered - Assert.Equal("layout start\nThis is the body\nlayout end", output); + Assert.Equal("layout start\r\nThis is the body\r\nlayout end", output); } [Fact] public void CanLoadAWriterViewWithALayout() @@ -36,7 +36,7 @@ public void CanLoadAWriterViewWithALayout() //Given a layout in a subfolder var files = new FakeFileSystem() { - {"views\\somelayout.hbs", "layout start\n{{{body}}}\nlayout end"}, + {"views\\somelayout.hbs", "layout start\r\n{{{body}}}\r\nlayout end"}, //And a view in the same folder which uses that layout { "views\\someview.hbs", "{{!< somelayout}}This is the body"} }; @@ -50,7 +50,7 @@ public void CanLoadAWriterViewWithALayout() var output = sb.ToString(); //Then the correct output should be rendered - Assert.Equal("layout start\nThis is the body\nlayout end", output); + Assert.Equal("layout start\r\nThis is the body\r\nlayout end", output); } [Fact] @@ -59,7 +59,7 @@ public void CanLoadAViewWithALayoutInTheRoot() //Given a layout in the root var files = new FakeFileSystem() { - {"somelayout.hbs", "layout start\n{{{body}}}\nlayout end"}, + {"somelayout.hbs", "layout start\r\n{{{body}}}\r\nlayout end"}, //And a view in a subfolder folder which uses that layout { "views\\someview.hbs", "{{!< somelayout}}This is the body"} }; @@ -70,7 +70,7 @@ public void CanLoadAViewWithALayoutInTheRoot() var output = render(null); //Then the correct output should be rendered - Assert.Equal("layout start\nThis is the body\nlayout end", output); + Assert.Equal("layout start\r\nThis is the body\r\nlayout end", output); } [Fact] @@ -81,9 +81,9 @@ public void CanRenderInlineBlocks() var files = new FakeFileSystem() { //Given a layout in a subfolder - { "partials/layout.hbs", "
\n{{> nav}}\n
\n
\n{{> content}}\n
"}, + { "partials/layout.hbs", "
\r\n{{> nav}}\r\n
\r\n
\r\n{{> content}}\r\n
"}, - { "template.hbs", "{{#> layout}}\n{{#*inline \"nav\"}}\n{{Text}}\n{{/inline}}\n{{#*inline \"content\"}}\nMy Content\n{{/inline}}\n{{/layout}}"} + { "template.hbs", "{{#> layout}}\r\n{{#*inline \"nav\"}}\r\n{{Text}}\r\n{{/inline}}\r\n{{#*inline \"content\"}}\r\nMy Content\r\n{{/inline}}\r\n{{/layout}}"} }; //When a viewengine renders that view @@ -95,7 +95,7 @@ public void CanRenderInlineBlocks() }); //Then the correct output should be rendered - Assert.Equal("
\n<My Nav>\n
\n
\nMy Content\n
", output); + Assert.Equal("
\r\n<My Nav>\r\n
\r\n
\r\nMy Content\r\n
", output); } [Fact] @@ -104,7 +104,7 @@ public void CanLoadAViewWithALayoutWithAVariable() //Given a layout in the root var files = new FakeFileSystem() { - {"somelayout.hbs", "{{var1}} start\n{{{body}}}\n{{var1}} end"}, + {"somelayout.hbs", "{{var1}} start\r\n{{{body}}}\r\n{{var1}} end"}, //And a view in a subfolder folder which uses that layout { "views\\someview.hbs", "{{!< somelayout}}This is the {{var2}}"} }; @@ -115,7 +115,7 @@ public void CanLoadAViewWithALayoutWithAVariable() var output = renderView(new { var1 = "layout", var2 = "body" }); //Then the correct output should be rendered - Assert.Equal("layout start\nThis is the body\nlayout end", output); + Assert.Equal("layout start\r\nThis is the body\r\nlayout end", output); } [Fact] @@ -124,7 +124,7 @@ public void CanLoadAViewWithALayoutInTheRootWithAVariable() //Given a layout in the root var files = new FakeFileSystem() { - {"somelayout.hbs", "{{var1}} start\n{{{body}}}\n{{var1}} end"}, + {"somelayout.hbs", "{{var1}} start\r\n{{{body}}}\r\n{{var1}} end"}, //And a view in a subfolder folder which uses that layout { "views\\someview.hbs", "{{!< somelayout}}This is the {{var2}}"} }; @@ -135,7 +135,7 @@ public void CanLoadAViewWithALayoutInTheRootWithAVariable() var output = render(new { var1 = "layout", var2 = "body" }); //Then the correct output should be rendered - Assert.Equal("layout start\nThis is the body\nlayout end", output); + Assert.Equal("layout start\r\nThis is the body\r\nlayout end", output); } [Fact] @@ -162,8 +162,8 @@ public void CanIgnoreCommentsContainingHtml() { var files = new FakeFileSystem() { - { "views\\layout.hbs", "Start\n{{{body}}}\nEnd" }, - { "views\\someview.hbs", "{{!< layout}}\n\nTemplate\n{{!--\n
Commented out HTML
\n--}}" }, + { "views\\layout.hbs", "Start\r\n{{{body}}}\r\nEnd" }, + { "views\\someview.hbs", "{{!< layout}}\r\n\r\nTemplate\r\n{{!--\r\n
Commented out HTML
\r\n--}}" }, }; var handlebarsConfiguration = new HandlebarsConfiguration() { FileSystem = files }; @@ -171,7 +171,7 @@ public void CanIgnoreCommentsContainingHtml() var render = handlebars.CompileView("views\\someview.hbs"); var output = render(null); - Assert.Equal("Start\n\nTemplate\n\nEnd", output); + Assert.Equal("Start\r\n\r\nTemplate\r\n\r\nEnd", output); } [Fact] @@ -179,8 +179,8 @@ public void CanUseDictionaryModelInLayout() { var files = new FakeFileSystem { - { "views\\layout.hbs", "Layout: {{property}}\n{{{body}}}" }, - { "views\\someview.hbs", "{{!< layout}}\n\nBody: {{property}}" }, + { "views\\layout.hbs", "Layout: {{property}}\r\n{{{body}}}" }, + { "views\\someview.hbs", "{{!< layout}}\r\n\r\nBody: {{property}}" }, }; var handlebarsConfiguration = new HandlebarsConfiguration { FileSystem = files }; @@ -193,7 +193,7 @@ public void CanUseDictionaryModelInLayout() } ); - Assert.Equal("Layout: Foo\n\nBody: Foo", output); + Assert.Equal("Layout: Foo\r\n\r\nBody: Foo", output); } [Fact] @@ -201,8 +201,8 @@ public void CanUseDynamicModelInLayout() { var files = new FakeFileSystem { - { "views\\layout.hbs", "Layout: {{property}}\n{{{body}}}" }, - { "views\\someview.hbs", "{{!< layout}}\n\nBody: {{property}}" }, + { "views\\layout.hbs", "Layout: {{property}}\r\n{{{body}}}" }, + { "views\\someview.hbs", "{{!< layout}}\r\n\r\nBody: {{property}}" }, }; dynamic model = new MyDynamicModel(); @@ -211,7 +211,7 @@ public void CanUseDynamicModelInLayout() var render = handlebars.CompileView("views\\someview.hbs"); var output = render(model); - Assert.Equal("Layout: Foo\n\nBody: Foo", output); + Assert.Equal("Layout: Foo\r\n\r\nBody: Foo", output); } [Fact] @@ -219,9 +219,9 @@ public void CanBindToModelInNestedLayout() { var files = new FakeFileSystem { - { "views\\parent_layout.hbs", "Parent layout: {{property}}\n{{{body}}}" }, - { "views\\layout.hbs", "{{!< parent_layout}}\nLayout: {{property}}\n{{{body}}}" }, - { "views\\someview.hbs", "{{!< layout}}\n\nBody: {{property}}" }, + { "views\\parent_layout.hbs", "Parent layout: {{property}}\r\n{{{body}}}" }, + { "views\\layout.hbs", "{{!< parent_layout}}\r\nLayout: {{property}}\r\n{{{body}}}" }, + { "views\\someview.hbs", "{{!< layout}}\r\n\r\nBody: {{property}}" }, }; var handlebarsConfiguration = new HandlebarsConfiguration { FileSystem = files }; @@ -234,7 +234,7 @@ public void CanBindToModelInNestedLayout() } ); - Assert.Equal("Parent layout: Foo\nLayout: Foo\n\nBody: Foo", output); + Assert.Equal("Parent layout: Foo\r\nLayout: Foo\r\n\r\nBody: Foo", output); } [Fact] @@ -242,8 +242,8 @@ public void CanUseNullModelInLayout() { var files = new FakeFileSystem { - { "views\\layout.hbs", "Layout: {{property}}\n{{{body}}}" }, - { "views\\someview.hbs", "{{!< layout}}\n\nBody: {{property}}" }, + { "views\\layout.hbs", "Layout: {{property}}\r\n{{{body}}}" }, + { "views\\someview.hbs", "{{!< layout}}\r\n\r\nBody: {{property}}" }, }; var handlebarsConfiguration = new HandlebarsConfiguration { FileSystem = files }; @@ -251,7 +251,7 @@ public void CanUseNullModelInLayout() var render = handlebars.CompileView("views\\someview.hbs"); var output = render(null); - Assert.Equal("Layout: \n\nBody: ", output); + Assert.Equal("Layout: \r\n\r\nBody: ", output); } [Fact] diff --git a/source/Handlebars/Compiler/Lexer/Converter/StaticConverter.cs b/source/Handlebars/Compiler/Lexer/Converter/StaticConverter.cs index ffc677b6..af015244 100644 --- a/source/Handlebars/Compiler/Lexer/Converter/StaticConverter.cs +++ b/source/Handlebars/Compiler/Lexer/Converter/StaticConverter.cs @@ -30,11 +30,7 @@ public override IEnumerable ConvertTokens(IEnumerable sequence) if (staticToken.Value != string.Empty) { - // Normalize to \n so output is platform-independent regardless of source file line endings. - var value = staticToken.Value.IndexOf('\r') >= 0 - ? staticToken.Value.Replace("\r\n", "\n").Replace("\r", "\n") - : staticToken.Value; - yield return HandlebarsExpression.Static(value); + yield return HandlebarsExpression.Static(staticToken.Value); } } } diff --git a/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs b/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs index dd870dc3..6e20973f 100644 --- a/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs +++ b/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs @@ -131,8 +131,8 @@ private static void InvokePartialWithFallback( /// to every non-empty line. All lines including the first receive the indent because the /// WhitespaceRemover already stripped the leading whitespace from the static token that preceded /// the partial tag. An empty trailing segment after the last newline does not receive an indent. - /// Newlines are normalised to \n so that output is consistent across platforms regardless - /// of whether the partial source was checked out with \r\n line endings. + /// Line endings are preserved verbatim — a \r immediately before a split point rides along + /// as part of the preceding segment, so \r\n content stays \r\n. /// private static void WriteWithIndent(EncodedTextWriter writer, string? content, string? indent) { @@ -141,24 +141,21 @@ private static void WriteWithIndent(EncodedTextWriter writer, string? content, s return; } - // Normalise line endings to \n so Windows \r\n does not produce \r artifacts. - var normalised = content.Replace("\r\n", "\n").Replace("\r", "\n"); - var pos = 0; - while (pos < normalised.Length) + while (pos < content.Length) { - var newlinePos = normalised.IndexOf('\n', pos); + var newlinePos = content.IndexOf('\n', pos); if (newlinePos < 0) { // No more newlines — write indent + rest and stop writer.Write(indent, false); - writer.Write(normalised.Substring(pos), false); + writer.Write(content.Substring(pos), false); break; } // Write indent + the segment up to and including the \n writer.Write(indent, false); - writer.Write(normalised.Substring(pos, newlinePos - pos + 1), false); + writer.Write(content.Substring(pos, newlinePos - pos + 1), false); pos = newlinePos + 1; } }