Skip to content

Commit

Permalink
Add support for IParsable<T>
Browse files Browse the repository at this point in the history
Ths is a requirement for using struct ids in ASP.NET core endpoints, for example.

This is implemented such that if in a given `IStructId<T>`,  the `T : IParsable<T>`, the parsable implementation is provided as a pass-through to the value implementation.

For `IStructId` which is for string-typed values, we always emit a simple implementation that just create a new struct id.
  • Loading branch information
kzu committed Nov 23, 2024
1 parent 2d2f394 commit 3f16349
Show file tree
Hide file tree
Showing 17 changed files with 438 additions and 22 deletions.
2 changes: 1 addition & 1 deletion src/Directory.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<SignAssembly>false</SignAssembly>
<SuppressNETCoreSdkPreviewMessage>true</SuppressNETCoreSdkPreviewMessage>

<NoWarn>CS0436;$(NoWarn)</NoWarn>
<RestoreSources>https://pkg.kzu.app/index.json;https://api.nuget.org/v3/index.json</RestoreSources>
</PropertyGroup>

Expand Down
9 changes: 0 additions & 9 deletions src/Sample/Ids.cs

This file was deleted.

10 changes: 10 additions & 0 deletions src/Sample/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Sample;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/{id}", (UserId id) => id);

app.Run();

readonly partial record struct UserId : IStructId;
38 changes: 38 additions & 0 deletions src/Sample/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:26295",
"sslPort": 44329
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5047",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7120;http://localhost:5047",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
7 changes: 5 additions & 2 deletions src/Sample/Sample.csproj
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RestoreSources>https://api.nuget.org/v3/index.json;$(PackageOutputPath)</RestoreSources>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="StructId" Version="42.*" />
<PackageReference Include="StructId" Version="42.253.1273" />
</ItemGroup>

</Project>
9 changes: 9 additions & 0 deletions src/Sample/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
5 changes: 4 additions & 1 deletion src/StructId.Analyzer/AnalysisExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,12 @@ static IEnumerable<INamedTypeSymbol> GetAllTypes(INamespaceSymbol namespaceSymbo
}
}

public static string GetTypeName(this ITypeSymbol type, string containingNamespace)
public static string GetTypeName(this ITypeSymbol type, string? containingNamespace)
{
var typeName = type.ToDisplayString(FullName);
if (containingNamespace == null)
return typeName;

if (typeName.StartsWith(containingNamespace + "."))
return typeName[(containingNamespace.Length + 1)..];

Expand Down
101 changes: 101 additions & 0 deletions src/StructId.Analyzer/ParsableGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;

namespace StructId;

[Generator(LanguageNames.CSharp)]
public class ParsableGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Locate the IParseable<T> type
var parseable = context.CompilationProvider
.Select((x, _) => x.GetTypeByMetadataName("System.IParsable`1"));

var ids = context.CompilationProvider
.SelectMany((x, _) => x.Assembly.GetAllTypes().OfType<INamedTypeSymbol>())
.Where(x => x.IsStructId())
.Where(x => x.IsPartial());

var combined = ids.Combine(parseable)
.Where(x =>
{
var (id, parseable) = x;

// NOTE: we never generate for compilations that don't have the IParsable<T> type (i.e. .NET6)
if (parseable == null)
return false;

var type = id.AllInterfaces
.First(x => x.Name == "IStructId")
.TypeArguments.FirstOrDefault();

// If we don't have a generic type of IStructId, then it's the string-based one
// which we can always parse
if (type == null)
return true;

return type.Is(parseable);
})
.Select((x, _) => x.Left);

context.RegisterImplementationSourceOutput(combined, GenerateCode);
}

void GenerateCode(SourceProductionContext context, INamedTypeSymbol symbol)
{
var ns = symbol.ContainingNamespace.Equals(symbol.ContainingModule.GlobalNamespace, SymbolEqualityComparer.Default)
? null
: symbol.ContainingNamespace.ToDisplayString();

// Generic IStructId<T> -> T, otherwise string
var type = symbol.AllInterfaces.First(x => x.Name == "IStructId").TypeArguments.
Select(x => x.GetTypeName(ns)).FirstOrDefault() ?? "string";

var template = type == "string"
? ThisAssembly.Resources.Templates.SParseable.Text
: ThisAssembly.Resources.Templates.TParseable.Text;

// parse template into a C# compilation unit
var parseable = CSharpSyntaxTree.ParseText(template).GetCompilationUnitRoot();

// if we got a ns, move all members after a file-scoped namespace declaration
if (ns != null)
{
var members = parseable.Members;
var fsns = FileScopedNamespaceDeclaration(ParseName(ns).WithLeadingTrivia(Whitespace(" ")))
.WithLeadingTrivia(LineFeed)
.WithTrailingTrivia(LineFeed)
.WithMembers(members);
parseable = parseable.WithMembers(SingletonList<MemberDeclarationSyntax>(fsns));
}

// replace all nodes with the identifier TStruct/SStruct with symbol.Name
var structIds = parseable.DescendantNodes()
.OfType<IdentifierNameSyntax>()
.Where(x => x.Identifier.Text == "TStruct" || x.Identifier.Text == "SStruct");
parseable = parseable.ReplaceNodes(structIds, (node, _) => IdentifierName(symbol.Name)
.WithLeadingTrivia(node.GetLeadingTrivia())
.WithTrailingTrivia(node.GetTrailingTrivia()));

var structTokens = parseable.DescendantTokens()
.OfType<SyntaxToken>()
.Where(x => x.IsKind(SyntaxKind.IdentifierToken))
.Where(x => x.Text == "TStruct" || x.Text == "SStruct");
// replace with a new identifier with symbol.name
parseable = parseable.ReplaceTokens(structTokens, (token, _) => Identifier(symbol.Name)
.WithLeadingTrivia(token.LeadingTrivia)
.WithTrailingTrivia(token.TrailingTrivia));

// replace all nodes with the identifier TValue with actual type
var placeholder = parseable.DescendantNodes().OfType<IdentifierNameSyntax>().Where(x => x.Identifier.Text == "TValue");
parseable = parseable.ReplaceNodes(placeholder, (_, _) => IdentifierName(type));

context.AddSource($"{symbol.ToFileName()}.parsable.cs", SourceText.From(parseable.ToFullString(), Encoding.UTF8));
}
}
17 changes: 11 additions & 6 deletions src/StructId.Analyzer/StructId.Analyzer.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,22 @@
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Pack="false" Version="4.8.0" />
<PackageReference Include="NuGetizer" Version="1.2.3" />
<PackageReference Include="PolySharp" PrivateAssets="All" Version="1.14.1" />
<PackageReference Include="ThisAssembly.AssemblyInfo" Version="42.42.1242-main" PrivateAssets="all" />
<PackageReference Include="ThisAssembly.Project" Version="42.42.1242-main" PrivateAssets="all" />
<PackageReference Include="ThisAssembly.AssemblyInfo" Version="2.0.9" PrivateAssets="all" />
<PackageReference Include="ThisAssembly.Project" Version="2.0.9" PrivateAssets="all" />
<PackageReference Include="ThisAssembly.Resources" Version="2.0.9" PrivateAssets="all" />
</ItemGroup>

<Target Name="CopyEmbeddedCode" Inputs="@(EmbeddedCode)" Outputs="@(EmbeddedCode -> '$(IntermediateOutputPath)%(Filename).txt')">
<Copy SourceFiles="@(EmbeddedCode)" DestinationFiles="@(EmbeddedCode -> '$(IntermediateOutputPath)%(Filename).txt')" SkipUnchangedFiles="true" />
<ItemGroup>
<TemplateCode Include="..\StructId\Templates\*.cs" Link="StructId\%(Filename)%(Extension)" />
</ItemGroup>

<Target Name="CopyTemplateCode" Inputs="@(TemplateCode)" Outputs="@(TemplateCode -> '$(IntermediateOutputPath)Templates\%(Filename).txt')">
<Copy SourceFiles="@(TemplateCode)" DestinationFiles="@(TemplateCode -> '$(IntermediateOutputPath)Templates\%(Filename).txt')" SkipUnchangedFiles="true" />
</Target>

<Target Name="AddEmbeddedResources" DependsOnTargets="CopyEmbeddedCode" BeforeTargets="SplitResourcesByCulture">
<Target Name="AddTemplateCode" DependsOnTargets="CopyTemplateCode" BeforeTargets="SplitResourcesByCulture">
<ItemGroup>
<EmbeddedResource Include="@(EmbeddedCode -> '$(IntermediateOutputPath)%(Filename).txt')" Link="%(EmbeddedCode.Filename).txt" Type="Non-Resx" />
<EmbeddedResource Include="@(TemplateCode -> '$(IntermediateOutputPath)Templates\%(Filename).txt')" Link="Templates\%(TemplateCode.Filename).txt" Type="Non-Resx" />
</ItemGroup>
</Target>

Expand Down
73 changes: 73 additions & 0 deletions src/StructId.Tests/Functional.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;

namespace StructId.Functional;

[TypeConverter(typeof(ProductId.TypeConverter))]
public readonly partial record struct ProductId : IStructId<Guid>
{
public class TypeConverter : ParseableTypeConverter<ProductId, Guid>
{
protected override ProductId Create(Guid value) => new(value);
}
}

public readonly partial record struct WalletId : IStructId
{
public class TypeConverter : StructIdConverters.StringTypeConverter<WalletId>
{
protected override WalletId Create(string value) => new(value);
}
}

public abstract class ParseableTypeConverter<TStruct, TValue> : TypeConverter
where TStruct : IStructId<TValue>
where TValue : struct, IParsable<TValue>
{
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) => sourceType == typeof(string);

public override object? ConvertFrom(ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object value) => value switch
{
string s => Create(TValue.Parse(s, culture)),
null => null,
_ => throw new ArgumentException($"Cannot convert from {value} to {typeof(TStruct).Name}", nameof(value))
};

protected abstract TStruct Create(TValue value);

public override object? ConvertTo(ITypeDescriptorContext? context, System.Globalization.CultureInfo? culture, object? value, Type destinationType)
{
if (destinationType == typeof(string))
{
return value switch
{
TValue id => id.ToString(),
TStruct id => id.Value.ToString(),
null => null,
_ => throw new ArgumentException($"Cannot convert {value} to string", nameof(value))
};
}

throw new ArgumentException($"Cannot convert {value ?? "(null)"} to {destinationType}", nameof(destinationType));
}
}

public class FunctionalTests
{
[Fact]
public void Test()
{
var guid = Guid.NewGuid();
var id1 = new ProductId(guid);
var id2 = new ProductId(guid);

Assert.Equal(id1, id2);
Assert.True(id1 == id2);
}
}
Loading

0 comments on commit 3f16349

Please sign in to comment.