Skip to content

Update C# grade to .NET 10 - #146

Open
sebgod wants to merge 25 commits into
Mercury-Language:masterfrom
sebgod:dotnet10-csharp
Open

Update C# grade to .NET 10#146
sebgod wants to merge 25 commits into
Mercury-Language:masterfrom
sebgod:dotnet10-csharp

Conversation

@sebgod

@sebgod sebgod commented May 2, 2026

Copy link
Copy Markdown
Contributor

This pull request modernizes and refactors the Mercury C# backend to target .NET 10+ and C# 14, removing legacy support for Mono and the .NET Framework. The build and runtime flow is now fully based on the .NET SDK and MSBuild, simplifying deployment and enabling new features like Native AOT publishing and trimming. Additionally, the C# backend now emits richer type information to support reflection-free runtime type inspection, which is crucial for compatibility with .NET's trimming and AOT toolchains.

Key changes include:

C# Backend Modernization and .NET 10+ Migration

  • The C# backend now exclusively targets .NET 10+ and C# 14, dropping all Mono and .NET Framework support. The build system uses the dotnet CLI and Roslyn, and all documentation, configuration, and build logic have been updated to reflect this. [1] [2] [3]
  • The build process for csharp grade now generates a .csproj and invokes dotnet build, producing all necessary assemblies and launchers without wrapper scripts or environment variable hacks. [1] [2]

New Features: Trimming and Native AOT

  • Library targets are now marked as trimmable, enabling downstream consumers to publish trimmed binaries. [1] [2]
  • Added a new --csharp-aot option to build fully native, self-contained executables via .NET's Native AOT. The build system and generated .csproj files have been updated to support this, with clear documentation of AOT compatibility requirements. [1] [2]

Runtime Type Information and Reflection-Free Introspection

  • All C# classes representing Mercury discriminated-union types now implement a new MR_DuTerm interface. This allows runtime code to walk type fields and tags without using System.Reflection, ensuring compatibility with .NET trimming and AOT. [1] [2] [3] [4] [5] [6] [7] [8] [9] [10]
  • DU representation classes are now emitted as C# record classes, which auto-generate equality, hash, and string conversion methods.

Documentation and Limitations

  • Documentation has been rewritten to clarify the new requirements, build process, and limitations, including explicit notes about unsupported features and the requirements for AOT builds.

Other Improvements

  • The C# implementation of library/io.file.m has been modernized for .NET 10 compatibility, removing obsolete or now-unnecessary code.

These changes collectively bring the Mercury C# backend up to date with modern .NET development practices, simplify the build and deployment process, and lay the groundwork for robust support of trimming and Native AOT scenarios.

sebgod and others added 25 commits June 15, 2026 08:06
When a .NET 10 (or later) SDK is found, prefer its bundled Roslyn
csc.dll over a stand-alone csc.exe or Mono mcs.  The dotnet-bundled
compiler IS the Microsoft Roslyn compiler, so it is classified as
csharp_microsoft and accepts the same flags as csc.exe today.

The probe walks `dotnet --list-sdks', filters to majors >= 10, and
verifies that <sdk>/Roslyn/bincore/csc.dll exists.  When that succeeds,
CSC is set to a quoted `dotnet exec csc.dll' invocation and inserted
ahead of csc / mcs in the candidate list.  Users may force this path
with --with-csharp-compiler=dotnet, or fall back to a stand-alone
compiler with --with-csharp-compiler=csc.

This is a no-op on hosts without a recent dotnet SDK; it changes only
the auto-detection result.  The compiler-side handling is unchanged in
this commit because the dotnet-bundled csc.dll is still classified as
csharp_microsoft and accepts the existing flag set.

m4/mercury.m4:
    Add a DOTNET / DOTNET_SDK_DIR / DOTNET_CSC_DLL probe before the
    existing csc / mcs detection.  Walk `dotnet --list-sdks' looking
    for any SDK whose major version is at least 10, and verify the
    Roslyn csc.dll under it.

    Insert a `dotnet' candidate at the head of CSC_COMPILERS when the
    probe found a usable SDK; otherwise keep the existing csc / mcs
    list.  Accept --with-csharp-compiler=dotnet as an explicit selector
    that errors out if no SDK was found.

    In the candidate loop, treat the literal string `dotnet' specially:
    set CSC to `"$DOTNET" exec "$DOTNET_CSC_DLL"' (with both paths
    quoted so install locations containing spaces survive) and break.

    Recognise the `... exec ... csc.dll ...' shape in the final
    CSHARP_COMPILER_TYPE classification and report it as `microsoft'.

    AC_SUBST the new DOTNET, DOTNET_SDK_DIR and DOTNET_CSC_DLL
    variables so they are visible to subsequent build-system changes.
Replace the per-module csc invocation in create_exe_or_lib_for_csharp/9
with a generated `<MainModule>.csproj' that the .NET SDK builds in one
shot.  MSBuild produces the .dll, the apphost binary (`<exe>.exe' on
Windows, `<exe>' on Linux/macOS) and the runtimeconfig.json -- all of
which the legacy flow had to assemble by hand or via a Mono wrapper
script.  This lays the groundwork for downstream consumers to opt into
<PublishTrimmed> (and, eventually, <PublishAot>) without further build
system changes.

The csproj references mer_std and any user link libraries via parsed
`-r:Lib.dll' entries from the existing get_mercury_std_libs_for_c_cs
and get_link_opts_for_libraries_for_c_cs helpers; each name is resolved
to a HintPath by searching the -lib: directories already collected from
options.  This keeps the helpers' shape unchanged so the C grade is
unaffected.

The csproj sits next to FullOutputFileName, with <OutputPath>./</OutputPath>
so MSBuild lands the assembly, apphost and runtimeconfig.json exactly
where Mercury expects them -- including under `Mercury/csharp/' when
--use-subdir is enabled, since FullOutputFileName already carries the
right prefix.  MSBuild's bin/ and obj/ subdirs sit alongside, just like
they would for a hand-written project.

The Mono-era launcher script and MONO_PATH environment threading are
gone: the apphost is the launcher.  On Windows that is `<exe>.exe',
matching Mercury's csharp_executable extension convention.  On
Linux/macOS the apphost lacks the `.exe' suffix, so we rename it after
build to satisfy post_link_maybe_make_symlink_or_copy.

Library targets get <IsTrimmable>true</IsTrimmable> so consumers can
publish trimmed; mmc itself still does not call `dotnet publish'.

compiler/link_target_code.m:
    Rewrite create_exe_or_lib_for_csharp/9.  The new body parses the
    legacy `-r:' flag strings into bare assembly names, resolves each
    against link_library_directories, emits a .csproj string, writes
    it to disk, and runs `dotnet build <csproj> -c Release -v:quiet
    --nologo'.

    Add helpers strip_csharp_exec_ext/1, parse_csharp_ref_flags/2,
    extract_csharp_ref_name/2, csharp_strip_quotes/1,
    resolve_csharp_refs/4, find_csharp_ref_dll/4 and the supporting
    csharp_ref_entry/0 type.

    Add csproj_content/8 to assemble the SDK-style project XML, plus
    format_compile_item/2, format_reference_item/2, parse_define_constant/2
    and xml_escape/1.  The csproj sets <TargetFramework>net10.0,
    <LangVersion>14, disables nullable annotations, suppresses
    EnableDefaultCompileItems, sets <UseAppHost>true on executables,
    flags libraries as <IsTrimmable>true and pipes any /define: csharp
    flags through to <DefineConstants>.

    Add maybe_rename_apphost/5 that renames the bare apphost to
    `<base>.exe' on hosts where the SDK does not append the suffix.

    Drop construct_cli_shell_script_for_csharp/3 and the now-unused
    csharp_file_name/3 and convert_to_windows_path_format/1 helpers.
    MSBuild handles the path quoting that those used to perform, and
    the Mono launcher script is no longer emitted.
Three small adjustments to reduce the risk of determinism or syntax
errors that would only surface during a full bootstrap:

- The if-then-else expression that picked the bare assembly name is
  rewritten as a goal-form if-then-else binding a fresh variable.
- parse_define_constant/2 was a disjunction of four semidet
  remove_prefix/3 calls; rewrite as nested if-then-else so the pred
  is unambiguously semidet rather than relying on commit-to-first.
- Move the csharp_ref_entry/0 type declaration above its first use
  in resolve_csharp_refs/4 to avoid a forward reference.

Behaviour is unchanged.

compiler/link_target_code.m:
    Rewrite extract_csharp_ref_name/2, parse_define_constant/2 and
    reorder the csharp_ref_entry/0 declaration.
mmc inferred csproj_content/8 as semidet because the switch on
LinkedTargetType only covered the two csharp_* constructors but the
declared mode accepted the full linked_target_type/0.  Use the existing
csharp_linked_target_type inst (already used by the parent
create_exe_or_lib_for_csharp/9) so the switch is exhaustive.

compiler/link_target_code.m:
    Change the mode of csproj_content/8 from
    `linked_target_type::in' to `linked_target_type::in(csharp_linked_target_type)'.
Three changes to make the C# backend's stdlib build under the new
csproj-driven linker on .NET SDK 10 (Phase 1 end-to-end validation
plus the first slice of Phase 2 work).

The csproj header text contained `--' inside an XML comment, which
MSBuild rejects with MSB4025.  Rephrase the comment so it stays valid.

io.file.m used three Mono / .NET-Framework-era APIs that .NET 5+ has
removed or repurposed:

  * `SecurityPermission.Demand()' for execute-permission checks.
    Code Access Security is gone, all checks are no-ops, and the type
    now lives in a separate legacy package.  Replace the demand with
    a deliberate empty block so we do not have to add a NuGet
    dependency just to no-op.

  * `Directory.CreateDirectory(string, DirectorySecurity)' overload
    for Win32 temp-dir creation.  The overload was removed; the new
    extension method requires the System.IO.FileSystem.AccessControl
    package.  Inheriting the parent ACL gives the caller the same
    permissions in practice for a temp directory under %TEMP%, so
    drop the explicit ACL setup and just call the plain overload.

  * `#if __MonoCS__' guards around the Linux/macOS mkdir P/Invoke
    branch.  The DllImport on libc works under .NET 5+, so the
    guards were excluding live code.  Replace the platform discriminator
    with RuntimeInformation.IsOSPlatform and drop the __MonoCS__
    preprocessor blocks entirely.

End-to-end smoke test: with /home/sebgod/mercury10-install on PATH,
`mmc --grade csharp --make hello' on samples/hello.m generates
hello.csproj, runs `dotnet build', and produces hello.exe (an ARM64
ELF apphost), hello.dll and hello.runtimeconfig.json.  Running
./hello.exe prints `Hello, world'.

compiler/link_target_code.m:
    Replace the `<!-- Generated by Mercury (mmc --grade csharp). -->'
    comment with a form that does not contain `--', avoiding MSBuild
    error MSB4025 when the SDK loads our generated csproj.

library/io.file.m:
    Drop the SecurityPermission.Demand() call inside the C# clause
    of check_file_accessibility/4; leave the `if (checkExecute)' block
    in place as a no-op with a comment explaining why.

    Rewrite the C# clause of do_make_temp_directory/8 to use
    RuntimeInformation.IsOSPlatform instead of switching on
    Environment.OSVersion.Platform, drop the obsolete Win32
    Directory.CreateDirectory(path, DirectorySecurity) call (just
    create the directory and let it inherit the parent ACL), and run
    the libc mkdir P/Invoke unconditionally on Linux and macOS
    instead of only under Mono.

    Drop the `#if __MonoCS__' guard around the
    `[DllImport("libc", EntryPoint="mkdir")]' declaration; the import
    is harmless on Windows and required on Linux/macOS under .NET 10.
…kend.

Three previously-stubbed or non-generic stdlib bits now have real C#
implementations on .NET 5+:

  * math.fma/3 and have_fma/0 dispatch to System.Math.FusedMultiplyAdd.
    The C# math.fma was previously calling private_builtin.sorry; the
    fused multiply-add intrinsic has been in .NET Core 3.0+ for years.

  * io.environment.get_environment_var_assoc_list/4 stops iterating
    Environment.GetEnvironmentVariables() via non-generic
    DictionaryEntry boxing.  The IDictionary returned has string keys
    and string values, so iterate its Keys collection directly.

  * benchmarking.ML_report_standard_stats/1 picks up GC.GetTotalMemory
    and Process.WorkingSet64 alongside the existing CPU/wall-clock
    numbers; ML_report_full_memory_stats/1 stops printing an apology
    string and now reports the managed heap, per-generation GC
    collection counts, working set (with peak), private bytes and
    virtual bytes.

End-to-end smoke test on .NET 10 ARM64 / WSL Ubuntu 24.04:

    math.fma(2.0, 3.0, 4.0) = 10.000000
    have_fma: yes
    env vars: 24 entries
    [User time: +0.11s, 0.01s Real time: +0.01s, 0.01s
     Managed heap: 274512 bytes, Working set: 55603200 bytes]
    [Managed heap: 304,664 bytes
     GC collections: gen0=0, gen1=0, gen2=0
     Working set: 56,557,568 bytes (peak 56,557,568)
     Private bytes: 93,519,872, Virtual bytes: 18,397,773,824]

library/math.m:
    Add `pragma foreign_proc("C#", have_fma, ...)` and
    `pragma foreign_proc("C#", fma(...), ...)' clauses.  The bodies
    set SUCCESS_INDICATOR to true and call System.Math.FusedMultiplyAdd
    respectively.  Drop the now-stale `.NET core 3.0' comment because
    the language floor for the C# grade is .NET 10.

library/io.environment.m:
    Replace the C# foreach over System.Collections.DictionaryEntry
    with a foreach over env.Keys that retrieves each value with the
    string-typed indexer.  Add a comment explaining why we keep the
    non-generic IDictionary type at the variable declaration (the API
    has not been re-typed in the BCL).

library/benchmarking.m:
    Extend ML_report_standard_stats with managed heap and working
    set; rewrite ML_report_full_memory_stats to print real numbers
    instead of an apology, using GC.GetTotalMemory, GC.CollectionCount
    and System.Diagnostics.Process counters.
…# backend.

Three more previously-stubbed C# foreign procedures get real bodies:

  * builtin.tuple_arity/2 reads the type info's args vector length
    rather than calling private_builtin.sorry.

  * builtin.tuple_arg/3 indexes the same vector for the existential
    type info and casts the term to object[] for the value (the C#
    backend always emits tuples that way).

  * builtin.compare_representation_3_p_0/3 delegates to
    rtti_implementation.generic_compare_3_p_0 instead of throwing
    `Sorry, not implemented'.  This is the same behaviour as
    compare_3_p_0 today; the divergence between compare and
    compare_representation only matters for types with user-defined
    equality, which the C# backend does not yet distinguish from
    structural equality, so the approximation is faithful for every
    type that does not override unification.

Smoke test on .NET 10 ARM64:

    math.fma(2.0, 3.0, 4.0) = 10.000000
    tuple functor={} arity=3
    compare_representation "abc" "abd": '<'

The tuple_* call paths run through deconstruct.functor at run time,
which exercises tuple_arity for the user-emitted tuple type info.

library/builtin.m:
    Add C# foreign_proc clauses for tuple_arity/2 and tuple_arg/3 that
    use TypeInfo_for_T.args.Length and TypeInfo_for_T.args[Index].

    Replace the runtime.Errors.SORRY stub in
    compare_representation_3_p_0 with a delegation to
    rtti_implementation.generic_compare_3_p_0 and document why.
Update NEWS.md and Documentation/README.CSharp.md to reflect the
modernised csharp grade: the SDK requirement, the csproj-based linker,
the absence of any wrapper script or MONO_PATH, the trim-friendly
library output, and the implemented (or still missing) standard library
predicates.

NEWS.md:
    Under "Portability improvements", add bullets covering: the .NET 10
    / C# 14 floor and the removal of Mono and .NET Framework support;
    the dotnet SDK probe and `--with-csharp-compiler=dotnet';
    the csproj-driven `dotnet build' linker flow and apphost output;
    the <IsTrimmable>true</IsTrimmable> property on libraries; the
    io.file.m modernisation; and the new C# foreign_proc bodies for
    math.fma, the benchmarking memory stats, tuple_arity, tuple_arg
    and compare_representation_3_p_0.

Documentation/README.CSharp.md:
    Replace the old prerequisites paragraph with a .NET SDK 10+
    requirement.  Drop the Mono/.NET Framework run instructions, the
    GAC and gacutil section, and the wrapper-shell-script section.
    Document the new csproj/dotnet-build flow, the apphost output and
    the <Private>true</Private> reference copying.  Add a "Trimmed
    publish" section noting that downstream consumers can opt into
    `<PublishTrimmed>true</PublishTrimmed>'.  Remove math.fma and the
    memory-stats entries from the unimplemented-procedures list (those
    are now done) and add deconstruct.functor_number plus the
    semidet / cc_nondet modes of exception.catch_impl, which remain
    unimplemented.
The csproj/dotnet-build flow added on this branch was only exercised
under WSL, where (a) the dotnet executable lives at a path with no
spaces (`/usr/share/dotnet/dotnet'), (b) every install prefix is a
true POSIX absolute path, and (c) the system has stand-alone `csc' or
`mcs' on PATH.  None of those hold for a stock MSYS2/CLANGARM64 shell
on Windows, where the build dies in three different ways: at mmc
startup, in the `dotnet build' invocation itself, and at run time
when the user-visible apphost cannot find its companion files.
Address each in turn.

m4/mercury.m4:
    Drop the `dotnet' branch of CSC selection.  The probe used to set
    CSC=`"\$DOTNET" exec "\$DOTNET_CSC_DLL"', a multi-word string with
    embedded double quotes.  After @csc@ substitution that lands in
    scripts/Mercury.config as the value of MERCURY_CSHARP_COMPILER,
    which the option file parser then word-splits at the embedded
    quotes; the leftover fragments (`exec', `C:/Program', ...) are
    re-injected as positional module names and abort mmc startup with
    `cannot find source for module ...'.  Since the csproj/dotnet-
    build flow no longer invokes csc per module, --csharp-compiler is
    purely vestigial in csharp grade; revert CSC_COMPILERS to
    `csc mcs' and let users with only the SDK installed keep using
    `--with-csharp-compiler=' to point at csc.exe.

    Keep the dotnet probe itself: link_target_code.m relies on
    `dotnet' being on PATH at link time, and the SDK version is also
    useful for diagnostics.  Refresh the comment on the probe and on
    the `--list-sdks' walk so it accurately describes what the loop
    does (keep the last >= 10 entry, which by --list-sdks's
    documented ascending order is the newest installed SDK >= 10)
    rather than claiming a max-version compare it never performs.

compiler/link_target_code.m:
    Switch the `dotnet build' call from invoke_long_system_command/8
    to invoke_system_command/7.  The long-cmd helper writes args to
    a temp file and invokes `dotnet @tmpfile', but the dotnet CLI
    does not tokenize @file response files like csc/msbuild do -- it
    reads the entire file as a single argument and then hunts for a
    matching `dotnet-<that whole string>' tool, so we get a
    `does not exist' error.  The dotnet command line stays well
    under the Windows length limit, so the @file machinery is not
    needed here anyway.

    In create_exe_or_lib_for_csharp/9, anchor every entry of
    SourceList to an absolute path (using dir.current_directory/3
    plus the new make_csproj_source_path_absolute/2 helper) before
    handing it to csproj_content/8.  SourceList paths are relative
    to the compiler's cwd, but MSBuild resolves <Compile Include>
    entries relative to the csproj file location.  With --use-subdirs
    plus --use-grade-subdirs the csproj sits at
        Mercury/csharp/<arch>/Mercury/bin/<name>.csproj
    so a cwd-relative `Mercury/csharp/<arch>/Mercury/css/foo.cs'
    becomes the doubly-nested
        ...bin/Mercury/csharp/<arch>/Mercury/css/foo.cs
    and csc reports CS2001.  Absolute paths sidestep csproj-relative
    resolution entirely.  Fall back to the unmodified list when
    dir.current_directory/3 fails, which still works in the common
    no-subdirs case.

    Extend post_link_maybe_make_symlink_or_copy/8 with a new
    copy_csharp_apphost_companions/7 helper that copies `<name>.dll',
    `<name>.runtimeconfig.json' and `<name>.deps.json' alongside the
    user-visible apphost.  When --use-subdirs sends the build into a
    deep subdir, the existing logic copied just the apphost up to
    cwd; the apphost then aborted at run time with
        The application to execute does not exist: '<cwd>/<name>.dll'.
    Companions that the build did not emit (e.g. `.deps.json' for
    trivial programs) are skipped silently.
Switch the csharp linker between `dotnet build' (the default) and
`dotnet publish -p:PublishAot=true -r <rid>' based on a new opt-in
option.  When enabled on a csharp_executable, the build produces a
single self-contained native binary -- no managed `.dll',
`runtimeconfig.json' or `deps.json' companions -- by routing the
generated csproj through MSBuild's PublishAot toolchain.  When
enabled on a csharp_library, only an `<IsAotCompatible>true</...>'
csproj marker is added; the build flow itself is unchanged so users
opting into AOT in their own consumer programs can see the marker
and trust the library.

The option is opt-in because the AOT-cleanliness contract cannot be
verified from the linker stage.  The user is responsible for ensuring
no module reachable from main consumes dynamic RTTI (type_desc,
construct, deconstruct, term_to_xml, generic io.write or
compare_representation) and that every linked Mercury library was
itself built AOT-compatible.  Trim or AOT warnings emitted by
`dotnet publish' surface as a non-zero exit and abort the link step
exactly like a normal C# compilation error.

A separate follow-up will mark `mer_std' itself AOT-compatible so a
program built with `--csharp-aot' against the standard library does
not see IL2104 trim warnings.  Until then `mer_std' produces such
warnings, but they are non-fatal: the apphost still runs.

compiler/options.m:
    Add the `csharp_aot' boolean option in the `oc_target_csharp'
    block alongside `csharp_compiler' and friends, defaulting to
    `bool(no)'.  Document the AOT-cleanliness contract and the
    library-marker behaviour in the help text.

compiler/link_target_code.m:
    Add a new `csharp_aot_request' enum (aot_off / aot_publish(Rid)
    / aot_library_marker) computed once in create_exe_or_lib_for_csharp/9
    from the option, the linked-target type and a derived RID.

    Add compute_dotnet_rid/2 mapping `target_arch' (typically a GNU
    triple like `aarch64-w64-mingw32') to a .NET runtime identifier
    (`win-arm64', `linux-x64', `osx-arm64', ...).  Recognised
    architectures are aarch64/arm64, x86_64/amd64 and i686/i386;
    recognised host strings are darwin/apple, mingw/windows/msvc and
    linux.  When derivation fails, `--csharp-aot' falls back to
    `dotnet build' and writes a notice to the progress stream so the
    opt-in is not silently denied.

    Thread the request through csproj_content/8 (now /9): aot_publish
    adds <PublishAot>true</PublishAot>, <SelfContained>true</...>,
    <InvariantGlobalization>true</...>, <RuntimeIdentifier>{Rid}</...>
    and <PublishDir>./</...> to the property group.  aot_library_marker
    adds <IsAotCompatible>true</...>.  aot_off adds nothing extra.

    Branch the linker invocation: aot_publish runs `dotnet publish
    <csproj> -c Release -r <rid> -v:quiet --nologo'; aot_off and
    aot_library_marker keep the existing `dotnet build <csproj>
    -c Release -v:quiet --nologo'.  invoke_system_command/7 is used
    for both, since the @file path mishandled by the dotnet CLI is
    still avoided.

    Gate copy_csharp_apphost_companions/7 on `--csharp-aot=no':
    AOT publish produces a single self-contained native binary, so
    there are no `.dll', `.runtimeconfig.json' or `.deps.json'
    companion files to copy alongside the user-visible apphost.

NEWS.md:
    Add a bullet under "Portability improvements" announcing the new
    `--csharp-aot' option, the published apphost shape, the AOT-
    cleanliness contract and the library-marker behaviour.

Documentation/README.CSharp.md:
    Add a "Native AOT publishing" section after "Trimmed publish"
    covering the same material in user-facing prose: which csproj
    properties get added under AOT, how the RID is derived, the two
    user-side preconditions and the fallback behaviour when the RID
    cannot be derived.
…Term interface.

Every C# class generated from a Mercury discriminated-union type ctor now
implements a new mercury.runtime.MR_DuTerm interface with four methods:
MR_GetField(int) for indexed positional-field access, MR_GetFieldCount(),
MR_GetSecondaryTag() for the data_tag value (-1 if absent) and
MR_DeepCopy(System.Func<object,object>) for a per-class override that
clones the term and recurses on every positional field.  This lets
library/rtti_implementation.m and library/builtin.m walk a term's fields
through plain virtual dispatch instead of Type.GetField / GetFields /
InvokeMember, removing the IL2075 trim warnings that the .NET trim/AOT
toolchain emits on those reflection paths today.  Reflective access to
fields disappears from mer_std, so dynamic RTTI consumers in mer_std
become AOT-correct, not just AOT-compatible-by-luck.

builtin.deep_copy's fallback for non-DU reference types (closures,
typeinfos, typeclass-infos, runtime structures) becomes a shallow
alias rather than the previous reflection-based field walk.  Those
values are immutable from Mercury's point of view, so the previous
walk was redundant in practice; dropping it is what lets mer_std
finally compile clean under PublishAot.

The interface route was picked over the originally-planned per-type-ctor
__Rtti static helper plus delegate fields on TypeCtorInfo_Struct because
it avoids touching TypeCtorInfo_Struct's positional `init(...)' call
convention, lets the trimmer drop the entire MR_DuTerm machinery in
programs that never reach an interface cast, and gives the right
inheritance semantics for sectag-using subclasses for free.

C and Java grades are unaffected: ml_target_csharp is the only branch
that adds MR_DuTerm to Implements, the rewrites all live inside the
existing `pragma foreign_proc("C#", ...)' / `foreign_code("C#", ...)'
clauses, and Java still walks fields by name via java.lang.reflect on
its own separate trim/AOT story.

runtime/mercury_dotnet.cs.in:
	Declare the public MR_DuTerm interface in namespace mercury.runtime.
	Place it just before PseudoTypeInfo, with a doc comment pointing
	at csharp-aot-rtti-plan.md.  The interface declares MR_GetField,
	MR_GetFieldCount, MR_GetSecondaryTag and
	MR_DeepCopy(System.Func<object,object> deepCopyFn).  The doc
	comment for MR_DeepCopy spells out that the fn is the recursive
	deep-copy entry point so nested DU terms, tuples, arrays and
	primitives all flow through the same dispatch.

compiler/ml_code_util.m:
	Add ml_csharp_mr_du_term_interface, an mlds_interface_id that
	resolves to mercury.runtime.MR_DuTerm.  Modelled on the existing
	ml_java_mercury_type_interface helper for the Java backend's
	MercuryType.

compiler/ml_type_gen.m:
	In the C# arm of the three mlds_class_defn construction sites for
	DU types -- the base class in ml_gen_hld_du_type, the secondary
	tag class in ml_gen_hld_secondary_tag_class and the per-functor
	subclass in ml_gen_hld_du_ctor_member -- replace the empty
	Implements list with [ml_csharp_mr_du_term_interface].  The
	ml_target_c branches stay empty; ml_target_java keeps its
	existing MercuryType wiring.

compiler/mlds_to_cs_class.m:
	Recognise "MR_DuTerm" in interface_is_special_for_csharp so that
	interface_to_string_for_csharp emits the unqualified
	`mercury.runtime.MR_DuTerm' (no arity suffix), matching the
	existing convention for "MercuryType".

	After the constructors are emitted in output_class_defn_for_csharp,
	if Implements contains ml_csharp_mr_du_term_interface emit the
	four MR_DuTerm method bodies via a new
	output_mr_du_term_methods_for_csharp predicate.  The bodies are
	derived from the class's own MemberFields list:
	classify_mr_du_term_fields walks it once, peeling off
	fvn_du_ctor_field_hld entries (the F1, F2, ... positional fields)
	into a list of cs_du_field/2 (each pairing the C# field name with
	its C# type string) and noting whether fvn_data_tag is present.
	output_mr_get_field_cases then emits one switch case per positional
	field returning `this.<csharpName>'.

	Pick `virtual ' on classes that inherit nothing and `override ' on
	classes that inherit another class -- in csharp DU emission the
	only inheritance edge is a sectag-using subclass extending its
	secondary-tag (or the single-functor base) class, so an
	inherits_class(_) class always has a reachable data_tag whether
	or not its own MemberFields list one; treat HasDataTag as yes in
	that case so MR_GetSecondaryTag returns this.data_tag instead of
	-1.

	For MR_DeepCopy, when the class has no positional fields the body
	is `return this.MemberwiseClone();'; otherwise it casts the clone
	to the class's own type, assigns
	`n.<fld> = (<typestr>) f(this.<fld>)' for each positional field
	via a new output_mr_deep_copy_assignments helper, and returns n.
	The cast is emitted unconditionally; for `object'-typed fields C#
	treats `(object) ...' as a no-op.

	Add `:- import_module ml_backend.ml_code_util.' to access the
	ml_csharp_mr_du_term_interface helper, and `:- import_module int.'
	for the case-index counter.

library/rtti_implementation.m:
	Rewrite the four C# reflection sites:
	- get_remote_secondary_tag now reads the secondary tag via
	  `((MR_DuTerm) X).MR_GetSecondaryTag()'.
	- get_type_info_from_term and get_typeclass_info_from_term keep
	  their object[] fast path and route the else branch through
	  `((MR_DuTerm) Term).MR_GetField(Index)' instead of building a
	  field-name string and looking it up by reflection.
	- ML_get_subterm_non_array collapses to a one-line
	  `((MR_DuTerm) term).MR_GetField(num_extra_args + index)'.
	  The previous helpers ML_get_functor_desc_by_tags,
	  ML_get_field_name_by_index, ML_get_subterm_by_field_name and
	  the type_ctor_base subtype-redirection branch are dropped --
	  subtypes share the same C# class (and hence the same
	  MR_GetField switch) as their base type, so no redirection
	  is needed.

	The Java pragma foreign_code block at the bottom is left alone.

library/builtin.m:
	Rewrite deep_copy.  The null / value-type / string / array
	branches are unchanged.  Reference-typed objects that implement
	MR_DuTerm dispatch through `du.MR_DeepCopy(deep_copy)', which
	clones the term and recurses on every positional field through
	this same deep_copy fn.  Other reference types (closures,
	typeinfos, ...) are returned as a shallow alias instead of the
	previous reflective walk; they are immutable from Mercury's
	point of view, so the alias is sufficient and the lost walk
	was already a no-op in practice.

	Drop the deep_copy_fields helper entirely, since the per-class
	MR_DeepCopy method now does the field-by-field assignment.
Now that the dynamic-RTTI helpers in rtti_implementation.m and
builtin.deep_copy route every type-introspection call through the
new MR_DuTerm interface (no System.Reflection.Type.GetField, no
Type.GetMember, no Activator), the standard library's csproj can
advertise itself as AOT-compatible to consumers built with
`--csharp-aot'.  Without this marker, `dotnet publish' raises
IL2104 ("Assembly mer_std produced trim warnings") on every program
that opts into Native AOT, even though no actual reflective access
remains in mer_std.

The change is mechanical: pass `--csharp-aot' when compiling mer_std
so that csproj_content/8 emits an `<IsAotCompatible>true</...>'
property in the library marker form.  The other Mercury libraries
(mer_browser, mer_ssdb, mer_mdbcomp) keep their plain
`<IsTrimmable>true</...>' marker for now -- they have not been
audited for AOT-compatibility, so they would attract trim warnings
that should not be hidden behind the stronger marker.

A small follow-up cleanup in csproj_content/8 drops the redundant
`<IsTrimmable>true</...>' line for AOT-marked libraries, since the
.NET SDK already implies trim-compatibility from
`<IsAotCompatible>'.  The compiled assembly still surfaces both
metadata strings (the SDK injects `IsTrimmable' as an assembly
attribute under the AOT contract), but the on-disk csproj is no
longer self-redundant.

library/Mmakefile:
    Add a csharp-grade conditional that appends `--csharp-aot' to
    MCFLAGS, so the generated mer_std.csproj receives the
    `<IsAotCompatible>true</...>' marker.  The existing
    `--allow-stubs --no-warn-stubs' block above is left unchanged.

compiler/link_target_code.m:
    In csproj_content/8, when the linked target is a
    `csharp_library' and the AotRequest is `aot_library_marker',
    suppress the `<IsTrimmable>true</...>' line.  The
    `<IsAotCompatible>true</...>' line emitted from the AOT property
    block already implies the trim contract, so the IsTrimmable line
    is redundant in that mode.  All other AOT modes (aot_off,
    aot_publish/1) keep the IsTrimmable line as before.
The C# backend's binary I/O was broken because mercury_open created both
a BufferedStream and a StreamReader/StreamWriter wrapping the same
underlying FileStream.  The two independent buffers caused position
desync: reading a byte via BufferedStream.ReadByte() would advance the
FileStream past what StreamReader expected, making subsequent text reads
(used by read_binary/write_binary) see premature EOF.

Fix by splitting binary and text modes in mercury_open:
- Binary modes (rb/wb/ab) no longer create StreamReader/StreamWriter.
- mercury_stdin_binary/mercury_stdout_binary use null reader/writer.
- mercury_getc: when reader is null, decode UTF-8 directly from the
  stream using new mercury_read_utf8_codepoint helper.
- mercury_print_string/do_write_char: when writer is null, encode to
  UTF-8 bytes using new mercury_write_codepoint_to_stream helper
  (uses System.Text.Rune.EncodeToUtf8 from .NET 10).

Also implement seek_binary_2 and binary_stream_offset_2 for C#,
which were previously unimplemented (threw sorry).  These use
Stream.Seek and Stream.Position respectively, with putback-aware
offset adjustment.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The ""R"" format specifier in .NET produces uppercase ""E"" in scientific
notation (e.g. 1.23E-21), but Mercury and the C backend use lowercase
""e"".  Switch to ""r"" which produces lowercase directly, avoiding any
post-processing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Three functions in string.m used try/catch around Char.ConvertToUtf32
to handle unpaired surrogates: unsafe_index, unsafe_index_next_repl_2,
and unsafe_prev_index_repl_2.  Replace with explicit IsHighSurrogate/
IsLowSurrogate checks which are cheaper and clearer.

Also replace all Char.ConvertFromUtf32 calls with
new System.Text.Rune(cp).ToString(), using the .NET 10 Rune type for
consistent Unicode handling across the C# backend.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
With the binary stream fix, float formatting fix, and other
improvements, the following tests now pass in the csharp grade:

- hard_coded/write_binary (binary I/O fix)
- hard_coded/foreign_name_mutable
- hard_coded/intermod_foreign_type
- hard_coded/print_stream
- hard_coded/stdlib_init

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
C# delegates (MethodPtrN_r0<T1, ..., TN>) are invariant on their type
parameters.  The compiler generates continuations with specific type
instantiations (e.g. MethodPtr2_r0<Exception_result_1, object>) but
call sites cast stored continuations to MethodPtr2_r0<object, object>.
This throws InvalidCastException at runtime.

Fix in two places:

1. library/exception.m: Add invoke_cont helper that uses
   System.Runtime.CompilerServices.Unsafe.As<T>() to reinterpret the
   delegate reference without a type check.  This is sound because all
   Mercury type parameters are boxed and the calling convention is
   identical.  Unsafe.As is a JIT intrinsic (zero overhead, AOT-safe).

2. compiler/mlds_to_cs_stmt.m: Change method pointer call codegen from
   ((<DelegateType>) expr) to
   System.Runtime.CompilerServices.Unsafe.As<<DelegateType>>(expr).
   This fixes the pattern globally for all newly compiled code.

Fixes test_try_all and any other test exercising multi/nondet
continuations with specific output types.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The C# backend previously used null to represent empty arrays because the
element type was not known at make_empty_array time.  Array.Empty<object>()
provides a zero-length array that eliminates NullReferenceException in
Clone(), Length, and other method calls on empty arrays.

- ML_new_array, ML_array_resize, ML_shrink_array: return Array.Empty
  instead of null for size-0 arrays
- make_empty_array: replace null with Array.Empty<object>()
- max, size: remove null guards (Length works on empty arrays)
- ML_array_resize, ML_shrink_array: also check Length == 0 alongside
  null for backward compat with any pre-existing null arrays

The compiler still generates (int[]) casts on from_list results, which
will need a follow-up fix to handle the object[] returned by
Array.Empty<object>().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The C# backend generates hard casts like (int[]) when the target type is a
typed primitive array. Since Array.Empty<object>() returns object[], this
cast throws InvalidCastException at runtime for empty arrays.

Fix: when casting to a typed primitive array (int[], double[], bool[], etc.),
generate `(expr as T[] ?? System.Array.Empty<T>())` instead of `(T[]) expr`.
This safely handles both the correctly-typed case (non-empty arrays are already
int[]) and the empty object[] case (as T[] returns null, ?? provides a
properly-typed empty array).

Unblocks hard_coded/array_sort and other tests using typed arrays from empty
lists in the csharp grade.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
array_sort: fixed by the typed array cast fix (4621ae8) which generates
\\�s T[] ?? System.Array.Empty<T>()\\ instead of \\(T[]) expr\\.

bug383, seek_test: were already working in the csharp grade after the
earlier fixes on this branch.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
library/time.m:
  Replace IsAmbiguousTime-based DST disambiguation with the same
  algorithm used by the Java backend: convert first, then check
  whether the result is in DST and adjust by the savings amount
  if it does not match the caller's DST indicator.  This fixes
  dst_test for the csharp grade.

runtime/mercury_dotnet.cs.in:
  Change Errors.SORRY and Errors.fatal_error to print to stderr
  and exit, matching C's MR_fatal_error() behaviour.  Use explicit
  \n to avoid Windows \r\n line endings.  This fixes
  functor_ho_inst_excp_1 and functor_ho_inst_excp_2 for csharp.

tests/EXPECT_FAIL_TESTS.csharp:
  Remove dst_test, functor_ho_inst_excp_1, functor_ho_inst_excp_2,
  and stream_putback_binary (now passing in csharp grade).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
DU representation classes (those implementing MR_DuTerm) are now emitted
as 'record class' instead of plain 'class'. This gives us auto-generated
structural Equals, GetHashCode, and ToString from C# 9+.

The change is in mlds_to_cs_class.m: when the class implements the
MR_DuTerm interface, we use 'record class' as the class kind. All three
DU hierarchy levels (base class, secondary tag class, functor class)
implement MR_DuTerm, so the entire DU hierarchy is consistently records.

record class supports inheritance, generics, and [System.Serializable],
so the existing codegen patterns are preserved unchanged.

Tested: 27+ csharp-grade tests pass with no regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The csharp grade now uses `mmc --make' exclusively, which drives
`dotnet build' on a generated csproj.  The old `--csharp-compiler'
/ `--csharp-compiler-type' options and the `csc'/`mcs' configure
probe were the last remnants of an mmake-driven csharp build path
that no longer exists, so they only ever caused confusion when
users tried to point them at a compiler that mmc would never invoke.

This commit removes:

- the user-facing `--csharp-compiler', `--csharp-compiler-type',
  `--output-csharp-compiler' and `--output-csharp-compiler-type'
  options;
- the `csharp_compiler_type' ADT and the `g_csharp_compiler_type'
  slot on the globals record;
- the `--with-csharp-compiler' configure switch and the `csc'/`mcs'
  probe in `m4/mercury.m4' (the existing `MERCURY_CHECK_DOTNET'
  macro at lines 277-341 already gates the csharp grade on a usable
  .NET 10 SDK);
- the `@CSC@' / `@CSHARP_COMPILER_TYPE@' / `@CSHARP_DELAYSIGN_FLAG@'
  AC_SUBST hooks and every place that injects them into generated
  config (`scripts/Mercury.config.in', the four `*_FLAGS.in' files);
- the dead `CSC = @csc@' Mmake variable.

The LIBGRADES test that decides whether to add `csharp' to the
auto-detected default-grade list now keys off `$DOTNET' /
`$DOTNET_SDK_DIR' instead of the dropped `$CSC' -- explicit
`--enable-libgrades=...,csharp' already worked, but the auto-
detection branch was broken until this fix.

NEWS.md:
    Note the change under "Changes to the Mercury compiler".

Documentation/README.CSharp.md:
    Drop the brief mention of an external csc/mcs compiler in the
    prerequisites section.

compiler/options.m:
    Remove the four removed option entries from the `option' ADT,
    the `optdef'/`optdb' tables, the `long_option' table, and the
    optimisation/special-handling switch.

compiler/op_mode.m:
    Drop the `opmq_output_csharp_compiler[_type]' query op modes.

compiler/handle_options.m:
    Drop the corresponding cases from `convert_op_mode' and the
    callers of the removed query modes; thread the smaller arity of
    `globals_init' and `check_option_values' through the call sites.

compiler/check_options.m:
    Remove the `csharp_compiler_type' argument from
    `check_option_values' and drop the
    `convert_csharp_compiler_type' block in
    `check_system_env_options'.

compiler/globals.m:
    Remove `csharp_compiler_type', the `g_csharp_compiler_type'
    field, the matching getter, and the
    `convert_csharp_compiler_type' helper.

compiler/mercury_compile_main.m:
    Drop the two `opmq_output_csharp_compiler{_type}' arms from
    the op-mode dispatcher.

m4/mercury.m4:
    Remove `MERCURY_CHECK_CSHARP_COMPILER' and its callers.

configure.ac:
    Remove `--with-csharp-compiler' and the call to
    `MERCURY_CHECK_CSHARP_COMPILER'.  Switch the LIBGRADES csharp
    test from `$CSC' to `$DOTNET' / `$DOTNET_SDK_DIR'.

scripts/Mercury.config.in:
    Drop `MERCURY_CSHARP_COMPILER', `MERCURY_CSHARP_COMPILER_TYPE',
    and the `--csharp-compiler[ -type]' flags they fed into
    `DEFAULT_MCFLAGS'.

scripts/Mmake.vars.in:
    Drop the dead `CSC = @csc@' line.  Keep the `CSCFLAGS' /
    `ALL_CSCFLAGS' machinery, since `options_file.m' still forwards
    Mmake-set `CSCFLAGS' to `mmc' as `--csharp-flag' values that
    end up in the generated csproj.

browser/MDB_FLAGS.in:
mdbcomp/MDBCOMP_FLAGS.in:
library/LIB_FLAGS.in:
ssdb/SSDB_FLAGS.in:
    Drop the `@CSHARP_DELAYSIGN_FLAG@' substitution; the dotnet
    SDK csproj path does not honour delay-signing through this
    hook.

tests/hard_coded/pretty_printer_stress_test.data:
tests/warnings/help_text.err_exp:
    Update golden output to drop references to the removed options.
The csharp libgrade now ships every Mercury library as a multi-targeted
assembly (net10.0 and netstandard2.0).  Downstream consumers stuck on
netstandard2.0 hosts (.NET Framework 4.6.1+, Unity, Mono, etc.) can now
reference Mercury libraries directly; net10.0 consumers continue to get
the AOT-/trim-friendly variant, and Mercury's own internal references
keep using the netstandard2.0 DLL at the existing path.

Executables stay single-target net10.0: an apphost ships against one
runtime, and the multi-target machinery only buys complexity there.

compiler/link_target_code.m:
- For `csharp_library' targets, emit `<TargetFrameworks>' instead of
  the single `<TargetFramework>' line, with per-TFM `<OutputPath>' and
  `<IntermediateOutputPath>' so the two builds do not clobber each
  other in `obj/Release/'.  The netstandard2.0 DLL still lands at the
  csproj root (preserving the prior single-target convention so
  existing `<HintPath>' references and Mercury's library lookup keep
  working) and the net10.0 DLL goes to `./net10.0/'.
- Scope `<IsTrimmable>' / `<IsAotCompatible>' to non-netstandard2.0
  TFMs via Condition= attributes, since both properties are net5.0+
  only and warn under netstandard2.0.
- Add a netstandard2.0-only `<PackageReference>' for
  System.Runtime.CompilerServices.Unsafe v4.5.3.  The compiler emits
  Unsafe.As<T> in delegate-pointer call sites (mlds_to_cs_stmt.m), and
  the type is intrinsic on net5.0+ but lives in a NuGet package on
  netstandard2.0.  Pinned to 4.5.3, the version paired with
  netstandard2.0 itself; we only use Unsafe.As<T>(object) which has
  been in the package since 4.4.0.
- Executables retain the single-target shape: `<TargetFramework>',
  unconditional `<OutputPath>./</...>' /
  `<AppendTargetFrameworkToOutputPath>false</...>' in the main
  PropertyGroup, no extra TFM-conditional groups.

runtime/mercury_dotnet.cs.in:
- Add an internal IsExternalInit polyfill guarded by
  `#if !NET5_0_OR_GREATER'.  C# 9's `record class' (which
  mlds_to_cs_class.m emits for Mercury DU types) references this type
  via the synthesised members of records and via init-only accessors
  in any future use; the framework provides it inbox on net5.0+ but
  not on netstandard2.0.

library/string.m:
- Replace five uses of `new System.Text.Rune(cp).ToString()' with
  `System.Char.ConvertFromUtf32(cp)'.  System.Text.Rune is net5.0+
  only; ConvertFromUtf32 has identical semantics for valid scalar
  values and exists in netstandard2.0.

library/io.primitives_write.m:
- Replace `System.Text.Rune' uses in `mercury_write_codepoint' and
  `mercury_write_codepoint_to_stream' with the portable
  `System.Char.ConvertFromUtf32' / `System.Text.Encoding.UTF8.GetBytes'
  pair.  This drops the previous Span/stackalloc/EncodeToUtf8 fast
  path, which was net5.0+ only, in favour of a string materialisation
  that works on both TFMs.  The single-codepoint path is not
  performance-critical.

library/math.m:
- TFM-gate `fma' and `have_fma' for the C# backend.  On net5.0+ both
  call System.Math.FusedMultiplyAdd.  On netstandard2.0 there is no
  single-rounded FMA primitive, so `have_fma' returns false and `fma'
  throws System.NotSupportedException -- mirroring the C backend's
  fatal_error path when MR_HAVE_FMA is undefined.  The previous
  unconditional `SUCCESS_INDICATOR = true' for `have_fma' was a lie
  on netstandard2.0 even before this change, since FusedMultiplyAdd
  was unresolvable.

Verified: `make install' on Windows arm64 (clang-arm64 / .NET SDK
10.0.107) builds every standard library DLL clean for both TFMs, the
generated mer_std.csproj contains the expected
<TargetFrameworks>net10.0;netstandard2.0</TargetFrameworks>, hello.m
links and runs (Hello, world / EXIT=0), and a multi-target user
library produces both ./greeter.dll (netstandard2.0) and
./net10.0/greeter.dll without touching any other Mercury machinery.

Follow-ups (not in this commit): only the netstandard2.0 DLL is copied
into the install prefix; the net10.0 DLLs are produced in the build
tree but not propagated.  A consumer on a net10.0 host still works,
because netstandard2.0 assemblies load on net5.0+, but they miss the
AOT/trim-friendly variant.  Installing both flavours requires
Mmakefile changes to library/, mdbcomp/, browser/, ssdb/.
`glue_dir_names_base_name' delegated to
`dir.relative_path_name_from_components', which joins with the
platform's native separator -- backslash on Windows.  The strings
this predicate returns flow into the `.d', `.dep' and `.dv' files
that `mmake' reads, so on Windows we emitted rules and prerequisites
like `Mercury\os\foo.o' that did not match the `Mercury/os/%.o'
pattern rules in `Mmake.rules'.  `make' silently treated those
backslashed names as targets it could not build, and the bootstrap
either skipped recompilation entirely or failed at the link step
with `No rule to make target Mercury\os\mercury_compile_init.o'.

Forward slash is universally accepted: Win32 file APIs normalise
`/' to `\', the Mercury runtime's own I/O code already handles
both, and `dir.is_directory_separator' treats `/' as the alternate
separator on Windows.  Hard-coding `"/"' in this one helper makes
the makefile output consume cleanly under MSYS2 mmake without
changing behaviour on Linux or macOS, where `/' was already the
native separator.

compiler/file_names.m:
	Replace the call to `dir.relative_path_name_from_components'
	in `glue_dir_names_base_name' with `string.join_list("/",
	Components)'.  Add a comment explaining why the platform
	separator is the wrong choice here.
@sebgod
sebgod force-pushed the dotnet10-csharp branch from eb2ddfa to 77b67c8 Compare June 14, 2026 22:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant