Skip to content

.NET integration ​

ghūl is hosted on and targets .NET 10 and can consume most types in .NET assemblies built with C#.

projects ​

The ghūl compiler is driven by MSBuild and uses the .NET SDK targets for most of the build process. Provided you reference the ghūl runtime library package, things should work as you'd expect for any other .NET SDK project. You can add package references, build assemblies and pack NuGet packages etc. all using the normal dotnet command line tools.

embedded resources ​

An EmbeddedResource item builds a file into the assembly, as it does in a C# project:

xml
<PropertyGroup>
    <RootNamespace>Greeter</RootNamespace>
</PropertyGroup>

<ItemGroup>
    <EmbeddedResource Include="data/greeting.txt" />
</ItemGroup>

The resource is named the way C# names it: the project's RootNamespace, then the file's folder path with each separator turned into a dot, then the file name. A LogicalName on the item replaces that name. The program reads the resource back through reflection:

ghul
let assembly = System.Reflection.Assembly.get_executing_assembly()

let use stream = assembly.get_manifest_resource_stream("Greeter.data.greeting.txt")!
let use reader = IO.StreamReader(stream)

write_line(reader.read_to_end())

name mangling ​

When consuming C# code the ghūl compiler transforms symbol names to better match ghūl conventions:

  • Class, struct and trait (=interface) names are left unchanged
  • .NET's generic arity suffix is removed, so KeyValuePair<K, V> is Collections.KeyValuePair[K, V]
  • Enum names and enum member names are transformed to MACRO_CASE
  • Method, property and field names are transformed to snake_case
  • A name that collides with a ghūl keyword is left unchanged too. The backtick is how you write such a name where the keyword reading would otherwise win, as in `class; it is not part of the name, and after a . none is needed

namespace and type name re-mapping ​

Some commonly used namespace and type names are re-mapped in line with ghūl conventions

namespaces ​

  • System.Collections.Generic is mapped to Collections
  • System.IO is mapped to IO

framework and collection types ​

Original TypeMapped Type
System.IDisposableGhul.Disposable
System.ConsoleIO.Std
System.Collections.IEnumerableCollections.NonGenericIterable
System.Collections.Generic.IReadOnlyCollectionCollections.Bag
System.Collections.Generic.ICollectionCollections.MutableBag
System.Collections.IEnumeratorCollections.MoveNext
System.Collections.Generic.IEnumerableCollections.Iterable
System.Collections.Generic.IEnumeratorCollections.Iterator
System.Collections.Generic.IReadOnlyListCollections.List
System.Collections.Generic.IListCollections.MutableList
System.Collections.Generic.ListCollections.LIST
System.Collections.Generic.IReadOnlyDictionaryCollections.Map
System.Collections.Generic.IDictionaryCollections.MutableMap
System.Collections.Generic.DictionaryCollections.MAP
System.Collections.Generic.IReadOnlySetCollections.Set
System.Collections.Generic.ISetCollections.MutableSet
System.Collections.Generic.HashSetCollections.SET
System.Collections.Generic.IComparerCollections.Comparer
System.Collections.Generic.IEqualityComparerCollections.EqualityComparer
System.Collections.Generic.ComparerCollections.ComparerBase
System.Collections.Generic.EqualityComparerCollections.EqualityComparerBase
System.Collections.Generic.StackCollections.STACK
System.Threading.Tasks.TaskTasks.TASK
System.Threading.Tasks.Task<T>Tasks.TASK[T]

primitive types ​

The primitive types are declared in Ghul.Intrinsics, which every file sees without a use:

Original TypeMapped Type
System.Voidvoid
System.Booleanbool
System.Charchar
System.Byteubyte
System.SBytebyte
System.UInt16ushort
System.Int16short
System.UInt32uint
System.Int32int
System.UInt64ulong
System.Int64long
System.UIntPtruword
System.IntPtrword
System.Singlesingle
System.Doubledouble
System.Decimaldecimal
System.Objectobject
System.Stringstring
System.Numerics.BigIntegerbigint

making your own types work with .NET ​

The mappings above are about reaching into .NET. This section is the other direction: what a ghūl type has to provide before .NET libraries treat it as a first-class value rather than as an opaque object. In each case the language already has the operator or member; the point is which one .NET is looking for.

equality ​

.NET collections compare values with Equals and GetHashCode. A MAP or a SET finds a key by its hash and then checks it with Equals; contains on a list checks each element with Equals. A ghūl type defines its equality with =~ and its hash with get_hash_code. When a type defines both, the compiler synthesises an Equals override that calls =~, so .NET collections compare the type the way ghūl code does:

ghul
class WITH_HASH(x: int) is
=~(other: WITH_HASH) -> bool => x == other.x
▲get_hash_code() -> int => x.get_hash_code()
si
// only =~, so .NET keeps comparing by identity:
class NO_HASH(x: int) is
=~(other: NO_HASH) -> bool => x == other.x
si
let with_hash = SET[WITH_HASH]()
with_hash.add(WITH_HASH(1))
write_line("with get_hash_code: {with_hash.contains(WITH_HASH(1))}")
let no_hash = SET[NO_HASH]()
no_hash.add(NO_HASH(1))
write_line("without get_hash_code: {no_hash.contains(NO_HASH(1))}")
[equality-without-hash] NO_HASH defines =~ but no get_hash_code, so .NET comparisons will not use the operator
with get_hash_code: true
without get_hash_code: false

Build the hash from the members =~ compares. System.HashCode.combine does this.

When a type defines =~ but not get_hash_code, the compiler reports an equality-without-hash warning and doesn't synthesise an Equals override. .NET collections then compare a class by reference and a struct member by member, whatever its =~ says. The compiler does not synthesise the hash itself, because =~ can ignore some members, and a hash of all of them would then disagree with it. The exception is a class marked @equality() or a struct whose members are all public: there the compiler synthesises both =~ and a matching get_hash_code.

ordering ​

Sorting, Ghul.Comparable[T], and the relational operators all come from <>, a three-way ordering returning a negative, zero, or positive int. Defining it gives a type <, <=, > and >= and makes it sortable by .NET at the same time:

ghul
class VERSION(major: int, minor: int): Ghul.Comparable[VERSION] is
▲<>(other: VERSION) -> int =>
if major != other.major then major - other.major else minor - other.minor fi
▲to_string() -> string => "{major}.{minor}"
si
let versions = LIST[VERSION]()
versions.add(VERSION(2, 1))
versions.add(VERSION(1, 9))
versions.sort()
write_line("sorted: {versions |> map(v => v.to_string()) |> join(", ")}")
write_line("1.0 < 1.1: {VERSION(1, 0) < VERSION(1, 1)}")
sorted: 1.9, 2.1
1.0 < 1.1: true

conversions ​

A .NET user-defined conversion operator (op_Implicit / op_Explicit) declared on either the source or the target type is reachable through cast:

ghul
// System.Half declares an explicit conversion from double, and an implicit one to single
conversions() is
let h = cast System.Half(1.5)
let f = cast single(h)
write_line("{h} {f}")
si
1.5 1.5

cast T(v) calls the operator and lets it throw on failure. cast T?(v) never throws: a failed conversion becomes the absent value, and any other exception still propagates.

disposal ​

A type that holds something to release implements Ghul.Disposable, which is .NET's IDisposable, by defining dispose. Write use in front of an expression that creates one, and the value is disposed when the enclosing block ends, however the block is left:

ghul
class SCOPE(name: string): Ghul.Disposable is
▲dispose() is
write_line("closing {name}")
si
si
let scope = use SCOPE("file")
write_line("inside the scope")
inside the scope
closing file

use gives back the value it disposes, so it can go wherever the expression could: an initializer, an argument, an operand. let use x = E is the older spelling of let x = use E.

let use x = E in disposes its local sooner: once the statement holding it has run, rather than when the block ends:

ghul
let length = (let use scope = SCOPE("second") in scope.read().length)
write_line("length {length}")
closing second
length 16

iteration ​

A type implementing Collections.Iterable[T] is a .NET IEnumerable<T>, so it works with for, with the pipe combinators, and with any .NET API taking a sequence. The requirement is an iterator property, and a generator is usually the shortest way to supply one:

ghul
class COUNTDOWN(from: int): Iterable[int] is
▲iterator: Iterator[int] => _counting().iterator
_counting() -> int{} is
let i mut = from
while i > 0 do
yield i
i = i - 1
od
si
si
for i in COUNTDOWN(3) do
write_line("tick {i}")
od

a gotcha when reflecting over your types ​

An auto-property's backing field is named $ followed by the property name, and reflection sees it alongside the property itself. A reflection-based serializer told to include fields will therefore emit everything twice. With System.Text.Json, leave include_fields alone unless the type genuinely has fields to serialize.

attributes ​

A pragma whose name isn't one of the compiler's own names a .NET attribute, and the compiler emits that attribute on the definition the pragma is written before: a type, a function or method, a field or property, or a single parameter. @Foo(...) finds FooAttribute when there is no plain Foo, as C# does. The arguments can be positional, named (name = value), arrays, or typeof.

deprecation ​

System.Obsolete marks a declaration as deprecated. The compiler reports every use of the declaration as a deprecated warning, with the attribute's message when it has one:

ghul
@System.Obsolete("use scaled instead")
doubled(x: int) -> int => x * 2
scaled(x: int, by: int) -> int => x * by
write_line("{doubled(21)} {scaled(21, 2)}")
[deprecated] doubled is deprecated: use scaled instead
42 42

With true as its second argument, @System.Obsolete("removed", true), the attribute makes each use an error instead. The compiler reads the attribute on a declaration from another assembly too, whichever language it was written in, and a call to a class's constructor counts as a use of the class. A use written inside a declaration that is itself deprecated is not reported, so an old member can go on calling another. Where a use is deliberate, @suppress("deprecated") silences the warning like any other. In the editor, hover shows the message under the signature and completion marks the item as deprecated.

method implementation flags ​

System.Runtime.CompilerServices.MethodImpl tells the runtime how to treat the method it is written on. Its options become the method's implementation flags rather than an attribute the method has, because the flags are where the runtime reads them: NO_INLINING keeps the method out of the inliner, and SYNCHRONIZED takes a lock around it. Reflection reads them back with get_method_implementation_flags:

ghul
class TIMING is
@System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.NO_INLINING)
measured(x: int) -> int static => x + 1
si
let method = (typeof TIMING).get_method("measured")!
write_line("{TIMING.measured(41)}")
write_line("{method.get_method_implementation_flags()}")
42
NoInlining

struct layout ​

System.Runtime.InteropServices.StructLayout on a class or struct sets how its fields are laid out in memory, which is what a native structure the type stands for has to match. A struct is laid out sequentially, in the order its members are declared, unless it asks otherwise. EXPLICIT places each field where a FieldOffset on it says, so two fields can share the same bytes. Every instance field of such a type needs a FieldOffset, and so it has to be a field rather than an auto-property. On a little-endian machine the low byte of a ushort comes first:

ghul
@System.Runtime.InteropServices.StructLayout(LayoutKind.EXPLICIT)
struct WORD_BYTES is
@System.Runtime.InteropServices.FieldOffset(0)
word: ushort field
@System.Runtime.InteropServices.FieldOffset(0)
low: ubyte field
init(value: ushort) is
word = value
si
si
let bytes = WORD_BYTES(0x1234us)
write_line("{bytes.low:X2}")
34

ASP.NET Core ​

ASP.NET Core minimal APIs work from ghūl. Extension methods aren't exposed as members, so the fluent builder calls go through the |> thread-first operator, which passes the left-hand side as the called method's first argument:

ghul
entry(args: string[]) is
let builder = WebApplication.create_builder(args)
let app = builder.build()
// '|>' threads app in as map_get's first argument:
app |> map_get("/hello", () => Results.ok("hello, world"))
app.run(null)
si

app |> map_get(...) calls the MapGet extension on app; the route handler is an anonymous function returning an IResult.

Controller-style APIs rely on attributes, which apply to classes and methods: [ApiController], [Route(...)], [HttpGet(...)] and so on. A parameter-binding attribute such as [FromBody] is written as a pragma on the parameter, as in @Microsoft.AspNetCore.Mvc.FromBody() body: T.

Entity Framework Core ​

Entity Framework Core works from ghūl. A context extends DbContext and exposes each table as a DbSet; EF Core's conventions expect PascalCase names, so @IL.name maps the ghūl members onto them:

ghul
// @IL.name maps these onto the PascalCase names EF Core's conventions expect.
class PRODUCT is
@IL.name("Id")
id: int public
@IL.name("Name")
name: string public
init() is si
si
class STORE_CONTEXT: DbContext is
@IL.name("Products")
products: DbSet[PRODUCT]
init(options: DbContextOptions) is
super.init(options)
si
si
add_product(context: STORE_CONTEXT, product: PRODUCT) -> Tasks.TASK is
context.products.add(product)
await context.save_changes_async(System.Threading.CancellationToken.none)
return
si

The Products set and the entity's Id and Name are the names EF Core's model builder and SQL generation look for. Reads and writes call the async methods directly, with await - save_changes_async here.

@IL.name("Name") sets the name a function, method or property has in the compiled assembly, while ghūl code goes on using the name it declares. On a property it also names the accessors get_Name and set_Name, and @IL.name.read("...") or @IL.name.assign("...") names one accessor on its own.

mocking with NSubstitute ​

The .NET base libraries include no mocking framework; NSubstitute is the lowest-friction third-party option from ghūl, and the compiler's own test suite uses it. Substitute.for builds a stand-in for a trait, and the Returns extension stubs a call through |>:

ghul
trait Clock is
◆now() -> System.DateTime
si
test_uses_a_stubbed_clock() static is
// Substitute.for takes the constructor arguments as an object[]; a
// trait has none, so pass an empty array.
let clock = Substitute.`for[Clock]([])
// stub a return value for a call:
clock.now() |> returns(System.DateTime(2020, 1, 1, 9, 0, 0), null)
IO.Std.write_line("stubbed hour is {clock.now().hour}")
si

for is a reserved word, so the example escapes it with a backtick. Its argument is the substitute's constructor arguments as an object[]; a trait has none, so the argument is an empty array. Where a full framework isn't warranted, a hand-written trait implementation is the zero-dependency alternative.