Contributing to ExpresZo .NET¶
Audience: Project contributors.
Thank you for your interest in contributing to ExpresZo! This guide covers the development setup, project layout, and PR workflow.
Development Setup¶
Prerequisites¶
- .NET 10 SDK (10.0.200 or newer).
- For the AOT canary locally, you'll also need the platform C/C++ toolchain required by Native AOT - see Microsoft's prerequisites. The canary is optional locally; CI runs it on Linux on every PR.
Getting Started¶
git clone https://github.com/pro-fa/expreszo-dotnet.git
cd expreszo-dotnet
dotnet restore Expreszo.slnx
dotnet build Expreszo.slnx --configuration Release
dotnet run --project test/Expreszo.Tests/Expreszo.Tests.csproj --configuration Release --no-build
Project Structure¶
expreszo-dotnet/
├── src/
│ └── Expreszo/
│ ├── Expreszo.csproj # library project (AOT-enabled analyzers)
│ ├── Parser.cs # public entry point
│ ├── Expression.cs # parsed expression class
│ ├── Value.cs # Value discriminated union
│ ├── Scope.cs # layered evaluation scope
│ ├── EvalContext.cs
│ ├── OperatorTable.cs
│ ├── Ast/ # AST node records + visitors
│ │ ├── Node.cs
│ │ ├── INodeVisitor.cs
│ │ └── Visitors/ # Simplify / Substitute / ToString / Symbols
│ ├── Parsing/ # Tokenizer + TokenCursor + PrattParser
│ ├── Evaluation/ # Evaluator (single ValueTask<Value> walker)
│ ├── Builtins/ # Operator / function presets
│ ├── Validation/ # ExpressionValidator
│ ├── Errors/ # Exception hierarchy + handlers
│ └── Json/ # JsonBridge
├── test/
│ └── Expreszo.Tests/ # TUnit test project (+ NSubstitute)
├── samples/
│ ├── ExpreszoDemo/ # end-user demo
│ └── AotCheck/ # AOT canary CI publishes with PublishAot=true
├── docs/ # MkDocs site (this file lives here)
├── Directory.Build.props
├── Expreszo.slnx # SDK-style solution file
└── .github/workflows/ci.yml
Development Workflow¶
Build¶
Warnings are treated as errors on the library project (TreatWarningsAsErrors=true). The AOT / trim analysers are on - code that needs dynamic code generation or untrimmable reflection will fail the build with IL2026 / IL3050.
Tests¶
ExpresZo uses TUnit on the Microsoft Testing Platform. .NET 10 removed VSTest backward compat in dotnet test, so tests are run by executing the test project directly:
# Run all tests
dotnet run --project test/Expreszo.Tests/Expreszo.Tests.csproj --configuration Release --no-build
# Filter by tree node (class / method)
dotnet run --project test/Expreszo.Tests/Expreszo.Tests.csproj --configuration Release --no-build -- --treenode-filter "/*/*/TokenizerTests/*"
# Coverage (Cobertura XML under test/Expreszo.Tests/bin/.../TestResults/)
dotnet run --project test/Expreszo.Tests/Expreszo.Tests.csproj --configuration Release --no-build -- --coverage --coverage-output-format cobertura
Target is ≥80% line coverage.
AOT canary (optional, locally)¶
dotnet publish samples/AotCheck/AotCheck.csproj \
--configuration Release \
--runtime <rid> --self-contained \
-p:PublishAot=true
# e.g. win-x64 on Windows, linux-x64 on Linux, osx-arm64 on Apple silicon
./artifacts/Expreszo.AotCheck
CI runs this on every PR. Any warning in the library call graph fails the publish.
Pack¶
Produces Expreszo.X.Y.Z.nupkg + .snupkg.
Benchmarks¶
Micro-benchmarks use BenchmarkDotNet and live in bench/Expreszo.Benchmarks/. They cover parsing, repeated evaluation, simplification, and end-to-end parse + evaluate cycles.
# List available benchmarks
dotnet run --project bench/Expreszo.Benchmarks -c Release -- --list flat
# Run everything (takes minutes)
dotnet run --project bench/Expreszo.Benchmarks -c Release
# Run a subset by filter
dotnet run --project bench/Expreszo.Benchmarks -c Release -- --filter '*Evaluation*'
# Quick smoke run (inaccurate numbers, but verifies the harness compiles and runs)
dotnet run --project bench/Expreszo.Benchmarks -c Release -- --job dry --filter '*Trivial*'
The benchmarks project is intentionally not AOT-compatible - BenchmarkDotNet itself uses reflection and runtime code generation to emit per-benchmark wrappers. Keeping the harness out of the AOT pipeline isolates the library's AOT guarantee from measurement infrastructure.
Docs¶
Documentation lives under docs/ and is built with MkDocs + Material for MkDocs.
pip install mkdocs mkdocs-material pymdown-extensions
mkdocs serve # live preview at http://127.0.0.1:8000/
mkdocs build # static site in site/
Code Style¶
General¶
file-scoped namespaces(enforced by.editorconfig).- 4-space indent for C#, 2-space for XML/JSON/YAML/Markdown.
- Nullable reference types enabled throughout.
- Prefer immutability:
sealed recordfor data,readonlyfields,ImmutableArray<T>,FrozenDictionary<T>when appropriate.
Naming¶
- Files:
PascalCase.cs. - Namespaces:
PascalCase, one per folder underExpreszo.*. - Classes / records / interfaces / enums:
PascalCase. - Methods / properties:
PascalCase. - Parameters / locals:
camelCase. - Private fields:
_camelCase. - Constants:
PascalCase(following BCL conventions, notUPPER_SNAKE_CASE).
Example¶
namespace Expreszo;
/// <summary>Brief one-liner.</summary>
public sealed class Thing
{
private readonly int _count;
public Thing(int count)
{
_count = count;
}
public bool TryDoSomething(string input, out int result)
{
// ...
}
}
XML docs¶
Every public member has /// <summary> at minimum. Longer descriptions go in <remarks>. Use <paramref> / <see cref="..."> / <list type="bullet"> as appropriate.
Testing Guidelines¶
Layout¶
- Test files mirror the source structure:
src/Expreszo/Parsing/*.cs→test/Expreszo.Tests/Parsing/*.cs. - Use TUnit's
[Test]for single cases and[Arguments(...)]for parameterised cases. - Prefer expression-driven tests (
Parser.Evaluate(...)) over hand-building AST nodes - they stay readable and double as documentation.
Example¶
namespace Expreszo.Tests.Parsing;
public class TokenizerTests
{
private static Token[] Tokenize(string expression)
{
var tokenizer = new Tokenizer(ParserConfig.Default, expression);
var tokens = new List<Token>();
while (true)
{
var t = tokenizer.Next();
tokens.Add(t);
if (t.Kind == TokenKind.Eof) break;
}
return [.. tokens];
}
[Test]
[Arguments("0", 0d)]
[Arguments("1", 1d)]
[Arguments("42", 42d)]
public async Task Tokenizes_decimal_numbers(string input, double expected)
{
var tokens = Tokenize(input);
await Assert.That(tokens[0].Kind).IsEqualTo(TokenKind.Number);
await Assert.That(tokens[0].Number).IsEqualTo(expected);
}
}
Test names can use snake_case_for_readability (CA1707 is suppressed in the test project).
Pull Request Process¶
- Branch
- Make changes
- Add or update tests for new behaviour.
- Update docs for user-visible changes (syntax, public API, security).
-
Keep the existing code style.
-
Run checks locally
dotnet build Expreszo.slnx --configuration Release
dotnet run --project test/Expreszo.Tests/Expreszo.Tests.csproj --configuration Release --no-build
- Commit
Use Conventional Commits:
feat:- new featuresfix:- bug fixesdocs:- documentationtest:- tests onlyrefactor:- no behaviour changeperf:- performance-
chore:- tooling / build / ci -
Push and open a PR
CI will run build, tests, pack, and the AOT canary on Linux. All four must pass.
Adding a Function¶
- Pick the appropriate preset in
src/Expreszo/Builtins/(or add a new one). - Register the function via
builder.AddFunction("name", impl). UseOperatorTableBuilder.Sync(args => ...)for synchronous functions; for async functions, return aValueTask<Value>directly and passisAsync: true. - If the function name is also reachable as a unary operator (e.g. trig functions), register it via
AddUnaryas well. - Add tests in
test/Expreszo.Tests/BuiltinsTests.csor the appropriate sub-file. - Document the function in
docs/syntax.md- the user-facing language reference.
Adding an Operator¶
- Extend the tokenizer (
Parsing/Tokenizer.cs) if the operator introduces a new symbolic form. Named operators (letters) go throughParserConfig.DefaultUnaryOps/DefaultBinaryOps. - Extend the Pratt parser (
Parsing/PrattParser.cs) to handle the operator at the right precedence level. - Register the implementation in the appropriate preset (
Builtins/). - Add AST-, parser-, and evaluator-level tests.
- Document the operator in
docs/syntax.md.
Releases¶
Package versions are derived from git tags via MinVer. Pushing an annotated tag of the form vMAJOR.MINOR.PATCH (e.g. v1.0.0, v1.2.0-rc.1) on main triggers the .github/workflows/release.yml pipeline, which:
- Restores, builds, runs the test suite, and runs the Native-AOT canary publish.
- Packs the library (
dotnet pack) - MinVer reads the tag and producesExpreszo.<version>.nupkg+ matching.snupkg. - Pushes the package to NuGet.org using the
NUGET_API_KEYsecret. - Creates a GitHub Release with auto-generated release notes and attaches the
.nupkg/.snupkgfiles.
Cutting a release¶
# Make sure main is green and pulled
git checkout main
git pull --ff-only
# Create an annotated tag
git tag -a v1.0.0 -m "v1.0.0"
# Push the tag - this triggers the release workflow
git push origin v1.0.0
Untagged builds get a pre-release version (e.g. 0.0.0-alpha.0.42) so local dotnet pack runs are easy to distinguish from published releases.
Pre-releases¶
Tags that contain a hyphen (e.g. v1.0.0-beta.1) are published as pre-releases - the workflow passes prerelease: true to the GitHub Release and NuGet surfaces them only when "Include prereleases" is enabled.
One-time setup for maintainers¶
- Generate an API key at nuget.org/account/apikeys scoped to the
Expreszopackage (or the whole account for a first publish). - In GitHub repo settings → Environments, create an environment called
nugetand addNUGET_API_KEYas an environment secret. Optionally add required reviewers so pushes require approval.
Questions?¶
- File an issue on GitHub.
- For design discussion before opening a PR, an issue with the
discussionlabel works well.
Thanks for contributing!