diff --git a/Calculator/Calculator.csproj b/Calculator/Calculator.csproj new file mode 100644 index 0000000..c8d77b8 --- /dev/null +++ b/Calculator/Calculator.csproj @@ -0,0 +1,11 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>Library</OutputType> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="ExtendedNumerics.BigRational" Version="2023.1000.2.328" /> + </ItemGroup> + +</Project> diff --git a/Calculator/CalculatorException.cs b/Calculator/CalculatorException.cs new file mode 100644 index 0000000..c744d2c --- /dev/null +++ b/Calculator/CalculatorException.cs @@ -0,0 +1,5 @@ +using System; + +namespace Calculator; + +sealed class CalculatorException(string message) : Exception(message); diff --git a/Calculator/CalculatorExpressionVisitor.cs b/Calculator/CalculatorExpressionVisitor.cs new file mode 100644 index 0000000..ba98d8b --- /dev/null +++ b/Calculator/CalculatorExpressionVisitor.cs @@ -0,0 +1,71 @@ +using Calculator.Math; +using Calculator.Parser; + +namespace Calculator; + +public sealed class CalculatorExpressionVisitor : ExpressionVisitor<NumberWithUnit> { + public NumberWithUnit VisitNumber(Expression.Number number) { + return new NumberWithUnit(number.NumberToken.Value, null); + } + + public NumberWithUnit VisitNumbersWithUnits(Expression.NumbersWithUnits numbersWithUnits) { + NumberWithUnit result = new Number.Rational(0); + + foreach ((Token.Number number, Unit unit) in numbersWithUnits.NumberTokensWithUnits) { + result += new NumberWithUnit(number.Value, unit); + } + + return result; + } + + public NumberWithUnit VisitGrouping(Expression.Grouping grouping) { + return Evaluate(grouping.Expression); + } + + public NumberWithUnit VisitUnary(Expression.Unary unary) { + (Token.Simple op, Expression right) = unary; + + return op.Type switch { + SimpleTokenType.PLUS => +Evaluate(right), + SimpleTokenType.MINUS => -Evaluate(right), + _ => throw new CalculatorException("Unsupported unary operator: " + op.Type) + }; + } + + public NumberWithUnit VisitBinary(Expression.Binary binary) { + (Expression left, Token.Simple op, Expression right) = binary; + + return op.Type switch { + SimpleTokenType.PLUS => Evaluate(left) + Evaluate(right), + SimpleTokenType.MINUS => Evaluate(left) - Evaluate(right), + SimpleTokenType.STAR => Evaluate(left) * Evaluate(right), + SimpleTokenType.SLASH => Evaluate(left) / Evaluate(right), + SimpleTokenType.PERCENT => Evaluate(left) % Evaluate(right), + SimpleTokenType.CARET => Evaluate(left).Pow(Evaluate(right)), + _ => throw new CalculatorException("Unsupported binary operator: " + op.Type) + }; + } + + public NumberWithUnit VisitUnitAssignment(Expression.UnitAssignment unitAssignment) { + (Expression left, Unit right) = unitAssignment; + + NumberWithUnit number = Evaluate(left); + + if (number.Unit is null) { + return number with { Unit = right }; + } + else { + throw new CalculatorException("Expression already has a unit, cannot assign a new unit: " + right); + } + } + + public NumberWithUnit VisitUnitConversion(Expression.UnitConversion unitConversion) { + (Expression left, Unit unit) = unitConversion; + + return Evaluate(left).ConvertTo(unit); + } + + private NumberWithUnit Evaluate(Expression expression) { + return expression.Accept(this); + } +} diff --git a/Calculator/Math/Number.cs b/Calculator/Math/Number.cs new file mode 100644 index 0000000..ffaeb49 --- /dev/null +++ b/Calculator/Math/Number.cs @@ -0,0 +1,152 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Numerics; +using ExtendedNumerics; + +namespace Calculator.Math; + +[SuppressMessage("ReSharper", "MemberCanBeProtected.Global")] +[SuppressMessage("ReSharper", "MemberCanBeInternal")] +public abstract record Number : IAdditionOperators<Number, Number, Number>, + ISubtractionOperators<Number, Number, Number>, + IMultiplyOperators<Number, Number, Number>, + IDivisionOperators<Number, Number, Number>, + IModulusOperators<Number, Number, Number>, + IUnaryPlusOperators<Number, Number>, + IUnaryNegationOperators<Number, Number>, + IAdditiveIdentity<Number, Number.Rational>, + IMultiplicativeIdentity<Number, Number.Rational> { + protected abstract decimal AsDecimal { get; } + + public abstract Number Pow(Number exponent); + + public abstract string ToString(IFormatProvider? formatProvider); + + public sealed override string ToString() { + return ToString(CultureInfo.InvariantCulture); + } + + /// <summary> + /// Represents an integer number with arbitrary precision. + /// </summary> + public sealed record Rational(BigRational Value) : Number { + protected override decimal AsDecimal => (decimal) Value; + + public override Number Pow(Number exponent) { + if (exponent is Rational { Value: {} rationalExponent }) { + Fraction fractionExponent = rationalExponent.GetImproperFraction(); + if (fractionExponent.Denominator == 1 && fractionExponent.Numerator >= 0) { + try { + return new Rational(BigRational.Pow(Value, fractionExponent.Numerator)); + } catch (OverflowException) {} + } + } + + return new Decimal(AsDecimal).Pow(exponent); + } + + public override string ToString(IFormatProvider? formatProvider) { + Fraction fraction = Value.GetImproperFraction(); + return fraction.Denominator == 1 ? fraction.Numerator.ToString(formatProvider) : AsDecimal.ToString(formatProvider); + } + } + + /// <summary> + /// Represents a decimal number with limited precision. + /// </summary> + public sealed record Decimal(decimal Value) : Number { + public Decimal(double value) : this((decimal) value) {} + + protected override decimal AsDecimal => Value; + + public override Number Pow(Number exponent) { + double doubleValue = (double) Value; + double doubleExponent = (double) exponent.AsDecimal; + return new Decimal(System.Math.Pow(doubleValue, doubleExponent)); + } + + public override string ToString(IFormatProvider? formatProvider) { + return Value.ToString(formatProvider); + } + } + + public static implicit operator Number(BigRational value) { + return new Rational(value); + } + + public static implicit operator Number(int value) { + return new Rational(value); + } + + public static implicit operator Number(long value) { + return new Rational(value); + } + + public static Rational AdditiveIdentity => new (BigInteger.Zero); + public static Rational MultiplicativeIdentity => new (BigInteger.One); + + public static Number Add(Number left, Number right) { + return left + right; + } + + public static Number Subtract(Number left, Number right) { + return left - right; + } + + public static Number Multiply(Number left, Number right) { + return left * right; + } + + public static Number Divide(Number left, Number right) { + return left / right; + } + + public static Number Remainder(Number left, Number right) { + return left % right; + } + + public static Number Pow(Number left, Number right) { + return left.Pow(right); + } + + public static Number operator +(Number value) { + return value; + } + + public static Number operator -(Number value) { + return Operate(value, BigRational.Negate, decimal.Negate); + } + + public static Number operator +(Number left, Number right) { + return Operate(left, right, BigRational.Add, decimal.Add); + } + + public static Number operator -(Number left, Number right) { + return Operate(left, right, BigRational.Subtract, decimal.Subtract); + } + + public static Number operator *(Number left, Number right) { + return Operate(left, right, BigRational.Multiply, decimal.Multiply); + } + + public static Number operator /(Number left, Number right) { + return Operate(left, right, BigRational.Divide, decimal.Divide); + } + + public static Number operator %(Number left, Number right) { + return Operate(left, right, BigRational.Mod, decimal.Remainder); + } + + private static Number Operate(Number value, Func<BigRational, BigRational> rationalOperation, Func<decimal, decimal> decimalOperation) { + return value is Rational rational + ? new Rational(rationalOperation(rational.Value)) + : new Decimal(decimalOperation(value.AsDecimal)); + } + + private static Number Operate(Number left, Number right, Func<BigRational, BigRational, BigRational> rationalOperation, Func<decimal, decimal, decimal> decimalOperation) { + return left is Rational leftRational && right is Rational rightRational + ? new Rational(rationalOperation(leftRational.Value, rightRational.Value)) + : new Decimal(decimalOperation(left.AsDecimal, right.AsDecimal)); + } +} diff --git a/Calculator/Math/NumberWithUnit.cs b/Calculator/Math/NumberWithUnit.cs new file mode 100644 index 0000000..c228236 --- /dev/null +++ b/Calculator/Math/NumberWithUnit.cs @@ -0,0 +1,108 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Numerics; + +namespace Calculator.Math; + +[SuppressMessage("ReSharper", "MemberCanBeInternal")] +public readonly record struct NumberWithUnit(Number Number, Unit? Unit) : IAdditionOperators<NumberWithUnit, NumberWithUnit, NumberWithUnit>, + ISubtractionOperators<NumberWithUnit, NumberWithUnit, NumberWithUnit>, + IMultiplyOperators<NumberWithUnit, NumberWithUnit, NumberWithUnit>, + IDivisionOperators<NumberWithUnit, NumberWithUnit, NumberWithUnit>, + IModulusOperators<NumberWithUnit, NumberWithUnit, NumberWithUnit>, + IUnaryPlusOperators<NumberWithUnit, NumberWithUnit>, + IUnaryNegationOperators<NumberWithUnit, NumberWithUnit> { + public NumberWithUnit ConvertTo(Unit targetUnit) { + if (Unit == null) { + return this with { Unit = targetUnit }; + } + else if (Units.All.TryGetUniverse(Unit, out var universe) && universe.TryConvert(Number, Unit, targetUnit, out var converted)) { + return new NumberWithUnit(converted, targetUnit); + } + else { + throw new ArithmeticException("Cannot convert '" + Unit + "' to '" + targetUnit + "'"); + } + } + + public string ToString(IFormatProvider? formatProvider) { + string number = Number.ToString(formatProvider); + return Unit == null ? number : number + " " + Unit; + } + + public override string ToString() { + return ToString(CultureInfo.InvariantCulture); + } + + public static implicit operator NumberWithUnit(Number number) { + return new NumberWithUnit(number, null); + } + + public static NumberWithUnit operator +(NumberWithUnit value) { + return value with { Number = +value.Number }; + } + + public static NumberWithUnit operator -(NumberWithUnit value) { + return value with { Number = -value.Number }; + } + + public static NumberWithUnit operator +(NumberWithUnit left, NumberWithUnit right) { + return Operate(left, right, Number.Add, static (leftNumber, leftUnit, rightNumber, rightUnit) => { + if (leftUnit == rightUnit) { + return new NumberWithUnit(leftNumber + rightNumber, leftUnit); + } + else if (Units.All.TryGetUniverse(leftUnit, out UnitUniverse? universe) && universe.TryConvert(rightNumber, rightUnit, leftUnit, out Number? rightConverted)) { + return new NumberWithUnit(leftNumber + rightConverted, leftUnit); + } + else { + throw new ArithmeticException("Cannot add '" + leftUnit + "' and '" + rightUnit + "'"); + } + }); + } + + public static NumberWithUnit operator -(NumberWithUnit left, NumberWithUnit right) { + return Operate(left, right, Number.Subtract, static (leftNumber, leftUnit, rightNumber, rightUnit) => { + if (leftUnit == rightUnit) { + return new NumberWithUnit(leftNumber - rightNumber, leftUnit); + } + else if (Units.All.TryGetUniverse(leftUnit, out UnitUniverse? universe) && universe.TryConvert(rightNumber, rightUnit, leftUnit, out Number? rightConverted)) { + return new NumberWithUnit(leftNumber - rightConverted, leftUnit); + } + else { + throw new ArithmeticException("Cannot subtract '" + leftUnit + "' and '" + rightUnit + "'"); + } + }); + } + + public static NumberWithUnit operator *(NumberWithUnit left, NumberWithUnit right) { + return OperateWithoutUnits(left, right, Number.Multiply, "Cannot multiply"); + } + + public static NumberWithUnit operator /(NumberWithUnit left, NumberWithUnit right) { + return OperateWithoutUnits(left, right, Number.Divide, "Cannot divide"); + } + + public static NumberWithUnit operator %(NumberWithUnit left, NumberWithUnit right) { + return OperateWithoutUnits(left, right, Number.Remainder, "Cannot modulo"); + } + + public NumberWithUnit Pow(NumberWithUnit exponent) { + return OperateWithoutUnits(this, exponent, Number.Pow, "Cannot exponentiate"); + } + + private static NumberWithUnit Operate(NumberWithUnit left, NumberWithUnit right, Func<Number, Number, Number> withoutUnitsOperation, Func<Number, Unit, Number, Unit, NumberWithUnit> withUnitsOperation) { + if (right.Unit is null) { + return left with { Number = withoutUnitsOperation(left.Number, right.Number) }; + } + else if (left.Unit is null) { + return right with { Number = withoutUnitsOperation(left.Number, right.Number) }; + } + else { + return withUnitsOperation(left.Number, left.Unit, right.Number, right.Unit); + } + } + + private static NumberWithUnit OperateWithoutUnits(NumberWithUnit left, NumberWithUnit right, Func<Number, Number, Number> withoutUnitsOperation, string messagePrefix) { + return Operate(left, right, withoutUnitsOperation, (_, leftUnit, _, rightUnit) => throw new ArithmeticException(messagePrefix + " '" + leftUnit + "' and '" + rightUnit + "'")); + } +} diff --git a/Calculator/Math/Unit.cs b/Calculator/Math/Unit.cs new file mode 100644 index 0000000..8650907 --- /dev/null +++ b/Calculator/Math/Unit.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Calculator.Math; + +public sealed record Unit(string ShortName, ImmutableArray<string> LongNames) { + internal void AssignNamesTo(Dictionary<string, Unit> nameToUnitDictionary) { + nameToUnitDictionary.Add(ShortName, this); + + foreach (string longName in LongNames) { + nameToUnitDictionary.Add(longName, this); + } + } + + public override string ToString() { + return ShortName; + } +} diff --git a/Calculator/Math/UnitUniverse.cs b/Calculator/Math/UnitUniverse.cs new file mode 100644 index 0000000..e324913 --- /dev/null +++ b/Calculator/Math/UnitUniverse.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Numerics; +using ExtendedNumerics; + +namespace Calculator.Math; + +sealed class UnitUniverse( + Unit primaryUnit, + FrozenDictionary<Unit, Func<Number, Number>> unitToConversionToPrimaryUnit, + FrozenDictionary<Unit, Func<Number, Number>> unitToConversionFromPrimaryUnit +) { + public ImmutableArray<Unit> AllUnits => unitToConversionToPrimaryUnit.Keys; + + internal bool TryConvert(Number value, Unit fromUnit, Unit toUnit, [NotNullWhen(true)] out Number? converted) { + if (fromUnit == toUnit) { + converted = value; + return true; + } + else if (unitToConversionToPrimaryUnit.TryGetValue(fromUnit, out var convertToPrimaryUnit) && unitToConversionFromPrimaryUnit.TryGetValue(toUnit, out var convertFromPrimaryUnit)) { + converted = convertFromPrimaryUnit(convertToPrimaryUnit(value)); + return true; + } + else { + converted = null; + return false; + } + } + + internal sealed record SI(string ShortPrefix, string LongPrefix, int Factor) { + internal static readonly List<SI> All = [ + new SI("Q", "quetta", 30), + new SI("R", "ronna", 27), + new SI("Y", "yotta", 24), + new SI("Z", "zetta", 21), + new SI("E", "exa", 18), + new SI("P", "peta", 15), + new SI("T", "tera", 12), + new SI("G", "giga", 9), + new SI("M", "mega", 6), + new SI("k", "kilo", 3), + new SI("h", "hecto", 2), + new SI("da", "deca", 1), + new SI("d", "deci", -1), + new SI("c", "centi", -2), + new SI("m", "milli", -3), + new SI("μ", "micro", -6), + new SI("n", "nano", -9), + new SI("p", "pico", -12), + new SI("f", "femto", -15), + new SI("a", "atto", -18), + new SI("z", "zepto", -21), + new SI("y", "yocto", -24), + new SI("r", "ronto", -27), + new SI("q", "quecto", -30) + ]; + } + + internal sealed class Builder { + private readonly Dictionary<Unit, Func<Number, Number>> unitToConversionToPrimaryUnit = new (ReferenceEqualityComparer.Instance); + private readonly Dictionary<Unit, Func<Number, Number>> unitToConversionFromPrimaryUnit = new (ReferenceEqualityComparer.Instance); + private readonly Unit primaryUnit; + + public Builder(Unit primaryUnit) { + this.primaryUnit = primaryUnit; + AddUnit(primaryUnit, 1); + } + + public Builder AddUnit(Unit unit, Func<Number, Number> convertToPrimaryUnit, Func<Number, Number> convertFromPrimaryUnit) { + unitToConversionToPrimaryUnit.Add(unit, convertToPrimaryUnit); + unitToConversionFromPrimaryUnit.Add(unit, convertFromPrimaryUnit); + return this; + } + + public Builder AddUnit(Unit unit, Number amountInPrimaryUnit) { + return AddUnit(unit, number => number * amountInPrimaryUnit, number => number / amountInPrimaryUnit); + } + + private void AddUnitSI(SI si, Func<SI, Unit> unitFactory, Func<int, int> factorModifier) { + int factor = factorModifier(si.Factor); + BigInteger powerOfTen = BigInteger.Pow(10, System.Math.Abs(factor)); + BigRational amountInPrimaryUnit = factor > 0 ? new BigRational(powerOfTen) : new BigRational(1, powerOfTen); + AddUnit(unitFactory(si), amountInPrimaryUnit); + } + + public Builder AddSI(Func<SI, Unit> unitFactory, Func<int, int> factorModifier) { + foreach (SI si in SI.All) { + AddUnitSI(si, unitFactory, factorModifier); + } + + return this; + } + + public Builder AddSI(Func<int, int> factorModifier) { + Unit PrefixPrimaryUnit(SI si) { + return new Unit(si.ShortPrefix + primaryUnit.ShortName, [..primaryUnit.LongNames.Select(longName => si.LongPrefix + longName)]); + } + + foreach (SI si in SI.All) { + AddUnitSI(si, PrefixPrimaryUnit, factorModifier); + } + + return this; + } + + public Builder AddSI() { + return AddSI(static factor => factor); + } + + public UnitUniverse Build() { + return new UnitUniverse( + primaryUnit, + unitToConversionToPrimaryUnit.ToFrozenDictionary(ReferenceEqualityComparer.Instance), + unitToConversionFromPrimaryUnit.ToFrozenDictionary(ReferenceEqualityComparer.Instance) + ); + } + } +} diff --git a/Calculator/Math/UnitUniverses.cs b/Calculator/Math/UnitUniverses.cs new file mode 100644 index 0000000..b75d837 --- /dev/null +++ b/Calculator/Math/UnitUniverses.cs @@ -0,0 +1,33 @@ +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Calculator.Math; + +sealed class UnitUniverses { + private readonly FrozenDictionary<Unit, UnitUniverse> unitToUniverse; + private readonly FrozenDictionary<string, Unit> nameToUnit; + + internal UnitUniverses(params UnitUniverse[] universes) { + Dictionary<Unit, UnitUniverse> unitToUniverseBuilder = new (ReferenceEqualityComparer.Instance); + Dictionary<string, Unit> nameToUnitBuilder = new (); + + foreach (UnitUniverse universe in universes) { + foreach (Unit unit in universe.AllUnits) { + unitToUniverseBuilder.Add(unit, universe); + unit.AssignNamesTo(nameToUnitBuilder); + } + } + + unitToUniverse = unitToUniverseBuilder.ToFrozenDictionary(ReferenceEqualityComparer.Instance); + nameToUnit = nameToUnitBuilder.ToFrozenDictionary(); + } + + public bool TryGetUnit(string name, [NotNullWhen(true)] out Unit? unit) { + return nameToUnit.TryGetValue(name, out unit); + } + + public bool TryGetUniverse(Unit unit, [NotNullWhen(true)] out UnitUniverse? universe) { + return unitToUniverse.TryGetValue(unit, out universe); + } +} diff --git a/Calculator/Math/Units.cs b/Calculator/Math/Units.cs new file mode 100644 index 0000000..fd4dd0f --- /dev/null +++ b/Calculator/Math/Units.cs @@ -0,0 +1,156 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Calculator.Parser; +using ExtendedNumerics; + +namespace Calculator.Math; + +[SuppressMessage("ReSharper", "ConvertToConstant.Local")] +[SuppressMessage("ReSharper", "HeapView.ObjectAllocation")] +static class Units { + public static UnitUniverse Time { get; } = TimeUniverse().Build(); + public static UnitUniverse Length { get; } = LengthUniverse().Build(); + public static UnitUniverse Mass { get; } = MassUniverse().Build(); + public static UnitUniverse Area { get; } = AreaUniverse().Build(); + public static UnitUniverse Volume { get; } = VolumeUniverse().Build(); + public static UnitUniverse Angle { get; } = AngleUniverse().Build(); + public static UnitUniverse Temperature { get; } = TemperatureUniverse().Build(); + public static UnitUniverse InformationEntropy { get; } = InformationEntropyUniverse().Build(); + + public static UnitUniverses All { get; } = new (Time, Length, Mass, Area, Volume, Angle, Temperature, InformationEntropy); + + private static UnitUniverse.Builder TimeUniverse() { + var minute = 60; + var hour = minute * 60; + var day = hour * 24; + var week = day * 7; + + return new UnitUniverse.Builder(new Unit("s", Pluralize("second"))) + .AddUnit(new Unit("min", Pluralize("minute")), minute) + .AddUnit(new Unit("h", Pluralize("hour")), hour) + .AddUnit(new Unit("d", Pluralize("day")), day) + .AddUnit(new Unit("wk", Pluralize("week")), week); + } + + private static UnitUniverse.Builder LengthUniverse() { + var inch = Parse("0", "0254"); + var foot = inch * 12; + var yard = foot * 3; + var furlong = yard * 220; + var mile = yard * 1760; + var nauticalMile = 1_852; + var lightYear = 9_460_730_472_580_800; + + return new UnitUniverse.Builder(new Unit("m", Pluralize("meter", "metre"))) + .AddSI() + .AddUnit(new Unit("in", [ "inch", "inches", "\"" ]), inch) + .AddUnit(new Unit("ft", [ "foot", "feet", "'" ]), foot) + .AddUnit(new Unit("yd", Pluralize("yard")), yard) + .AddUnit(new Unit("fur", Pluralize("furlong")), furlong) + .AddUnit(new Unit("mi", Pluralize("mile")), mile) + .AddUnit(new Unit("nmi", Pluralize("nautical mile")), nauticalMile) + .AddUnit(new Unit("ly", Pluralize("light-year", "light year")), lightYear); + } + + private static UnitUniverse.Builder MassUniverse() { + var pound = Parse("453", "59237"); + var stone = pound * 14; + var ounce = pound / 16; + var dram = ounce / 16; + + return new UnitUniverse.Builder(new Unit("g", Pluralize("gram"))) + .AddSI() + .AddUnit(new Unit("lb", [ "lbs", "pound", "pounds" ]), pound) + .AddUnit(new Unit("st", Pluralize("stone")), stone) + .AddUnit(new Unit("oz", Pluralize("ounce")), ounce) + .AddUnit(new Unit("dr", Pluralize("dram")), dram); + } + + private static UnitUniverse.Builder AreaUniverse() { + static Unit SquareMeter(string shortPrefix, string longPrefix) { + return new Unit(shortPrefix + "m2", [ + $"square {shortPrefix}m", + $"{shortPrefix}m square", + $"{shortPrefix}m squared", + $"{longPrefix}meter squared", + $"{longPrefix}meters squared", + $"{longPrefix}metre squared", + $"{longPrefix}metres squared", + ..Pluralize($"square {longPrefix}meter", $"square {longPrefix}metre") + ]); + } + + return new UnitUniverse.Builder(SquareMeter(string.Empty, string.Empty)) + .AddSI(static si => SquareMeter(si.ShortPrefix, si.LongPrefix), static factor => factor * 2) + .AddUnit(new Unit("a", Pluralize("are")), 100) + .AddUnit(new Unit("ha", Pluralize("hectare")), 10_000); + } + + private static UnitUniverse.Builder VolumeUniverse() { + static Unit CubicMeter(string shortPrefix, string longPrefix) { + return new Unit(shortPrefix + "m3", [ + $"cubic {shortPrefix}m", + $"{shortPrefix}m cubed", + $"{longPrefix}meter cubed", + $"{longPrefix}meters cubed", + $"{longPrefix}metre cubed", + $"{longPrefix}metres cubed", + ..Pluralize($"cubic {longPrefix}meter", $"cubic {longPrefix}metre") + ]); + } + + return new UnitUniverse.Builder(new Unit("l", Pluralize("litre", "liter"))) + .AddSI() + .AddUnit(CubicMeter(string.Empty, string.Empty), 1000) + .AddSI(static si => CubicMeter(si.ShortPrefix, si.LongPrefix), static factor => (factor * 3) + 3); + } + + private static UnitUniverse.Builder AngleUniverse() { + return new UnitUniverse.Builder(new Unit("deg", [ "°", "degree", "degrees" ])) + .AddUnit(new Unit("rad", Pluralize("radian")), new Number.Decimal((decimal) System.Math.PI / 180M)) + .AddUnit(new Unit("grad", Pluralize("gradian", "grade", "gon")), Ratio(9, 10)); + } + + private static BigRational KelvinOffset { get; } = Parse("273", "15"); + + private static UnitUniverse.Builder TemperatureUniverse() { + return new UnitUniverse.Builder(new Unit("°C", [ "C", "Celsius", "celsius" ])) + .AddUnit(new Unit("°F", [ "F", "Fahrenheit", "fahrenheit" ]), static f => (f - 32) * Ratio(5, 9), static c => c * Ratio(9, 5) + 32) + .AddUnit(new Unit("K", [ "Kelvin", "kelvin" ]), static k => k - KelvinOffset, static c => c + KelvinOffset); + } + + private static UnitUniverse.Builder InformationEntropyUniverse() { + var bit = Ratio(1, 8); + var nibble = bit * 4; + + return new UnitUniverse.Builder(new Unit("B", Pluralize("byte"))) + .AddSI() + .AddUnit(new Unit("b", Pluralize("bit")), bit) + .AddUnit(new Unit("nibbles", [ "nibble" ]), nibble) + .AddUnit(new Unit("KiB", Pluralize("kibibyte")), Pow(1024, 1)) + .AddUnit(new Unit("MiB", Pluralize("mebibyte")), Pow(1024, 2)) + .AddUnit(new Unit("GiB", Pluralize("gibibyte")), Pow(1024, 3)) + .AddUnit(new Unit("TiB", Pluralize("tebibyte")), Pow(1024, 4)) + .AddUnit(new Unit("PiB", Pluralize("pebibyte")), Pow(1024, 5)) + .AddUnit(new Unit("EiB", Pluralize("exbibyte")), Pow(1024, 6)) + .AddUnit(new Unit("ZiB", Pluralize("zebibyte")), Pow(1024, 7)) + .AddUnit(new Unit("YiB", Pluralize("yobibyte")), Pow(1024, 8)); + } + + private static BigRational Parse(string integerPart, string fractionalPart) { + return Tokenizer.ParseNumber(integerPart, fractionalPart); + } + + private static BigRational Ratio(long numerator, long denominator) { + return new BigRational(numerator, denominator); + } + + private static BigRational Pow(int value, int exponent) { + return BigRational.Pow(value, exponent); + } + + private static ImmutableArray<string> Pluralize(params string[] names) { + return [..names.SelectMany(static name => new [] { name, name + "s" })]; + } +} diff --git a/Calculator/Parser/Expression.cs b/Calculator/Parser/Expression.cs new file mode 100644 index 0000000..200bd72 --- /dev/null +++ b/Calculator/Parser/Expression.cs @@ -0,0 +1,57 @@ +using System.Collections.Immutable; +using System.Linq; +using Calculator.Math; + +namespace Calculator.Parser; + +public abstract record Expression { + private Expression() {} + + public abstract T Accept<T>(ExpressionVisitor<T> visitor); + + public sealed record Number(Token.Number NumberToken) : Expression { + public override T Accept<T>(ExpressionVisitor<T> visitor) { + return visitor.VisitNumber(this); + } + } + + public sealed record NumbersWithUnits(ImmutableArray<(Token.Number, Unit)> NumberTokensWithUnits) : Expression { + public override T Accept<T>(ExpressionVisitor<T> visitor) { + return visitor.VisitNumbersWithUnits(this); + } + + public override string ToString() { + return nameof(NumbersWithUnits) + " { " + string.Join(", ", NumberTokensWithUnits.Select(static (number, unit) => number + " " + unit)) + " }"; + } + } + + public sealed record Grouping(Expression Expression) : Expression { + public override T Accept<T>(ExpressionVisitor<T> visitor) { + return visitor.VisitGrouping(this); + } + } + + public sealed record Unary(Token.Simple Operator, Expression Right) : Expression { + public override T Accept<T>(ExpressionVisitor<T> visitor) { + return visitor.VisitUnary(this); + } + } + + public sealed record Binary(Expression Left, Token.Simple Operator, Expression Right) : Expression { + public override T Accept<T>(ExpressionVisitor<T> visitor) { + return visitor.VisitBinary(this); + } + } + + public sealed record UnitAssignment(Expression Left, Unit Unit) : Expression { + public override T Accept<T>(ExpressionVisitor<T> visitor) { + return visitor.VisitUnitAssignment(this); + } + } + + public sealed record UnitConversion(Expression Left, Unit Unit) : Expression { + public override T Accept<T>(ExpressionVisitor<T> visitor) { + return visitor.VisitUnitConversion(this); + } + } +} diff --git a/Calculator/Parser/ExpressionVisitor.cs b/Calculator/Parser/ExpressionVisitor.cs new file mode 100644 index 0000000..d34893a --- /dev/null +++ b/Calculator/Parser/ExpressionVisitor.cs @@ -0,0 +1,17 @@ +namespace Calculator.Parser; + +public interface ExpressionVisitor<T> { + T VisitNumber(Expression.Number number); + + T VisitNumbersWithUnits(Expression.NumbersWithUnits numbersWithUnits); + + T VisitGrouping(Expression.Grouping grouping); + + T VisitUnary(Expression.Unary unary); + + T VisitBinary(Expression.Binary binary); + + T VisitUnitAssignment(Expression.UnitAssignment unitAssignment); + + T VisitUnitConversion(Expression.UnitConversion unitConversion); +} diff --git a/Calculator/Parser/ParseException.cs b/Calculator/Parser/ParseException.cs new file mode 100644 index 0000000..aa04df2 --- /dev/null +++ b/Calculator/Parser/ParseException.cs @@ -0,0 +1,5 @@ +using System; + +namespace Calculator.Parser; + +sealed class ParseException(string message) : Exception(message); diff --git a/Calculator/Parser/Parser.cs b/Calculator/Parser/Parser.cs new file mode 100644 index 0000000..90ad5ec --- /dev/null +++ b/Calculator/Parser/Parser.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Calculator.Math; + +namespace Calculator.Parser; + +public sealed class Parser(ImmutableArray<Token> tokens) { + private int current = 0; + + private bool IsEOF => current >= tokens.Length; + + private static readonly ImmutableArray<SimpleTokenType> PLUS_MINUS = [ + SimpleTokenType.PLUS, + SimpleTokenType.MINUS + ]; + + private static readonly ImmutableArray<SimpleTokenType> STAR_SLASH_PERCENT = [ + SimpleTokenType.STAR, + SimpleTokenType.SLASH, + SimpleTokenType.PERCENT + ]; + + private static readonly ImmutableArray<SimpleTokenType> CARET = [ + SimpleTokenType.CARET + ]; + + private bool Match(SimpleTokenType expectedTokenType, [NotNullWhen(true)] out Token.Simple? token) { + return Match(simpleToken => simpleToken.Type == expectedTokenType, out token); + } + + private bool Match(ImmutableArray<SimpleTokenType> expectedTokenTypes, [NotNullWhen(true)] out Token.Simple? token) { + return Match(simpleToken => expectedTokenTypes.Contains(simpleToken.Type), out token); + } + + private bool Match<T>([NotNullWhen(true)] out T? token) where T : Token { + return Match(static _ => true, out token); + } + + private bool Match<T>(Predicate<T> predicate, [NotNullWhen(true)] out T? token) where T : Token { + if (!IsEOF && tokens[current] is T t && predicate(t)) { + current++; + token = t; + return true; + } + + token = null; + return false; + } + + public Expression Parse() { + Expression term = Term(); + + if (!IsEOF) { + throw new ParseException("Incomplete expression"); + } + + return term; + } + + private Expression Term() { + return Binary(Factor, PLUS_MINUS); + } + + private Expression Factor() { + return Binary(Exponentiation, STAR_SLASH_PERCENT); + } + + private Expression Exponentiation() { + return Binary(Conversion, CARET); + } + + private Expression Binary(Func<Expression> term, ImmutableArray<SimpleTokenType> expectedTokenTypes) { + Expression left = term(); + + while (Match(expectedTokenTypes, out Token.Simple? op)) { + Expression right = term(); + left = new Expression.Binary(left, op, right); + } + + return left; + } + + private Expression Conversion() { + Expression left = Unary(); + + while (MatchUnitConversionOperator()) { + if (!MatchUnit(out Unit? unit)) { + throw new ParseException("Expected a unit literal"); + } + + left = new Expression.UnitConversion(left, unit); + } + + return left; + } + + private Expression Unary() { + if (Match(PLUS_MINUS, out Token.Simple? op)) { + Expression right = Unary(); + return new Expression.Unary(op, right); + } + else { + return Primary(); + } + } + + private Expression Primary() { + if (Match(LiteralPredicate, out Token? literal)) { + if (literal is not Token.Number number) { + throw new ParseException("Expected a number literal"); + } + + Expression expression; + + if (!MatchUnit(out Unit? unit)) { + expression = new Expression.Number(number); + } + else { + var numbersWithUnits = ImmutableArray.CreateBuilder<(Token.Number, Unit)>(); + numbersWithUnits.Add((number, unit)); + + while (MatchNumberWithUnit(out var numberWithUnit)) { + numbersWithUnits.Add(numberWithUnit.Value); + } + + expression = new Expression.NumbersWithUnits(numbersWithUnits.ToImmutable()); + } + + if (Match(SimpleTokenType.LEFT_PARENTHESIS, out _)) { + expression = new Expression.Binary(expression, new Token.Simple(SimpleTokenType.STAR), InsideParentheses()); + } + + return expression; + } + + if (Match(SimpleTokenType.LEFT_PARENTHESIS, out _)) { + return new Expression.Grouping(InsideParentheses()); + } + + throw new ParseException("Unexpected token type: " + tokens[current]); + } + + private static bool LiteralPredicate(Token token) { + return token is Token.Text or Token.Number; + } + + private Expression InsideParentheses() { + Expression term = Term(); + + if (!Match(SimpleTokenType.RIGHT_PARENTHESIS, out _)) { + throw new ParseException("Expected ')' after expression."); + } + + int position = current; + + if (MatchUnitConversionOperator()) { + if (MatchUnit(out Unit? toUnit)) { + return new Expression.UnitConversion(term, toUnit); + } + else { + current = position; + } + } + + if (MatchUnit(out Unit? unit)) { + return new Expression.UnitAssignment(term, unit); + } + else { + return term; + } + } + + private bool MatchNumberWithUnit([NotNullWhen(true)] out (Token.Number, Unit)? numberWithUnit) { + if (!Match(out Token.Number? number)) { + numberWithUnit = null; + return false; + } + + if (!MatchUnit(out Unit? unit)) { + throw new ParseException("Expected a unit literal"); + } + + numberWithUnit = (number, unit); + return true; + } + + private bool MatchUnit([NotNullWhen(true)] out Unit? unit) { + int position = current; + + List<string> words = []; + + while (Match(out Token.Text? text)) { + words.Add(text.Value); + } + + for (int i = words.Count; i > 0; i--) { + string unitName = string.Join(' ', words.Take(i)); + + if (Units.All.TryGetUnit(unitName, out unit)) { + current = position + i; + return true; + } + } + + current = position; + unit = null; + return false; + } + + private bool MatchUnitConversionOperator() { + return Match<Token.Text>(static text => text.Value is "to" or "in", out _); + } +} diff --git a/Calculator/Parser/SimpleTokenType.cs b/Calculator/Parser/SimpleTokenType.cs new file mode 100644 index 0000000..ad40562 --- /dev/null +++ b/Calculator/Parser/SimpleTokenType.cs @@ -0,0 +1,13 @@ +namespace Calculator.Parser; + +public enum SimpleTokenType { + PLUS, + MINUS, + STAR, + SLASH, + PERCENT, + CARET, + + LEFT_PARENTHESIS, + RIGHT_PARENTHESIS +} diff --git a/Calculator/Parser/Token.cs b/Calculator/Parser/Token.cs new file mode 100644 index 0000000..bb9296d --- /dev/null +++ b/Calculator/Parser/Token.cs @@ -0,0 +1,38 @@ +using System; +using System.Globalization; + +namespace Calculator.Parser; + +public abstract record Token { + private Token() {} + + public sealed record Simple(SimpleTokenType Type) : Token { + #pragma warning disable CS8524 // The switch expression does not handle some values of its input type (it is not exhaustive) involving an unnamed enum value. + public override string ToString() { + return Type switch { + SimpleTokenType.PLUS => "+", + SimpleTokenType.MINUS => "-", + SimpleTokenType.STAR => "*", + SimpleTokenType.SLASH => "/", + SimpleTokenType.PERCENT => "%", + SimpleTokenType.CARET => "^", + SimpleTokenType.LEFT_PARENTHESIS => "(", + SimpleTokenType.RIGHT_PARENTHESIS => ")", + _ => throw new ArgumentOutOfRangeException() + }; + } + #pragma warning restore CS8524 // The switch expression does not handle some values of its input type (it is not exhaustive) involving an unnamed enum value. + } + + public sealed record Text(string Value) : Token { + public override string ToString() { + return Value; + } + } + + public sealed record Number(Math.Number Value) : Token { + public override string ToString() { + return Value.ToString(CultureInfo.InvariantCulture); + } + } +} diff --git a/Calculator/Parser/TokenizationException.cs b/Calculator/Parser/TokenizationException.cs new file mode 100644 index 0000000..1bcd51d --- /dev/null +++ b/Calculator/Parser/TokenizationException.cs @@ -0,0 +1,7 @@ +using System; + +namespace Calculator.Parser; + +sealed class TokenizationException(string message, char character) : Exception(message) { + public char Character { get; } = character; +} diff --git a/Calculator/Parser/Tokenizer.cs b/Calculator/Parser/Tokenizer.cs new file mode 100644 index 0000000..f5f2df7 --- /dev/null +++ b/Calculator/Parser/Tokenizer.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Immutable; +using System.Globalization; +using System.Numerics; +using System.Text; +using ExtendedNumerics; + +namespace Calculator.Parser; + +public sealed class Tokenizer(string input) { + private int position = 0; + + private bool IsEOF => position >= input.Length; + + private char Advance() { + return input[position++]; + } + + private bool Match(char c) { + return Match(found => found == c, out _); + } + + private bool Match(Predicate<char> predicate, out char c) { + if (IsEOF) { + c = default; + return false; + } + + c = input[position]; + + if (!predicate(c)) { + return false; + } + + position++; + return true; + } + + private void MatchWhile(StringBuilder result, Predicate<char> predicate) { + while (Match(predicate, out char c)) { + result.Append(c); + } + } + + private string MatchWhile(Predicate<char> predicate) { + var result = new StringBuilder(); + MatchWhile(result, predicate); + return result.ToString(); + } + + private string MatchRest(char firstChar, Predicate<char> predicate) { + var result = new StringBuilder(); + result.Append(firstChar); + MatchWhile(result, predicate); + return result.ToString(); + } + + public ImmutableArray<Token> Scan() { + ImmutableArray<Token>.Builder tokens = ImmutableArray.CreateBuilder<Token>(); + + void AddToken(Token token) { + tokens.Add(token); + } + + void AddSimpleToken(SimpleTokenType tokenType) { + AddToken(new Token.Simple(tokenType)); + } + + while (!IsEOF) { + char c = Advance(); + switch (c) { + case ' ': + // Ignore whitespace. + break; + + case '+': + AddSimpleToken(SimpleTokenType.PLUS); + break; + + case '-': + AddSimpleToken(SimpleTokenType.MINUS); + break; + + case '*': + AddSimpleToken(SimpleTokenType.STAR); + break; + + case '/': + AddSimpleToken(SimpleTokenType.SLASH); + break; + + case '%': + AddSimpleToken(SimpleTokenType.PERCENT); + break; + + case '^': + AddSimpleToken(SimpleTokenType.CARET); + break; + + case '(': + AddSimpleToken(SimpleTokenType.LEFT_PARENTHESIS); + break; + + case ')': + AddSimpleToken(SimpleTokenType.RIGHT_PARENTHESIS); + break; + + case '"' or '\'': + AddToken(new Token.Text(c.ToString())); + break; + + case '°': + case {} when char.IsLetter(c): + AddToken(new Token.Text(MatchRest(c, char.IsLetterOrDigit))); + break; + + case {} when char.IsAsciiDigit(c): + string integerPart = MatchRest(c, char.IsAsciiDigit); + + if (Match('.')) { + string fractionalPart = MatchWhile(char.IsAsciiDigit); + AddToken(new Token.Number(ParseNumber(integerPart, fractionalPart))); + } + else { + AddToken(new Token.Number(ParseNumber(integerPart))); + } + + break; + + default: + throw new TokenizationException("Unexpected character: " + c, c); + } + } + + return tokens.ToImmutable(); + } + + internal static BigRational ParseNumber(string integerPart, string? fractionalPart = null) { + if (fractionalPart == null) { + return new BigRational(BigInteger.Parse(integerPart, NumberStyles.Integer, CultureInfo.InvariantCulture)); + } + else { + BigInteger numerator = BigInteger.Parse(integerPart + fractionalPart, NumberStyles.Integer, CultureInfo.InvariantCulture); + BigInteger denominator = BigInteger.Pow(10, fractionalPart.Length); + return new BigRational(numerator, denominator); + } + } +} diff --git a/Query.sln b/Query.sln index 175f4f1..8895696 100644 --- a/Query.sln +++ b/Query.sln @@ -17,6 +17,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AppMeme", "AppMeme\AppMeme. EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AppSys", "AppWindows\AppSys.csproj", "{E71AFA58-A144-4170-AF7B-05730C04CF59}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Calculator", "Calculator\Calculator.csproj", "{C4B1529F-943C-4C30-A40A-4A4C248FC92F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -47,6 +49,10 @@ Global {E71AFA58-A144-4170-AF7B-05730C04CF59}.Debug|Any CPU.Build.0 = Debug|Any CPU {E71AFA58-A144-4170-AF7B-05730C04CF59}.Release|Any CPU.ActiveCfg = Release|Any CPU {E71AFA58-A144-4170-AF7B-05730C04CF59}.Release|Any CPU.Build.0 = Release|Any CPU + {C4B1529F-943C-4C30-A40A-4A4C248FC92F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4B1529F-943C-4C30-A40A-4A4C248FC92F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4B1529F-943C-4C30-A40A-4A4C248FC92F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4B1529F-943C-4C30-A40A-4A4C248FC92F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE