Source Generators

Reference index · Summary of this topic Several Go semantics cannot be written directly in C#, so the converter emits compact, attributed partial declarations and lets a set of Roslyn source generators (src/gen/go2cs-gen/, referenced as an analyzer by every converted project) synthesize the rest at compile time. This keeps the visible converted code close to the Go original. The principal generators and attributes:

TypeGenerator, ImplementGenerator, EscapeRegistryGenerator and NativeArrayOpenerGenerator also fill golib registries that code finds at run time, and none of them fills its registry at startup; see Run-time registrations wait for the first lookup that misses. The same assembly ships two analyzers, which report and generate nothing: StringLiteralBindingAnalyzer (GO2CS0004) and StackAppendBufferAnalyzer (GO2CS0007).

What the generators read is, for the most part, the code itself: where a type is declared, a receiver’s ref, a parameter’s type, and a few short comments written in Go’s own spelling. Marker comments: what converted code carries instead of an attribute lists each of them and the attributes that remain. The records a package keeps out of view are classified in Extended attributes: what stays on the declaration and what moves.

A defined type over a NAMED type passes the underlying’s name to the wrapper template, never as a kind. type MyBool bool and type winlibcall libcall reach InheritedTypeTemplate with the underlying’s name as its TypeClass, and that name is read (the bool arm prints Go’s lowercase true). The template’s KIND arms key on six values the kind arms set themselves — Array, Slice, Map, Channel, Pointer, Numeric — so a user type spelled exactly one of them took that kind’s template: mapstructure’s tests declare type MapCopy Map over a struct named Map, which generated IDictionary members over a struct. NamedUnderlyingTypeClass re-keys only those six names, so such a wrapper generates exactly as one over any other name. (Guarded by GenTests’ KindNamedUnderlyingTests, which compares each of the six with a wrapper over Plain.)

A generator’s view of ACCESSIBILITY is provisional — its own output is what supplies the access modifier. The converter emits a Go type as a bare partial interface X (or partial struct X) nested in the package class and leaves the access modifier to TypeGenerator, which derives it from the Go export convention (GetScope — public for an exported name, internal otherwise; an explicit modifier on the converter’s part wins). A C# nested type with no modifier is private, so without another part carrying a modifier the declaration is private — accessible from inside its own package class and inaccessible from any other class in the assembly. A generator cannot see its own output, so a semantic query that crosses package classes sees the provisional accessibility, not the real one.

The shape is a test package and its production package compiled into ONE assembly as two classes (the recompile test-project model), as with io’s external test package: io_test.closer : io.Closer and io_test.testMultiWriter_sink : io.Writer would bind their base to an IErrorTypeSymbol (CandidateReason.Inaccessible, candidate go.io_package.Closer/Interface/Private) with zero members, so every method the base contributes would silently vanish from the generated interface shell and adapters — while the FINAL compilation, which does have the generated public partial interface Closer, still demands them (CS0535 across Δcloser<T>, ΔcloserᴛObj, PipeReaderжcloser, PipeWriterжcloser, ΔtestMultiWriter_sink<T> and bytes_BufferжtestMultiWriter_sink). The same io.Closer resolves normally as a base of io.ReadCloser inside io_package, and fmt.Stringer resolves normally from a referenced assembly (already public in metadata), which makes the failure look spelling-related. It is not: writing the base global::go.io_package.Closer fails identically.

Common.GetAllBaseInterfaces replaces the raw AllInterfaces walk in InterfaceDeclarationSyntaxExtensions.GetInterfaceMethods and in both of ImplementGenerator’s method-collection paths. It recovers an Inaccessible base from the error symbol’s CandidateSymbols when the candidate is an interface declared in this compilation’s assembly, and folds in that recovered base’s own transitive bases (AllInterfaces cannot traverse through an error symbol). The recovery is sound rather than a bypass: the generator is about to declare that very type public or internal, both reachable from anywhere in the assembly. A genuinely inaccessible foreign type keeps its error symbol.

The deeper remedy — the converter stating the access modifier in source itself, so the pre-generation source stops understating it — removes the whole class of provisional-accessibility blind spots. Writing that modifier onto the inline converted type declaration would coarsen the Go-shaped declaration the converter works to keep readable, so the converter writes it in package_info.cs instead (next section). (No behavioral guard is possible for this row: one behavioral project is one Go package is one C# assembly, so two package classes never share an assembly there. The reproducer is the -tests pipeline on io.)

package_info.cs’s TypeAccessibility section pins each type’s accessibility IN SOURCE

The blind spot above is closed at the root without touching a converted .cs: package_info.cs carries a TypeAccessibility section, emitted inside the package class body (its entries are type declarations, and the types they name are nested in that class), carrying one condensed single-line partial declaration per converter-emitted converted type:

[GoPackage("io")]
public static partial class io_package
{
    // <TypeAccessibility>
    internal partial struct discard {}
    internal partial struct nopCloser {}
    public partial interface Closer {}
    public partial interface Reader {}
    public partial struct LimitedReader {}
    // </TypeAccessibility>
}

C# lets a partial type carry its access modifier on any one of its parts, so the inline partial interface Closer stays bare and Go-shaped while this part fixes the accessibility — and it is fixed in source, so a generator’s semantic query across package classes sees public/internal instead of the provisional private. The section carries its own explanatory prose in the style of the file’s other sections; a package info file written before the section existed has the prose and markers inserted on the next conversion (ensureTypeAccessibilitySection), so no migration step is needed.

Details that make it a pure relocation of the modifier rather than a change of it:

Measured, with a positive control (the -tests pipeline on io, whose CS0535 cluster is the reproducer): recovery on + section on → 0 CS0535; recovery neutered + section on → 0; recovery neutered + section off → the cluster returns.

Extended attributes: what stays on the declaration and what moves

The converted type declaration is the line a reader of converted code actually reads, so every other attribute stamped on it is machinery competing with the Go original for that reader’s attention. package_info.cs already exists to hold per-type records out of view, and the TypeAccessibility section above holds the access modifier there. A stamp can follow it whenever its consumer reads the attribute off the TYPE rather than off a particular declaration — C# unions the attributes of every part of a partial type, so which part carries one is invisible to runtime reflection and to any generator that resolves the symbol.

That single criterion classifies the whole surface. Apart from the no-inline mark, which a declared method carries as the word partial (below) and a func literal as [MethodImpl(MethodImplOptions.NoInlining)], and the explicit layout of a struct with a zero-size field ([StructLayout]/[FieldOffset]), the converter stamps nothing from the BCL. The type’s own definition, a receiver, a struct tag, an embedded field and an array length are not attributes at all (Marker comments), so the attributes a package records out of view are the ones below; What stays an attribute, and why lists the few still written inline:

Stamp Lands on Consumer Verdict
[GoValueClone("f1", "f2")] struct TypeGenerator, reading field names to emit Clone() Moved
[GoLocalName("Point")] struct (lifted function-local named type) golib’s reflection bridge, GoReflect.TypeNaming Moved
[GoMemberRecord("E", GoMemberFact.Descriptor, typeof(efaceᴅ))] struct, in package_info.cs golib’s reflection bridge Moved — it was a [GoDescriptorType] on the field; the record names the field and its carrier, and the carrier is declared in the same section
[GoInit] method the C# compiler — it is a using alias for ModuleInitializerAttribute Must stay — the compiler requires it on the method it initializes with
[GoPackage], [GoImplement<T,I>], [GoImplicitConv<S,T>], [GoTypeAlias], [GoSStringTwin] package class / assembly generators, runtime, and the converter’s own next run Already there — these are emitted into package_info.cs and never touched a mainline declaration
[GoManualConversion], [GoRequiresUnsafe] module the converter Already off — hand-written, module-scoped
[GoInterfaceShell], [GoTwinForwarder], [GoReflectCompanion] interface / lambda / field golib Not converter-emitted — the first two written by the generators ([GoTwinForwarder] by StrGenerator, on a twin delegate’s lambda), the last by hand

So the movable set is [GoValueClone] and [GoLocalName], and both are recorded in package_info.cs. [GoValueClone("intbuf")] partial struct fmt { reads partial struct fmt {, with the record in package_info.cs carrying the rest:

    // <TypeAccessibility>
    [GoValueClone("grid")] internal partial struct holder {}
    [GoValueClone("b")] internal partial struct inner {}
    internal partial struct row {}
    // </TypeAccessibility>

Mechanics worth knowing:

Marker comments: what converted code carries instead of an attribute

Converted code carries very few attributes. Where C# needs a fact that Go’s syntax states without a keyword, the converter puts that fact in one of two places a Go reader already looks:

At build time the go2cs source generators read the signature or the comment and write the attribute form into a generated part of the same type. The runtime and reflection read that generated part. The converted file stays the code a person reads; the attributes live where nobody has to.

Go Converted C# What carries the fact
func (b *Reader) Read(p []byte) Read(this ref Reader b, slice<byte> p) the ref on the receiver
type Scanner struct { … } partial struct Scanner { its place in the package class
type Duration int64 partial struct Duration /*num:int64*/; the comment after the name
struct { io.Reader } (an embedded interface) /*embed*/ public Reader Reader; the comment before the field
Method string `json:"method"` public @string Method; /*`json:"method"`*/ the comment after the field
func hash(b [32]byte) hash(/*[32]*/ array<byte> b) the comment before the type
func DecodeRuneInString(s string) DecodeRuneInString(sstring s) the sstring parameter
type AssignD <-chan bool partial struct AssignD /*<-chan bool*/; the direction, in the comment after the name
m map[[2]string][3]int internal /*[3]*/ map</*[2]*/ array<@string>, array<nint>> m; a second comment, before the key type
a function under //go:linkname /*linkname*/ internal static partial void registerLoadFromEmbeddedTZData(…) { the comment before the declaration

What each one carries, and why a Go reader does not need it

A pointer receiver is this ref T. Go’s func (b *Reader) becomes an extension method whose receiver is passed by reference, and a value receiver func (d Duration) becomes this Duration d. The ref is the whole fact: it is what lets the method change the caller’s value, as Go’s pointer receiver does. From it the generator writes the overload that takes a pointer (ж<Reader>), and the runtime decides which methods belong to Reader and which to *Reader. A Go reader sees a receiver and whether it is a pointer, which is what the Go line showed.

public static (nint n, error err) Read(this ref Reader b, slice<byte> p) {

A Go type needs no mark. Every struct and interface the converter declares inside a package class is a Go type, so the declaration is just partial struct Scanner {. The generator completes it (its constructor, equality, promoted members) and marks its own generated part, so reflection reports a defined Go type.

A defined type states its underlying type after its name. type Duration int64 has no body in C#; the generator builds one from the comment. num:int64 says “a number over int64”, and the other forms name a slice, a map, a channel, a pointer or another named type the same way. If the comment is deleted the type loses its arithmetic and conversions, and the build fails rather than running wrong.

partial struct Duration /*num:int64*/;

An embedded field says /*embed*/. Go embeds a field by leaving out its name; C# needs the name, and a Go field may share its type’s name without being embedded, so the name alone cannot say it. The comment does, on an embedded interface, an embedded predeclared type, and a pointer to one. Promotion of the embedded type’s methods, and reflect’s Anonymous, follow from it. An embedded struct value needs no comment: it is a partial ref property (public partial ref Inner Inner { get; }).

    /*embed*/ public Reader Reader;
    /*embed*/ internal nint @int;

A struct tag keeps Go’s spelling, at the end of the field’s line. The tag sits where Go puts it and reads as Go wrote it, backquotes included. encoding/json and every other package that reads tags through reflect receives the same string. A tag that a comment cannot hold as written uses Go’s quoted spelling instead: one that contains a backquote, a */, or any character Go does not count as printable (a tab, or U+2028), or that is not valid UTF-8. Inside the quoted spelling a */ is written *\x2f, so the tag cannot end the comment early. A grouped declaration carries the tag once, as Go does.

    public @string Plain; /*`json:"plain"`*/
    public nint Grouped, Pair; /*`json:"g"`*/
    public @string Quoted; /*"a:\"`b`\""*/

An array length is Go’s own prefix, before the type. C# has no fixed-length array type, so [32]byte becomes array<byte> and the length would be lost. The comment /*[32]*/ keeps it, written as Go’s array prefix, on a parameter, on a field, on a map’s key type argument, and on a defined type whose inner lengths its definition does not spell. Nested arrays list each length in order.

    internal /*[3]*/ ж<array<nint>> p;
internal static nint hash(/*[32]*/ array<byte> b) {
internal static void fill(nint n, ref array<int32> p, /*[4][8]*/ array<array<byte>> grid) {

A string twin is the function that takes an sstring. A few registered functions exist twice, so that a string literal passed to one binds a view and is not copied (see An sstring twin). The converter writes the Go body once, on the member whose parameter is an sstring, and the generator writes the @string overload from it. No other converted function takes an sstring parameter, so the parameter type is the mark.

public static (rune r, nint size) DecodeRuneInString(sstring s) {

A defined channel type keeps its direction in its definition comment. Go’s type AssignD <-chan bool is a wrapper over golib’s channel<bool>, which has no direction in its type. The comment after the name spells the type as Go does, arrow included, and the generator records the direction from it.

partial struct AssignD /*<-chan bool*/;

A linkname forwarder says /*linkname*/. Where Go’s //go:linkname makes one function another package’s, the converter writes a partial method whose body calls the target. The comment tells the generator to write the method’s declaring part with [StackTraceHidden], so the forwarder does not appear in a stack trace: in Go the two names are one function.

/*linkname*/ internal static partial void registerLoadFromEmbeddedTZData(Func<@string, (@string, error)> _) {
    go.time_package.registerLoadFromEmbeddedTZData(_);
}

What keeps a comment safe once the program depends on it

Converted files are regenerated from Go, not edited by hand, so a marker is normally written and read by tools on both sides.

What stays an attribute, and why

An attribute stays in converted code when the C# compiler or the .NET runtime reads it as an attribute from that exact declaration, or when the declaration has no name a generated record could use.

| Still an attribute | Where | Why it cannot move | |—|—|—| | [GoInit] | a package’s init functions | It is .NET’s module-initializer attribute under a Go name. The C# compiler acts on it, and initializers run in declaration order, which is Go’s package initialization order. | | [StructLayout], [FieldOffset] | a struct that describes native memory | The .NET runtime lays out memory from them. | | [MethodImpl(MethodImplOptions.NoInlining)] | a func literal, a local function, an init, or a declaration with no body | The JIT reads it from the method itself. A declared method carries the same mark as the word partial (see The no-inline mark rides a generated declaring part); a lambda or a local function cannot be partial, an init keeps its place in the file, and a declaration with no body is already the declaring part. | | [GoArrayDims(N)] | an array parameter of a func literal or a local function | The compiler names those methods, so no record can refer to them. | | [GoWrapper] | the lambda that wraps a method expression | The runtime reads it from the lambda’s own method, which has no declaration to attach a record to. |

A package’s bookkeeping attributes ([GoPackage], [GoImplement], [GoImplicitConv], [GoTypeAlias] and the rest) are in package_info.cs, not in the code a reader opens.

Hand-written code

A hand-written file (a *_impl.cs companion, or a whole file marked as a manual conversion) keeps the attributes: [GoRecv], [GoType], [GoType("…")], [GoEmbedded], [GoTag], [GoArrayDims] and [GoStr] all mean what they always have, and every generator and the runtime accept either spelling. Nobody writing C# by hand has to adopt a comment.

Inside a converted package the signature rules read a hand-written file too: an unmarked this ref receiver is a pointer receiver, and an sstring parameter makes a twin. The repository’s guards require the attribute on such a method, so a hand-written file always says what it means.

In a package written entirely by hand (testing and unsafe), marked [assembly: GoHandOwnedPackage], nothing is inferred from a signature or from where a type is declared: [GoRecv], [GoType] and [GoStr] are the only way to say those things there.

[GoRecv] internal static bool rwlock(this ref fdMutex mu, bool read) {

The no-inline mark rides a generated declaring part

Rule. A function whose frame a Go stack walk counts must not be inlined by the JIT, or runtime.Caller, runtime.Callers, runtime.Stack and the profiles name the wrong function. computeNoInliningClosure (src/go2cs/callerInliningAnalysis.go) decides which functions those are: a direct runtime.Caller / runtime.Callers user, a thin forwarder into one, a function inside a constant skip window, a caller passing a constant to a skip that is its callee’s own parameter (log’s Fatal* and Panic* into output(calldepth)), a hop into a skip-counted walker, a //go:noinline function, a goroutine creator, and a thin allocator in a package that reads the heap profile. How each takes the mark:

Shape Emitted form Why
Declared function or method with a body partial where the [MethodImpl(MethodImplOptions.NoInlining)] prefix stood: internal static partial @string ptr(this ref counter c) { NoInliningPartialGenerator writes the declaring part with the attribute; C# merges the attributes of both parts into the one compiled method
A /*linkname*/ forwarder /*linkname*/ internal static partial void f(…) { a partial with a body too, but the comment makes go2cs-gen write its declaring part with [StackTraceHidden] instead of NoInlining
Func literal (lambda) [MethodImpl(MethodImplOptions.NoInlining)] @string () => here() a lambda has no partial form
Local function (a literal only ever called) [MethodImpl(MethodImplOptions.NoInlining)] @string local() { a local function has no partial form
init [MethodImpl(MethodImplOptions.NoInlining)] [GoInit] internal static void initΔ2() { C# runs module initializers in declaration order, and a partial method’s declaration is its DECLARING part, a generated file that sorts after every source file, so a partial init would run after the package’s other inits. The generator refuses a hand-written partial init by name (GO2CS0003, an error)
Declaration with no Go body [MethodImpl(MethodImplOptions.NoInlining)] internal static partial … f(…); already the declaring part; its body is a *_impl.cs companion
The runtime package’s own init functions neither they are emitted as never-called methods, /* [GoInit] runtime bootstrap init - not run; .NET is the runtime */, with no attribute in either rendering
Hand-owned files the attribute, written by hand not converter output

The converter still registers System.Runtime.CompilerServices (or, in a file with a using static, the MethodImplAttribute/MethodImplOptions aliases) for a carrier, so a file’s using block reads the same in both renderings and the change to a converted file is exactly the signature line.

What the generator matches. NoInliningPartials.IsCarrier: a partial method with a body whose symbol has no declaring part (IsPartialDefinition: false, PartialDefinitionPart: null). A converted bodyless declaration plus its *_impl.cs body always has a declaring part, so it never matches; neither does a method without partial. A match that is a module initializer is refused by name (GO2CS0003) and gets no declaring part, so the build stays red at that method.

What it writes. One <file>.noinline.g.cs per source file that holds carriers. The signature is copied as text from the implementing part with its attributes, body and the space before the body removed, so modifiers, return type (tuple element names included), type parameters, parameters (this, ref, params, names) and constraints match exactly; a mismatched parameter name would be CS8826. The file repeats the source file’s extern alias and using directives, except global using (already in scope everywhere; repeating an alias one is CS1537), then the namespace’s own usings, then the namespace and the containing type chain re-opened as partial. The attribute is written fully qualified, so it binds whatever the file’s usings are. When the compilation can see golib’s public System.Security.DynamicSecurityMethodAttribute (every converted project can), the same attribute list also carries global::System.Security.DynamicSecurityMethod: [global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.NoInlining), global::System.Security.DynamicSecurityMethod]. For that attribute the C# compiler sets RequireSecObject, and the CLR then never tail-calls into the method, so runtime.Caller sees the caller’s frame as well as the method’s own (NoInliningPartials.CallerFrameMarkAvailable, CallerFrameAttribute). A default parameter value would have to move to the declaring part (CS1066 on the implementing one); the converter writes none on a carrier.

Readers of the mark. RecvGenerator makes a pointer-receiver method’s ж<T> overload no-inline when the method is, and StrGenerator does the same for an sstring twin’s @string forwarder (RecvGenerator.HasNoInliningMark); both forwarders carry DynamicSecurityMethod beside the mark under the same condition. Neither sees another generator’s output, so both test the carrier shape as well as the attribute.

Failure mode. If the generator does not run, every carrier is CS0759 (an implementing part with no declaring part): a build failure, never a silently inlinable method. A NuGet package carries the merged attribute in its compiled metadata.

Guards.

Run-time registrations wait for the first lookup that misses

Some generated code must also be found at run time, through a lookup in a golib registry. Four generators fill one:

Generator Registers Read by Queue on GoDeferredRegistrations
ImplementGenerator the pointer adapters a type assertion or type switch uses to wrap a box AdapterRegistry Adapters
TypeGenerator the factories that build a struct’s Go zero value GoZeroFactory<T> ZeroFactories
EscapeRegistryGenerator the escaping containers’ operations, and the variadic trampolines GoTypeOps, GoReflect EscapeRegistry
NativeArrayOpenerGenerator the native array openers NativeArrayOpeners NativeArrayOpeners

None of them fills its registry while the program starts. For each registry it fills, a package class gets one generated module initializer, code .NET runs when the assembly loads, and that initializer only queues the package’s registration method on golib’s GoDeferredRegistrations. This is the shape DeferredRegistration writes, here for the adapters:

[global::System.Runtime.CompilerServices.ModuleInitializer]
internal static void ᴛDeferAdapters() =>
    global::go.GoDeferredRegistrations.Adapters.Defer(ᴛRegisterAdapters);

When a lookup in a registry finds nothing, the registry runs every registration queued so far, each once, and looks again. A program whose lookups never miss runs none of them, so under the JIT less code is compiled at startup. An assembly loaded after a run queues its own registrations, and the next miss runs them. A caller that finds another thread running the queue waits for it. Under Native AOT nothing is lost: the queued method is a delegate the module initializer creates, so it, and every generic instantiation it names, is compiled ahead of time.

The queues live on GoDeferredRegistrations rather than on their registries, so queueing does not run a registry’s static constructor (GoTypeOps registers the builtin types in its own). These module initializers also take no part in Go’s initialization order: they read nothing and queue a method that runs later, so where they fall among a package’s import hooks and init functions changes nothing.

Guarded by GolibTests’ DeferredRegistrationTests: the first miss of each registry (a native array, a constructed zero, an adapter, a container’s operations, a variadic family) runs its queued registration, and a registration queued after a run is run by the next miss.


← The go.golib support namespace · Index · The standard-library conversion applies -tags purego →