SsalKit.Generators.Toolkit 0.0.6

dotnet add package SsalKit.Generators.Toolkit --version 0.0.6
                    
NuGet\Install-Package SsalKit.Generators.Toolkit -Version 0.0.6
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SsalKit.Generators.Toolkit" Version="0.0.6">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SsalKit.Generators.Toolkit" Version="0.0.6" />
                    
Directory.Packages.props
<PackageReference Include="SsalKit.Generators.Toolkit">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SsalKit.Generators.Toolkit --version 0.0.6
                    
#r "nuget: SsalKit.Generators.Toolkit, 0.0.6"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SsalKit.Generators.Toolkit@0.0.6
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SsalKit.Generators.Toolkit&version=0.0.6
                    
Install as a Cake Addin
#tool nuget:?package=SsalKit.Generators.Toolkit&version=0.0.6
                    
Install as a Cake Tool

← SsalKit

English | 한국어 | 日本語

SsalKit.Generators.Toolkit

A source-only toolkit for authoring Roslyn source generators: equatable arrays, an indented code writer, C# naming helpers, hint-name sanitization, cache-safe diagnostic descriptions, and a diagnostic descriptor factory — embedded directly into your compilation, with no runtime assembly to ship. NuGet

Why SsalKit.Generators.Toolkit?

Every non-trivial Roslyn source generator ends up reimplementing the same handful of utilities: a wrapper that gives ImmutableArray<T> structural equality so the incremental pipeline caches correctly, a small code writer that tracks indentation while emitting generated source, helpers that turn arbitrary symbol names into valid C# identifiers, a sanitizer for AddSource's hintName argument, a cache-safe stand-in for Diagnostic that doesn't pin a syntax tree in the pipeline, and a factory that cuts down the boilerplate of declaring DiagnosticDescriptors.

Distributing that as an ordinary NuGet package creates a real problem: a source generator is packaged as an analyzer, and any library it depends on has to be packaged alongside it in the same analyzers/dotnet/cs folder — there's no ordinary dependency resolution for analyzer-time DLLs. That means every consumer of a helper library would need custom packaging just to carry it along for the ride.

SsalKit.Generators.Toolkit takes a different approach:

  • Source-only, not a runtime assembly. The package ships plain .cs files as contentFiles, and those files are compiled directly into your generator project. There's no DLL to package alongside your analyzer, because there's no DLL at all.
  • Zero package dependencies. The embedded sources only need the Roslyn APIs your generator project already references — nothing new to resolve, nothing to conflict with your own Microsoft.CodeAnalysis.* version pin.
  • Invisible to your consumers. Because the helpers are compiled as internal types directly into your generator assembly, nothing about this package leaks into the public surface of the generator you ship.
  • Eight small, focused components, not a framework: EquatableArray<T>, IndentedCodeWriter, CSharpNaming, HintNameSanitizer, DiagnosticInfo/LocationInfo, DiagnosticDescriptorFactory, SymbolFacts, and AttributeLocations — plus the IsExternalInit polyfill every netstandard2.0 generator needs to write record models at all. Take what you need; unused internal types simply sit there unreferenced.

Installation

dotnet add package SsalKit.Generators.Toolkit

The package sets DevelopmentDependency=true, so a plain dotnet add package (or a <PackageReference> without extra attributes) already gets PrivateAssets="all" applied by NuGet automatically — the reference won't flow to anything that depends on your generator. Making that explicit is still recommended, since it documents the intent for anyone reading the .csproj and keeps behavior stable if the implicit default ever changes:

<ItemGroup>
  <PackageReference Include="SsalKit.Generators.Toolkit" Version="0.1.0" PrivateAssets="all" />
</ItemGroup>

Prerequisites

  • Your project is a Roslyn component (a source generator and/or analyzer) — this package has no use outside that context.
  • Your project targets netstandard2.0 (or is otherwise compatible with it), the standard TFM for Roslyn components.
  • Your project's LangVersion is C# 10 or higher. The embedded sources themselves only use C# 10 syntax (see Embedded source contract below), but the package doesn't attempt to raise or lower your project's language version.
  • Your project already references Microsoft.CodeAnalysis (or Microsoft.CodeAnalysis.CSharp), at version 4.4.0 or newer. The reference itself is a hard requirement of DiagnosticDescriptorFactory, DiagnosticInfo, SymbolFacts and AttributeLocations, which name Roslyn types directly. Since every Roslyn component project references it anyway, SsalKit.Generators.Toolkit deliberately does not declare it as a package dependency — doing so would pin a version for you and interfere with your own back-compat choice — so nothing enforces the floor: on an older Roslyn the build simply fails, with the error below.

Why 4.4.0

One line needs it. SymbolFacts.FindGeneratedCodeAccessBlocker reads INamedTypeSymbol.IsFileLocal, the API Roslyn added in 4.4.0 together with C# 11's file-local types. Every other component compiles against considerably older Roslyn versions. On 4.3.x and earlier the build fails with:

error CS1061: 'INamedTypeSymbol' does not contain a definition for 'IsFileLocal'

There is no version-independent substitute worth shipping: the alternative is to recognize a file-local type by the mangled name the compiler gives it (<Source>F0__Widget), which is an implementation detail with no compatibility promise, and getting it wrong means generated code that names a type it cannot see.

If you pin an older Roslyn deliberately — to keep a generator loadable in an older IDE — drop that one file rather than the package. contentFiles arrive as Compile items during restore, so the removal has to happen in a target rather than in a plain ItemGroup:

<Target Name="DropToolkitSymbolFacts" BeforeTargets="CoreCompile">
  <ItemGroup>
    <Compile Remove="@(Compile)"
             Condition="'%(NuGetPackageId)' == 'SsalKit.Generators.Toolkit' And '%(Filename)' == 'SymbolFacts'" />
  </ItemGroup>
</Target>

Nothing else in the package references SymbolFacts, so removing it costs you only that component.

Components

EquatableArray<T>

Wraps an ImmutableArray<T> so it compares by content instead of by reference. Incremental generator pipelines rely on EqualityComparer<T>.Default to decide whether a stage's output changed since the last run — a plain ImmutableArray<T> breaks that check (it's reference-equal only), silently defeating the pipeline's caching. EquatableArray<T> fixes that for any T : IEquatable<T>.

using System.Collections.Immutable;
using SsalKit.Generators.Toolkit;

// Pipeline model held across incremental generator runs.
internal readonly struct ServiceModel : IEquatable<ServiceModel>
{
    public ServiceModel(string typeName, ImmutableArray<string> interfaceNames)
    {
        TypeName = typeName;
        InterfaceNames = interfaceNames.ToEquatableArray(); // or EquatableArray.Create(interfaceNames)
    }

    public string TypeName { get; }
    public EquatableArray<string> InterfaceNames { get; }

    public bool Equals(ServiceModel other) =>
        TypeName == other.TypeName && InterfaceNames.Equals(other.InterfaceNames);

    // ... GetHashCode(), object.Equals(), etc.
}

A default instance is not equal to EquatableArray<T>.Empty. The wrapper keeps the distinction ImmutableArray<T> makes between "no array at all" and "an array with no elements": Length, AsImmutableArray() and enumeration treat the two alike, equality and hashing do not. That only bites in one place, but it bites hard — a stage that yields default on one run and Empty on the next reports a change on every keystroke even though both mean "nothing". Pick one spelling for empty: ToEquatableArray() over an empty sequence and EquatableArray<T>.Empty both give the non-default form, an uninitialized field gives the default one.

IndentedCodeWriter

A small, allocation-light writer that tracks indentation while you build up generated source text, so you don't hand-manage indent strings yourself. Line breaks are always "\n" (deterministic across build machines) and blank lines never carry trailing indentation whitespace (stable diffs).

using SsalKit.Generators.Toolkit;

var writer = new IndentedCodeWriter();
writer.WriteAutoGeneratedHeader(); // "// <auto-generated/>" + "#nullable enable" + blank line
writer.WriteLine("namespace MyGenerator.Generated;");
writer.WriteLine();

using (writer.Block("internal static class MyAppWebServiceRegistration"))
{
    using (writer.Block("public static void Register(IServiceCollection services)"))
    {
        writer.WriteLine("services.AddSingleton<ICacheService, CacheService>();");
    }
}

string source = writer.ToString();
context.AddSource("MyAppWebServiceRegistration.g.cs", source);

Block(header) writes header, an opening { on its own line, indents, and writes a closing } when the using scope ends. Block(header, closer) lets you supply a different closing token (e.g. "};" for an object initializer), and Indent() gives you a bare indentation scope without any braces at all.

WriteAutoGeneratedHeader() writes // <auto-generated/> and #nullable enable; WriteAutoGeneratedHeader(suppressWarnings: true) adds a bare #pragma warning disable between them, which clears every warning for the rest of the file. Reach for it when the code you emit has to survive a consumer's TreatWarningsAsErrors and whatever analyzer set they run — a build that fails inside a file its author cannot edit is a bad afternoon. It stays opt-in because suppressing everything also hides warnings you would want to see, such as an obsolete API your generator emits a call to. The parameterless call is unchanged, so existing generators emit exactly the header they always did.

Indentation is applied per write, not per line. The writer indents once, when something is written at the start of a line, and then treats the text as opaque — so a string that already contains line breaks (a raw string literal holding a whole method body) has only its first line indented. Write such a block one line at a time, or emit it at indentation level zero and let its own layout stand.

For XML documentation comments — which a generated method easily spends 10–20 lines on — WriteDocLine(content) and WriteDocLines(contents) attach the /// prefix for you:

writer.WriteDocLines(
    "<summary>",
    "Picks a single random element of <paramref name=\"items\"/>.",
    "</summary>",
    "<param name=\"items\">The candidate items.</param>");

An empty string writes a bare /// with no trailing space, so generated documentation blocks never carry trailing whitespace.

WriteDocLines comes in two overloads: params string[] for the literal run above, and IEnumerable<string> for a block assembled conditionally, where an array literal forces the whole thing into an if/else over two nearly identical lists:

IEnumerable<string> docs = new[] { "<summary>", summaryText, "</summary>" };
if (isObsolete)
{
    docs = docs.Concat(new[] { "<remarks>Superseded by <c>" + replacement + "</c>.</remarks>" });
}

writer.WriteDocLines(docs);

The sequence is enumerated exactly once, as it is written, so a deferred query is never materialized first. string[] is the more specific parameter type, so existing call sites — loose arguments, an explicit string[], or none at all — keep binding to the params overload exactly as before.

CSharpNaming

Turns arbitrary text (assembly names, symbol names, anything with dots or other separators) into valid C# identifier fragments, and escapes reserved keywords.

using SsalKit.Generators.Toolkit;

string methodName = CSharpNaming.ToPascalCaseIdentifier(assemblyName, fallback: "Assembly");
// "MyApp.Web" -> "MyAppWeb"

string paramName = CSharpNaming.ToCamelCaseIdentifier(typeSymbol.Name);
// "IOService" -> "ioService", "UserRepository" -> "userRepository"

string safeParamName = CSharpNaming.EscapeKeyword(paramName);
// "class" -> "@class"; anything else is returned unchanged

string flattened = CSharpNaming.JoinIdentifierSegments(new[] { "Outer", "Inner" });
// -> "Outer_Inner" (the usual way to flatten a nested type's name into a top-level one)

ToPascalCaseIdentifier/ToCamelCaseIdentifier return fallback whenever the input is null, empty, or has no letters or digits at all, and prepend _ if the result would otherwise start with a digit. EscapeKeyword only escapes reserved keywords (class, namespace, return, ...) — contextual keywords like var or nameof are left alone, since they're always valid identifiers.

ToCamelCaseIdentifier can return a reserved keyword"Class", "Event" and "Params" all lower-case into one — and does not escape it for you. That is the right split of responsibilities, because a fragment spliced into a longer identifier (classFactory) must not pick up a stray @ in the middle. Compose the two at the point of use, CSharpNaming.EscapeKeyword(CSharpNaming.ToCamelCaseIdentifier(name)), whenever the result is emitted as a parameter or local name on its own. ToPascalCaseIdentifier needs no such care: every C# reserved keyword is lower-case.

JoinIdentifierSegments joins, it never sanitizes: each segment is assumed to already be a valid identifier (run it through ToPascalCaseIdentifier first if that isn't the case). null and empty segments are skipped rather than joined, so the result never starts or ends with the separator and never contains two in a row; an empty list yields string.Empty. The separator defaults to '_' and can be overridden.

HintNameSanitizer

Turns a candidate string — typically a type's fully qualified or metadata name — into a value safe to pass as the hintName argument of AddSource. Generic arity markers (Foo`1) and nested-type separators (Outer+Inner) are the most common source of AddSource failures when a raw FQN is used directly.

using SsalKit.Generators.Toolkit;

string hintName = HintNameSanitizer.Sanitize(typeSymbol.ToDisplayString());
// "Namespace.Outer<Inner>" -> "Namespace.Outer_Inner_.g.cs" (unsafe characters replaced, suffix appended)

string fromFqn = HintNameSanitizer.Sanitize(
    typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
// "global::Namespace.MyType" -> "Namespace.MyType.g.cs" (the alias qualifier is stripped)

// One file named after a pair of types: no pre-stripping needed on either name.
string forPair = HintNameSanitizer.Sanitize(containerFqn + "." + codeEnumFqn + ".Mapping");
// "global::My.Container.global::My.Codes.Mapping" -> "My.Container.My.Codes.Mapping.g.cs"

context.AddSource(hintName, sourceText);

Sanitize guarantees the result ends with suffix (default ".g.cs", not duplicated if already present), replaces every character outside Roslyn's accepted hint-name set with _, and caps the overall length at 200 characters (trimming from the front, so the more distinguishing tail — and the suffix — always survive). It does not guarantee uniqueness across multiple calls; callers are responsible for passing distinguishable input.

Every global:: — which SymbolDisplayFormat.FullyQualifiedFormat puts on every name — is stripped, not replaced character by character, so passing a fully qualified name straight through doesn't leave a global__ run in the generated file name. The qualifier is removed wherever it appears, not just at the front, so a hint name a caller composed from several fully qualified names needs no pre-stripping of its own. Only the exact global:: qualifier is removed: a segment that merely starts with the word (global.MyType) is left alone. A candidate that is nothing but global:: qualifiers falls back to "Generated", as null/empty/whitespace does.

DiagnosticInfo / LocationInfo

A cache-safe stand-in for Diagnostic. Carrying a real Diagnostic (or a Location) through an incremental pipeline pins the SyntaxTree it came from — and through it the whole Compilation — inside the generator's cache, which both leaks memory and defeats the caching itself, since two runs over identical source produce Locations that never compare equal. DiagnosticInfo holds only the descriptor, an optional LocationInfo (file path plus spans), and the message arguments, and compares by value across all three.

using SsalKit.Generators.Toolkit;

// In the transform stage: reduce a symbol/node to a cache-safe value.
var info = new DiagnosticInfo(
    DiagnosticDescriptors.UnsupportedWeightType,
    LocationInfo.CreateFrom(memberSymbol.Locations.FirstOrDefault()),
    memberDisplayName,
    memberType.ToDisplayString());

// In the source-output stage: rehydrate and report.
context.RegisterSourceOutput(diagnostics, static (spc, reported) =>
{
    foreach (var diagnostic in reported)
    {
        spc.ReportDiagnostic(diagnostic.ToDiagnostic());
    }
});

LocationInfo.CreateFrom accepts a Location or a SyntaxNode and returns null for anything that isn't a location in source (a metadata or "none" location, or a null argument) — which is exactly what Diagnostic.Create accepts for "report without a location", so the null needs no special handling downstream. Both types implement IEquatable<T> with a matching GetHashCode, so they can sit inside an EquatableArray<T> or any other pipeline model.

Message arguments are held as an EquatableArray<string>strings only, not the object?[] that Diagnostic.Create itself takes. An arbitrary object argument brings whatever equality its own type implements (reference equality, for most), so two runs that produced the "same" diagnostic could compare unequal and defeat the cache; worse, such an argument could be a symbol or syntax node and root a whole Compilation. Format at the call site instead — render each argument to a string (with an invariant format, for anything culture-sensitive) before constructing the DiagnosticInfo, and ToDiagnostic() passes those strings through verbatim. The params string[] constructor overload builds the array for you.

DiagnosticDescriptorFactory

Cuts down the repetitive DiagnosticDescriptor constructor call (id, title, message format, category, severity, isEnabledByDefault, description, ...) that every generator's diagnostics table repeats for each entry.

using Microsoft.CodeAnalysis;
using SsalKit.Generators.Toolkit;

internal static class DiagnosticDescriptors
{
    private static readonly DiagnosticDescriptorFactory Factory = new("SSAL", "SsalKit.Guard");

    public static readonly DiagnosticDescriptor DuplicateErrorCode = Factory.Error(
        id: 1,
        title: "Duplicate error code",
        messageFormat: "Error code '{0}' is already assigned to '{1}'",
        description: "Each member decorated with [ErrorCodes] must declare a unique error code.");
    // -> id "SSAL001"

    public static readonly DiagnosticDescriptor UnusedErrorCode = Factory.Warning(
        id: 2,
        title: "Unused error code",
        messageFormat: "Error code '{0}' is never thrown",
        description: "Consider removing the unused error code or using it in a Guard call.");
    // -> id "SSAL002"
}

Every descriptor produced by a given factory instance shares the same id prefix/category, is formatted as {idPrefix}{id:D3} (e.g. "SSAL001"), and has isEnabledByDefault: true. Both Error(...) and Warning(...) accept an optional params string[] customTags for additional descriptor tags.

One trade-off to know about. Because the id is composed at run time rather than written as a literal at the DiagnosticDescriptor constructor call, Microsoft.CodeAnalysis.Analyzers' release-tracking rules (RS2000-RS2003) can no longer resolve any of your ids, and will report every entry in AnalyzerReleases.Shipped.md/AnalyzerReleases.Unshipped.md as unmatched. If you maintain those files, suppress RS2002/RS2003 and check the same thing from a test instead: read the two release files, compare their (id, category, severity) rows against your descriptor table, and assert that every descriptor is in some analyzer's SupportedDiagnostics. That test is easy to write, and it verifies more than the analyzer did.

SymbolFacts

The symbol-level questions almost every generator ends up asking, none of which need a Compilation: how a type is written in generated code, whether the generated file may name it at all, whether it is generic, and how a run's diagnostics are ordered.

using SsalKit.Generators.Toolkit;

// global::-qualified name, which is how a type reference in generated code should be written.
string fqn = SymbolFacts.ToFqn(typeSymbol);          // "global::Game.Loot.LootEntry"

// Namespace name, or "" for the global namespace -- what you need to decide whether to emit a
// namespace declaration at all.
string ns = SymbolFacts.GetContainingNamespaceName(typeSymbol);

// May the generated member be declared public without an inconsistent-accessibility error?
bool canBePublic = SymbolFacts.IsEffectivelyPublic(typeSymbol);

// Type parameters of its own, or inherited from a containing type.
bool isGeneric = SymbolFacts.IsGenericOrNestedInGeneric(typeSymbol);

// Can a separate generated file in the same assembly name this type?
bool nameable = SymbolFacts.IsAccessibleFromGeneratedCode(typeSymbol);

IsAccessibleFromGeneratedCode walks the whole nesting chain: a public type nested in a private one is no more reachable than a private one, and a file-local type reports Accessibility.Internal so it has to be asked about separately. protected internal passes (the generated file gets the internal half); private protected does not (generated code derives from nothing, so it never gets the protected half). An IErrorTypeSymbol -- an unresolved name -- is rejected: there is no type to name, and emitting a reference to it would turn one compiler error into two.

When you want to report on an inaccessible type rather than just skip it, FindGeneratedCodeAccessBlocker returns the offending link of the chain instead of a bare bool, so your message can name it and say whether it is the type itself or a container:

var blocker = SymbolFacts.FindGeneratedCodeAccessBlocker(typeSymbol);
if (blocker is not null)
{
    string reason = ReferenceEquals(blocker, typeSymbol)
        ? "it is declared '" + blocker.DeclaredAccessibility + "'"
        : "it is nested inside '" + blocker.ToDisplayString() + "'";
    // ... report
}

Finally, SortForDiagnosticDeterminism orders an ImmutableArray<DiagnosticInfo> by source file, then position, then id, with location-less diagnostics last. Pipeline nodes run in whatever order the host chooses, so without a final sort the diagnostic sequence of two identical builds can differ -- which shows up as flaky snapshot tests and unstable build logs.

AttributeLocations

One method, for the one place a generator almost always gets a location slightly wrong.

Location location = AttributeLocations.GetLocation(attributeData, decoratedSymbol);

The best place to report a rule about an attribute application is the attribute application itself -- the token the user wrote and can delete -- not the whole decorated declaration. But AttributeData.ApplicationSyntaxReference is null whenever the attribute did not come from source, and a synthesized symbol has no locations at all, so the naive one-liner has two holes in it. This falls back through both: attribute syntax, then the decorated symbol's first location, then Location.None (which Diagnostic.Create accepts, reporting without a file position rather than dropping the diagnostic). The location is built from the syntax reference's tree and span rather than by calling GetSyntax(), so the attribute node is never materialized (or, for a lazily read tree, re-parsed) just to be asked for a span the reference already carries.

A Location must not travel through a pipeline, though — it pins its syntax tree and, through it, a whole Compilation. GetLocationInfo gives the same answer already projected into the cache-safe form, so a transform stage has no reason to name the raw one at all:

LocationInfo? location = AttributeLocations.GetLocationInfo(attributeData, decoratedSymbol);

The null it returns for a location that isn't in source is what Diagnostic.Create accepts as "report without a position", so it needs no special case downstream.

IsExternalInit (compiler polyfill)

netstandard2.0 reference assemblies don't ship System.Runtime.CompilerServices.IsExternalInit, which the C# compiler requires before it will emit an init accessor — and therefore before it will accept a record declaration at all. Since pipeline models are the natural place for records, every generator project ends up hand-rolling the same empty type. The package ships it so you don't have to.

It's the one embedded file that doesn't live in the SsalKit.Generators.Toolkit namespace: the compiler looks the type up by its fixed fully qualified name, so it can't be moved.

Opting out. If your compilation already declares that type — your own polyfill, or another package's — two definitions are a CS0101 duplicate-definition error. Define SSALKIT_GENERATORS_TOOLKIT_EXCLUDE_ISEXTERNALINIT to drop this copy:

<PropertyGroup>
  <DefineConstants>$(DefineConstants);SSALKIT_GENERATORS_TOOLKIT_EXCLUDE_ISEXTERNALINIT</DefineConstants>
</PropertyGroup>

Opting out is always safe: nothing else in the package depends on it, because the toolkit's own sources deliberately avoid the syntax it enables (see below).

Embedded source contract

Every .cs file this package ships starts with the same three lines:

// <auto-generated/>
#pragma warning disable
#nullable enable
  • // <auto-generated/> tells your own analyzers (and any consumer-facing tooling) to treat the file as generated code, skipping style/quality rules that would otherwise apply.
  • #pragma warning disable unconditionally clears every warning in the file, so it compiles clean under your project's exact warning configuration — including TreatWarningsAsErrors.
  • #nullable enable fixes the file's own nullable contract regardless of your project's nullable setting.

On top of the header, every type across the eight components is internal, and every file lives in the fixed SsalKit.Generators.Toolkit namespace — since the types are internal, two different generator assemblies that each embed this package never collide with each other. (The IsExternalInit polyfill is the single, deliberate exception to the namespace rule, for the reason given above.)

The sources themselves deliberately avoid record types and init-only properties, even though the package now ships the polyfill that would enable them. That keeps opting out of the polyfill a free choice: if the toolkit's own code needed init, excluding the polyfill would break the rest of the package along with it. DiagnosticInfo and LocationInfo are therefore ordinary classes with hand-written IEquatable<T> implementations rather than records. IsExternalInit.cs is also the only file allowed to carry conditional compilation; everything else compiles identically in every consumer, whatever their DefineConstants are.

The language surface is capped at C# 10 (file-scoped namespaces are fine; primary constructors and collection expressions are not), since your project's LangVersion can't be assumed to be any newer. No file declares a global using, either: a global using in an embedded file would apply to every file of your compilation and silently change how names bind in source this package never sees.

Two more rules follow from the same fact — that these sources are compiled with your options rather than the package's:

  • Every hash accumulator is explicitly unchecked. A hash is meant to wrap around; under <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> an unwrapped one throws OverflowException instead, from a file you cannot edit, on the equality path of a pipeline model. EquatableArray<T>, DiagnosticInfo and LocationInfo wrap theirs.
  • Output is never Environment.NewLine. IndentedCodeWriter emits "\n" unconditionally, so generated files are byte-for-byte identical whichever machine built them.

All of these — the header, internal-only types, the namespace, C# 10, no global using, unchecked hashing, no Environment.NewLine — are enforced by tests over the shipped files, not by convention alone.

Known limitation

If your generator project grants a test project access to its internal types via [InternalsVisibleTo], don't also let that test project reference SsalKit.Generators.Toolkit directly. Both paths would bring the same internal types (same namespace, same names) into the test project's compilation — once via the generator assembly (through InternalsVisibleTo) and once via the package's own embedded sources — which the compiler sees as an ambiguous duplicate and rejects.

If your tests need these helpers, reach them the same way the rest of your test project reaches the generator's other internal types: through [InternalsVisibleTo] on the generator project, not through a second, direct package reference.

License

MIT — see LICENSE.


AI disclosure: This project was built with AI assistance (Claude).

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • .NETStandard 2.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.0.6 196 8/9/2026
0.0.5 160 7/31/2026
0.0.4 169 7/26/2026
0.0.3 161 7/25/2026