From 44bca889fad6af2028980bcd403785c2955246f4 Mon Sep 17 00:00:00 2001 From: Alexander Kalnitskiy Date: Fri, 14 Aug 2026 14:30:28 +0300 Subject: [PATCH 1/3] Report the reason, the expression and the location of a failed arithmetic operation Previously any arithmetic result which could not be converted to a template value was reported as a bare "Arithmetic operation result could not be evaluated" with no location at all, which made it impossible to tell which template expression had failed and why. Arithmetic expressions now keep the place they were parsed from, and the failure is thrown as ArithmeticOperationException, which carries: - Reason: DivisionByZero, NotANumber or ResultOutOfRange; - Expression: the failed expression as it is written in the template; - Location: inherited from UnrenderableTemplateModelException, now filled in. The same parts are put into Exception.Data under the QuokkaExceptionData keys, so that the calling code can build its own (e.g. localized) message without parsing the message text. UnrenderableTemplateModelException also puts its location into Exception.Data, so every runtime error is uniform in that regard. The resulting message reads: Arithmetic operation result could not be evaluated: division by zero in "cart.total / cart.itemCount" at 12:34 ArithmeticOperationException derives from UnrenderableTemplateModelException, so the existing catch blocks keep working. Co-Authored-By: Claude Fable 5 --- .../Exceptions/ArithmeticErrorReason.cs | 38 +++++ .../ArithmeticOperationException.cs | 93 ++++++++++ .../Exceptions/QuokkaExceptionData.cs | 56 ++++++ .../UnrenderableTemplateModelException.cs | 12 ++ .../Generated.Partials/QuokkaBaseVisitor.cs | 11 ++ .../Arithmetic/AdditionExpression.cs | 3 +- .../Arithmetic/ArithmeticExpression.cs | 21 ++- .../Arithmetic/MultiplicationExpression.cs | 3 +- .../Arithmetic/NegationExpression.cs | 3 +- .../Arithmetic/NumberExpression.cs | 3 +- .../VariantValueArithmeticExpression.cs | 3 +- .../Expressions/ExpressionSource.cs | 37 ++++ .../ArithmeticExpressionVisitor.cs | 14 +- .../Rendering/RenderArithmeticErrorsTests.cs | 161 ++++++++++++++++++ .../Rendering/RenderExceptionsTests.cs | 2 +- 15 files changed, 447 insertions(+), 13 deletions(-) create mode 100644 Engine/Mindbox.Quokka.Abstractions/Exceptions/ArithmeticErrorReason.cs create mode 100644 Engine/Mindbox.Quokka.Abstractions/Exceptions/ArithmeticOperationException.cs create mode 100644 Engine/Mindbox.Quokka.Abstractions/Exceptions/QuokkaExceptionData.cs create mode 100644 Engine/Quokka.Core/Templating/Expressions/ExpressionSource.cs create mode 100644 Engine/Quokka.Tests/Rendering/RenderArithmeticErrorsTests.cs diff --git a/Engine/Mindbox.Quokka.Abstractions/Exceptions/ArithmeticErrorReason.cs b/Engine/Mindbox.Quokka.Abstractions/Exceptions/ArithmeticErrorReason.cs new file mode 100644 index 0000000..e1c16a3 --- /dev/null +++ b/Engine/Mindbox.Quokka.Abstractions/Exceptions/ArithmeticErrorReason.cs @@ -0,0 +1,38 @@ +// // Copyright 2022 Mindbox Ltd +// // +// // Licensed under the Apache License, Version 2.0 (the "License"); +// // you may not use this file except in compliance with the License. +// // You may obtain a copy of the License at +// // +// // http://www.apache.org/licenses/LICENSE-2.0 +// // +// // Unless required by applicable law or agreed to in writing, software +// // distributed under the License is distributed on an "AS IS" BASIS, +// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// // See the License for the specific language governing permissions and +// // limitations under the License. + +namespace Mindbox.Quokka +{ + /// + /// The reason why an arithmetic operation result could not be evaluated. + /// Meant to be used by the calling code to build its own message, e.g. a localized one. + /// + public enum ArithmeticErrorReason + { + /// + /// The result is infinite. Within a template this is only reachable by dividing by zero. + /// + DivisionByZero, + + /// + /// The result is not a number, e.g. when zero is divided by zero. + /// + NotANumber, + + /// + /// The result is a finite number, but it is too large to be represented as a template value. + /// + ResultOutOfRange + } +} diff --git a/Engine/Mindbox.Quokka.Abstractions/Exceptions/ArithmeticOperationException.cs b/Engine/Mindbox.Quokka.Abstractions/Exceptions/ArithmeticOperationException.cs new file mode 100644 index 0000000..9876fa1 --- /dev/null +++ b/Engine/Mindbox.Quokka.Abstractions/Exceptions/ArithmeticOperationException.cs @@ -0,0 +1,93 @@ +// // Copyright 2022 Mindbox Ltd +// // +// // Licensed under the Apache License, Version 2.0 (the "License"); +// // you may not use this file except in compliance with the License. +// // You may obtain a copy of the License at +// // +// // http://www.apache.org/licenses/LICENSE-2.0 +// // +// // Unless required by applicable law or agreed to in writing, software +// // distributed under the License is distributed on an "AS IS" BASIS, +// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// // See the License for the specific language governing permissions and +// // limitations under the License. + +using System; + +namespace Mindbox.Quokka +{ + /// + /// This exception occurs when an arithmetic operation within a template produces a result + /// which can't be used as a template value: an infinite one, a not-a-number one or an out of range one. + /// + [Serializable] + public class ArithmeticOperationException : UnrenderableTemplateModelException + { + /// + /// The error text without the reason, the expression and the location appended to it. + /// + public const string ErrorText = "Arithmetic operation result could not be evaluated"; + + /// + /// The reason the result could not be evaluated. + /// + public ArithmeticErrorReason Reason { get; } + + /// + /// The source text of the failed expression as it is written in the template, + /// e.g. "cart.total / cart.itemCount". Null if the text can't be restored. + /// + public string Expression { get; } + + public ArithmeticOperationException( + ArithmeticErrorReason reason, + string expression, + Location location, + Exception inner) + : base(BuildMessage(reason, expression, location), inner, location) + { + Reason = reason; + Expression = expression; + + Data[QuokkaExceptionData.ErrorText] = ErrorText; + Data[QuokkaExceptionData.Reason] = reason.ToString(); + + if (expression != null) + Data[QuokkaExceptionData.Expression] = expression; + } + + /// + /// The reason in the same wording the exception message uses. + /// + public static string GetReasonText(ArithmeticErrorReason reason) + { + switch (reason) + { + case ArithmeticErrorReason.DivisionByZero: + return "division by zero"; + + case ArithmeticErrorReason.NotANumber: + return "the result is not a number"; + + case ArithmeticErrorReason.ResultOutOfRange: + return "the result is out of the supported number range"; + + default: + throw new ArgumentOutOfRangeException(nameof(reason), reason, null); + } + } + + private static string BuildMessage(ArithmeticErrorReason reason, string expression, Location location) + { + var message = $"{ErrorText}: {GetReasonText(reason)}"; + + if (!string.IsNullOrWhiteSpace(expression)) + message += $" in \"{expression}\""; + + if (location != null) + message += $" at {location}"; + + return message; + } + } +} diff --git a/Engine/Mindbox.Quokka.Abstractions/Exceptions/QuokkaExceptionData.cs b/Engine/Mindbox.Quokka.Abstractions/Exceptions/QuokkaExceptionData.cs new file mode 100644 index 0000000..9ac05d1 --- /dev/null +++ b/Engine/Mindbox.Quokka.Abstractions/Exceptions/QuokkaExceptionData.cs @@ -0,0 +1,56 @@ +// // Copyright 2022 Mindbox Ltd +// // +// // Licensed under the Apache License, Version 2.0 (the "License"); +// // you may not use this file except in compliance with the License. +// // You may obtain a copy of the License at +// // +// // http://www.apache.org/licenses/LICENSE-2.0 +// // +// // Unless required by applicable law or agreed to in writing, software +// // distributed under the License is distributed on an "AS IS" BASIS, +// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// // See the License for the specific language governing permissions and +// // limitations under the License. + +namespace Mindbox.Quokka +{ + /// + /// Keys of the entries filled in by template exceptions. + /// The entries hold the parts the exception message is built from, so that the calling code + /// can render its own message (e.g. a localized one) without parsing the message text. + /// + public static class QuokkaExceptionData + { + /// + /// The error text without any details appended to it, e.g. + /// "Arithmetic operation result could not be evaluated". Value type is . + /// + public const string ErrorText = "Quokka.ErrorText"; + + /// + /// The name of the reason enumeration member, e.g. "DivisionByZero". Value type is . + /// + public const string Reason = "Quokka.Reason"; + + /// + /// The source text of the template expression which failed, e.g. "cart.total / cart.itemCount". + /// Value type is . + /// + public const string Expression = "Quokka.Expression"; + + /// + /// The location of the failure within the template in the "line:column" form. Value type is . + /// + public const string Location = "Quokka.Location"; + + /// + /// The line of the failure within the template. Value type is . + /// + public const string Line = "Quokka.Line"; + + /// + /// The column of the failure within the template. Value type is . + /// + public const string Column = "Quokka.Column"; + } +} diff --git a/Engine/Mindbox.Quokka.Abstractions/Exceptions/UnrenderableTemplateModelException.cs b/Engine/Mindbox.Quokka.Abstractions/Exceptions/UnrenderableTemplateModelException.cs index 9441854..32736bf 100644 --- a/Engine/Mindbox.Quokka.Abstractions/Exceptions/UnrenderableTemplateModelException.cs +++ b/Engine/Mindbox.Quokka.Abstractions/Exceptions/UnrenderableTemplateModelException.cs @@ -30,12 +30,24 @@ public UnrenderableTemplateModelException(string message, Location location) : base(message) { Location = location; + FillLocationData(location); } public UnrenderableTemplateModelException(string message, Exception inner, Location location) : base(message, inner) { Location = location; + FillLocationData(location); + } + + private void FillLocationData(Location location) + { + if (location == null) + return; + + Data[QuokkaExceptionData.Location] = location.ToString(); + Data[QuokkaExceptionData.Line] = location.Line; + Data[QuokkaExceptionData.Column] = location.Column; } } } diff --git a/Engine/Quokka.Core/Generated.Partials/QuokkaBaseVisitor.cs b/Engine/Quokka.Core/Generated.Partials/QuokkaBaseVisitor.cs index a4adf77..c4f7d0d 100644 --- a/Engine/Quokka.Core/Generated.Partials/QuokkaBaseVisitor.cs +++ b/Engine/Quokka.Core/Generated.Partials/QuokkaBaseVisitor.cs @@ -15,6 +15,7 @@ using System; using Antlr4.Runtime; +using Antlr4.Runtime.Misc; using Antlr4.Runtime.Tree; namespace Mindbox.Quokka.Generated @@ -28,6 +29,16 @@ protected Location GetLocationFromToken(IToken token) return new Location(token.Line, token.Column); } + protected ExpressionSource GetExpressionSource(ParserRuleContext context) + { + var lastToken = context.Stop; + var text = lastToken != null && lastToken.StopIndex >= context.Start.StartIndex + ? context.Start.InputStream.GetText(new Interval(context.Start.StartIndex, lastToken.StopIndex)) + : null; + + return new ExpressionSource(GetLocationFromToken(context.Start), text); + } + protected QuokkaBaseVisitor(VisitingContext visitingContext) { VisitingContext = visitingContext; diff --git a/Engine/Quokka.Core/Templating/Expressions/Arithmetic/AdditionExpression.cs b/Engine/Quokka.Core/Templating/Expressions/Arithmetic/AdditionExpression.cs index 77fb036..1fe4be9 100644 --- a/Engine/Quokka.Core/Templating/Expressions/Arithmetic/AdditionExpression.cs +++ b/Engine/Quokka.Core/Templating/Expressions/Arithmetic/AdditionExpression.cs @@ -43,7 +43,8 @@ public override void Accept(ITemplateVisitor treeVisitor) treeVisitor.EndVisit(); } - public AdditionExpression(IEnumerable operands) + public AdditionExpression(ExpressionSource source, IEnumerable operands) + : base(source) { this.operands = operands.ToList().AsReadOnly(); } diff --git a/Engine/Quokka.Core/Templating/Expressions/Arithmetic/ArithmeticExpression.cs b/Engine/Quokka.Core/Templating/Expressions/Arithmetic/ArithmeticExpression.cs index 39446c0..ed2f19f 100644 --- a/Engine/Quokka.Core/Templating/Expressions/Arithmetic/ArithmeticExpression.cs +++ b/Engine/Quokka.Core/Templating/Expressions/Arithmetic/ArithmeticExpression.cs @@ -19,6 +19,13 @@ namespace Mindbox.Quokka { internal abstract class ArithmeticExpression : Expression { + private readonly ExpressionSource source; + + protected ArithmeticExpression(ExpressionSource source) + { + this.source = source; + } + public abstract double GetValue(RenderContext renderContext); public abstract void PerformSemanticAnalysis(AnalysisContext context); @@ -67,7 +74,7 @@ public sealed override void RegisterAssignmentToVariable( // do nothing } - private static object NormalizeValue(double value) + private object NormalizeValue(double value) { try { @@ -82,8 +89,18 @@ private static object NormalizeValue(double value) } catch (OverflowException ex) { - throw new UnrenderableTemplateModelException("Arithmetic operation result could not be evaluated", ex, null); + throw new ArithmeticOperationException(GetErrorReason(value), source?.Text, source?.Location, ex); } } + + private static ArithmeticErrorReason GetErrorReason(double value) + { + if (Double.IsNaN(value)) + return ArithmeticErrorReason.NotANumber; + + return Double.IsInfinity(value) + ? ArithmeticErrorReason.DivisionByZero + : ArithmeticErrorReason.ResultOutOfRange; + } } } diff --git a/Engine/Quokka.Core/Templating/Expressions/Arithmetic/MultiplicationExpression.cs b/Engine/Quokka.Core/Templating/Expressions/Arithmetic/MultiplicationExpression.cs index e7c4124..99ad8ee 100644 --- a/Engine/Quokka.Core/Templating/Expressions/Arithmetic/MultiplicationExpression.cs +++ b/Engine/Quokka.Core/Templating/Expressions/Arithmetic/MultiplicationExpression.cs @@ -28,7 +28,8 @@ public override TypeDefinition GetResultType(AnalysisContext context) : TypeDefinition.Decimal; } - public MultiplicationExpression(IEnumerable operands) + public MultiplicationExpression(ExpressionSource source, IEnumerable operands) + : base(source) { this.operands = operands.ToList().AsReadOnly(); } diff --git a/Engine/Quokka.Core/Templating/Expressions/Arithmetic/NegationExpression.cs b/Engine/Quokka.Core/Templating/Expressions/Arithmetic/NegationExpression.cs index 2be644d..52e1eb5 100644 --- a/Engine/Quokka.Core/Templating/Expressions/Arithmetic/NegationExpression.cs +++ b/Engine/Quokka.Core/Templating/Expressions/Arithmetic/NegationExpression.cs @@ -23,7 +23,8 @@ public override TypeDefinition GetResultType(AnalysisContext context) return innerExpression.GetResultType(context); } - public NegationExpression(ArithmeticExpression innerExpression) + public NegationExpression(ExpressionSource source, ArithmeticExpression innerExpression) + : base(source) { this.innerExpression = innerExpression; } diff --git a/Engine/Quokka.Core/Templating/Expressions/Arithmetic/NumberExpression.cs b/Engine/Quokka.Core/Templating/Expressions/Arithmetic/NumberExpression.cs index 997ecb8..2c0b499 100644 --- a/Engine/Quokka.Core/Templating/Expressions/Arithmetic/NumberExpression.cs +++ b/Engine/Quokka.Core/Templating/Expressions/Arithmetic/NumberExpression.cs @@ -20,7 +20,8 @@ internal class NumberExpression : ArithmeticExpression { private readonly double number; - public NumberExpression(double number) + public NumberExpression(ExpressionSource source, double number) + : base(source) { this.number = number; } diff --git a/Engine/Quokka.Core/Templating/Expressions/Arithmetic/VariantValueArithmeticExpression.cs b/Engine/Quokka.Core/Templating/Expressions/Arithmetic/VariantValueArithmeticExpression.cs index 4d57144..651c356 100644 --- a/Engine/Quokka.Core/Templating/Expressions/Arithmetic/VariantValueArithmeticExpression.cs +++ b/Engine/Quokka.Core/Templating/Expressions/Arithmetic/VariantValueArithmeticExpression.cs @@ -20,7 +20,8 @@ internal class VariantValueArithmeticExpression : ArithmeticExpression { private readonly VariantValueExpression variantValueExpression; - public VariantValueArithmeticExpression(VariantValueExpression variantValueExpression) + public VariantValueArithmeticExpression(ExpressionSource source, VariantValueExpression variantValueExpression) + : base(source) { this.variantValueExpression = variantValueExpression; } diff --git a/Engine/Quokka.Core/Templating/Expressions/ExpressionSource.cs b/Engine/Quokka.Core/Templating/Expressions/ExpressionSource.cs new file mode 100644 index 0000000..3838755 --- /dev/null +++ b/Engine/Quokka.Core/Templating/Expressions/ExpressionSource.cs @@ -0,0 +1,37 @@ +// // Copyright 2022 Mindbox Ltd +// // +// // Licensed under the Apache License, Version 2.0 (the "License"); +// // you may not use this file except in compliance with the License. +// // You may obtain a copy of the License at +// // +// // http://www.apache.org/licenses/LICENSE-2.0 +// // +// // Unless required by applicable law or agreed to in writing, software +// // distributed under the License is distributed on an "AS IS" BASIS, +// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// // See the License for the specific language governing permissions and +// // limitations under the License. + +namespace Mindbox.Quokka +{ + /// + /// The place an expression comes from within the template text. Kept by the compiled expression + /// so that runtime errors can point at the exact expression which failed. + /// + internal sealed class ExpressionSource + { + public Location Location { get; } + + /// + /// The expression as it is written in the template. Null for expressions + /// whose text can't be restored from the parse tree. + /// + public string Text { get; } + + public ExpressionSource(Location location, string text) + { + Location = location; + Text = text; + } + } +} diff --git a/Engine/Quokka.Core/Templating/Visitors/Expressions/ArithmeticExpressionVisitor.cs b/Engine/Quokka.Core/Templating/Visitors/Expressions/ArithmeticExpressionVisitor.cs index 228d481..0ac0174 100644 --- a/Engine/Quokka.Core/Templating/Visitors/Expressions/ArithmeticExpressionVisitor.cs +++ b/Engine/Quokka.Core/Templating/Visitors/Expressions/ArithmeticExpressionVisitor.cs @@ -45,7 +45,7 @@ public override ArithmeticExpression VisitArithmeticExpression(QuokkaParser.Arit .Skip(1) .Select(child => child.Accept(new AdditionalExpressionVisitor(VisitingContext)))); - return new AdditionExpression(operands); + return new AdditionExpression(GetExpressionSource(context), operands); } public override ArithmeticExpression VisitMultiplicationExpression(QuokkaParser.MultiplicationExpressionContext context) @@ -67,26 +67,30 @@ public override ArithmeticExpression VisitMultiplicationExpression(QuokkaParser. .Skip(1) .Select(child => child.Accept(new MultiplicativeExpressionVisitor(VisitingContext)))); - return new MultiplicationExpression(operands); + return new MultiplicationExpression(GetExpressionSource(context), operands); } public override ArithmeticExpression VisitNegationExpression(QuokkaParser.NegationExpressionContext context) { - return new NegationExpression(Visit(context.arithmeticAtom())); + return new NegationExpression(GetExpressionSource(context), Visit(context.arithmeticAtom())); } public override ArithmeticExpression VisitArithmeticAtom(QuokkaParser.ArithmeticAtomContext context) { var number = context.Number(); if (number != null) - return new NumberExpression(double.Parse(number.GetText(), CultureInfo.InvariantCulture)); + return new NumberExpression( + GetExpressionSource(context), + double.Parse(number.GetText(), CultureInfo.InvariantCulture)); return base.VisitArithmeticAtom(context); } public override ArithmeticExpression VisitVariantValueExpression(QuokkaParser.VariantValueExpressionContext context) { - return new VariantValueArithmeticExpression(context.Accept(new VariantValueExpressionVisitor(VisitingContext))); + return new VariantValueArithmeticExpression( + GetExpressionSource(context), + context.Accept(new VariantValueExpressionVisitor(VisitingContext))); } protected override ArithmeticExpression AggregateResult(ArithmeticExpression aggregate, ArithmeticExpression nextResult) diff --git a/Engine/Quokka.Tests/Rendering/RenderArithmeticErrorsTests.cs b/Engine/Quokka.Tests/Rendering/RenderArithmeticErrorsTests.cs new file mode 100644 index 0000000..5cba22f --- /dev/null +++ b/Engine/Quokka.Tests/Rendering/RenderArithmeticErrorsTests.cs @@ -0,0 +1,161 @@ +// // Copyright 2022 Mindbox Ltd +// // +// // Licensed under the Apache License, Version 2.0 (the "License"); +// // you may not use this file except in compliance with the License. +// // You may obtain a copy of the License at +// // +// // http://www.apache.org/licenses/LICENSE-2.0 +// // +// // Unless required by applicable law or agreed to in writing, software +// // distributed under the License is distributed on an "AS IS" BASIS, +// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// // See the License for the specific language governing permissions and +// // limitations under the License. + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Mindbox.Quokka.Tests +{ + [TestClass] + public class RenderArithmeticErrorsTests + { + [TestMethod] + public void Render_DivisionByZero_ReportsReasonExpressionAndLocation() + { + var template = new Template("Average check: ${ Total / OrderCount }"); + + var exception = Assert.ThrowsException( + () => template.Render( + new CompositeModelValue( + new ModelField("Total", 500), + new ModelField("OrderCount", 0)))); + + Assert.AreEqual(ArithmeticErrorReason.DivisionByZero, exception.Reason); + Assert.AreEqual("Total / OrderCount", exception.Expression); + Assert.AreEqual(1, exception.Location.Line); + Assert.AreEqual(18, exception.Location.Column); + Assert.AreEqual( + "Arithmetic operation result could not be evaluated: division by zero " + + "in \"Total / OrderCount\" at 1:18", + exception.Message); + } + + [TestMethod] + public void Render_ZeroDividedByZero_ReportsNotANumber() + { + var template = new Template("${ Total / OrderCount }"); + + var exception = Assert.ThrowsException( + () => template.Render( + new CompositeModelValue( + new ModelField("Total", 0), + new ModelField("OrderCount", 0)))); + + Assert.AreEqual(ArithmeticErrorReason.NotANumber, exception.Reason); + Assert.AreEqual( + "Arithmetic operation result could not be evaluated: the result is not a number " + + "in \"Total / OrderCount\" at 1:3", + exception.Message); + } + + [TestMethod] + public void Render_ResultTooLargeToBeRepresented_ReportsResultOutOfRange() + { + var template = new Template("${ First * Second }"); + + var exception = Assert.ThrowsException( + () => template.Render( + new CompositeModelValue( + new ModelField("First", 1000000000000000m), + new ModelField("Second", 1000000000000000m)))); + + Assert.AreEqual(ArithmeticErrorReason.ResultOutOfRange, exception.Reason); + Assert.AreEqual( + "Arithmetic operation result could not be evaluated: the result is out of the supported number range " + + "in \"First * Second\" at 1:3", + exception.Message); + } + + [TestMethod] + public void Render_DivisionByZero_ReportsLocationWithinMultilineTemplate() + { + var template = new Template( + "Hello!\r\n" + + "Your average check is ${ Total / OrderCount }.\r\n" + + "Bye!"); + + var exception = Assert.ThrowsException( + () => template.Render( + new CompositeModelValue( + new ModelField("Total", 500), + new ModelField("OrderCount", 0)))); + + Assert.AreEqual(2, exception.Location.Line); + Assert.AreEqual(25, exception.Location.Column); + } + + [TestMethod] + public void Render_DivisionByZeroWithinLargerExpression_ReportsWholeExpression() + { + var template = new Template("${ Total / OrderCount + 1 }"); + + var exception = Assert.ThrowsException( + () => template.Render( + new CompositeModelValue( + new ModelField("Total", 500), + new ModelField("OrderCount", 0)))); + + Assert.AreEqual("Total / OrderCount + 1", exception.Expression); + } + + [TestMethod] + public void Render_DivisionByZero_FillsExceptionDataWithMessageParts() + { + var template = new Template("${ Total / OrderCount }"); + + var exception = Assert.ThrowsException( + () => template.Render( + new CompositeModelValue( + new ModelField("Total", 500), + new ModelField("OrderCount", 0)))); + + Assert.AreEqual( + "Arithmetic operation result could not be evaluated", + exception.Data[QuokkaExceptionData.ErrorText]); + Assert.AreEqual("DivisionByZero", exception.Data[QuokkaExceptionData.Reason]); + Assert.AreEqual("Total / OrderCount", exception.Data[QuokkaExceptionData.Expression]); + Assert.AreEqual("1:3", exception.Data[QuokkaExceptionData.Location]); + Assert.AreEqual(1, exception.Data[QuokkaExceptionData.Line]); + Assert.AreEqual(3, exception.Data[QuokkaExceptionData.Column]); + } + + [TestMethod] + public void Render_DivisionByZero_IsStillCaughtAsUnrenderableTemplateModelException() + { + var template = new Template("${ Total / OrderCount }"); + + var exception = Assert.ThrowsException( + () => template.Render( + new CompositeModelValue( + new ModelField("Total", 500), + new ModelField("OrderCount", 0)))); + + Assert.IsInstanceOfType(exception, typeof(UnrenderableTemplateModelException)); + } + + [TestMethod] + public void Render_NullValueError_FillsExceptionDataWithLocation() + { + var template = new Template("${ Total }"); + + var exception = Assert.ThrowsException( + () => template.Render( + new CompositeModelValue( + new ModelField("Total", new PrimitiveModelValue(null))))); + + Assert.AreEqual("1:3", exception.Data[QuokkaExceptionData.Location]); + Assert.AreEqual(1, exception.Data[QuokkaExceptionData.Line]); + Assert.AreEqual(3, exception.Data[QuokkaExceptionData.Column]); + } + } +} diff --git a/Engine/Quokka.Tests/Rendering/RenderExceptionsTests.cs b/Engine/Quokka.Tests/Rendering/RenderExceptionsTests.cs index 3988fd1..87e4a96 100644 --- a/Engine/Quokka.Tests/Rendering/RenderExceptionsTests.cs +++ b/Engine/Quokka.Tests/Rendering/RenderExceptionsTests.cs @@ -36,7 +36,7 @@ public void Render_FunctonIvocationError_UnrendereableException() } [TestMethod] - [ExpectedException(typeof(UnrenderableTemplateModelException))] + [ExpectedException(typeof(UnrenderableTemplateModelException), AllowDerivedTypes = true)] public void Render_DivisionByZero_UnrendereableException() { var template = new DefaultTemplateFactory(new[] { new FaultyFunction() }) From 3dda3fc244d9dbf0bf3a807fed08d7364970f879 Mon Sep 17 00:00:00 2001 From: Alexander Kalnitskiy Date: Fri, 14 Aug 2026 14:57:14 +0300 Subject: [PATCH 2/3] Report the expression and the location for every runtime render error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arithmetic errors were given the reason, the expression and the location in the previous commit; the remaining runtime render errors — a null value usage, a missing variable value and a failed function call — still reported neither the expression nor the location in the message text. All runtime render errors now share one message shape, built in one place (the UnrenderableTemplateModelException base): [:
][ in ""][ at ] - An attempt to use a null value in "cell.Value" at 4:14 - Value for variable not found in "a" at 1:41 - Function invocation resulted in error: Argument must be positive in "picky()" at 1:3 The stable error texts are exposed as constants, the expression is exposed as a typed property on the base exception, and Exception.Data is filled uniformly with QuokkaExceptionData.ErrorText and Expression in addition to the location entries, so the calling code can build its own (e.g. localized) message for any runtime error without parsing the message text. Function calls now keep their source text the same way arithmetic expressions do, so the failed call is reported as written in the template, e.g. "max(a, b)" rather than just the function name. Co-Authored-By: Claude Fable 5 --- .../ArithmeticOperationException.cs | 26 +------ .../UnrenderableTemplateModelException.cs | 58 ++++++++++++++ .../Functions/FunctionCallExpression.cs | 23 ++++-- .../Variables/MemberValueExpression.cs | 7 +- .../Variables/VariableValueExpression.cs | 14 +++- .../FunctionCallExpressionVisitor.cs | 2 +- .../RenderCollectionFunctionsTests.cs | 2 +- .../Rendering/RenderExceptionsTests.cs | 75 ++++++++++++++++++- 8 files changed, 166 insertions(+), 41 deletions(-) diff --git a/Engine/Mindbox.Quokka.Abstractions/Exceptions/ArithmeticOperationException.cs b/Engine/Mindbox.Quokka.Abstractions/Exceptions/ArithmeticOperationException.cs index 9876fa1..e647b93 100644 --- a/Engine/Mindbox.Quokka.Abstractions/Exceptions/ArithmeticOperationException.cs +++ b/Engine/Mindbox.Quokka.Abstractions/Exceptions/ArithmeticOperationException.cs @@ -33,27 +33,16 @@ public class ArithmeticOperationException : UnrenderableTemplateModelException /// public ArithmeticErrorReason Reason { get; } - /// - /// The source text of the failed expression as it is written in the template, - /// e.g. "cart.total / cart.itemCount". Null if the text can't be restored. - /// - public string Expression { get; } - public ArithmeticOperationException( ArithmeticErrorReason reason, string expression, Location location, Exception inner) - : base(BuildMessage(reason, expression, location), inner, location) + : base(ErrorText, GetReasonText(reason), expression, location, inner) { Reason = reason; - Expression = expression; - Data[QuokkaExceptionData.ErrorText] = ErrorText; Data[QuokkaExceptionData.Reason] = reason.ToString(); - - if (expression != null) - Data[QuokkaExceptionData.Expression] = expression; } /// @@ -76,18 +65,5 @@ public static string GetReasonText(ArithmeticErrorReason reason) throw new ArgumentOutOfRangeException(nameof(reason), reason, null); } } - - private static string BuildMessage(ArithmeticErrorReason reason, string expression, Location location) - { - var message = $"{ErrorText}: {GetReasonText(reason)}"; - - if (!string.IsNullOrWhiteSpace(expression)) - message += $" in \"{expression}\""; - - if (location != null) - message += $" at {location}"; - - return message; - } } } diff --git a/Engine/Mindbox.Quokka.Abstractions/Exceptions/UnrenderableTemplateModelException.cs b/Engine/Mindbox.Quokka.Abstractions/Exceptions/UnrenderableTemplateModelException.cs index 32736bf..74182f9 100644 --- a/Engine/Mindbox.Quokka.Abstractions/Exceptions/UnrenderableTemplateModelException.cs +++ b/Engine/Mindbox.Quokka.Abstractions/Exceptions/UnrenderableTemplateModelException.cs @@ -24,8 +24,31 @@ namespace Mindbox.Quokka [Serializable] public class UnrenderableTemplateModelException : TemplateException { + /// + /// The error text of a null value usage, without the expression and the location appended to it. + /// + public const string NullValueErrorText = "An attempt to use a null value"; + + /// + /// The error text of a variable whose value is not present in the model, + /// without the expression and the location appended to it. + /// + public const string ValueNotFoundErrorText = "Value for variable not found"; + + /// + /// The error text of a failed template function call, without the failure details, + /// the expression and the location appended to it. + /// + public const string FunctionFailedErrorText = "Function invocation resulted in error"; + public Location Location { get; } + /// + /// The source text of the failed expression as it is written in the template, + /// e.g. "cart.total / cart.itemCount". Null if the text can't be restored. + /// + public string Expression { get; } + public UnrenderableTemplateModelException(string message, Location location) : base(message) { @@ -40,6 +63,41 @@ public UnrenderableTemplateModelException(string message, Exception inner, Locat FillLocationData(location); } + public UnrenderableTemplateModelException( + string errorText, + string details, + string expression, + Location location, + Exception inner) + : base(BuildMessage(errorText, details, expression, location), inner) + { + Location = location; + Expression = expression; + + Data[QuokkaExceptionData.ErrorText] = errorText; + + if (expression != null) + Data[QuokkaExceptionData.Expression] = expression; + + FillLocationData(location); + } + + private static string BuildMessage(string errorText, string details, string expression, Location location) + { + var message = errorText; + + if (!string.IsNullOrWhiteSpace(details)) + message += $": {details}"; + + if (!string.IsNullOrWhiteSpace(expression)) + message += $" in \"{expression}\""; + + if (location != null) + message += $" at {location}"; + + return message; + } + private void FillLocationData(Location location) { if (location == null) diff --git a/Engine/Quokka.Core/Templating/Expressions/Functions/FunctionCallExpression.cs b/Engine/Quokka.Core/Templating/Expressions/Functions/FunctionCallExpression.cs index 94ad1cb..b3fae7d 100644 --- a/Engine/Quokka.Core/Templating/Expressions/Functions/FunctionCallExpression.cs +++ b/Engine/Quokka.Core/Templating/Expressions/Functions/FunctionCallExpression.cs @@ -22,15 +22,17 @@ internal class FunctionCallExpression : VariantValueExpression { public string FunctionName { get; } - public Location Location { get; } + public Location Location => source.Location; + + private readonly ExpressionSource source; private readonly IReadOnlyList argumentValues; - public FunctionCallExpression(string functionName, IEnumerable argumentValues, Location location) + public FunctionCallExpression(string functionName, IEnumerable argumentValues, ExpressionSource source) { FunctionName = functionName; this.argumentValues = argumentValues.ToList().AsReadOnly(); - Location = location; + this.source = source; } public override void PerformSemanticAnalysis(AnalysisContext context, TypeDefinition expectedExpressionType) @@ -63,14 +65,21 @@ public override VariableValueStorage Evaluate(RenderContext renderContext) } catch (FunctionCallRuntimeException targetException) { - throw new UnrenderableTemplateModelException(targetException.Message, targetException, Location); + throw new UnrenderableTemplateModelException( + UnrenderableTemplateModelException.FunctionFailedErrorText, + targetException.Message, + source.Text ?? FunctionName, + source.Location, + targetException); } catch (Exception ex) { throw new UnrenderableTemplateModelException( - $"Function {FunctionName} invocation resulted in error", - ex, - Location); + UnrenderableTemplateModelException.FunctionFailedErrorText, + null, + source.Text ?? FunctionName, + source.Location, + ex); } } diff --git a/Engine/Quokka.Core/Templating/Expressions/Variables/MemberValueExpression.cs b/Engine/Quokka.Core/Templating/Expressions/Variables/MemberValueExpression.cs index b7f7f01..f861358 100644 --- a/Engine/Quokka.Core/Templating/Expressions/Variables/MemberValueExpression.cs +++ b/Engine/Quokka.Core/Templating/Expressions/Variables/MemberValueExpression.cs @@ -95,8 +95,11 @@ public override VariableValueStorage Evaluate(RenderContext renderContext) .Take(i + 1))); throw new UnrenderableTemplateModelException( - $"An attempt to use the value of \"{memberChainStringRepresentation}\" expression which happens to be null", - location); + UnrenderableTemplateModelException.NullValueErrorText, + null, + memberChainStringRepresentation, + location, + null); } } diff --git a/Engine/Quokka.Core/Templating/Expressions/Variables/VariableValueExpression.cs b/Engine/Quokka.Core/Templating/Expressions/Variables/VariableValueExpression.cs index 0e33cb8..648631b 100644 --- a/Engine/Quokka.Core/Templating/Expressions/Variables/VariableValueExpression.cs +++ b/Engine/Quokka.Core/Templating/Expressions/Variables/VariableValueExpression.cs @@ -67,8 +67,11 @@ public override VariableValueStorage Evaluate(RenderContext renderContext) var valueStorage = TryGetValueStorage(renderContext); if (valueStorage == null || valueStorage.CheckIfValueIsNull()) throw new UnrenderableTemplateModelException( - $"An attempt to use the value of variable \"{variableName}\" which happens to be null", - variableLocation); + UnrenderableTemplateModelException.NullValueErrorText, + null, + variableName, + variableLocation, + null); return valueStorage; } @@ -89,8 +92,11 @@ public VariableValueStorage TryGetValueStorage(RenderContext renderContext) { return renderContext.VariableScope.TryGetValueStorageForVariable(variableName) ?? throw new UnrenderableTemplateModelException( - $"Value for variable {variableName} not found", - variableLocation); + UnrenderableTemplateModelException.ValueNotFoundErrorText, + null, + variableName, + variableLocation, + null); } diff --git a/Engine/Quokka.Core/Templating/Visitors/Expressions/FunctionCallExpressionVisitor.cs b/Engine/Quokka.Core/Templating/Visitors/Expressions/FunctionCallExpressionVisitor.cs index 1e12103..88fd593 100644 --- a/Engine/Quokka.Core/Templating/Visitors/Expressions/FunctionCallExpressionVisitor.cs +++ b/Engine/Quokka.Core/Templating/Visitors/Expressions/FunctionCallExpressionVisitor.cs @@ -51,7 +51,7 @@ public override FunctionCallExpression VisitFunctionCallExpression(QuokkaParser. return new FunctionCallExpression( functionNameToken.GetText(), arguments, - GetLocationFromToken(functionNameToken.Symbol)); + GetExpressionSource(context)); } } } diff --git a/Engine/Quokka.Tests/Rendering/RenderCollectionFunctionsTests.cs b/Engine/Quokka.Tests/Rendering/RenderCollectionFunctionsTests.cs index 4ec3959..9ad1747 100644 --- a/Engine/Quokka.Tests/Rendering/RenderCollectionFunctionsTests.cs +++ b/Engine/Quokka.Tests/Rendering/RenderCollectionFunctionsTests.cs @@ -547,7 +547,7 @@ public void Render_ForBlockTableRows_EmptyCellValue_Exception() catch (UnrenderableTemplateModelException exception) { Assert.AreEqual( - "An attempt to use the value of \"cell.Value\" expression which happens to be null", + "An attempt to use a null value in \"cell.Value\" at 4:14", exception.Message); return; } diff --git a/Engine/Quokka.Tests/Rendering/RenderExceptionsTests.cs b/Engine/Quokka.Tests/Rendering/RenderExceptionsTests.cs index 87e4a96..c21dcfe 100644 --- a/Engine/Quokka.Tests/Rendering/RenderExceptionsTests.cs +++ b/Engine/Quokka.Tests/Rendering/RenderExceptionsTests.cs @@ -61,9 +61,67 @@ public void Render_AssignmentBlock_AssignmentInsideFalseBranch() new ArrayModelValue()))); } + [TestMethod] + public void Render_NullVariableValue_ReportsExpressionAndLocation() + { + var template = new Template("Hello, ${ FirstName }!"); + + var exception = Assert.ThrowsException( + () => template.Render( + new CompositeModelValue( + new ModelField("FirstName", new PrimitiveModelValue(null))))); + + Assert.AreEqual("An attempt to use a null value in \"FirstName\" at 1:10", exception.Message); + Assert.AreEqual("FirstName", exception.Expression); + } + + [TestMethod] + public void Render_VariableValueNotFound_ReportsExpressionAndLocation() + { + var template = new Template("@{ if 1 < 0 }@{ set a = 5 }@{ end if }${ a }"); + + var exception = Assert.ThrowsException( + () => template.Render(new CompositeModelValue())); + + Assert.AreEqual("Value for variable not found in \"a\" at 1:41", exception.Message); + } + + [TestMethod] + public void Render_FunctionInvocationError_ReportsExpressionLocationAndDataParts() + { + var template = new DefaultTemplateFactory(new[] { new FaultyFunction() }) + .CreateTemplate("${ fail() }"); + + var exception = Assert.ThrowsException( + () => template.Render(new CompositeModelValue())); + + Assert.AreEqual("Function invocation resulted in error in \"fail()\" at 1:3", exception.Message); + Assert.AreEqual( + UnrenderableTemplateModelException.FunctionFailedErrorText, + exception.Data[QuokkaExceptionData.ErrorText]); + Assert.AreEqual("fail()", exception.Data[QuokkaExceptionData.Expression]); + Assert.AreEqual("1:3", exception.Data[QuokkaExceptionData.Location]); + Assert.AreEqual(1, exception.Data[QuokkaExceptionData.Line]); + Assert.AreEqual(3, exception.Data[QuokkaExceptionData.Column]); + } + + [TestMethod] + public void Render_FunctionRuntimeError_ReportsFunctionMessageExpressionAndLocation() + { + var template = new DefaultTemplateFactory(new[] { new PickyFunction() }) + .CreateTemplate("${ picky() }"); + + var exception = Assert.ThrowsException( + () => template.Render(new CompositeModelValue())); + + Assert.AreEqual( + "Function invocation resulted in error: Argument must be positive in \"picky()\" at 1:3", + exception.Message); + } + private class FaultyFunction : ScalarTemplateFunction { - public FaultyFunction() + public FaultyFunction() : base("fail", typeof(int)) { } @@ -75,5 +133,20 @@ internal override object GetScalarInvocationResult( throw new Exception("Error"); } } + + private class PickyFunction : ScalarTemplateFunction + { + public PickyFunction() + : base("picky", typeof(int)) + { + } + + internal override object GetScalarInvocationResult( + RenderContext renderContext, + IList argumentsValues) + { + throw new FunctionCallRuntimeException("Argument must be positive", null); + } + } } } From c43ac4304381c61ec02084df24b7fc5e01a7bbb8 Mon Sep 17 00:00:00 2001 From: Alexander Kalnitskiy Date: Fri, 14 Aug 2026 15:15:30 +0300 Subject: [PATCH 3/3] Address review findings: argument errors, expression cap, version, docs - A runtime error raised while evaluating a function argument now propagates as itself: arguments are evaluated before the try block, so an arithmetic failure inside an argument is no longer re-wrapped into a generic "Function invocation resulted in error" losing its reason and expression. - The captured expression source text is capped at 100 characters (an ellipsis is appended), so unbounded template expressions can't bloat messages, Data entries or the compiled template. - Expression is normalized to null when blank, so Message and Data[Quokka.Expression] can't disagree about whether an expression exists. - Location.Column is documented as 0-based, matching the values the engine has always produced (ANTLR CharPositionInLine). - The DivisionByZero doc no longer claims infinity is only reachable by dividing by zero (a chain of multiplications can overflow to infinity), and the QuokkaExceptionData doc scopes the Data contract to runtime render errors. - Version bumped to 8.4.0: new public API and changed error messages. Co-Authored-By: Claude Fable 5 --- Engine/Directory.Build.props | 2 +- .../Errors/Location.cs | 2 +- .../Exceptions/ArithmeticErrorReason.cs | 3 +- .../Exceptions/QuokkaExceptionData.cs | 3 +- .../UnrenderableTemplateModelException.cs | 9 ++-- .../Generated.Partials/QuokkaBaseVisitor.cs | 5 ++ .../Expressions/ExpressionSource.cs | 4 +- .../Functions/FunctionCallExpression.cs | 12 ++--- .../Rendering/RenderArithmeticErrorsTests.cs | 51 +++++++++++++++++++ .../Rendering/RenderExceptionsTests.cs | 3 +- 10 files changed, 76 insertions(+), 18 deletions(-) diff --git a/Engine/Directory.Build.props b/Engine/Directory.Build.props index b0e18f5..ac66d0e 100644 --- a/Engine/Directory.Build.props +++ b/Engine/Directory.Build.props @@ -16,6 +16,6 @@ - 8.3.0 + 8.4.0 diff --git a/Engine/Mindbox.Quokka.Abstractions/Errors/Location.cs b/Engine/Mindbox.Quokka.Abstractions/Errors/Location.cs index af4bb1d..31f0e19 100644 --- a/Engine/Mindbox.Quokka.Abstractions/Errors/Location.cs +++ b/Engine/Mindbox.Quokka.Abstractions/Errors/Location.cs @@ -25,7 +25,7 @@ public sealed class Location public int Line { get; } /// - /// Column index (1-based) + /// Column index (0-based) /// public int Column { get; } diff --git a/Engine/Mindbox.Quokka.Abstractions/Exceptions/ArithmeticErrorReason.cs b/Engine/Mindbox.Quokka.Abstractions/Exceptions/ArithmeticErrorReason.cs index e1c16a3..920192c 100644 --- a/Engine/Mindbox.Quokka.Abstractions/Exceptions/ArithmeticErrorReason.cs +++ b/Engine/Mindbox.Quokka.Abstractions/Exceptions/ArithmeticErrorReason.cs @@ -21,7 +21,8 @@ namespace Mindbox.Quokka public enum ArithmeticErrorReason { /// - /// The result is infinite. Within a template this is only reachable by dividing by zero. + /// The result is infinite. Within a template this practically always means a division by zero; + /// an overflow of intermediate values to infinity is also reported this way. /// DivisionByZero, diff --git a/Engine/Mindbox.Quokka.Abstractions/Exceptions/QuokkaExceptionData.cs b/Engine/Mindbox.Quokka.Abstractions/Exceptions/QuokkaExceptionData.cs index 9ac05d1..d1f2716 100644 --- a/Engine/Mindbox.Quokka.Abstractions/Exceptions/QuokkaExceptionData.cs +++ b/Engine/Mindbox.Quokka.Abstractions/Exceptions/QuokkaExceptionData.cs @@ -15,7 +15,8 @@ namespace Mindbox.Quokka { /// - /// Keys of the entries filled in by template exceptions. + /// Keys of the entries filled in by runtime render errors + /// ( and its descendants). /// The entries hold the parts the exception message is built from, so that the calling code /// can render its own message (e.g. a localized one) without parsing the message text. /// diff --git a/Engine/Mindbox.Quokka.Abstractions/Exceptions/UnrenderableTemplateModelException.cs b/Engine/Mindbox.Quokka.Abstractions/Exceptions/UnrenderableTemplateModelException.cs index 74182f9..54deb63 100644 --- a/Engine/Mindbox.Quokka.Abstractions/Exceptions/UnrenderableTemplateModelException.cs +++ b/Engine/Mindbox.Quokka.Abstractions/Exceptions/UnrenderableTemplateModelException.cs @@ -45,7 +45,8 @@ public class UnrenderableTemplateModelException : TemplateException /// /// The source text of the failed expression as it is written in the template, - /// e.g. "cart.total / cart.itemCount". Null if the text can't be restored. + /// e.g. "cart.total / cart.itemCount", truncated if it is too long. + /// Null if the text can't be restored. /// public string Expression { get; } @@ -72,12 +73,12 @@ public UnrenderableTemplateModelException( : base(BuildMessage(errorText, details, expression, location), inner) { Location = location; - Expression = expression; + Expression = string.IsNullOrWhiteSpace(expression) ? null : expression; Data[QuokkaExceptionData.ErrorText] = errorText; - if (expression != null) - Data[QuokkaExceptionData.Expression] = expression; + if (Expression != null) + Data[QuokkaExceptionData.Expression] = Expression; FillLocationData(location); } diff --git a/Engine/Quokka.Core/Generated.Partials/QuokkaBaseVisitor.cs b/Engine/Quokka.Core/Generated.Partials/QuokkaBaseVisitor.cs index c4f7d0d..ea0e2ea 100644 --- a/Engine/Quokka.Core/Generated.Partials/QuokkaBaseVisitor.cs +++ b/Engine/Quokka.Core/Generated.Partials/QuokkaBaseVisitor.cs @@ -29,6 +29,8 @@ protected Location GetLocationFromToken(IToken token) return new Location(token.Line, token.Column); } + private const int MaxExpressionSourceLength = 100; + protected ExpressionSource GetExpressionSource(ParserRuleContext context) { var lastToken = context.Stop; @@ -36,6 +38,9 @@ protected ExpressionSource GetExpressionSource(ParserRuleContext context) ? context.Start.InputStream.GetText(new Interval(context.Start.StartIndex, lastToken.StopIndex)) : null; + if (text != null && text.Length > MaxExpressionSourceLength) + text = text.Substring(0, MaxExpressionSourceLength) + "…"; + return new ExpressionSource(GetLocationFromToken(context.Start), text); } diff --git a/Engine/Quokka.Core/Templating/Expressions/ExpressionSource.cs b/Engine/Quokka.Core/Templating/Expressions/ExpressionSource.cs index 3838755..2527136 100644 --- a/Engine/Quokka.Core/Templating/Expressions/ExpressionSource.cs +++ b/Engine/Quokka.Core/Templating/Expressions/ExpressionSource.cs @@ -23,8 +23,8 @@ internal sealed class ExpressionSource public Location Location { get; } /// - /// The expression as it is written in the template. Null for expressions - /// whose text can't be restored from the parse tree. + /// The expression as it is written in the template, truncated if it is too long. + /// Null for expressions whose text can't be restored from the parse tree. /// public string Text { get; } diff --git a/Engine/Quokka.Core/Templating/Expressions/Functions/FunctionCallExpression.cs b/Engine/Quokka.Core/Templating/Expressions/Functions/FunctionCallExpression.cs index b3fae7d..6c3da1a 100644 --- a/Engine/Quokka.Core/Templating/Expressions/Functions/FunctionCallExpression.cs +++ b/Engine/Quokka.Core/Templating/Expressions/Functions/FunctionCallExpression.cs @@ -54,14 +54,14 @@ public override VariableValueStorage Evaluate(RenderContext renderContext) if (function == null) throw new InvalidOperationException($"Function {FunctionName} not found"); + var arguments = argumentValues + .Select((argumentValue, argumentNumber) => + argumentValue.GetValue(renderContext, function.Arguments.GetArgument(argumentNumber))) + .ToList(); + try { - return function.Invoke( - renderContext, - argumentValues - .Select((argumentValue, argumentNumber) => - argumentValue.GetValue(renderContext, function.Arguments.GetArgument(argumentNumber))) - .ToList()); + return function.Invoke(renderContext, arguments); } catch (FunctionCallRuntimeException targetException) { diff --git a/Engine/Quokka.Tests/Rendering/RenderArithmeticErrorsTests.cs b/Engine/Quokka.Tests/Rendering/RenderArithmeticErrorsTests.cs index 5cba22f..3e98709 100644 --- a/Engine/Quokka.Tests/Rendering/RenderArithmeticErrorsTests.cs +++ b/Engine/Quokka.Tests/Rendering/RenderArithmeticErrorsTests.cs @@ -14,6 +14,8 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; +using Mindbox.Quokka.Abstractions; + namespace Mindbox.Quokka.Tests { [TestClass] @@ -157,5 +159,54 @@ public void Render_NullValueError_FillsExceptionDataWithLocation() Assert.AreEqual(1, exception.Data[QuokkaExceptionData.Line]); Assert.AreEqual(3, exception.Data[QuokkaExceptionData.Column]); } + + [TestMethod] + public void Render_DivisionByZeroInFunctionArgument_ReportsArithmeticError() + { + var template = new DefaultTemplateFactory(new[] { new EchoFunction() }) + .CreateTemplate("${ echo(Total / OrderCount) }"); + + var exception = Assert.ThrowsException( + () => template.Render( + new CompositeModelValue( + new ModelField("Total", 500), + new ModelField("OrderCount", 0)))); + + Assert.AreEqual(ArithmeticErrorReason.DivisionByZero, exception.Reason); + Assert.AreEqual("Total / OrderCount", exception.Expression); + } + + [TestMethod] + public void Render_FailedExpressionLongerThanLimit_TruncatesReportedExpression() + { + var template = new Template( + "${ TheFirstVeryLongVariableName + TheSecondVeryLongVariableName " + + "+ TheThirdVeryLongVariableName + TheFourthVeryLongVariableName / DivisorWhichHappensToBeZero }"); + + var exception = Assert.ThrowsException( + () => template.Render( + new CompositeModelValue( + new ModelField("TheFirstVeryLongVariableName", 1), + new ModelField("TheSecondVeryLongVariableName", 2), + new ModelField("TheThirdVeryLongVariableName", 3), + new ModelField("TheFourthVeryLongVariableName", 4), + new ModelField("DivisorWhichHappensToBeZero", 0)))); + + Assert.AreEqual(101, exception.Expression.Length); + Assert.IsTrue(exception.Expression.EndsWith("…")); + } + + private class EchoFunction : ScalarTemplateFunction + { + public EchoFunction() + : base("echo", new DecimalFunctionArgument("number")) + { + } + + public override decimal Invoke(RenderSettings settings, decimal value) + { + return value; + } + } } } diff --git a/Engine/Quokka.Tests/Rendering/RenderExceptionsTests.cs b/Engine/Quokka.Tests/Rendering/RenderExceptionsTests.cs index c21dcfe..d96ae1a 100644 --- a/Engine/Quokka.Tests/Rendering/RenderExceptionsTests.cs +++ b/Engine/Quokka.Tests/Rendering/RenderExceptionsTests.cs @@ -39,8 +39,7 @@ public void Render_FunctonIvocationError_UnrendereableException() [ExpectedException(typeof(UnrenderableTemplateModelException), AllowDerivedTypes = true)] public void Render_DivisionByZero_UnrendereableException() { - var template = new DefaultTemplateFactory(new[] { new FaultyFunction() }) - .CreateTemplate("${ 5 / 0 }"); + var template = new Template("${ 5 / 0 }"); template.Render(new CompositeModelValue()); }