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 new file mode 100644 index 0000000..920192c --- /dev/null +++ b/Engine/Mindbox.Quokka.Abstractions/Exceptions/ArithmeticErrorReason.cs @@ -0,0 +1,39 @@ +// // 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 practically always means a division by zero; + /// an overflow of intermediate values to infinity is also reported this way. + /// + 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..e647b93 --- /dev/null +++ b/Engine/Mindbox.Quokka.Abstractions/Exceptions/ArithmeticOperationException.cs @@ -0,0 +1,69 @@ +// // 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; } + + public ArithmeticOperationException( + ArithmeticErrorReason reason, + string expression, + Location location, + Exception inner) + : base(ErrorText, GetReasonText(reason), expression, location, inner) + { + Reason = reason; + + Data[QuokkaExceptionData.Reason] = reason.ToString(); + } + + /// + /// 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); + } + } + } +} diff --git a/Engine/Mindbox.Quokka.Abstractions/Exceptions/QuokkaExceptionData.cs b/Engine/Mindbox.Quokka.Abstractions/Exceptions/QuokkaExceptionData.cs new file mode 100644 index 0000000..d1f2716 --- /dev/null +++ b/Engine/Mindbox.Quokka.Abstractions/Exceptions/QuokkaExceptionData.cs @@ -0,0 +1,57 @@ +// // 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 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. + /// + 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..54deb63 100644 --- a/Engine/Mindbox.Quokka.Abstractions/Exceptions/UnrenderableTemplateModelException.cs +++ b/Engine/Mindbox.Quokka.Abstractions/Exceptions/UnrenderableTemplateModelException.cs @@ -24,18 +24,89 @@ 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", truncated if it is too long. + /// Null if the text can't be restored. + /// + public string Expression { get; } + 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); + } + + public UnrenderableTemplateModelException( + string errorText, + string details, + string expression, + Location location, + Exception inner) + : base(BuildMessage(errorText, details, expression, location), inner) + { + Location = location; + Expression = string.IsNullOrWhiteSpace(expression) ? null : 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) + 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..ea0e2ea 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,21 @@ 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; + var text = lastToken != null && lastToken.StopIndex >= context.Start.StartIndex + ? 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); + } + 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..2527136 --- /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, truncated if it is too long. + /// 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/Expressions/Functions/FunctionCallExpression.cs b/Engine/Quokka.Core/Templating/Expressions/Functions/FunctionCallExpression.cs index 94ad1cb..6c3da1a 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) @@ -52,25 +54,32 @@ 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) { - 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/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.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/RenderArithmeticErrorsTests.cs b/Engine/Quokka.Tests/Rendering/RenderArithmeticErrorsTests.cs new file mode 100644 index 0000000..3e98709 --- /dev/null +++ b/Engine/Quokka.Tests/Rendering/RenderArithmeticErrorsTests.cs @@ -0,0 +1,212 @@ +// // 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; + +using Mindbox.Quokka.Abstractions; + +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]); + } + + [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/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 3988fd1..d96ae1a 100644 --- a/Engine/Quokka.Tests/Rendering/RenderExceptionsTests.cs +++ b/Engine/Quokka.Tests/Rendering/RenderExceptionsTests.cs @@ -36,11 +36,10 @@ 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() }) - .CreateTemplate("${ 5 / 0 }"); + var template = new Template("${ 5 / 0 }"); template.Render(new CompositeModelValue()); } @@ -61,9 +60,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 +132,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); + } + } } }