From 393f21e89d51716a10d902402e1a84b6e70bfb2c Mon Sep 17 00:00:00 2001 From: "Olof Lagerkvist (LTRData)" Date: Tue, 8 Sep 2026 20:47:37 +0200 Subject: [PATCH 1/3] Migrate netexpr to the modern expression pipeline --- .github/workflows/netexpr-review.yml | 78 +++++++++++++++++++ ...ctory.build.props => Directory.Build.props | 0 MathTools.slnx | 1 + docs/netexpr-migration.md | 57 ++++++++++++++ netexpr.Review/Program.cs | 57 ++++++++++++++ netexpr.Review/netexpr.Review.csproj | 12 +++ netexpr/Program.vb | 57 +++++++++----- netexpr/netexpr.vbproj | 2 +- 8 files changed, 245 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/netexpr-review.yml rename Directory.build.props => Directory.Build.props (100%) create mode 100644 docs/netexpr-migration.md create mode 100644 netexpr.Review/Program.cs create mode 100644 netexpr.Review/netexpr.Review.csproj diff --git a/.github/workflows/netexpr-review.yml b/.github/workflows/netexpr-review.yml new file mode 100644 index 0000000..78ba9a2 --- /dev/null +++ b/.github/workflows/netexpr-review.yml @@ -0,0 +1,78 @@ +name: netexpr package review + +on: + push: + branches: [master, experimental/math-expression-migration] + paths: [netexpr/**, netexpr.Review/**, Directory.Build.props, .github/workflows/netexpr-review.yml] + pull_request: + paths: [netexpr/**, netexpr.Review/**, Directory.Build.props, .github/workflows/netexpr-review.yml] + workflow_dispatch: + +permissions: + contents: read + +jobs: + review: + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2025] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + LocalNuGetPath: ${{ runner.temp }}/netexpr-feed + NUGET_PACKAGES: ${{ runner.temp }}/netexpr-packages + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@v4 + with: + path: consumer + - uses: actions/checkout@v4 + with: + repository: LTRData/Library + ref: 162d02a80c456e2c782eaad86a6f132db7e2f704 + path: library + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + - name: Configure the shared package feed + run: | + New-Item -ItemType Directory -Force $env:LocalNuGetPath | Out-Null + @' + + + + + + + + + + + + + '@ | Set-Content NuGet.Config + - name: Build Library packages + run: | + foreach ($name in @('LTRData.Extensions', 'LTRData.MathExpression')) { + $project = "library/$name/$name.csproj" + dotnet restore $project --configfile NuGet.Config + if ($LASTEXITCODE) { throw "Restore failed: $project" } + dotnet build $project -c Release --no-restore + if ($LASTEXITCODE) { throw "Package build failed: $project" } + } + - name: Build every netexpr target + run: | + dotnet restore consumer/netexpr/netexpr.vbproj --configfile NuGet.Config + if ($LASTEXITCODE) { throw 'Consumer restore failed' } + dotnet build consumer/netexpr/netexpr.vbproj -c Release --no-restore + if ($LASTEXITCODE) { throw 'Consumer build failed' } + - name: Run command-line scenarios + run: | + dotnet restore consumer/netexpr.Review/netexpr.Review.csproj --configfile NuGet.Config + if ($LASTEXITCODE) { throw 'Review restore failed' } + dotnet run --project consumer/netexpr.Review/netexpr.Review.csproj -c Release --no-restore + if ($LASTEXITCODE) { throw 'Review failed' } diff --git a/Directory.build.props b/Directory.Build.props similarity index 100% rename from Directory.build.props rename to Directory.Build.props diff --git a/MathTools.slnx b/MathTools.slnx index 758f2b1..091d33c 100644 --- a/MathTools.slnx +++ b/MathTools.slnx @@ -7,4 +7,5 @@ + diff --git a/docs/netexpr-migration.md b/docs/netexpr-migration.md new file mode 100644 index 0000000..db1b506 --- /dev/null +++ b/docs/netexpr-migration.md @@ -0,0 +1,57 @@ +# netexpr modern expression API review + +The migration uses `MathParser`, `MathBinder`, the standard symbol catalog and +interpreted evaluation from `LTRData.MathExpression` 1.1.0-preview.1. All existing +targets remain: net35, net40, net8.0, net9.0 and net10.0. + +Formula arguments are joined as before. Variables are prompted in first-use order, +with case-insensitive `name=value` assignments and invariant numeric syntax. +Repeated names share a slot; reassignment updates it while other values are pending. +Blank input is ignored; EOF with missing values returns a useful error. Parse and +binding errors include diagnostic codes and source spans. + +The modern language has right-associative power (`2^3^2` = 512), with power before +unary sign (`-2^2` = -4). `^` and `**` both mean power. Shift/bitwise syntax and the +old parser's accidental precedence rules are not retained. The authoritative +[language specification](https://github.com/LTRData/Library/blob/experimental/math-expression-redesign/docs/math-expression-redesign/language-specification.md) +describes functions, constants and implicit multiplication. + +Output and the historical numeric exit code are retained. The printed result is +invariant; the exit code uses VB `CInt` rounding (ties to even), or -1 for errors, +non-finite values or conversion overflow. A nonzero result is therefore not a +conventional process-success code, and a shell may truncate it. + +## Build through the local feed + +Set `LocalNuGetPath` to a shared package directory. In the Library experimental +checkout, build these projects in Release, which produces their NuGet packages: + +```sh +dotnet build LTRData.Extensions/LTRData.Extensions.csproj -c Release +dotnet build LTRData.MathExpression/LTRData.MathExpression.csproj -c Release +``` + +Configure this repository's local NuGet.Config to include the shared directory, +then build and run the review executable: + +```sh +dotnet restore netexpr/netexpr.vbproj --force-evaluate +dotnet build netexpr/netexpr.vbproj -c Release --no-restore +dotnet run --project netexpr.Review/netexpr.Review.csproj -c Release +``` + +The review executable calls the real command-line entry point with redirected +streams under Swedish culture, checking 13 scenarios. It returns zero on success, +independently of the calculator's numeric exit-code convention. The only +ProjectReference is between projects in this repository; Library is consumed +through packages. `Directory.Build.props` now has the standard filename casing so +Unix builds apply the same common settings. + +The focused `netexpr package review` workflow repeats the chain on Linux and +Windows. It uses a pinned Library commit, a shared output feed, package source +mapping and a fresh cache. Nothing is published to a NuGet server. For equivalent +local configuration, see Library's +[local package workflow](https://github.com/LTRData/Library/blob/experimental/math-expression-redesign/docs/local-package-workflow.md). + +Try your own saved formulas and shell scripts, particularly any that use old +operator syntax or assume a conventional zero-on-success exit code. diff --git a/netexpr.Review/Program.cs b/netexpr.Review/Program.cs new file mode 100644 index 0000000..8e7a8d5 --- /dev/null +++ b/netexpr.Review/Program.cs @@ -0,0 +1,57 @@ +using System.Globalization; + +var originalOut = Console.Out; +var originalError = Console.Error; +var originalInput = Console.In; +var originalCulture = CultureInfo.CurrentCulture; +var count = 0; +try +{ + // Formula syntax, parameter numbers and results remain invariant on a Swedish host. + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("sv-SE"); + Check(["2^3^2"], "", 512, "512"); + Check(["-2^2"], "", -4, "-4"); + Check(["1", "+", "2", "*", "3"], "", 7, "7"); + Check(["atan2(0, 1)"], "", 0, "0"); + Check([".5 x"], "X=4\n", 2, "2"); + Check(["b+a+B"], "\nz=1\nb=wrong\nb=2\nB=3\na=4\n", 10, "10", + "Parameter z not part of expression.", "Invalid parameter value b=wrong", prompt: "b, a"); + Check(["a+b"], "a=3\n", -1, null, "Input ended before all parameter values were supplied."); + Check(["sin(1,2)"], "", -1, null, "MATH300"); + Check(["1 << 10"], "", -1, null, "MATH"); + Check(["(1+2"], "", -1, null, "MATH"); + Check(["2.5"], "", 2, "2.5"); + Check(["sqrt(-1)"], "", -1, "NaN"); + Check([], "", -1, null, "Syntax:"); + originalOut.WriteLine($"PASS: {count} CLI scenarios, invariant culture, variables, diagnostics and numeric exit codes."); + return 0; +} +finally +{ + Console.SetOut(originalOut); + Console.SetError(originalError); + Console.SetIn(originalInput); + CultureInfo.CurrentCulture = originalCulture; +} + +void Check(string[] arguments, string input, int exitCode, string? lastLine, + string? error = null, string? secondError = null, string? prompt = null) +{ + using var output = new StringWriter(CultureInfo.InvariantCulture); + using var errors = new StringWriter(CultureInfo.InvariantCulture); + using var reader = new StringReader(input); + Console.SetOut(output); + Console.SetError(errors); + Console.SetIn(reader); + var actual = netexpr.Program.Main(arguments); + if (actual != exitCode) throw new Exception($"Exit code for {string.Join(' ', arguments)}: expected {exitCode}, got {actual}. {errors}"); + if (lastLine is not null && output.ToString().TrimEnd().Split('\n')[^1].TrimEnd('\r') != lastLine) + throw new Exception($"Unexpected output: {output}"); + foreach (var expected in new[] { error, secondError }) + if (expected is not null && !errors.ToString().Contains(expected, StringComparison.Ordinal)) + throw new Exception($"Missing diagnostic {expected}: {errors}"); + if (error is null && errors.GetStringBuilder().Length != 0) throw new Exception(errors.ToString()); + if (prompt is not null && !output.ToString().Contains("Enter values for parameters: " + prompt + Environment.NewLine)) + throw new Exception($"Unexpected variable order or duplicate variable: {output}"); + count++; +} diff --git a/netexpr.Review/netexpr.Review.csproj b/netexpr.Review/netexpr.Review.csproj new file mode 100644 index 0000000..8e97c9c --- /dev/null +++ b/netexpr.Review/netexpr.Review.csproj @@ -0,0 +1,12 @@ + + + Exe + net10.0 + enable + enable + false + + + + + diff --git a/netexpr/Program.vb b/netexpr/Program.vb index 011164b..372999b 100644 --- a/netexpr/Program.vb +++ b/netexpr/Program.vb @@ -1,5 +1,4 @@ Imports System.Globalization -Imports System.Linq.Expressions Imports LTRData.Extensions.Formatting Imports LTRData.MathExpression @@ -29,21 +28,39 @@ Public Module Program Return -1 End If - Dim exprParser As New MathExpressionParser(CultureInfo.InvariantCulture) - Dim parameters As ParameterExpression() = Nothing + Dim parse = MathParser.Default.Parse(String.Join(" ", args)) + If Not parse.Success Then + Return ReportDiagnostics(parse.Diagnostics) + End If + + Dim binding = MathBinder.Bind(parse.Root, MathSymbolCatalog.Standard) + If Not binding.Success Then + Return ReportDiagnostics(binding.Diagnostics) + End If - Dim expr = exprParser.ParseExpression(String.Join(" ", args), parameters) + Dim expression = binding.Expression + Dim parameters = expression.Variables + Dim values(parameters.Count - 1) As Double + Dim supplied(parameters.Count - 1) As Boolean + Dim remaining = parameters.Count - If parameters.Length > 0 Then + If remaining > 0 Then Console.WriteLine($"Enter values for parameters: {parameters.Select(Function(p) p.Name).Join(", ")}") End If - Dim paramValues As New Dictionary(Of String, KeyValuePair(Of ParameterExpression, Double)) + While remaining > 0 + Dim input = Console.ReadLine() + If input Is Nothing Then + Console.Error.WriteLine("Input ended before all parameter values were supplied.") + Return -1 + End If - While Not parameters.All(Function(p) paramValues.ContainsKey(p.Name)) + Dim line = input.Split(separator, StringSplitOptions.RemoveEmptyEntries) + If line.Length = 0 Then + Continue While + End If - Dim line = Console.ReadLine().Split(separator, StringSplitOptions.RemoveEmptyEntries) - Dim param = parameters.FirstOrDefault(Function(p) p.Name = line(0)) + Dim param = parameters.FirstOrDefault(Function(p) String.Equals(p.Name, line(0), StringComparison.OrdinalIgnoreCase)) If param Is Nothing Then Console.Error.WriteLine($"Parameter {line(0)} not part of expression.") Continue While @@ -62,18 +79,15 @@ Public Module Program Continue While End If - paramValues.Add(param.Name, New KeyValuePair(Of ParameterExpression, Double)(param, value)) + values(param.Slot) = value + If Not supplied(param.Slot) Then + supplied(param.Slot) = True + remaining -= 1 + End If End While -#If NETFRAMEWORK AndAlso Not NET40_OR_GREATER Then - Dim lambda = Expression.Lambda(expr, paramValues.Values.Select(Function(v) v.Key).ToArray()).Compile() -#Else - Dim lambda = Expression.Lambda(expr, paramValues.Values.Select(Function(v) v.Key)).Compile() -#End If - - Dim values = paramValues.Values.Select(Function(v) CObj(v.Value)).ToArray() - Dim returnValue = CDbl(lambda.DynamicInvoke(values)) + Dim returnValue = expression.Evaluate(values) Console.WriteLine(returnValue.ToString(NumberFormatInfo.InvariantInfo)) @@ -92,4 +106,11 @@ Public Module Program End Function + Private Function ReportDiagnostics(diagnostics As IEnumerable(Of MathDiagnostic)) As Integer + For Each diagnostic In diagnostics + Console.Error.WriteLine(diagnostic.ToString()) + Next + Return -1 + End Function + End Module diff --git a/netexpr/netexpr.vbproj b/netexpr/netexpr.vbproj index df99c88..c3356a6 100644 --- a/netexpr/netexpr.vbproj +++ b/netexpr/netexpr.vbproj @@ -8,7 +8,7 @@ - + From b194a2c0a97df91f68d650d5173eb52de00daff3 Mon Sep 17 00:00:00 2001 From: "Olof Lagerkvist (LTRData)" Date: Tue, 8 Sep 2026 20:50:45 +0200 Subject: [PATCH 2/3] Use available workflow context for package directories --- .github/workflows/netexpr-review.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/netexpr-review.yml b/.github/workflows/netexpr-review.yml index 78ba9a2..10af9a3 100644 --- a/.github/workflows/netexpr-review.yml +++ b/.github/workflows/netexpr-review.yml @@ -20,8 +20,8 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 20 env: - LocalNuGetPath: ${{ runner.temp }}/netexpr-feed - NUGET_PACKAGES: ${{ runner.temp }}/netexpr-packages + LocalNuGetPath: ${{ github.workspace }}/.package-review/netexpr-feed + NUGET_PACKAGES: ${{ github.workspace }}/.package-review/netexpr-packages DOTNET_CLI_TELEMETRY_OPTOUT: 1 defaults: run: From 6c8a6ffbf821504e1d017ba1967140898eef752d Mon Sep 17 00:00:00 2001 From: "Olof Lagerkvist (LTRData)" Date: Tue, 8 Sep 2026 22:45:10 +0200 Subject: [PATCH 3/3] Consume stable math expressions in netexpr review --- .github/workflows/netexpr-review.yml | 2 +- docs/netexpr-migration.md | 7 ++++--- netexpr/netexpr.vbproj | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/netexpr-review.yml b/.github/workflows/netexpr-review.yml index 10af9a3..7e7d793 100644 --- a/.github/workflows/netexpr-review.yml +++ b/.github/workflows/netexpr-review.yml @@ -33,7 +33,7 @@ jobs: - uses: actions/checkout@v4 with: repository: LTRData/Library - ref: 162d02a80c456e2c782eaad86a6f132db7e2f704 + ref: 0e853ea1abdc3d4484695db1e026ebf88535ae42 path: library - uses: actions/setup-dotnet@v5 with: diff --git a/docs/netexpr-migration.md b/docs/netexpr-migration.md index db1b506..d1a348f 100644 --- a/docs/netexpr-migration.md +++ b/docs/netexpr-migration.md @@ -1,7 +1,7 @@ # netexpr modern expression API review The migration uses `MathParser`, `MathBinder`, the standard symbol catalog and -interpreted evaluation from `LTRData.MathExpression` 1.1.0-preview.1. All existing +interpreted evaluation from `LTRData.MathExpression` 1.1.0. All existing targets remain: net35, net40, net8.0, net9.0 and net10.0. Formula arguments are joined as before. Variables are prompted in first-use order, @@ -53,5 +53,6 @@ mapping and a fresh cache. Nothing is published to a NuGet server. For equivalen local configuration, see Library's [local package workflow](https://github.com/LTRData/Library/blob/experimental/math-expression-redesign/docs/local-package-workflow.md). -Try your own saved formulas and shell scripts, particularly any that use old -operator syntax or assume a conventional zero-on-success exit code. +The application owner has built and tested this migration successfully. Saved +formulas and shell scripts remain useful review cases, particularly any that use +old operator syntax or assume a conventional zero-on-success exit code. diff --git a/netexpr/netexpr.vbproj b/netexpr/netexpr.vbproj index c3356a6..7b71160 100644 --- a/netexpr/netexpr.vbproj +++ b/netexpr/netexpr.vbproj @@ -8,7 +8,7 @@ - +