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— driven by a converted type declaration: apartialstruct, class or interface declared in a package class, with its definition, when it has one, in the comment after its name. Emits the body of a converted type: a struct’s members and equality, a named numeric/slice/array/map/channel type’s wrapper and operators (see Named Numeric Types and Constant Contexts and Slices and Arrays), and struct-embedding field/method promotion. For a value type it also writes generated parts that re-open the type at its own type parameters (partial struct Pair<K, V> : …) for golib’s reflection: golib’sIGoMethodSourceon a generic value type with Go methods, each method closed over the type’s own parameters by compiled code (MethodSource.cs), andIGoMethodSeton a value type with exported Go methods, the compiled method funcs and method values reflect hands out (MethodSet.cs). On a non-generic type’s generatedToStringit writes a[DynamicDependency]per Go method (MethodSetKeep.cs), so a fully trimmed publish keeps the methods golib’s method-set scan reads. See Interfaces.ImplementGenerator— wires up Go’s duck-typed interfaces: finds the concrete types that satisfy eachpartial interfaceand emits the implementation glue and implicit conversions.RecvGenerator— emits pointer-receiver overloads for methods whose receiver isthis ref T, so each is also callable through the pointer/box form. A variadic method keeps itsparamsin the generated overload: cryptobyte’sfunc (b *Builder) add(bytes ...byte)emits the value formadd(this ref Builder b, params Span<byte> bytesʗp), and aж<Builder>overload with a bareSpan<byte>could not bind a call passing individual elements through a box (c.add(0xff),caж<Builder>closure parameter), which would fall back to the ref-receiver value method — CS1929.GetMethodInfopreserves theparamsmodifier (the Go variadic is always the last, non-receiver parameter, so it never lands on thethis ж<T>receiver). Guarded byVariadicBoxReceiver(a*sinkwithadd(bytes ...byte)called on a box — via a closure and directly — with zero, one, several, and spread arguments, values vs Go).ImplicitConvGenerator— emits the implicit conversion operators that let a named type and its underlying types be used interchangeably.StrGenerator— driven by ansstringparameter. For an sstring twin, emits the@stringoverload that forwards to thesstringmember under[OverloadResolutionPriority(-1)], and for a package-level function the canonical value delegate<Name>ᶠ. Types are rendered fully qualified from the symbols, because the converted file’susingaliases are not in scope in a generated file.PartialStubGenerator— emits a throwingpartialimplementation for any bodylesspartialmethod that has no other implementing part (e.g. assembly/cgo functions with no convertible body), while leaving real hand-written companion implementations untouched.LiteralInterceptorGenerator— boxes a bare C# string literal the converter passes to ananyslot as the Go string it is. For each such call it emits a file-local interceptor that writes a cached, pre-boxed@stringinto the slot and calls the original method; a call it cannot intercept isGO2CS0005, and an overload that captured the literal isGO2CS0006. It runs on the same input compilation as the other generators, so it cannot see their output: it resolves a call whose other arguments use generated members from its one-member method group, and the converter keeps the pre-boxed field where the receiver or the bound overload is itself generated.NoInliningPartialGenerator— the other direction: for apartialmethod that HAS a body and no declaring part, emits the declaring part carrying[MethodImpl(MethodImplOptions.NoInlining)]. This is how the converter’s no-inline mark reaches a declared method; see The no-inline mark rides a generated declaring part.MemberRecordGenerator— reads the comments converted code writes for a Go fact about a member (an embedded field, a struct tag, an array length; see Marker comments) and writes each as a[GoMemberRecord]on a generated partial of the member’s type, which golib’s reflection bridge reads back.ChanDirSigGenerator— for every method whose signature carries a directional channel, writes a[GoSigChanDir]on a generated partial of the method’s type, soreflectrecovers the direction the C# signature drops. A pointer-receiver method gets a second entry keyed onж<T>, the receiver of the overloadRecvGeneratoradds.ConstraintProxyGenerator— publishes the generic constraint proxy (жLesser<E>) of every self-referential constraint interface a package declares (where T : Lesser<T>), driven by the constraint clauses alone; see Generic Constraints.FieldAccessGenerator— for every struct type a[GoFieldAccess(…)]record lists, emits a field property onж<T>, so a field reached through a pointer reinterpreted fromunsafe.Pointerreads at its Go offset; see Pointers.EscapeRegistryGenerator— registers, per package class, golib’s operations for every closed container the compilation converts to an interface or reaches from a value it converts, soreflectreaches a container’s operations through a registry rather than through code on the generic container. The same walk registers the variadic func signatures that can reachreflect, whoseCallandMakeFunctrampolines golib keeps (Manually-Converted Declarations).NativeArrayOpenerGenerator— registers golib’s native array openers for exactly the element types the compilation spells where code reaches a native array by an element type it holds only at run time: a foreign*[N]*Eand the window over a native struct’s[N]Xfield. An element nothing registered is a Go panic that names it, under the JIT as under Native AOT.LazyHolderGenerator— completes a Go package var that is built on its first read. The converter writes the var’s declaration inside a holder class named for it,[GoLazy] partial class lazyᴛX { internal static T X = …; }; the generator gives the package class the var’srefaccessor under its Go name (internal static ref T X => ref lazyᴛX.X;), or the box’s accessor (ᏑX) for a var whose address is taken, so every use site reads as it does for an eager var. A holder that does not hold exactly one such field isGO2CS0009. See A pure-data var is built on its first read.RuntimeInitHookGenerator— for a converted EXECUTABLE that references the convertedruntimepackage, writes one module initializer that installs golib’sCrashReport.RuntimeInitializer. A crash report that finds no traceback renderer runs it to initializeruntime, so a program that imports nothing still prints Go’sgoroutine 1 [running]:traceback, and a program that never crashes never loadsruntimefor it under the JIT.
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:
- The rule is
TypeGenerator’s, mirrored exactly.recordTypeAccessibilityuses the explicit modifier the converter emitted inline (the publicization pre-pass’spublic) when there is one, and otherwisegeneratedTypeScope, a Go-side mirror ofCommon.GetScope. Deliberately notgetAccess:GetScopereads the C# identifier verbatim, so aΔcollision-rename (a Greek capital) reads as exported wheregetAccessstrips the prefix first. Mirroring the generator is what keeps the corpus’s effective accessibility byte-for-byte unchanged and avoidsCS0262between the two parts. - Generic types repeat the type-parameter list, never the constraints (
internal partial struct entry<K, V> {}) — a partial declaration may leave constraints to another part. An arity-0 constraint interface repeats the CRTP<ΔT>marker list the inline declaration carries. - Every partial-emitting kind is covered: struct (defined struct, map, channel, slice/array, defined-over-selector), class (named pointer types), interface. A named func type is emitted as a C#
delegate, which is not partial and already carries its own modifier, so it has no entry. - Hand-owned files are deliberately excluded. A file whose destination
.cscarries[module: go.GoManualConversion]has its converter output redirected to the non-compiled.cs.autosibling, so the declarations that actually compile are the author’s — their kind, name and modifier are the author’s to choose, and a generated entry could contradict them (CS0261/CS0262) or conjure a phantom empty type. Across the corpus that leaves a handful of marked files that declare converted types (mostlyruntime/runtime2.cs), and among them the interfacesync.Lockerin the hand-rewrittensync/mutex.cs. Such a declaration is whyCommon.GetAllBaseInterfaces’sInaccessiblerecovery is kept: it is a narrow backstop for hand-owned declarations, not the primary mechanism. Writing the modifier explicitly on a hand-owned declaration removes the need for it there. - Test variants get the same treatment, split by owning class. Under the recompile test model the production entries reach
package_test_info.csthrough the verbatim seed frompackage_info.cs, the internal variant’s test-local types merge into that same (production-class) section, and the external variant’s types — which live in<pkg>_test_package— go topackage_info_external_test.cs, whose first and only class is the test class.writeExternalVariantMetadataclears the accessibility set before writingpackage_test_info.csso a test-class type can never land in the production class, and the unit is written whenever the external variant declares types, not only when it records test-anchoredGoImplement/GoImplicitConvattributes. Under ordinary reference the test class is the anchor; under white-box reference the external test class is preferred, with the internal bridge used when no external variant exists. - The generator still writes the modifier on its own part — the section is a third part with the same modifier, which C# accepts. Removing it is not safe while the section deliberately does not cover hand-owned declarations: those parts would become
private. - Generator-side consequence: a converted type has two converter-written parts, so any lookup that resolves a type by name through the syntax trees can land on either.
Compilation.FindStructDeclarationandImplicitConvGenerator.GetStructDeclaration— both of which go on to read members or the definition comment — prefer the converter’s own declaration (Common.IsGoTypeDefinition), falling back to first-match. Without that, syntax-tree order (i.e. compile-item order, wherepackage_info.cscan sort ahead of the package sources) would silently decide.
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:
- The attributes and the access modifier travel together, by construction.
recordTypeAccessibilitytakes the stamps as an argument and returns what the caller must still write inline — empty when the record absorbed them. So the one path that writes no record keeps the stamps on the declaration and cannot lose them: a hand-owned file (whose emission goes to the non-compiled.cs.autoreview sibling). A-testsbridge unit, whose metadata anchor can be a different test class, writes them on attribute-only partials with no modifier in its own metadata unit,package_info_internal_test.cs, for example[GoLocalName("Inner")] partial interface TestLocalEmbeddedInterface_Inner {}. A hand-written conversion therefore still stamps[GoValueClone]inline, and it still works. TypeGeneratorreads the stamp across every partial declaration, starting with the one its receiver matched and continuing through the symbol’s otherDeclaringSyntaxReferences. That is what makes both placements equivalent rather than one replacing the other. The match itself stays syntactic, as it is for every attribute this generator reads.- The section sorts on the DECLARATION, not the line.
typeAccessibilityKeystrips the attribute prefix before comparing, so a stamped entry keeps the place its accessibility/kind/name earns instead of being pulled into a leading block by its[. Sorting the raw line is legal but scrambles a section whose whole value is being readable at a glance. - Not a semantic change anywhere. The relocation moves where the attribute is written, exactly as the
TypeAccessibilitysection moved where the modifier is written. Nothing observes a difference: the generatedClone()is identical, andGoReflect’s%Toutput is identical.
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:
- In the signature. A pointer receiver is
this ref T. A string-twin function takes ansstring. A Go type is a type declared in the package class. Nothing is added to say so twice. - In a short comment written the way Go writes it. An embedded field, a struct tag, an array length and a defined type’s underlying type each become a comment beside the declaration they describe.
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
- A Go comment is never taken for a marker. When the converter carries one of Go’s own comments into the
C#, and that comment opens the way a marker does, it is written with a space after the
/*. A Go/*embed*/arrives as/* embed*/, which no generator reads. A marker is also read in one position only, the one the converter writes it in. - A record that names a member the type does not have is refused by name. The runtime checks each generated record against the type the first time it is read, and stops with the member’s name instead of applying a fact to the wrong thing.
- A comment no record can carry is a build error (
GO2CS0003; everygo2cs-gencode is listed in diagnostic codes), never a silent loss. So is a record whose key would match two methods of one type; the error names both. - The generators’ tests are built from the converter’s own output, so a change in how the converter spells a marker fails a test instead of passing against a hand-written copy of the old spelling.
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.
- GenTests
NoInliningPartialGeneratorTestscompiles the converter’s rendering of each carrier shape (thin forwarder,params, named-tuple result, generic,this refandthisreceivers, a pointer receiver on a generic type,Main) and readsNoInliningback from the emitted metadata; checks that a hand-written partialinitis refused by name; checks the declaring part’s text; checks that a lambda and a local function keep their own attribute and gain nothing, that a hand-owned declaration-plus-body pair gains nothing, that a carrier beside aglobal usingalias compiles, and that theж<T>overloads ofptrandget<T>are no-inline while an unmarked method’s is not. - GolibTests
TailCallFrameGuardTestsguards theDynamicSecurityMethodcaller-frame mark. - Behavioral
NoInlinePartialruns with tiered compilation off (runtimeconfig.template.json), so the first call is optimized code, and compares its output withgo run: every shape prints the frameruntime.Callernames. Built with a generator that writes the declaring part without the attribute, six of its lines (plain,variadic,generic,ptr,val,get) printmain.maininstead. - Behavioral
InitOrderNoInlineputs inits in three files, one of them marked by//go:noinline, and compares the order they print withgo run; with the marked init writtenpartialit ran last. - The converter’s frame tests (
noinlineDirective_test.go,callerSkipWindowFrames_test.goand the others) read the mark throughkeepsOwnFrame(a ` static partial ` declaration line that ends in a body), andgoCreatorFrame_test.goreads a literal’s attribute directly.
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 →