diff --git a/src/Stubble.Compilation/Renderers/TokenRenderers/InterpolationTokenRenderer.cs b/src/Stubble.Compilation/Renderers/TokenRenderers/InterpolationTokenRenderer.cs index 28d9e7f..e88814c 100644 --- a/src/Stubble.Compilation/Renderers/TokenRenderers/InterpolationTokenRenderer.cs +++ b/src/Stubble.Compilation/Renderers/TokenRenderers/InterpolationTokenRenderer.cs @@ -5,7 +5,6 @@ using System; using System.Linq.Expressions; -using System.Reflection; using System.Text; using System.Threading.Tasks; using Stubble.Compilation.Contexts; @@ -20,6 +19,8 @@ namespace Stubble.Compilation.Renderers.TokenRenderers /// public class InterpolationTokenRenderer : ExpressionObjectRenderer { + private static Type[] formatProviderTypeArgs = new[] { typeof(IFormatProvider) }; + /// protected override void Write(CompilationRenderer renderer, InterpolationToken obj, CompilerContext context) { @@ -29,13 +30,24 @@ protected override void Write(CompilationRenderer renderer, InterpolationToken o if (!context.CompilationSettings.SkipHtmlEncoding && obj.EscapeResult && expression != null) { - var isValueType = expression.Type.GetIsValueType(); + Expression stringExpression; + if (expression.Type == typeof(string)) + { + stringExpression = expression; + } + else + { + var formattedToString = expression.Type + .GetMethod(nameof(object.ToString), formatProviderTypeArgs); + + var item = expression.Type.GetIsValueType() + ? expression + : Expression.Coalesce(expression, Expression.Constant(string.Empty)); - var stringExpression = expression.Type == typeof(string) - ? expression - : Expression.Call( - isValueType ? expression : Expression.Coalesce(expression, Expression.Constant(string.Empty)), - expression.Type.GetMethod("ToString", Type.EmptyTypes)); + stringExpression = formattedToString is object + ? Expression.Call(item, formattedToString, Expression.Constant(context.CompilationSettings.CultureInfo)) + : Expression.Call(item, expression.Type.GetMethod(nameof(object.ToString), Type.EmptyTypes)); + } expression = Expression.Invoke(context.CompilerSettings.EncodingFuction, stringExpression); } diff --git a/src/Stubble.Compilation/Settings/CompilationSettings.cs b/src/Stubble.Compilation/Settings/CompilationSettings.cs index 1de234b..668e99e 100644 --- a/src/Stubble.Compilation/Settings/CompilationSettings.cs +++ b/src/Stubble.Compilation/Settings/CompilationSettings.cs @@ -3,6 +3,8 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // +using System.Globalization; + namespace Stubble.Compilation.Settings { /// @@ -27,6 +29,11 @@ public class CompilationSettings /// public bool SkipHtmlEncoding { get; set; } + /// + /// Gets or sets the CultureInfo to use for rendering format-dependent values (doubles, etc.). + /// + public CultureInfo CultureInfo { get; set; } = CultureInfo.InvariantCulture; + /// /// Gets the default render settings /// @@ -37,7 +44,8 @@ public static CompilationSettings GetDefaultRenderSettings() { SkipRecursiveLookup = false, ThrowOnDataMiss = false, - SkipHtmlEncoding = false + SkipHtmlEncoding = false, + CultureInfo = CultureInfo.InvariantCulture }; } } diff --git a/src/Stubble.Core/Renderers/StringRenderer/TokenRenderers/InterpolationTokenRenderer.cs b/src/Stubble.Core/Renderers/StringRenderer/TokenRenderers/InterpolationTokenRenderer.cs index 17cab9f..774364f 100644 --- a/src/Stubble.Core/Renderers/StringRenderer/TokenRenderers/InterpolationTokenRenderer.cs +++ b/src/Stubble.Core/Renderers/StringRenderer/TokenRenderers/InterpolationTokenRenderer.cs @@ -4,7 +4,7 @@ // using System; -using System.Net; +using System.Globalization; using System.Threading.Tasks; using Stubble.Core.Contexts; using Stubble.Core.Tokens; @@ -16,6 +16,22 @@ namespace Stubble.Core.Renderers.StringRenderer.TokenRenderers /// public class InterpolationTokenRenderer : StringObjectRenderer { + /// + /// Renders the value to string using a locale. + /// + /// The object to convert + /// The culture to use + /// The object stringified into the locale + protected static string ConvertToStringInCulture(object obj, CultureInfo culture) + { + if (obj is null || obj is string) + { + return obj as string; + } + + return Convert.ToString(obj, culture); + } + /// protected override void Write(StringRender renderer, InterpolationToken obj, Context context) { @@ -27,7 +43,7 @@ protected override void Write(StringRender renderer, InterpolationToken obj, Con if (functionValueDynamic != null || functionValue != null) { object functionResult = functionValueDynamic != null ? functionValueDynamic.Invoke(context.View) : functionValue.Invoke(); - var resultString = functionResult.ToString(); + var resultString = ConvertToStringInCulture(functionResult, context.RenderSettings.CultureInfo); if (resultString.Contains("{{")) { renderer.Render(context.RendererSettings.Parser.Parse(resultString), context); @@ -39,7 +55,7 @@ protected override void Write(StringRender renderer, InterpolationToken obj, Con if (!context.RenderSettings.SkipHtmlEncoding && obj.EscapeResult && value != null) { - value = context.RendererSettings.EncodingFuction(value.ToString()); + value = context.RendererSettings.EncodingFuction(ConvertToStringInCulture(value, context.RenderSettings.CultureInfo)); } if (obj.Indent > 0) @@ -47,12 +63,12 @@ protected override void Write(StringRender renderer, InterpolationToken obj, Con renderer.Write(' ', obj.Indent); } - renderer.Write(value?.ToString()); + renderer.Write(ConvertToStringInCulture(value, context.RenderSettings.CultureInfo)); } - /// - protected override async Task WriteAsync(StringRender renderer, InterpolationToken obj, Context context) - { + /// + protected override async Task WriteAsync(StringRender renderer, InterpolationToken obj, Context context) + { var value = context.Lookup(obj.Content.ToString()); var functionValueDynamic = value as Func; @@ -61,7 +77,7 @@ protected override async Task WriteAsync(StringRender renderer, InterpolationTok if (functionValueDynamic != null || functionValue != null) { object functionResult = functionValueDynamic != null ? functionValueDynamic.Invoke(context.View) : functionValue.Invoke(); - var resultString = functionResult.ToString(); + var resultString = ConvertToStringInCulture(functionResult, context.RenderSettings.CultureInfo); if (resultString.Contains("{{")) { await renderer.RenderAsync(context.RendererSettings.Parser.Parse(resultString), context); @@ -73,7 +89,7 @@ protected override async Task WriteAsync(StringRender renderer, InterpolationTok if (!context.RenderSettings.SkipHtmlEncoding && obj.EscapeResult && value != null) { - value = context.RendererSettings.EncodingFuction(value.ToString()); + value = context.RendererSettings.EncodingFuction(ConvertToStringInCulture(value, context.RenderSettings.CultureInfo)); } if (obj.Indent > 0) @@ -81,7 +97,7 @@ protected override async Task WriteAsync(StringRender renderer, InterpolationTok renderer.Write(' ', obj.Indent); } - renderer.Write(value?.ToString()); + renderer.Write(ConvertToStringInCulture(value, context.RenderSettings.CultureInfo)); } } } diff --git a/src/Stubble.Core/Settings/RenderSettings.cs b/src/Stubble.Core/Settings/RenderSettings.cs index 39f8927..04c7a22 100644 --- a/src/Stubble.Core/Settings/RenderSettings.cs +++ b/src/Stubble.Core/Settings/RenderSettings.cs @@ -3,6 +3,8 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // +using System.Globalization; + namespace Stubble.Core.Settings { /// @@ -27,6 +29,11 @@ public class RenderSettings /// public bool SkipHtmlEncoding { get; set; } + /// + /// Gets or sets the CultureInfo to use for rendering format-dependent values (doubles, etc.). + /// + public CultureInfo CultureInfo { get; set; } = CultureInfo.InvariantCulture; + /// /// Gets the default render settings /// @@ -38,6 +45,7 @@ public static RenderSettings GetDefaultRenderSettings() SkipRecursiveLookup = false, ThrowOnDataMiss = false, SkipHtmlEncoding = false, + CultureInfo = CultureInfo.InvariantCulture }; } } diff --git a/test/Stubble.Compilation.Tests/SpecTests.cs b/test/Stubble.Compilation.Tests/SpecTests.cs index 99bb58a..d37f6db 100644 --- a/test/Stubble.Compilation.Tests/SpecTests.cs +++ b/test/Stubble.Compilation.Tests/SpecTests.cs @@ -9,22 +9,22 @@ namespace Stubble.Compilation.Tests public class SpecTests { internal readonly ITestOutputHelper OutputStream; - internal readonly CompilerSettings Settings; public SpecTests(ITestOutputHelper output) { OutputStream = output; - Settings = new CompilerSettingsBuilder().BuildSettings(); } [Theory] [MemberData(nameof(Specs.SpecTests), MemberType = typeof(Specs))] public void CompilationRendererSpecTest(SpecTest data) { - OutputStream.WriteLine(data.Name); + OutputStream.WriteLine(data.Name); + var settings = CompilationSettings.GetDefaultRenderSettings(); + settings.CultureInfo = data.CultureInfo ?? settings.CultureInfo; - var stubble = new StubbleCompilationRenderer(Settings); - var output = data.Partials != null ? stubble.Compile(data.Template, data.Data, data.Partials) : stubble.Compile(data.Template, data.Data); + var stubble = new StubbleCompilationRenderer(); + var output = data.Partials != null ? stubble.Compile(data.Template, data.Data, data.Partials, settings) : stubble.Compile(data.Template, data.Data, settings); var outputResult = output(data.Data); @@ -36,10 +36,12 @@ public void CompilationRendererSpecTest(SpecTest data) [MemberData(nameof(Specs.SpecTests), MemberType = typeof(Specs))] public async Task CompilationRendererSpecTest_Async(SpecTest data) { - OutputStream.WriteLine(data.Name); + OutputStream.WriteLine(data.Name); + var settings = CompilationSettings.GetDefaultRenderSettings(); + settings.CultureInfo = data.CultureInfo ?? settings.CultureInfo; - var stubble = new StubbleCompilationRenderer(Settings); - var output = await (data.Partials != null ? stubble.CompileAsync(data.Template, data.Data, data.Partials) : stubble.CompileAsync(data.Template, data.Data)); + var stubble = new StubbleCompilationRenderer(); + var output = await (data.Partials != null ? stubble.CompileAsync(data.Template, data.Data, data.Partials, settings) : stubble.CompileAsync(data.Template, data.Data, settings)); var outputResult = output(data.Data); diff --git a/test/Stubble.Core.Tests/SpecTests.cs b/test/Stubble.Core.Tests/SpecTests.cs index c102404..c70a4d7 100644 --- a/test/Stubble.Core.Tests/SpecTests.cs +++ b/test/Stubble.Core.Tests/SpecTests.cs @@ -1,5 +1,6 @@ using Stubble.Test.Shared.Spec; using System.Threading.Tasks; +using Stubble.Core.Settings; using Xunit; using Xunit.Abstractions; @@ -18,9 +19,12 @@ public SpecTests(ITestOutputHelper output) [MemberData(nameof(Specs.SpecTestsWithLambda), MemberType = typeof(Specs))] public void StringRendererSpecTest(SpecTest data) { + var settings = RenderSettings.GetDefaultRenderSettings(); + settings.CultureInfo = data.CultureInfo ?? settings.CultureInfo; + OutputStream.WriteLine(data.Name); - var stubble = new StubbleVisitorRenderer(); - var output = data.Partials != null ? stubble.Render(data.Template, data.Data, data.Partials) : stubble.Render(data.Template, data.Data); + var stubble = new StubbleVisitorRenderer(); + var output = data.Partials != null ? stubble.Render(data.Template, data.Data, data.Partials, settings) : stubble.Render(data.Template, data.Data, settings); OutputStream.WriteLine("Expected \"{0}\", Actual \"{1}\"", data.Expected, output); Assert.Equal(data.Expected, output); @@ -30,9 +34,12 @@ public void StringRendererSpecTest(SpecTest data) [MemberData(nameof(Specs.SpecTestsWithLambda), MemberType = typeof(Specs))] public async Task StringRendererSpecTest_Async(SpecTest data) { + var settings = RenderSettings.GetDefaultRenderSettings(); + settings.CultureInfo = data.CultureInfo ?? settings.CultureInfo; + OutputStream.WriteLine(data.Name); var stubble = new StubbleVisitorRenderer(); - var output = await (data.Partials != null ? stubble.RenderAsync(data.Template, data.Data, data.Partials) : stubble.RenderAsync(data.Template, data.Data)); + var output = await (data.Partials != null ? stubble.RenderAsync(data.Template, data.Data, data.Partials, settings) : stubble.RenderAsync(data.Template, data.Data, settings)); OutputStream.WriteLine("Expected \"{0}\", Actual \"{1}\"", data.Expected, output); Assert.Equal(data.Expected, output); diff --git a/test/Stubble.Test.Shared/Spec/Spec.Interpolation.cs b/test/Stubble.Test.Shared/Spec/Spec.Interpolation.cs index 5f05dba..7a9e615 100644 --- a/test/Stubble.Test.Shared/Spec/Spec.Interpolation.cs +++ b/test/Stubble.Test.Shared/Spec/Spec.Interpolation.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Globalization; using System.Linq; namespace Stubble.Test.Shared.Spec @@ -94,6 +95,14 @@ public static partial class Specs Template = @"""{{&power}} jiggawatts!""", Expected = @"""1.21 jiggawatts!""" }, + new SpecTest { + Name = @"Culture-specific Decimal Interpolation", + Desc = @"Decimals should interpolate seamlessly with proper significance.", + Data = new { power = 1.21, }, + CultureInfo = CultureInfo.GetCultureInfo("ru-RU"), + Template = @"""{{power}} jiggawatts!""", + Expected = @"""1,21 jiggawatts!""" + }, new SpecTest { Name = @"Basic Context Miss Interpolation", Desc = @"Failed context lookups should default to empty strings.", diff --git a/test/Stubble.Test.Shared/Spec/SpecTest.cs b/test/Stubble.Test.Shared/Spec/SpecTest.cs index a4b24a9..27d6939 100644 --- a/test/Stubble.Test.Shared/Spec/SpecTest.cs +++ b/test/Stubble.Test.Shared/Spec/SpecTest.cs @@ -3,9 +3,10 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // -using Newtonsoft.Json; -using System; +using Newtonsoft.Json; +using System; using System.Collections.Generic; +using System.Globalization; using Xunit.Abstractions; namespace Stubble.Test.Shared.Spec @@ -26,6 +27,8 @@ public class SpecTest public IDictionary Partials { get; set; } - public Exception ExpectedException { get; set; } + public Exception ExpectedException { get; set; } + + public CultureInfo CultureInfo { get; set; } } }