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);
+ }
+ }
}
}