From 7cdf70967323a753cc2674e1212b58e4b1cf759b Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Sat, 20 Jun 2026 15:42:57 -0400 Subject: [PATCH 1/2] fix: preserve double backslash in static template text (issue #349) \\ not followed by {{ now passes through verbatim. \\{{ still emits a single \ before the expression (spec-compliant escape behavior preserved). Co-Authored-By: Claude Sonnet 4.6 --- source/Handlebars.Test/IssueTests.cs | 43 ++ source/Handlebars/Compiler/Lexer/Tokenizer.cs | 382 +++++++++--------- 2 files changed, 234 insertions(+), 191 deletions(-) diff --git a/source/Handlebars.Test/IssueTests.cs b/source/Handlebars.Test/IssueTests.cs index cb5b5531..432e896f 100644 --- a/source/Handlebars.Test/IssueTests.cs +++ b/source/Handlebars.Test/IssueTests.cs @@ -1040,6 +1040,49 @@ public void Issue458_BlockHelperTemplateCompilationAndRender() var render = handlebars.Compile("{{#if show}}visible{{/if}}"); var actual = render(new { show = true }); Assert.Equal("visible", actual); + +// Issue: https://github.com/Handlebars-Net/Handlebars.Net/issues/349 + // Backslash-backslash not followed by {{ should pass through verbatim + [Fact] + public void DoubleBackslashNotBeforeMustachePassesThroughVerbatim() + { + // Template string (C# verbatim): \\*.{{Name}} + // Actual characters: \, \, *, ., {, {, N, a, m, e, }, } + // Expected output: \, \, *, ., W, o, r, l, d + var template = Handlebars.Compile(@"\\*.{{Name}}"); + var result = template(new { Name = "World" }); + Assert.Equal(@"\\*.World", result); + } + + [Fact] + public void DoubleBackslashNotBeforeMustacheInJsonLikeTemplate() + { + // Reproduces the exact scenario from issue #349: + // a JSON-like template where \\ must survive rendering unchanged. + // C# string "**\\\\*.{{Name}}" has chars: *, *, \, \, *, ., {, {, N, a, m, e, }, } + // Expected output chars: *, *, \, \, *, ., W, o, r, l, d + var template = Handlebars.Compile("{\"Description\":\"**\\\\*.{{Name}}\"}"); + var result = template(new { Name = "World" }); + Assert.Equal("{\"Description\":\"**\\\\*.World\"}", result); + } + + [Fact] + public void DoubleBackslashBeforeMustacheStillProducesSingleBackslash() + { + // Existing spec behavior (8.3) must be preserved: + // \\{{name}} → \Alice (the \\ before {{ means literal \, then evaluate) + var template = Handlebars.Compile(@"\\{{name}}"); + var result = template(new { name = "Alice" }); + Assert.Equal(@"\Alice", result); + } + + [Fact] + public void SingleBackslashNotBeforeMustachePassesThroughVerbatim() + { + // A single backslash not before {{ should pass through unchanged + var template = Handlebars.Compile(@"\*.{{Name}}"); + var result = template(new { Name = "World" }); + Assert.Equal(@"\*.World", result); } } } \ No newline at end of file diff --git a/source/Handlebars/Compiler/Lexer/Tokenizer.cs b/source/Handlebars/Compiler/Lexer/Tokenizer.cs index 1f03b8b0..6957dcf2 100644 --- a/source/Handlebars/Compiler/Lexer/Tokenizer.cs +++ b/source/Handlebars/Compiler/Lexer/Tokenizer.cs @@ -1,191 +1,191 @@ -using System; -using System.Collections.Generic; -using HandlebarsDotNet.Pools; - -namespace HandlebarsDotNet.Compiler.Lexer -{ - internal static class Tokenizer - { - private static readonly Parser WordParser = new WordParser(); - private static readonly Parser LiteralParser = new LiteralParser(); - private static readonly Parser CommentParser = new CommentParser(); - private static readonly Parser PartialParser = new PartialParser(); - private static readonly Parser BlockWordParser = new BlockWordParser(); - private static readonly Parser BlockParamsParser = new BlockParamsParser(); - //TODO: structure parser - - public static IEnumerable Tokenize(ExtendedStringReader source) - { - try - { - return Parse(source); - } - catch (Exception ex) - { - throw new HandlebarsParserException("An unhandled exception occurred while trying to compile the template", ex); - } - } - - private static IEnumerable Parse(ExtendedStringReader source) - { - bool inExpression = false; - bool trimWhitespace = false; - using var container = StringBuilderPool.Shared.Use(); - var buffer = container.Value; - - var node = source.Read(); - while (true) - { - if (node == -1) - { - if (buffer.Length > 0) - { - if (inExpression) - { - throw new InvalidOperationException("Reached end of template before expression was closed"); - } - else - { - yield return Token.Static(buffer.ToString(), source.GetContext()); - } - } - break; - } - if (inExpression) - { - if ((char)node == '(') - { - yield return Token.StartSubExpression(); - } - - var token = WordParser.Parse(source); - token ??= LiteralParser.Parse(source); - token ??= CommentParser.Parse(source); - token ??= PartialParser.Parse(source); - token ??= BlockWordParser.Parse(source); - token ??= BlockParamsParser.Parse(source); - - if (token != null) - { - yield return token; - - if ((char)source.Peek() == '=') - { - source.Read(); - yield return Token.Assignment(source.GetContext()); - continue; - } - } - if ((char)node == '}' && (char)source.Read() == '}') - { - bool escaped = true; - bool raw = false; - if ((char)source.Peek() == '}') - { - source.Read(); - escaped = false; - } - if ((char)source.Peek() == '}') - { - source.Read(); - raw = true; - } - node = source.Read(); - yield return Token.EndExpression(escaped, trimWhitespace, raw, source.GetContext()); - inExpression = false; - } - else if ((char)node == ')') - { - node = source.Read(); - yield return Token.EndSubExpression(source.GetContext()); - } - else if (char.IsWhiteSpace((char)node) || char.IsWhiteSpace((char)source.Peek())) - { - node = source.Read(); - } - else if ((char)node == '~') - { - node = source.Read(); - trimWhitespace = true; - } - else - { - if (token == null) - { - - throw new HandlebarsParserException("Reached unparseable token in expression: " + source.ReadLine(), source.GetContext()); - } - node = source.Read(); - } - } - else - { - if ((char)node == '\\' && (char)source.Peek() == '\\') - { - source.Read(); // consume second '\' - if ((char)source.Peek() == '{') - { - // \\{{ → single literal backslash followed by evaluated expression - buffer.Append('\\'); - } - else - { - // \\ not followed by {{ → preserve both backslashes verbatim - buffer.Append('\\'); - buffer.Append('\\'); - } - node = source.Read(); - } - else if ((char)node == '\\' && (char)source.Peek() == '{') - { - source.Read(); - if ((char)source.Peek() == '{') - { - source.Read(); - buffer.Append('{', 2); - } - else - { - buffer.Append("\\{"); - } - node = source.Read(); - } - else if ((char)node == '{' && (char)source.Peek() == '{') - { - bool escaped = true; - bool raw = false; - trimWhitespace = false; - node = source.Read(); - if ((char)source.Peek() == '{') - { - node = source.Read(); - escaped = false; - } - if ((char)source.Peek() == '{') - { - node = source.Read(); - raw = true; - } - if ((char)source.Peek() == '~') - { - source.Read(); - node = source.Peek(); - trimWhitespace = true; - } - yield return Token.Static(buffer.ToString(), source.GetContext()); - yield return Token.StartExpression(escaped, trimWhitespace, raw, source.GetContext()); - trimWhitespace = false; - buffer.Clear(); - inExpression = true; - } - else - { - buffer.Append((char)node); - node = source.Read(); - } - } - } - } - } -} - +using System; +using System.Collections.Generic; +using HandlebarsDotNet.Pools; + +namespace HandlebarsDotNet.Compiler.Lexer +{ + internal static class Tokenizer + { + private static readonly Parser WordParser = new WordParser(); + private static readonly Parser LiteralParser = new LiteralParser(); + private static readonly Parser CommentParser = new CommentParser(); + private static readonly Parser PartialParser = new PartialParser(); + private static readonly Parser BlockWordParser = new BlockWordParser(); + private static readonly Parser BlockParamsParser = new BlockParamsParser(); + //TODO: structure parser + + public static IEnumerable Tokenize(ExtendedStringReader source) + { + try + { + return Parse(source); + } + catch (Exception ex) + { + throw new HandlebarsParserException("An unhandled exception occurred while trying to compile the template", ex); + } + } + + private static IEnumerable Parse(ExtendedStringReader source) + { + bool inExpression = false; + bool trimWhitespace = false; + using var container = StringBuilderPool.Shared.Use(); + var buffer = container.Value; + + var node = source.Read(); + while (true) + { + if (node == -1) + { + if (buffer.Length > 0) + { + if (inExpression) + { + throw new InvalidOperationException("Reached end of template before expression was closed"); + } + else + { + yield return Token.Static(buffer.ToString(), source.GetContext()); + } + } + break; + } + if (inExpression) + { + if ((char)node == '(') + { + yield return Token.StartSubExpression(); + } + + var token = WordParser.Parse(source); + token ??= LiteralParser.Parse(source); + token ??= CommentParser.Parse(source); + token ??= PartialParser.Parse(source); + token ??= BlockWordParser.Parse(source); + token ??= BlockParamsParser.Parse(source); + + if (token != null) + { + yield return token; + + if ((char)source.Peek() == '=') + { + source.Read(); + yield return Token.Assignment(source.GetContext()); + continue; + } + } + if ((char)node == '}' && (char)source.Read() == '}') + { + bool escaped = true; + bool raw = false; + if ((char)source.Peek() == '}') + { + source.Read(); + escaped = false; + } + if ((char)source.Peek() == '}') + { + source.Read(); + raw = true; + } + node = source.Read(); + yield return Token.EndExpression(escaped, trimWhitespace, raw, source.GetContext()); + inExpression = false; + } + else if ((char)node == ')') + { + node = source.Read(); + yield return Token.EndSubExpression(source.GetContext()); + } + else if (char.IsWhiteSpace((char)node) || char.IsWhiteSpace((char)source.Peek())) + { + node = source.Read(); + } + else if ((char)node == '~') + { + node = source.Read(); + trimWhitespace = true; + } + else + { + if (token == null) + { + + throw new HandlebarsParserException("Reached unparseable token in expression: " + source.ReadLine(), source.GetContext()); + } + node = source.Read(); + } + } + else + { + if ((char)node == '\\' && (char)source.Peek() == '\\') + { + source.Read(); // consume second '\' + if ((char)source.Peek() == '{') + { + // \\{{ → single literal backslash followed by evaluated expression + buffer.Append('\\'); + } + else + { + // \\ not followed by {{ → preserve both backslashes verbatim + buffer.Append('\\'); + buffer.Append('\\'); + } + node = source.Read(); + } + else if ((char)node == '\\' && (char)source.Peek() == '{') + { + source.Read(); + if ((char)source.Peek() == '{') + { + source.Read(); + buffer.Append('{', 2); + } + else + { + buffer.Append("\\{"); + } + node = source.Read(); + } + else if ((char)node == '{' && (char)source.Peek() == '{') + { + bool escaped = true; + bool raw = false; + trimWhitespace = false; + node = source.Read(); + if ((char)source.Peek() == '{') + { + node = source.Read(); + escaped = false; + } + if ((char)source.Peek() == '{') + { + node = source.Read(); + raw = true; + } + if ((char)source.Peek() == '~') + { + source.Read(); + node = source.Peek(); + trimWhitespace = true; + } + yield return Token.Static(buffer.ToString(), source.GetContext()); + yield return Token.StartExpression(escaped, trimWhitespace, raw, source.GetContext()); + trimWhitespace = false; + buffer.Clear(); + inExpression = true; + } + else + { + buffer.Append((char)node); + node = source.Read(); + } + } + } + } + } +} + From 9f8ccfa16459c1813cd7a238a3cb716c0933dee4 Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Sat, 20 Jun 2026 16:33:40 -0400 Subject: [PATCH 2/2] fix: close missing brace in IssueTests.cs after conflict resolution Issue349 test methods were spliced inside Issue458_BlockHelperTemplateCompilationAndRender, causing CS0106 compiler errors on the nested public modifiers. Co-Authored-By: Claude Sonnet 4.6 --- source/Handlebars.Test/IssueTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/Handlebars.Test/IssueTests.cs b/source/Handlebars.Test/IssueTests.cs index 432e896f..e155e0a1 100644 --- a/source/Handlebars.Test/IssueTests.cs +++ b/source/Handlebars.Test/IssueTests.cs @@ -1040,8 +1040,9 @@ public void Issue458_BlockHelperTemplateCompilationAndRender() var render = handlebars.Compile("{{#if show}}visible{{/if}}"); var actual = render(new { show = true }); Assert.Equal("visible", actual); + } -// Issue: https://github.com/Handlebars-Net/Handlebars.Net/issues/349 + // Issue: https://github.com/Handlebars-Net/Handlebars.Net/issues/349 // Backslash-backslash not followed by {{ should pass through verbatim [Fact] public void DoubleBackslashNotBeforeMustachePassesThroughVerbatim()