This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

C# generation

Morphir.CodeGeneration.CSharp landed in PR #478, tracked by issue #474. The core accepts typed VersionedModel values in memory, preserves their source family/version, and returns immutable sources and diagnostics. The entry point is CSharpGenerator.Generate(model, options, cancellationToken). GenerationOptions.Default selects numeric C# 14, independently of the SDK. Numeric C# 15 uses the same public representation and runtime contract.

Both typed paths now lower transparent aliases, generic closed records, tuples, function and unit payloads, and closed custom cases. Generated payload properties are get-only. Both typed paths now emit literals, variables and references, application, returned functions, lambdas, nonrecursive value/function lets, conditionals, top-level self recursion, constructors, tuples, lists and closed record operations. Ordered patterns include wildcard, alias, literal, tuple, constructor, empty-list, head/tail and unit patterns. The SDK binding table covers the frozen 67-operation inventory. Generated calls execute the 207 required frozen observations in both typed families. The eight representation exclusions remain exclusions. The main specification and archived 13/13 tasks record the delivered bounded core. Archive follow-up landing and coordinator cleanup remain pending. Decoding, configuration, source publication and compiler execution belong to callers. CLI registration and a production Roslyn adapter are separate initiatives.

Host compatibility

EnvironmentEvidence
Windows arm64, .NET 10.0.14, SDK 10.0.403 Roslyn driverFresh package consumer restores, AddSource compiles, constant executes, caller cancellation returns no sources
Windows arm64, .NET 10.0.14, SDK 10.0.403 fresh in-memory consumerBoth typed families and numeric profiles generate nonempty type/function/pattern/record/SDK text; deterministic sources, cancellation and refusal checks pass
Self-contained win-arm64, net10.0, TrimMode=linkPublished consumer executes with identical observations/source hashes to its untrimmed build; no IL warnings in this smoke
Legacy .NET Framework / netstandard IDE compiler hostsUntested; Models and core require net10.0
Other IDE/runtime/OS combinationsUntested
Native AOTUntested

The demonstrated production package closure is Models, FSharp.Core 11.0.100 and FSharp.SystemTextJson 1.4.36. The test runner additionally brings Expecto and Mono.Cecil. Roslyn references come from the selected SDK only in the test host. Tooling, Ion, Pipeline and the CLI are absent from the core package closure. Language syntax compatibility does not establish IDE-host compatibility.

Numeric language profiles and compiler release channels are separate. The same shared type/function/pattern/record/SDK corpus has compiled and executed under C# 14.0 with stable SDK 10.0.403, compiler 5.9.0-1.26523.112, and C# 15.0 with SDK 11.0.100-rc.2.26504.105, compiler 5.12.0-1.26504.105. Both consumers target net10.0 and run on 10.0.14. The RC compiler is isolated under an owned test directory. Root global.json stays on the stable SDK. Both independently constructed classic/v4 models produce identical source bytes. Original classic car-rental versions 1/2/3, a labeled synthesized v4 car input, direct daily-rate inputs and a compatible v4 ecosystem type slice also compile and execute under both pinned compilers.

tests/Morphir.CodeGeneration.CSharp.Tests/verify-host.ps1 packs the real core and Models into a local proof feed and restores a fresh consumer with a private cache. The first attempt exposed the F# SDK’s implicit FSharp.Core 10.1.403 reference downgrading the required 11.0.100 assembly, causing runtime load failure. The corrected F# test host disables that implicit reference and uses the actual transitive closure. No model library was retargeted. Both attempts are retained in the owned evidence inventory.

verify-trimming.ps1 packs Models and the core into a fresh local feed, restores a separate private cache, and runs a typed in-memory consumer before and after self-contained trimming. That consumer’s package closure contains only Models, the core, FSharp.Core and FSharp.SystemTextJson. It loads no Roslyn, Expecto, Mono.Cecil or SDK runtime. The generated text still declares the SDK runtime requirement. This smoke establishes the listed consumer behavior on the tested host; it does not establish arbitrary IDE integration or Native AOT support.

Calling the core

Supply an already decoded Morphir.IR.Model.VersionedModel. Classic Library versions 1, 2, 3, 3.0.0 and 3.1.0, and v4 Library versions 4 and 4.0.0, retain their exact source version and typed family. Empty libraries succeed with an empty source list. Specs, Application and unsupported versions produce located failures with no sources. No serialization bridge or implicit migration runs.

open Morphir.CodeGeneration.CSharp

let result = CSharpGenerator.Generate(model, { LanguageVersion = 14 }, cancellationToken)
match result.Status with
| GenerationStatus.Success -> consumeSources result.Sources
| GenerationStatus.Failure -> reportDiagnostics result.Diagnostics
| GenerationStatus.Cancelled -> reportCancellation ()

The caller supplies model, the token and the three handling functions. Sources contain portable relative .g.cs paths and deterministic LF text. Encode text as UTF-8 without a BOM when publishing it. Decoding, package resolution, compilation, configuration and file publication remain caller responsibilities. Numeric 15 uses the same API and case representation. See the diagnostic contract.

Business fixture evidence

The shared parameterized test matrix executes the original classic car-rental 1/2/3 files unchanged and preserves their raw source hashes and version spelling. Independent observations require daily rate 5000, Available/Reserved/Rented/ Available lifecycle states, invoice 15000, seven typed rejections and persistence of both the original inventory and prior billing obligation.

The equivalent v4 car library is labeled SYNTHESIZED TEST INPUT. Its typed builder exists only in tests and supplies no expected result. It is neither an upstream v4 fixture nor production migration support. The existing direct v4 daily-rate fixture executes independently and returns 5000.

Whole original ecosystem examples retain their source limitations. The regulatory example references missing types; books-and-records has open-deal and close-deal draft IncompleteBody declarations. Both originals fail with located diagnostics and no partial sources. The separately labeled COMPATIBLE TYPE SLICE retains every original books-and-records type, visibility and payload, excluding exactly those two draft values. Its nonempty generated records/cases execute payload, equality/hash, nested dictionary persistence and get-only property checks. This is selected-input evidence, not full-original conformance.

Type mappings and equality

Morphir typeC# representation
Basics Int, Float, Boolint, double, bool
String, Char, Decimalstring, char, exact admitted decimal
Unit, OrderMorphir.SDK.CSharp.Unit, Order
List, Maybe, ResultSDK immutable wrappers, Result<E,V> with error first
Dict, SetSDK immutable wrappers with Morphir comparers
Tuple with at least two itemsC# value tuple
FunctionNested System.Func<Input,Output>
Closed recordSealed class with constructor and get-only payloads
Custom typeClosed abstract base with nested sealed case classes

Transparent aliases expand before emission. Structurally equivalent records use one deterministic representation, including aliases with substituted generic arguments. Unused alias parameters do not change the underlying representation. Anonymous record payloads receive deterministic named record classes. Private constructors remain inaccessible from a separate assembly. A public payload exposing a private nominal type fails with private-type-exposure.

Every generated record and case implements IMorphirValue. Equality checks the exact generated type or case and calls Basics.Equal for every payload. Hashing uses MorphirValue.Hash for the same payloads. Typed, boxed and operator equality route through that protocol. Consumers verify nested collections, NaN, signed zero and decimal scale against the SDK contract. No CLR IEquatable fallback or reflection establishes Morphir equality. The protocol does not establish the physical immutability of arbitrary caller-supplied generic payloads.

Names, literals and functions

Package paths become escaped namespaces. A module named Example.Functions uses the static class Example.FunctionsModule; records and cases are nested under that class. Source paths use safe prefixes, such as p_Example/m_FunctionsModule.g.cs, including on Windows. Portable comparisons ignore case. A collision diagnostic identifies both owners. Generated local identities are fresh within each function and do not depend on source spelling. Names that collide with a required structural protocol, generic parameter or inherited CLR member fail with a located name-collision. Malformed typed names fail with invalid-name. An empty library succeeds without allocating a package namespace or file path.

Top-level definitions emit typed static methods. Function values and partial applications use nested Func values that call those methods. Local bindings evaluate before their name enters scope, and local functions capture their original outer bindings. Delegate invocation uses .Invoke, and arguments evaluate once in source order. The shared compiled consumer observes those properties through a caller-supplied callback, generic identity, a captured let, shadowing, a three-argument partial application and a returned lambda.

Literals use culture-independent numeric text and explicit UTF-16 escapes. Supplementary strings are admitted. Char requires one non-surrogate BMP scalar. Int literals must fit checked Int32. A v4 decimal lexeme must hold exactly in Decimal96; rounded or out-of-range values fail with representation-loss. Signed-zero Float bits are preserved, and classic nonfinite Float literals map to the corresponding double constants. Invalid UTF-16 strings fail explicitly.

Type checking runs before a successful source set is returned. Unknown variables and references, inconsistent applications, infinite types and unsupported numeric constraints produce located diagnostics. Local recursive groups remain outside the admitted slice. The business fixture scope and evidence are described above.

Cancellation checks cover typed traversal, recursive alias/record expansion, name allocation, SDK comparer construction and text emission. The final result boundary checks the token again before returning success. Cancellation returns Cancelled, the original typed family/source version, and no sources. The active-generation tests interrupt alias expansion and million-character strings in both families. Their measured stop times describe that bounded test run, not a general latency guarantee. Invalid later modules also return no partial source set.

Matches evaluate their subject once and select the first matching branch. Destructuring and pattern lambdas bind fresh lexical identities after their pattern succeeds. An unmatched non-exhaustive match or destructuring raises a pattern failure. This is the source operation’s failure, with all branches checked during generation. Unsupported source operations fail generation.

Record expressions evaluate fields in source order. Updates evaluate the target once, then update values in source order, and construct a new immutable record. Constructor argument order follows the selected structural record declaration. The classic model’s record map has key order; v4 retains its authored field order. Field functions require a known closed record type. Missing fields, incompatible updates, duplicate binders and inconsistent branch types produce located failures.

SDK bindings

Generated code calls the public Morphir.SDK.CSharp API. The generator package does not load that runtime to generate text. Its declared output requirement is net10.0 plus Morphir.SDK.CSharp, independently of the compiler host’s packages.

Source moduleAdmitted runtime calls
BasicsChecked Int32/IEEE Float arithmetic, conversion/rounding, comparisons, min/max/clamp, Order cases and structural equality
Listsingleton, cons, map, append, head, tail and length
MaybeJust/Nothing constructors and patterns, map and andThen
ResultOk/Err constructors and patterns, map and andThen; error type first, value type second
Dictempty, singleton, fromList, get, insert, update, remove, toList and union
Setempty, singleton, fromList, insert, remove, toList and union
DecimalExact add, sub, mul, div, compare and toString

Map and andThen use the actual instance combinators. Nothing and Err bypass callbacks, and callback exceptions propagate. SDK constructors also work as function values. Dictionary/set factories select MorphirComparers.Int, Float, Char or String, composing list and two/three-tuple comparers as needed. No CLR default comparer or reflection fallback is generated.

Ordering requires concrete admitted comparable shapes, with finite Float values. Bool, Decimal, Unit, Order, records, variants and function shapes have no SDK ordering. A factory whose key comparer cannot be resolved fails with unsupported-comparable. Known function payloads fail equality admission with unsupported-equality. Unmapped SDK references fail with unmapped-sdk; dependencies without an implementation fail with unresolved-reference or unresolved-type.

Type/literal incompatibilities are generation diagnostics. Value-dependent SDK failures, such as checked overflow or nonfinite Float-to-Int conversion, remain the SDK’s execution errors. Decimal operations preserve the exact Decimal96 subset and do not round unsupported fractions. Ordinary Decimal equality ignores authored scale; Decimal arithmetic produces canonical values. The generated-call corpus checks frozen values/errors, input persistence and equality/hash invariants.

1 - C# generation API and diagnostics

CSharpGenerator.Generate(VersionedModel, GenerationOptions, CancellationToken) is synchronous and has no file, process, configuration or compiler dependency. GenerationOptions.LanguageVersion accepts numeric 14 or 15; the default is 14. Compiler release channel is chosen by the caller independently.

The existing GenerationOptions record and three-argument entry point are unchanged. To choose an emitted namespace, call NamespaceOverride.TryCreate(string). It returns Result<NamespaceOverride, GenerationDiagnostic>; a successful immutable value exposes Value. Pass it to CSharpGenerator.Generate(model, options, namespaceOverride, cancellationToken). Both overloads use the same core implementation. An override applies to the input Library package and all its qualified type, value and constructor references. Package-derived source paths and the original typed IR remain unchanged. Separate or concurrent requests never share naming state.

The namespace accepts dotted literal BMP C# identifiers in Unicode Normalization Form C, including @-escaped reserved words. It admits the identifier character categories defined by the C# lexical specification. Formatting characters retain their literal spelling and have the compiler’s identifier-equivalence behavior. Source \u/\U escape syntax and surrogate characters are outside this literal-name boundary. Values are validated rather than normalized or interpreted as source text. Null, empty, malformed and injection-like input returns invalid-namespace at $options/namespace. An explicit null/default namespace value from a C# caller to the four-argument overload returns that same located refusal and no sources. An already cancelled caller still receives Cancelled first.

GenerationResult returns Status, Family, exact SourceVersion, selected LanguageVersion, Sources, Diagnostics and Runtime. Each source has RelativePath and Text. The runtime requirement is net10.0 plus the public Morphir.SDK.CSharp package. The core package itself does not reference that runtime. Consumers must resolve a runtime implementing the frozen SDK contract.

Success returns the complete source set and no diagnostics. Failure returns the first located refusal and no sources. Cancelled returns no sources or diagnostics, retaining input family/version and the selected profile. A caller that needs exception-based cancellation can translate this status at its edge. Cancellation checks cover traversal and emission, with a final token check before success. Invalid later modules also discard all earlier generated text.

Diagnostics carry stable Rule, structural Location and human-readable Message fields. Locations identify the version/options/distribution or the package/module/type/value/pattern/expression being checked. Callers should branch on Rule, not parse messages. The CLI successor can map these categories without loading a compiler or changing core semantics.

Diagnostic identities use explicit structural spellings, for example Name["car","rental"], ModulePath[Name["inventory"]] and FQName(PackagePath[Name["example"]],ModulePath[Name["data"]],Name["person"]). Raw words and path segments remain distinguishable. Strings are quoted, quotes and backslashes are escaped, null strings are written null, and control or surrogate characters use \u followed by four hexadecimal digits. Type mismatch messages describe Parameter(Name[...]), Named(FQName(...),[...]), Product([...]), Fields[Name[...]:...,Name[...]:...], Arrow(...,...) or Unit. These diagnostic-only spellings avoid structural reflection in NativeAOT. Module validation locations now use $modules/ModulePath[...]. This intentionally changes diagnostic text and that location spelling while retaining rule IDs, refusal and cancellation behavior, generated source bytes and paths. Callers should continue branching on Rule rather than parsing human messages.

CategoryRules
Input/profile boundaryunsupported-language, unsupported-version, unsupported-distribution, invalid-namespace
Target names and portable pathsinvalid-name, name-collision, path-collision, path-length
Type identity and closureunresolved-type, unmapped-sdk-type, unbound-type, type-arity, tuple-arity, recursive-alias, ambiguous-type, anonymous-record, private-type-exposure
Type/representation constraintstype-mismatch, open-record, unsupported-constraints, unsupported-numeric, unsupported-comparable, unsupported-equality, representation-loss, document-literal
Value and constructor resolutionunbound-variable, unresolved-reference, unresolved-constructor, ambiguous-constructor, constructor-arity, unmapped-sdk
Unsupported source semanticsunsupported-body, unsupported-expression, local-recursion
Patterns and recordsduplicate-pattern-binding, empty-match, unknown-field

Unknown dependencies and SDK operations fail explicitly. No object-erased value, runtime stub or success-shaped partial artifact substitutes for a refusal. Admitted value-dependent SDK failures, such as checked overflow, remain execution errors. An admitted non-exhaustive source pattern raises pattern failure when no branch matches; every branch is still checked during generation.

Generated records/cases have get-only payloads and explicitly implement the SDK structural equality/hash protocol. That protocol alone does not establish the physical immutability of arbitrary externally supplied CLR generic payloads. The tested Morphir inputs use the admitted typed representations and immutable SDK collections. No arbitrary CLR IEquatable or reflection fallback is emitted.

See usage, host matrix and fixture scope and the generate command. The production AdditionalFiles/config/incremental adapter remains deferred.