Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System;
using System.Net.Http;

namespace AStar.Dev.Source.Generators.Attributes;

/// <summary>
/// An attribute to automatically register a minimal APU endpoint.
/// </summary>
/// <remarks>
/// This attribute is used to mark classes for automatic endpoint registration in the system.
/// It supports specifying an HTTP method type and an optional method group name for categorization or grouping.
/// </remarks>
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public class AutoRegisterEndpointAttribute(HttpMethod? methodType, string? methodGroupName = null) : Attribute
{
/// <summary>
/// Gets the HTTP method type associated with the endpoint.
/// </summary>
/// <remarks>
/// If no specific HTTP method is provided during initialization, the default value is <see cref="HttpMethod.Get"/>.
/// </remarks>
/// <value>
/// Represents the HTTP method used for the endpoint, as an instance of the <see cref="System.Net.Http.HttpMethod"/> class.
/// </value>
public HttpMethod MethodType { get; } = methodType ?? HttpMethod.Get;

/// <summary>
/// Gets the group name associated with the method for organizational purposes.
/// </summary>
/// <remarks>
/// This property provides a way to categorize or group methods logically within a larger structure.
/// If no group name is specified during initialization, the value will be <see langword="null"/>.
/// </remarks>
/// <value>
/// Represents the name of the method group as a <see cref="string"/>. Can be <see langword="null"/> if not explicitly set.
/// </value>
public string? MethodGroupName { get; } = methodGroupName;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

namespace AStar.Dev.Source.Generators.Attributes;

/// <summary>
/// Attribute used to indicate that a class or struct should be automatically registered as an <see cref="IOptions{T}"/>,
/// with an optional section name provided for configuration purposes.
/// </summary>
/// <remarks>
/// This attribute can only be applied to classes or structs. It is not inherited by derived types
/// and does not allow multiple usage on the same target.
/// </remarks>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false, AllowMultiple = false)]
public class AutoRegisterOptionsAttribute(string? sectionName = null) : Attribute
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
namespace AStar.Dev.Source.Generators.Attributes;

[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class ServiceAttribute(ServiceLifetime lifetime = ServiceLifetime.Scoped) : Attribute
public sealed class AutoRegisterServiceAttribute(ServiceLifetime lifetime = ServiceLifetime.Scoped) : Attribute
{
/// <summary>
/// Specifies the lifetime of the service. Defaults to Scoped.
Expand Down
3 changes: 3 additions & 0 deletions src/AStar.Dev.Source.Generators.Attributes/ServiceLifetime.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
namespace AStar.Dev.Source.Generators.Attributes;

/// <summary>
/// Specifies the lifetime of a service within a dependency injection container.
/// </summary>
public enum ServiceLifetime { Singleton, Scoped, Transient }
34 changes: 24 additions & 10 deletions src/AStar.Dev.Source.Generators/AStar.Dev.Source.Generators.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,29 @@
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IsRoslynComponent>true</IsRoslynComponent>
<PackageId>AStar.Dev.Source.Generators</PackageId>
<!-- Do NOT include referenced projects in this generator package -->
<IncludeReferencedProjects>false</IncludeReferencedProjects>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<!-- Include generator DLL in analyzers folder for proper loading -->
<IncludeBuildOutput>false</IncludeBuildOutput>
<NoWarn>$(NoWarn);NU5128</NoWarn>

<!-- Package metadata -->
<Version>0.1.3</Version>
<Authors>AStar Dev</Authors>
<Company>AStar</Company>
<Version>0.1.4</Version>
<Authors>AStar Development, Jason Barden</Authors>
<Company>AStar Development</Company>
<Description>Source generators and supporting attribute types for AStar.Dev projects.</Description>
<PackageTags>source-generator;strongid;attributes;astartools</PackageTags>
<PackageProjectUrl>https://example.com/AStar.Dev.Source.Generators</PackageProjectUrl>
<RepositoryUrl>https://github.com/your-org/SourceGenerators1111</RepositoryUrl>
<PackageProjectUrl>https://astardevelopment.co.uk</PackageProjectUrl>
<RepositoryUrl>https://github.com/astar-development/astar-dev-source-generators.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<PackageReleaseNotes>Initial 0.1.2 release.</PackageReleaseNotes>
<PackageReleaseNotes>v0.1.4 - Add missing namespace in generated ServiceRegistrations class.
- Rename ServiceAttribute to AutoRegisterServiceAttribute for clarity</PackageReleaseNotes>
<RepositoryBranch>main</RepositoryBranch>
<Title>AStar Dev Source Generators</Title>
<PackageReadmeFile>Readme.md</PackageReadmeFile>
<PackageIcon>astar.png</PackageIcon>
<IncludeSymbols>True</IncludeSymbols>
</PropertyGroup>

<ItemGroup>
Expand Down Expand Up @@ -59,7 +62,18 @@
<!-- Include build props to auto-reference the Attributes assembly -->
<None Include="build\AStar.Dev.Source.Generators.props" Pack="true" PackagePath="build" />
</ItemGroup>
<!-- (BuildAndCopyAttributes target above handles building and copying the Attributes DLL) -->

<ItemGroup>
<ProjectReference Include="..\AStar.Dev.Source.Generators.Attributes\AStar.Dev.Source.Generators.Attributes.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="astar.png">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
<None Update="Readme.md">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
var fullTypeName = ns != null ? string.Concat(ns, ".", typeName) : typeName;
string? sectionName = null;
AttributeData? attr = typeSymbol.GetAttributes().FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == AttrFqn);
if(attr != null && attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is string s && !string.IsNullOrWhiteSpace(s))
if(attr is { ConstructorArguments.Length: > 0 } && attr.ConstructorArguments[0].Value is string s && !string.IsNullOrWhiteSpace(s))
{
sectionName = s;
}
Expand All @@ -72,23 +72,27 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
if(attrSyntax?.ArgumentList?.Arguments.Count > 0)
{
ExpressionSyntax expr = attrSyntax.ArgumentList.Arguments[0].Expression;
if(expr is LiteralExpressionSyntax literal && literal.Token.Value is string literalValue)
{
sectionName = literalValue;
}
if(expr is LiteralExpressionSyntax { Token.Value: string literalValue }) sectionName = literalValue;
}
}

if(string.IsNullOrWhiteSpace(sectionName))
return !string.IsNullOrWhiteSpace(sectionName)
? new OptionsTypeInfo(typeName, fullTypeName, sectionName!, ctx.TargetNode.GetLocation())
: ExtractSectionNameFromMembers(ctx, typeSymbol, sectionName, typeName, fullTypeName);
}

private static OptionsTypeInfo? ExtractSectionNameFromMembers(GeneratorAttributeSyntaxContext ctx, INamedTypeSymbol typeSymbol, string? sectionName, string typeName, string fullTypeName)
{
foreach(ISymbol member in typeSymbol.GetMembers())
{
foreach(ISymbol member in typeSymbol.GetMembers())
if(member is not IFieldSymbol { IsStatic: true, IsConst: true, Name: "SectionName" } field || field.Type.SpecialType != SpecialType.System_String ||
field.ConstantValue is not string val || string.IsNullOrWhiteSpace(val))
{
if(member is IFieldSymbol field && field.IsStatic && field.IsConst && field.Name == "SectionName" && field.Type.SpecialType == SpecialType.System_String && field.ConstantValue is string val && !string.IsNullOrWhiteSpace(val))
{
sectionName = val;
break;
}
continue;
}

sectionName = val;
break;
}

return new OptionsTypeInfo(typeName, fullTypeName, sectionName ?? string.Empty, ctx.TargetNode.GetLocation());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,7 @@ public static string Generate(IReadOnlyList<OptionsTypeInfo> types)
_ = sb.AppendLine(" {");
_ = sb.AppendLine(" public static IServiceCollection AddAutoRegisteredOptions(this IServiceCollection services, IConfiguration configuration)");
_ = sb.AppendLine(" {");
foreach(OptionsTypeInfo info in types)
{
_ = sb.AppendLine($" services.AddOptions<{info.FullTypeName}>()\n .Bind(configuration.GetSection(\"{EscapeString(info.SectionName)}\"))\n .ValidateDataAnnotations()\n .ValidateOnStart();");
}
foreach(OptionsTypeInfo info in types) _ = sb.AppendLine($" services.AddOptions<{info.FullTypeName}>()\n .Bind(configuration.GetSection(\"{EscapeString(info.SectionName)}\"))\n .ValidateDataAnnotations()\n .ValidateOnStart();");

_ = sb.AppendLine(" return services;");
_ = sb.AppendLine(" }");
Expand All @@ -30,5 +27,5 @@ public static string Generate(IReadOnlyList<OptionsTypeInfo> types)
return sb.ToString();
}

private static string EscapeString(string s) => s?.Replace("\\", "\\\\").Replace("\"", "\\\"") ?? string.Empty;
private static string EscapeString(string s) => s?.Replace("\\", @"\\").Replace("\"", "\\\"") ?? string.Empty;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,31 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using AStar.Dev.Source.Generators.Attributes;

namespace AStar.Dev.Source.Generators.ServiceRegistrationGeneration;

internal static class ServiceCollectionCodeGenerator
{
public static string Generate(IReadOnlyList<ServiceRegistrationGenerator.ServiceModel> items)
public static string Generate(IReadOnlyList<ServiceModel> items)
{
IEnumerable<string> registrations = BuildServiceRegistrations(items);
return BuildSourceFile(registrations);
ServiceModel? item = items.FirstOrDefault();
return BuildSourceFile(registrations, item?.Namespace ?? "AStar.Dev");
}

private static IEnumerable<string> BuildServiceRegistrations(IReadOnlyList<ServiceRegistrationGenerator.ServiceModel> items)
private static IEnumerable<string> BuildServiceRegistrations(IReadOnlyList<ServiceModel> items)
{
var seen = new HashSet<string>(StringComparer.Ordinal);

foreach(ServiceRegistrationGenerator.ServiceModel model in items)
foreach(ServiceModel model in items)
{
foreach(var registration in CreateRegistrationsForModel(model).Where(seen.Add))
{
yield return registration;
}
}
}

private static IEnumerable<string> CreateRegistrationsForModel(ServiceRegistrationGenerator.ServiceModel model)
private static IEnumerable<string> CreateRegistrationsForModel(ServiceModel model)
{
var method = GetRegistrationMethod(model.Lifetime);

Expand All @@ -43,20 +43,23 @@ private static IEnumerable<string> CreateRegistrationsForModel(ServiceRegistrati
}
}

private static string GetRegistrationMethod(ServiceRegistrationGenerator.Lifetime lifetime) => lifetime switch
{
ServiceRegistrationGenerator.Lifetime.Singleton => "AddSingleton",
ServiceRegistrationGenerator.Lifetime.Scoped => "AddScoped",
ServiceRegistrationGenerator.Lifetime.Transient => "AddTransient",
_ => "AddScoped"
};
private static string GetRegistrationMethod(ServiceLifetime lifetime)
=> lifetime switch
{
ServiceLifetime.Singleton => "AddSingleton",
ServiceLifetime.Scoped => "AddScoped",
ServiceLifetime.Transient => "AddTransient",
_ => "AddScoped"
};

private static string BuildSourceFile(IEnumerable<string> registrations)
private static string BuildSourceFile(IEnumerable<string> registrations, string @namespace)
{
var sb = new StringBuilder();
_ = sb.AppendLine("// <auto-generated/>");
_ = sb.AppendLine("using Microsoft.Extensions.DependencyInjection;");
_ = sb.AppendLine();
_ = sb.AppendLine($"namespace {@namespace};");
_ = sb.AppendLine();
_ = sb.AppendLine("public static class GeneratedServiceCollectionExtensions");
_ = sb.AppendLine("{");
_ = sb.AppendLine(" public static IServiceCollection AddAnnotatedServices(this IServiceCollection s)");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System.Collections.Generic;
using System.Linq;
using AStar.Dev.Source.Generators.Attributes;
using Microsoft.CodeAnalysis;

namespace AStar.Dev.Source.Generators.ServiceRegistrationGeneration;

internal sealed class ServiceModel(ServiceLifetime lifetime, string implFqn, string? serviceFqn, bool alsoAsSelf, string @namespace)
{
public ServiceLifetime Lifetime { get; } = lifetime;
public string ImplFqn { get; } = implFqn;
public string? ServiceFqn { get; } = serviceFqn;
public string? Namespace { get; } = @namespace;
public bool AlsoAsSelf { get; } = alsoAsSelf;

public static ServiceModel? TryCreate(INamedTypeSymbol impl, AttributeData attr)
{
if(!IsValidImplementationType(impl))
return null;

ServiceLifetime lifetime = ExtractLifetime(attr);
INamedTypeSymbol? asType = ExtractAsType(attr);
var asSelf = ExtractAsSelf(attr);
INamedTypeSymbol? service = asType ?? InferServiceType(impl);
var ns = impl.ContainingNamespace.IsGlobalNamespace ? null : impl.ContainingNamespace.ToDisplayString();

// Only skip if no service and not alsoAsSelf
return service is null && !asSelf
? null
: new ServiceModel(
lifetime: lifetime,
implFqn: impl.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
serviceFqn: service?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
alsoAsSelf: asSelf,
@namespace: ns
);
}

private static bool IsValidImplementationType(INamedTypeSymbol impl) => !impl.IsAbstract &&
impl.Arity == 0 &&
impl.DeclaredAccessibility == Accessibility.Public;

private static ServiceLifetime ExtractLifetime(AttributeData attr) => attr.ConstructorArguments.Length == 1 &&
attr.ConstructorArguments[0].Value is int li
? (ServiceLifetime)li
: ServiceLifetime.Scoped;

private static INamedTypeSymbol? ExtractAsType(AttributeData attr)
{
foreach(KeyValuePair<string, TypedConstant> na in attr.NamedArguments)
{
if(na.Key == "As" && na.Value.Value is INamedTypeSymbol ts)
return ts;
}

return null;
}

private static bool ExtractAsSelf(AttributeData attr)
{
foreach(KeyValuePair<string, TypedConstant> na in attr.NamedArguments)
{
if(na.Key == "AsSelf" && na.Value.Value is bool b)
return b;
}

return false;
}

private static INamedTypeSymbol? InferServiceType(INamedTypeSymbol impl)
{
INamedTypeSymbol[] candidates = [.. impl.AllInterfaces.Where(IsEligibleServiceInterface)];

return candidates.Length == 1 ? candidates[0] : null;
}

private static bool IsEligibleServiceInterface(INamedTypeSymbol i) => i.DeclaredAccessibility == Accessibility.Public &&
i.TypeKind == TypeKind.Interface &&
i.Arity == 0 &&
i.ToDisplayString() != "System.IDisposable";
}
Loading