diff --git a/src/ExCSS.Tests/PropertyTests/CalcPropertyTests.cs b/src/ExCSS.Tests/PropertyTests/CalcPropertyTests.cs new file mode 100644 index 00000000..951f06b6 --- /dev/null +++ b/src/ExCSS.Tests/PropertyTests/CalcPropertyTests.cs @@ -0,0 +1,63 @@ +using Xunit; + +namespace ExCSS.Tests.PropertyTests +{ + public class CalcPropertyTests : CssConstructionFunctions + { + [Theory] + // calc()/min()/max()/clamp() (CSS Values 4 10) are accepted wherever a compatible numeric value is, + // and the authored text is preserved. + [InlineData("width: calc(100% - 20px)", "width")] + [InlineData("width: calc(50% + 2em)", "width")] + [InlineData("width: calc((100% - 20px) / 3)", "width")] + [InlineData("margin-top: calc(10px * 2)", "margin-top")] + [InlineData("width: min(100%, 500px)", "width")] + [InlineData("width: max(50%, 200px)", "width")] + [InlineData("width: clamp(200px, 50%, 500px)", "width")] + [InlineData("width: calc(min(10px, 5%) + 2px)", "width")] + public void CalcAcceptedInLengthContext(string declaration, string name) + { + var property = ParseDeclaration(declaration); + + Assert.Equal(name, property.Name); + Assert.True(property.HasValue); + } + + [Fact] + public void CalcPreservesAuthoredText() + { + var property = ParseDeclaration("width: calc(100% - 20px)"); + + Assert.Equal("calc(100% - 20px)", property.Value); + } + + [Theory] + // A calc() also works in the angle context. + [InlineData("transform: rotate(calc(45deg * 2))")] + [InlineData("transform: rotate(calc(1turn / 4))")] + public void CalcAcceptedInAngleContext(string declaration) + { + var property = ParseDeclaration(declaration); + + Assert.True(property.HasValue); + } + + [Theory] + // Type-checking (CSS Values 4 10.10): dimensionally-inconsistent or malformed expressions are + // rejected. + [InlineData("width: calc(1px + 1s)")] // length + time + [InlineData("width: calc(10px * 20px)")] // length * length + [InlineData("width: calc(100% / 0)")] // divide by a constant zero + [InlineData("width: calc(1px +1px)")] // "+" needs whitespace on both sides + [InlineData("width: calc(5)")] // bare number is not a length + [InlineData("width: calc()")] // empty + [InlineData("width: clamp(1px, 2px)")] // clamp() takes exactly three arguments + [InlineData("width: calc(1px + )")] // missing operand + public void CalcRejectsInvalidExpressions(string declaration) + { + var property = ParseDeclaration(declaration); + + Assert.False(property.HasValue); + } + } +} diff --git a/src/ExCSS/Calc/CalcCategory.cs b/src/ExCSS/Calc/CalcCategory.cs new file mode 100644 index 00000000..5f5e04ce --- /dev/null +++ b/src/ExCSS/Calc/CalcCategory.cs @@ -0,0 +1,21 @@ +using System; + +namespace ExCSS +{ + /// + /// The CSS calc() type-checking category a (sub-)expression resolves to. A flags enum so that + /// Length | Percentage can represent the combined <length-percentage> type once a + /// length and a percentage have been added or subtracted together. + /// + [Flags] + internal enum CalcCategory + { + Number = 1, + Length = 2, + Percentage = 4, + LengthPercentage = Length | Percentage, + Angle = 8, + Time = 16, + Resolution = 32 + } +} diff --git a/src/ExCSS/Calc/CalcNode.cs b/src/ExCSS/Calc/CalcNode.cs new file mode 100644 index 00000000..3407efc8 --- /dev/null +++ b/src/ExCSS/Calc/CalcNode.cs @@ -0,0 +1,120 @@ +using System.Collections.Generic; + +namespace ExCSS +{ + /// + /// AST node for a parsed calc()/min()/max()/clamp() expression (CSS Values 4 §10). + /// + internal abstract class CalcNode + { + } + + internal sealed class NumberCalcNode : CalcNode + { + public NumberCalcNode(double value) + { + Value = value; + } + + public double Value { get; } + } + + internal sealed class DimensionCalcNode : CalcNode + { + public DimensionCalcNode(double value, Length.Unit unit) + { + Value = value; + Unit = unit; + } + + public double Value { get; } + public Length.Unit Unit { get; } + } + + internal sealed class PercentageCalcNode : CalcNode + { + public PercentageCalcNode(double value) + { + Value = value; + } + + public double Value { get; } + } + + internal sealed class AngleCalcNode : CalcNode + { + public AngleCalcNode(double value, Angle.Unit unit) + { + Value = value; + Unit = unit; + } + + public double Value { get; } + public Angle.Unit Unit { get; } + } + + internal sealed class TimeCalcNode : CalcNode + { + public TimeCalcNode(double value, Time.Unit unit) + { + Value = value; + Unit = unit; + } + + public double Value { get; } + public Time.Unit Unit { get; } + } + + internal sealed class ResolutionCalcNode : CalcNode + { + public ResolutionCalcNode(double value, Resolution.Unit unit) + { + Value = value; + Unit = unit; + } + + public double Value { get; } + public Resolution.Unit Unit { get; } + } + + /// A leading unary sign directly before a parenthesized group or a nested function call. + internal sealed class UnaryCalcNode : CalcNode + { + public UnaryCalcNode(bool negative, CalcNode operand) + { + Negative = negative; + Operand = operand; + } + + public bool Negative { get; } + public CalcNode Operand { get; } + } + + /// A binary +/-/*// operation. + internal sealed class BinaryCalcNode : CalcNode + { + public BinaryCalcNode(char op, CalcNode left, CalcNode right) + { + Operator = op; + Left = left; + Right = right; + } + + public char Operator { get; } + public CalcNode Left { get; } + public CalcNode Right { get; } + } + + /// A min(), max(), or clamp() call over its comma-separated arguments. + internal sealed class CallCalcNode : CalcNode + { + public CallCalcNode(string name, IReadOnlyList arguments) + { + Name = name; + Arguments = arguments; + } + + public string Name { get; } + public IReadOnlyList Arguments { get; } + } +} diff --git a/src/ExCSS/Calc/CalcParser.cs b/src/ExCSS/Calc/CalcParser.cs new file mode 100644 index 00000000..7814e4dc --- /dev/null +++ b/src/ExCSS/Calc/CalcParser.cs @@ -0,0 +1,264 @@ +using System.Collections.Generic; + +namespace ExCSS +{ + /// + /// Recursive-descent parser for calc()/min()/max()/clamp() expressions (CSS Values 4 §10). Grammar: + /// <calc-sum> := <calc-product> (('+'|'-') <calc-product>)*, + /// <calc-product> := <calc-value> (('*'|'/') <calc-value>)*, + /// <calc-value> := <number> | <dimension> | <percentage> | '(' <calc-sum> ')' | <nested-call> | <unary-sign> <calc-value>. + /// Binary +/- require whitespace on both sides; *// do not. Returns null on + /// any grammar violation. + /// + internal static class CalcParser + { + public static CalcNode Parse(FunctionToken function) + { + var name = function.Data; + + if (name.Isi(FunctionNames.Calc)) + { + var tokens = new List(function.ArgumentTokens); + var pos = 0; + var node = ParseSum(tokens, ref pos); + if (node == null) return null; + + SkipWhitespace(tokens, ref pos); + return pos == tokens.Count ? node : null; + } + + if (name.Isi(FunctionNames.Min) || name.Isi(FunctionNames.Max)) + { + var args = ParseCommaSeparatedSums(function, out var groupCount); + return groupCount >= 1 && args != null + ? new CallCalcNode(name.Isi(FunctionNames.Min) ? FunctionNames.Min : FunctionNames.Max, args) + : null; + } + + if (name.Isi(FunctionNames.Clamp)) + { + var args = ParseCommaSeparatedSums(function, out var groupCount); + return groupCount == 3 && args != null ? new CallCalcNode(FunctionNames.Clamp, args) : null; + } + + return null; + } + + public static bool IsCalcFamily(string name) + { + return name.Isi(FunctionNames.Calc) || name.Isi(FunctionNames.Min) || + name.Isi(FunctionNames.Max) || name.Isi(FunctionNames.Clamp); + } + + private static List ParseCommaSeparatedSums(FunctionToken function, out int groupCount) + { + var groups = function.ArgumentTokens.ToList(); + groupCount = groups.Count; + + var args = new List(groups.Count); + + foreach (var group in groups) + { + if (group.Count == 0) return null; + + var pos = 0; + var node = ParseSum(group, ref pos); + if (node == null) return null; + + SkipWhitespace(group, ref pos); + if (pos != group.Count) return null; + + args.Add(node); + } + + return args; + } + + private static CalcNode ParseSum(List tokens, ref int pos) + { + var left = ParseProduct(tokens, ref pos); + if (left == null) return null; + + while (true) + { + var beforeWhitespace = pos; + SkipWhitespace(tokens, ref pos); + var sawWhitespaceBefore = pos != beforeWhitespace; + + if (pos >= tokens.Count || tokens[pos].Type != TokenType.Delim || + (tokens[pos].Data != "+" && tokens[pos].Data != "-")) + { + pos = beforeWhitespace; + break; + } + + // Binary +/- must have whitespace on both sides; without it this can't be a valid binary + // operator here (an attached sign is already merged into the adjacent numeric token). + if (!sawWhitespaceBefore) return null; + + var op = tokens[pos].Data[0]; + pos++; + + var afterOp = pos; + SkipWhitespace(tokens, ref pos); + if (pos == afterOp) return null; + + var right = ParseProduct(tokens, ref pos); + if (right == null) return null; + + left = new BinaryCalcNode(op, left, right); + } + + return left; + } + + private static CalcNode ParseProduct(List tokens, ref int pos) + { + var left = ParseValue(tokens, ref pos); + if (left == null) return null; + + while (true) + { + var save = pos; + SkipWhitespace(tokens, ref pos); + + if (pos >= tokens.Count || tokens[pos].Type != TokenType.Delim || + (tokens[pos].Data != "*" && tokens[pos].Data != "/")) + { + pos = save; + break; + } + + var op = tokens[pos].Data[0]; + pos++; + SkipWhitespace(tokens, ref pos); + + var right = ParseValue(tokens, ref pos); + if (right == null) return null; + + left = new BinaryCalcNode(op, left, right); + } + + return left; + } + + private static CalcNode ParseValue(List tokens, ref int pos) + { + SkipWhitespace(tokens, ref pos); + if (pos >= tokens.Count) return null; + + var token = tokens[pos]; + + switch (token.Type) + { + case TokenType.Number: + pos++; + return new NumberCalcNode(((NumberToken) token).Value); + + case TokenType.Percentage: + pos++; + return new PercentageCalcNode(((UnitToken) token).Value); + + case TokenType.Dimension: + return ParseDimension((UnitToken) token, tokens, ref pos); + + case TokenType.RoundBracketOpen: + { + pos++; + var inner = ParseSum(tokens, ref pos); + if (inner == null) return null; + + SkipWhitespace(tokens, ref pos); + if (pos >= tokens.Count || tokens[pos].Type != TokenType.RoundBracketClose) return null; + pos++; + return inner; + } + + case TokenType.Delim when token.Data == "+" || token.Data == "-": + { + var negative = token.Data == "-"; + pos++; + + // A unary sign must be immediately followed by a parenthesized group or a nested + // function call (a signed number/dimension is already a single token from the lexer). + if (pos >= tokens.Count || + (tokens[pos].Type != TokenType.RoundBracketOpen && tokens[pos].Type != TokenType.Function)) + { + return null; + } + + var operand = ParseValue(tokens, ref pos); + return operand == null ? null : new UnaryCalcNode(negative, operand); + } + + case TokenType.Function: + { + var nested = (FunctionToken) token; + if (!IsCalcFamily(nested.Data)) return null; + pos++; + return Parse(nested); + } + + default: + return null; + } + } + + private static CalcNode ParseDimension(UnitToken token, List tokens, ref int pos) + { + var lengthUnit = Length.GetUnit(token.Unit); + if (IsSupportedLengthUnit(lengthUnit)) + { + pos++; + return new DimensionCalcNode(token.Value, lengthUnit); + } + + var angleUnit = Angle.GetUnit(token.Unit); + if (angleUnit != Angle.Unit.None) + { + pos++; + return new AngleCalcNode(token.Value, angleUnit); + } + + var timeUnit = Time.GetUnit(token.Unit); + if (timeUnit != Time.Unit.None) + { + pos++; + return new TimeCalcNode(token.Value, timeUnit); + } + + var resolutionUnit = Resolution.GetUnit(token.Unit); + if (resolutionUnit != Resolution.Unit.None) + { + pos++; + return new ResolutionCalcNode(token.Value, resolutionUnit); + } + + return null; + } + + private static bool IsSupportedLengthUnit(Length.Unit unit) + { + switch (unit) + { + case Length.Unit.Em: + case Length.Unit.Rem: + case Length.Unit.Ex: + case Length.Unit.Px: + case Length.Unit.Mm: + case Length.Unit.Cm: + case Length.Unit.In: + case Length.Unit.Pt: + case Length.Unit.Pc: + return true; + default: + return false; + } + } + + private static void SkipWhitespace(List tokens, ref int pos) + { + while (pos < tokens.Count && tokens[pos].Type == TokenType.Whitespace) pos++; + } + } +} diff --git a/src/ExCSS/Calc/CalcTypeChecker.cs b/src/ExCSS/Calc/CalcTypeChecker.cs new file mode 100644 index 00000000..84a8320b --- /dev/null +++ b/src/ExCSS/Calc/CalcTypeChecker.cs @@ -0,0 +1,153 @@ +using System.Collections.Generic; +using System.Linq; + +namespace ExCSS +{ + /// + /// Implements CSS calc()'s type-checking rules (CSS Values 4 §10.10) over a parsed + /// tree, returning the resolved category or null when the expression is + /// dimensionally inconsistent (e.g. adding a length to a time, or dividing by something that isn't a + /// number). Also folds pure-number subtrees, which is what makes a constant divide-by-zero divisor + /// detectable here. + /// + internal static class CalcTypeChecker + { + public static CalcCategory? Check(CalcNode node) + { + switch (node) + { + case NumberCalcNode _: + return CalcCategory.Number; + + case DimensionCalcNode _: + return CalcCategory.Length; + + case PercentageCalcNode _: + return CalcCategory.Percentage; + + case AngleCalcNode _: + return CalcCategory.Angle; + + case TimeCalcNode _: + return CalcCategory.Time; + + case ResolutionCalcNode _: + return CalcCategory.Resolution; + + case UnaryCalcNode unary: + return Check(unary.Operand); + + case BinaryCalcNode binary when binary.Operator == '+' || binary.Operator == '-': + return Combine(Check(binary.Left), Check(binary.Right)); + + case BinaryCalcNode binary when binary.Operator == '*': + { + var left = Check(binary.Left); + var right = Check(binary.Right); + if (left == null || right == null) return null; + if (left == CalcCategory.Number) return right; + if (right == CalcCategory.Number) return left; + return null; + } + + case BinaryCalcNode binary when binary.Operator == '/': + { + var left = Check(binary.Left); + var right = Check(binary.Right); + if (left == null || right != CalcCategory.Number) return null; + + // A Number-category subtree is built entirely from number leaves, so it always folds - + // this is what lets a constant divide-by-zero be rejected here rather than deferred. + var divisor = FoldNumber(binary.Right); + return divisor == null || divisor.Value == 0d ? (CalcCategory?) null : left; + } + + case CallCalcNode call: + { + CalcCategory? combined = null; + foreach (var argument in call.Arguments) + { + var category = Check(argument); + if (category == null) return null; + combined = combined == null ? category : Combine(combined, category); + if (combined == null) return null; + } + + return combined; + } + + default: + return null; + } + } + + // Folds a pure-number subtree to a concrete value; null if it isn't purely numeric. + public static double? FoldNumber(CalcNode node) + { + switch (node) + { + case NumberCalcNode number: + return number.Value; + + case UnaryCalcNode unary: + { + var value = FoldNumber(unary.Operand); + return value == null ? (double?) null : unary.Negative ? -value.Value : value.Value; + } + + case BinaryCalcNode binary: + { + var left = FoldNumber(binary.Left); + var right = FoldNumber(binary.Right); + if (left == null || right == null) return null; + + switch (binary.Operator) + { + case '+': return left.Value + right.Value; + case '-': return left.Value - right.Value; + case '*': return left.Value * right.Value; + case '/': return right.Value != 0d ? left.Value / right.Value : (double?) null; + default: return null; + } + } + + case CallCalcNode call: + { + var values = new List(call.Arguments.Count); + foreach (var argument in call.Arguments) + { + var value = FoldNumber(argument); + if (value == null) return null; + values.Add(value.Value); + } + + if (call.Name.Isi(FunctionNames.Min)) return values.Min(); + if (call.Name.Isi(FunctionNames.Max)) return values.Max(); + if (call.Name.Isi(FunctionNames.Clamp) && values.Count == 3) + { + var min = values[0]; + var val = values[1]; + var max = values[2]; + // clamp(min, val, max) == max(min, min(val, max)); if min > max, min wins. + var upper = val < max ? val : max; + return upper < min ? min : upper; + } + + return null; + } + + default: + // Dimension/percentage leaves are never purely numeric. + return null; + } + } + + private static CalcCategory? Combine(CalcCategory? left, CalcCategory? right) + { + if (left == null || right == null) return null; + if (left == CalcCategory.Number && right == CalcCategory.Number) return CalcCategory.Number; + if (left.Value.HasFlag(CalcCategory.Number) || right.Value.HasFlag(CalcCategory.Number)) return null; + return left.Value | right.Value; + } + } +} diff --git a/src/ExCSS/Enumerations/FunctionNames.cs b/src/ExCSS/Enumerations/FunctionNames.cs index 36a5272e..c73bf06f 100644 --- a/src/ExCSS/Enumerations/FunctionNames.cs +++ b/src/ExCSS/Enumerations/FunctionNames.cs @@ -20,6 +20,9 @@ public static class FunctionNames public static readonly string Counter = "counter"; public static readonly string Counters = "counters"; public static readonly string Calc = "calc"; + public static readonly string Min = "min"; + public static readonly string Max = "max"; + public static readonly string Clamp = "clamp"; public static readonly string Toggle = "toggle"; public static readonly string Translate = "translate"; public static readonly string TranslateX = "translateX"; diff --git a/src/ExCSS/Model/Converters.cs b/src/ExCSS/Model/Converters.cs index 1efadd3d..f97ae5dc 100644 --- a/src/ExCSS/Model/Converters.cs +++ b/src/ExCSS/Model/Converters.cs @@ -6,10 +6,12 @@ namespace ExCSS internal static class Converters { public static readonly IValueConverter LineWidthConverter = - new StructValueConverter(ValueExtensions.ToBorderWidth); + new StructValueConverter(ValueExtensions.ToBorderWidth) + .Or(new CalcValueConverter(CalcCategory.Length)); public static readonly IValueConverter LengthConverter = - new StructValueConverter(ValueExtensions.ToLength); + new StructValueConverter(ValueExtensions.ToLength) + .Or(new CalcValueConverter(CalcCategory.Length)); public static readonly IValueConverter ResolutionConverter = new StructValueConverter(ValueExtensions.ToResolution); @@ -44,10 +46,12 @@ public static readonly IValueConverter BinaryConverter = new StructValueConverter(ValueExtensions.ToBinary); public static readonly IValueConverter - AngleConverter = new StructValueConverter(ValueExtensions.ToAngle); + AngleConverter = new StructValueConverter(ValueExtensions.ToAngle) + .Or(new CalcValueConverter(CalcCategory.Angle)); public static readonly IValueConverter NumberConverter = - new StructValueConverter(ValueExtensions.ToSingle); + new StructValueConverter(ValueExtensions.ToSingle) + .Or(new CalcValueConverter(CalcCategory.Number)); public static readonly IValueConverter NaturalNumberConverter = new StructValueConverter(ValueExtensions.ToNaturalSingle); @@ -65,7 +69,8 @@ public static readonly IValueConverter new StructValueConverter(ValueExtensions.ToColor); public static readonly IValueConverter LengthOrPercentConverter = - new StructValueConverter(ValueExtensions.ToDistance); + new StructValueConverter(ValueExtensions.ToDistance) + .Or(new CalcValueConverter(CalcCategory.LengthPercentage)); public static readonly IValueConverter PercentOrFractionConverter = new StructValueConverter(ValueExtensions.ToPercentOrFraction); @@ -74,7 +79,8 @@ public static readonly IValueConverter new StructValueConverter(ValueExtensions.ToPercentOrNumber); public static readonly IValueConverter AngleNumberConverter = - new StructValueConverter(ValueExtensions.ToAngleNumber); + new StructValueConverter(ValueExtensions.ToAngleNumber) + .Or(new CalcValueConverter(CalcCategory.Angle | CalcCategory.Number)); public static readonly IValueConverter SideOrCornerConverter = WithAny( Assign(Keywords.Left, -1.0).Or(Keywords.Right, 1.0).Option(0.0), diff --git a/src/ExCSS/Parser/Lexer.cs b/src/ExCSS/Parser/Lexer.cs index 177fc564..0809c9eb 100644 --- a/src/ExCSS/Parser/Lexer.cs +++ b/src/ExCSS/Parser/Lexer.cs @@ -1038,11 +1038,27 @@ private Token NewIdent(string value) private Token NewFunction(string value) { var function = new FunctionToken(value, _position); + + // Track paren depth so a bare parenthesized group nested in the arguments - e.g. + // calc((100% - 20px) / 3) - isn't mistaken for the end of the function; only the ')' matching + // this function's own '(' terminates it. (Also submitted standalone as the nested-parentheses + // lexer fix; if that merges first, this rebases cleanly.) + var depth = 1; var token = Get(); while (token.Type != TokenType.EndOfFile) { function.AddArgumentToken(token); - if (token.Type == TokenType.RoundBracketClose) break; + + if (token.Type == TokenType.RoundBracketOpen) + { + depth++; + } + else if (token.Type == TokenType.RoundBracketClose) + { + depth--; + if (depth == 0) break; + } + token = Get(); } diff --git a/src/ExCSS/ValueConverters/CalcValueConverter.cs b/src/ExCSS/ValueConverters/CalcValueConverter.cs new file mode 100644 index 00000000..6362f1b1 --- /dev/null +++ b/src/ExCSS/ValueConverters/CalcValueConverter.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; + +namespace ExCSS +{ + /// + /// Accepts a calc()/min()/max()/clamp() expression whose type-checked category is within + /// allowed, and preserves the authored text. Composed onto the existing length/percent/number/ + /// angle converters via .Or(...) so support cascades to every property built on them. + /// + internal sealed class CalcValueConverter : IValueConverter + { + private readonly CalcCategory _allowed; + + public CalcValueConverter(CalcCategory allowed) + { + _allowed = allowed; + } + + public IPropertyValue Convert(IEnumerable value) + { + if (value.OnlyOrDefault() is not FunctionToken function) return null; + if (!CalcParser.IsCalcFamily(function.Data)) return null; + + var node = CalcParser.Parse(function); + if (node == null) return null; + + var category = CalcTypeChecker.Check(node); + if (category == null || (category.Value & ~_allowed) != 0) return null; + + return new CalcValue(value); + } + + public IPropertyValue Construct(Property[] properties) + { + return properties.Guard(); + } + + private sealed class CalcValue : IPropertyValue + { + public CalcValue(IEnumerable tokens) + { + Original = new TokenValue(tokens); + } + + public string CssText => Original.Text; + + public TokenValue Original { get; } + + public TokenValue ExtractFor(string name) => Original; + } + } +}