Source code generated with a Roslyn Source Generator ends up in the assembly the source generator ran in, which means that access modifiers restrict access with the same scope as any manually written code in the same assembly. When a generator generates a lot of code, some of that code might be used as internal helpers for the generated code, and should not be exposed to the consumer of the generated code. One example is limiting the public API produced by that code.
Current Access Modifiers
One, rather new, access modifier was introduced in C# 11 to support similar scenarios; file. file scopes access to the current file, in source generator lingo that is defined by the hintName in the SourceProductionContext. The problem with file is that it doesn’t scale with generators that generate a lot of source code.
There are discussions about other scoped access modifiers in the C# language, but none have been implemented up to this date. A namespace scoped access modifier could partly solve the above problem, if one accept that a consumer might still declare types in that same namespace, and hence also be able to access those scoped types. Not really what we are looking for here.
More Roslyn to the Rescue
A work around to this problem is to build a Roslyn Analyzer. An analyzer can inspect code in the assembly it is installed in and find symbol usage that we want to prohibit, like referencing any internal members produced by a source code generator. The trick is to identify code being generated by a specific generator and scope it, i.e. it should only ever be referenced by code generated by that generator.
Fortunately, “files” in source code generators (remember the hintName above) does include the name of the source generator in the generated path, something like baseDirectory/assemblyName/generatorTypeName/hintName, which we can use.
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
namespace Foo;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class AccessToGeneratedInternalSymbolAnalyzer : DiagnosticAnalyzer
{
private static readonly string SourceGeneratorName = typeof(FooGenerator).FullName!;
private static readonly DiagnosticDescriptor Rule = new(
id: "AB0001",
title: "Access to internal generated symbol",
messageFormat: "Cannot access internal generated {0} '{1}'",
category: "Usage",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true,
description: $"Internal symbols produced by {SourceGeneratorName} should not be accessed as they can introduce breaking changes without notice.");
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = [Rule];
public override void Initialize(AnalysisContext context)
{
context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis(
GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics);
context.RegisterSyntaxNodeAction(
ReportSyntaxNodesReferencingInternalGeneratedSymbols,
SyntaxKind.IdentifierName,
SyntaxKind.GenericName);
context.RegisterSymbolAction(
ReportSymbolsReferencingInternalGeneratedSymbols,
SymbolKind.NamedType);
}
private static void ReportSyntaxNodesReferencingInternalGeneratedSymbols(SyntaxNodeAnalysisContext context)
{
if (IsFromSourceGenerator(context.Node.SyntaxTree))
{
return;
}
var symbol = context.SemanticModel.GetSymbolInfo(context.Node, context.CancellationToken).Symbol;
if (symbol is not null && ReferencesGeneratedInternalSymbols(symbol))
{
context.ReportDiagnostic(
AccessToGeneratedInternalSymbol(symbol,
context.Node.GetLocation()));
}
}
private static void ReportSymbolsReferencingInternalGeneratedSymbols(SymbolAnalysisContext context)
{
var symbol = context.Symbol;
if (!ReferencesGeneratedInternalSymbols(symbol))
{
return;
}
foreach (var location in symbol.Locations.Where(location =>
!IsFromSourceGenerator(location.SourceTree)))
{
context.ReportDiagnostic(
AccessToGeneratedInternalSymbol(symbol, location));
}
}
private static Diagnostic AccessToGeneratedInternalSymbol(ISymbol symbol, Location location) =>
Diagnostic.Create(
Rule,
location,
DescribeKind(symbol),
symbol.ToDisplayString());
private static string DescribeKind(ISymbol symbol) =>
symbol switch
{
IMethodSymbol { MethodKind: MethodKind.Constructor } => "constructor",
IMethodSymbol => "method",
IPropertySymbol => "property",
IFieldSymbol => "field",
IEventSymbol => "event",
INamedTypeSymbol => "type",
_ => "member",
};
private static bool ReferencesGeneratedInternalSymbols(ISymbol symbol)
{
for (var current = symbol; current is not null; current = current.ContainingType)
{
if (current is
{
DeclaredAccessibility: Accessibility.Internal,
DeclaringSyntaxReferences.Length: > 0
} &&
current.DeclaringSyntaxReferences.Any(reference =>
IsFromSourceGenerator(reference.SyntaxTree)))
{
return true;
}
}
return false;
}
private static bool IsFromSourceGenerator(SyntaxTree? tree) =>
tree is not null &&
tree.FilePath.Replace('\\', '/').Contains($"/{SourceGeneratorName}/");
}
Note IsFromSourceGenerator, which checks if a syntax tree is declared in a file(hint) that was generated by the source generator.
Symbols
Any named type symbols, i.e. classes, structs, enum etc., that references any symbols generated by the source generator get reported.
Example:
// Generated
internal partial class Foo {}
// This partial class is not generated and cannot extend the generated type declaration -> Report
internal partial class Foo
{
void Fee() {}
}
Syntax Trees
Any syntax node identifiers, i.e. the name of the instance of a class, method, property, parameter etc., and generic names, that references any nodes generated by the source code generator get reported. We ignore any syntax trees generated by the source generator it self.
Example:
// Generated
internal class Foo {}
// fee is not generated and cannot reference Foo -> Report
Foo fee = new Foo();
Limitations
The access modifiers in the C# language are authoritative, violate them and the code doesn’t compile. An analyzer can be opted out. Whether this is perceived as a limitation or not is debatable considering a consumer opts in to use a source code generator. The internal access modifier can be ignored by the owner of the protected assembly using the InternalsVisibleToAttribute. For an analyzer the consumer can control this using preprocessor directives.
Summary
Only the C# language can really solve this issue, but until then using an analyzer might be an acceptable work-around.











