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
2 changes: 1 addition & 1 deletion src/StreamJsonRpc.Analyzers/GeneratorModels/EventModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,6 @@ internal override void WriteEvents(SourceWriter writer)
return null;
}

return new EventModel(evt.Name, evt.Type.ToDisplayString(ProxyGenerator.FullyQualifiedWithNullableFormat), invokeMethod.Parameters[1].Type.ToDisplayString(ProxyGenerator.FullyQualifiedWithNullableFormat));
return new EventModel(evt.Name, evt.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), invokeMethod.Parameters[1].Type.ToDisplayString(ProxyGenerator.FullyQualifiedWithNullableFormat));
}
}
58 changes: 41 additions & 17 deletions src/StreamJsonRpc.Analyzers/GeneratorModels/InterfaceModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,17 @@

namespace StreamJsonRpc.Analyzers.GeneratorModels;

internal record InterfaceModel(string Prefix, string FullName, string Name, Container? Container, ImmutableEquatableArray<MethodModel> Methods, ImmutableEquatableArray<EventModel> Events)
/// <summary>
/// Describes an interface for which a proxy may be generated.
/// </summary>
/// <param name="FullName">The full name of the type, including generic type parameters.</param>
/// <param name="Name">The leaf name of the type, excluding generic type parameters.</param>
/// <param name="TypeParameters">A list of generic type parameters on the interface.</param>
/// <param name="Container">The declaring type or namespace in which the interface is declared.</param>
/// <param name="Methods">The methods in the interface.</param>
/// <param name="Events">The events in the interface.</param>
/// <param name="HasUnsupportedMemberTypes">Indicates whether the interface has additional members that are not supported.</param>
internal record InterfaceModel(string FullName, string Name, ImmutableEquatableArray<string> TypeParameters, Container? Container, ImmutableEquatableArray<MethodModel> Methods, ImmutableEquatableArray<EventModel> Events, bool HasUnsupportedMemberTypes)
{
internal required bool IsPartial { get; init; }

Expand All @@ -20,26 +30,40 @@ internal record InterfaceModel(string Prefix, string FullName, string Name, Cont

internal static InterfaceModel Create(INamedTypeSymbol iface, KnownSymbols symbols, bool declaredInThisCompilation, CancellationToken cancellationToken)
{
ImmutableEquatableArray<MethodModel> methods = new([..
iface.GetAllMembers()
.OfType<IMethodSymbol>()
.Where(m => m.AssociatedSymbol is null && !SymbolEqualityComparer.Default.Equals(m.ContainingType, symbols.IDisposable))
.Select(method => MethodModel.Create(method, symbols))]);

ImmutableEquatableArray<EventModel> events = new([..
iface.GetAllMembers()
.OfType<IEventSymbol>()
.Select(evt => EventModel.Create(evt, symbols))
.Where(evt => evt is not null)!]);

string fileNamePrefix = iface.ToDisplayString(GenerationHelpers.QualifiedNameOnlyFormat);
bool hasUnsupportedMemberTypes = false;
List<MethodModel> methods = [];
List<EventModel> events = [];

foreach (ISymbol member in iface.GetAllMembers())
{
switch (member)
{
case IMethodSymbol method when SymbolEqualityComparer.Default.Equals(method.ContainingType, symbols.IDisposable):
// We don't map this special Dispose method.
break;
case IMethodSymbol { AssociatedSymbol: not null }:
// We'll handle these as part of the associated symbol.
break;
case IMethodSymbol method:
methods.Add(MethodModel.Create(method, symbols));
break;
case IEventSymbol evt when EventModel.Create(evt, symbols) is EventModel evtModel:
events.Add(evtModel);
break;
default:
hasUnsupportedMemberTypes = true;
break;
}
}

return new InterfaceModel(
fileNamePrefix,
iface.ToDisplayString(ProxyGenerator.FullyQualifiedNoGlobalWithNullableFormat),
iface.Name,
[.. iface.TypeParameters.Select(tp => tp.Name)],
Container.CreateFor((INamespaceOrTypeSymbol?)iface.ContainingType ?? iface.ContainingNamespace, cancellationToken),
methods,
events)
methods.ToImmutableEquatableArray(),
events.ToImmutableEquatableArray(),
hasUnsupportedMemberTypes)
{
IsPartial = iface.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax(cancellationToken) is InterfaceDeclarationSyntax syntax && syntax.Modifiers.Any(SyntaxKind.PartialKeyword),
IsPublic = iface.IsActuallyPublic(),
Expand Down
70 changes: 55 additions & 15 deletions src/StreamJsonRpc.Analyzers/GeneratorModels/ProxyModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace StreamJsonRpc.Analyzers.GeneratorModels;

internal record ProxyModel : FormattableModel
{
private readonly ImmutableEquatableArray<FormattableModel> formattableElements;
private readonly ImmutableEquatableArray<FormattableModel> formattableElements = [];
Comment thread
AArnott marked this conversation as resolved.

internal ProxyModel(ImmutableEquatableSet<InterfaceModel> interfaces, string? externalProxyName = null)
{
Expand All @@ -20,35 +20,63 @@ internal ProxyModel(ImmutableEquatableSet<InterfaceModel> interfaces, string? ex

this.Interfaces = interfaces;

string nonGenericPart;
string? genericPart;
if (externalProxyName is null)
{
string name = CreateProxyName(interfaces);
this.Name = $"{name.Replace('.', '_')}_Proxy";
this.FileName = $"{name.Replace('<', '_').Replace('>', '_')}.g.cs";
(nonGenericPart, genericPart) = SplitGenericName(name);
string nonGenericProxy = $"{nonGenericPart.Replace('.', '_')}_Proxy";
this.Name = nonGenericProxy;
}
else
{
this.Name = externalProxyName;
(nonGenericPart, genericPart) = SplitGenericName(externalProxyName);
this.Name = nonGenericPart;
}

int methodSuffix = 0;
this.formattableElements = this.Interfaces.SelectMany(i => i.Methods).Concat<FormattableModel>(
this.Interfaces.SelectMany(i => i.Events)).Distinct()
.Select(e => e is MethodModel method ? method with { UniqueSuffix = ++methodSuffix } : e)
.ToImmutableEquatableArray();
this.Arity = genericPart is null ? 0 : genericPart.Count(c => c == ',') + 1;
this.GenericTypeDefinitionSuffix = genericPart is null ? string.Empty : $"<{new string(',', this.Arity - 1)}>";
this.GenericTypeSuffix = genericPart ?? string.Empty;

this.HasInvalidInterfaces = this.Interfaces.Any(iface => iface.HasUnsupportedMemberTypes);

if (!this.HasInvalidInterfaces)
{
int methodSuffix = 0;
this.formattableElements = this.Interfaces.SelectMany(i => i.Methods).Concat<FormattableModel>(
this.Interfaces.SelectMany(i => i.Events)).Distinct()
.Select(e => e is MethodModel method ? method with { UniqueSuffix = ++methodSuffix } : e)
.ToImmutableEquatableArray();
}
}

internal ImmutableEquatableSet<InterfaceModel> Interfaces { get; }

/// <summary>
/// Gets the leaf name of the proxy type, excluding generic type parameters.
/// </summary>
internal string Name { get; }

internal int Arity { get; }

internal string GenericTypeDefinitionSuffix { get; }

internal string GenericTypeSuffix { get; }

internal string? FileName { get; }

internal bool HasInvalidInterfaces { get; }

internal void WriteInterfaceMapping(SourceWriter writer, InterfaceModel iface)
{
string genericTypeParameters = iface.TypeParameters.Length > 0
? $"<{string.Join(", ", iface.TypeParameters)}>"
: string.Empty;
writer.WriteLine($$"""
[global::StreamJsonRpc.Reflection.JsonRpcProxyMappingAttribute(typeof({{ProxyGenerator.GenerationNamespace}}.{{this.Name}}))]
partial interface {{iface.Name}}
[global::StreamJsonRpc.Reflection.JsonRpcProxyMappingAttribute(typeof({{ProxyGenerator.GenerationNamespace}}.{{this.Name}}{{this.GenericTypeDefinitionSuffix}}))]
partial interface {{iface.Name}}{{genericTypeParameters}}
{
}
""");
Expand Down Expand Up @@ -114,16 +142,20 @@ internal void GenerateSource(SourceProductionContext context, bool isPublic)
}

writer.WriteLine($$"""
{{visibility}} class {{this.Name}} : global::StreamJsonRpc.Reflection.ProxyBase
{{visibility}} class {{this.Name}}{{this.GenericTypeSuffix}} : global::StreamJsonRpc.Reflection.ProxyBase
""");

writer.Indentation++;
foreach (InterfaceModel iface in this.Interfaces)
if (!this.HasInvalidInterfaces)
{
writer.WriteLine($", global::{iface.FullName}");
writer.Indentation++;
foreach (InterfaceModel iface in this.Interfaces)
{
writer.WriteLine($", global::{iface.FullName}");
}

writer.Indentation--;
}

writer.Indentation--;
writer.WriteLine("""
{
""");
Expand Down Expand Up @@ -213,6 +245,14 @@ private void WriteConstructor(SourceWriter writer)
""");
}

private static (string NonGenericPart, string? GenericPart) SplitGenericName(string name)
{
int genericStart = name.IndexOf('<');
return genericStart < 0
? (name, null)
: (name[..genericStart], name[genericStart..]);
}

private static string CreateProxyName(ImmutableEquatableSet<InterfaceModel> interfaces)
{
// We need to create a unique, deterministic name given the set of interfaces the proxy must implement.
Expand Down
70 changes: 46 additions & 24 deletions src/StreamJsonRpc.Analyzers/ProxyGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,34 +40,42 @@ public partial class ProxyGenerator : IIncrementalGenerator
/// <inheritdoc />
public void Initialize(IncrementalGeneratorInitializationContext context)
{
IncrementalValuesProvider<ImmutableEquatableArray<ProxyModel>> proxyProvider = context.SyntaxProvider.ForAttributeWithMetadataName<ImmutableEquatableArray<ProxyModel>>(
IncrementalValuesProvider<ImmutableEquatableArray<ProxyModel>> rpcContractProxyProvider = context.SyntaxProvider.ForAttributeWithMetadataName(
Types.JsonRpcContractAttribute.FullName,
(node, cancellationToken) => true,
(context, cancellationToken) =>
PrepareProxy);
IncrementalValuesProvider<ImmutableEquatableArray<ProxyModel>> rpcMarshalableProxyProvider = context.SyntaxProvider.ForAttributeWithMetadataName(
Types.RpcMarshalableAttribute.FullName,
(node, cancellationToken) => true,
PrepareProxy);
IncrementalValueProvider<ImmutableEquatableSet<ProxyModel>> proxyProvider = rpcContractProxyProvider.Collect().Combine(rpcMarshalableProxyProvider.Collect()).Select(
((ImmutableArray<ImmutableEquatableArray<ProxyModel>> Left, ImmutableArray<ImmutableEquatableArray<ProxyModel>> Right) input, CancellationToken token) => input.Left.SelectMany(p => p).Concat(input.Right.SelectMany(p => p)).ToImmutableEquatableSet());

ImmutableEquatableArray<ProxyModel> PrepareProxy(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken)
{
if (context.TargetSymbol is not INamedTypeSymbol iface)
{
if (context.TargetSymbol is not INamedTypeSymbol iface)
{
return [];
}
return [];
}

if (!KnownSymbols.TryCreate(context.SemanticModel.Compilation, out KnownSymbols? symbols))
{
return [];
}
if (!KnownSymbols.TryCreate(context.SemanticModel.Compilation, out KnownSymbols? symbols))
{
return [];
}

// Skip inaccessible interfaces.
if (!context.SemanticModel.Compilation.IsSymbolAccessibleWithin(context.TargetSymbol, context.SemanticModel.Compilation.Assembly))
{
// Reported by StreamJsonRpc0001
return [];
}
// Skip inaccessible interfaces.
if (!context.SemanticModel.Compilation.IsSymbolAccessibleWithin(context.TargetSymbol, context.SemanticModel.Compilation.Assembly))
{
// Reported by StreamJsonRpc0001
return [];
}

IEnumerable<ProxyModel> proxies = ExpandInterfaceToGroups(iface, symbols)
.Select(group => new ProxyModel(
[.. group.Select(i => InterfaceModel.Create(i, symbols, declaredInThisCompilation: true, cancellationToken))]));
IEnumerable<ProxyModel> proxies = ExpandInterfaceToGroups(iface, symbols)
.Select(group => new ProxyModel(
[.. group.Select(i => InterfaceModel.Create(i, symbols, declaredInThisCompilation: true, cancellationToken))]));

return [.. proxies];
});
return [.. proxies];
}

IncrementalValuesProvider<AttachUse> attachUseProvider = context.SyntaxProvider.CreateSyntaxProvider(
(node, cancellationToken) => node is InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax { Name.Identifier.ValueText: "Attach" } },
Expand Down Expand Up @@ -100,14 +108,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
IncrementalValueProvider<bool> publicProxy = context.CompilationProvider.Select((c, token) => this.AreProxiesPublic(c));
IncrementalValueProvider<bool> interceptorsEnabled = context.AnalyzerConfigOptionsProvider.Select((provider, token) => AreInterceptorsEnabled(provider.GlobalOptions));

IncrementalValueProvider<FullModel> fullModel = proxyProvider.Collect().Combine(attachUseProvider.Collect()).Combine(publicProxy.Combine(interceptorsEnabled)).Select(
IncrementalValueProvider<FullModel> fullModel = proxyProvider.Combine(attachUseProvider.Collect()).Combine(publicProxy.Combine(interceptorsEnabled)).Select(
(combined, attach) =>
{
ImmutableArray<ProxyModel> proxies = [.. combined.Left.Left.SelectMany(g => g)];
ImmutableEquatableSet<ProxyModel> proxies = combined.Left.Left;
ImmutableArray<AttachUse> attachUses = combined.Left.Right;
bool publicProxies = combined.Right.Left;
bool interceptorsEnabled = combined.Right.Right;
return new FullModel(proxies.ToImmutableEquatableSet(), attachUses.ToImmutableEquatableArray(), publicProxies, interceptorsEnabled);
return new FullModel(proxies, attachUses.ToImmutableEquatableArray(), publicProxies, interceptorsEnabled);
});

context.RegisterSourceOutput(fullModel, (context, model) => model.GenerateSource(context));
Expand Down Expand Up @@ -323,6 +331,20 @@ private static bool TryGetImplementingProxy(INamedTypeSymbol[] ifaces, KnownSymb
return false;
}

/// <summary>
/// Expands the specified interface into groups based on the presence of the JsonRpcProxyInterfaceGroupAttribute.
/// Each group consists of the primary interface and any additional interfaces defined by the attribute.
/// </summary>
/// <remarks>This method inspects the attributes of the primary interface to determine groupings. If the
/// JsonRpcProxyInterfaceGroupAttribute is present and specifies additional interfaces, each group will include the
/// primary interface followed by those interfaces. If the attribute is not present, the primary interface is
/// returned as its own group.</remarks>
/// <param name="primary">The primary interface symbol to expand into groups. This symbol is always included as the first element in each
/// group.</param>
/// <param name="symbols">A container for well-known symbols, including the JsonRpcProxyInterfaceGroupAttribute used to identify interface
/// groups.</param>
/// <returns>An enumerable collection of interface groups, where each group is an array of INamedTypeSymbol. If no groups are
/// defined, a single group containing only the primary interface is returned.</returns>
private static IEnumerable<INamedTypeSymbol[]> ExpandInterfaceToGroups(INamedTypeSymbol primary, KnownSymbols symbols)
{
bool anyGroupsDefined = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ namespace StreamJsonRpc.Reflection;
/// </summary>
internal partial class MessageFormatterRpcMarshaledContextTracker
{
private static readonly IReadOnlyCollection<(Type ImplicitlyMarshaledType, JsonRpcProxyOptions ProxyOptions, JsonRpcTargetOptions TargetOptions, RpcMarshalableAttribute Attribute)> ImplicitlyMarshaledTypes = new (Type, JsonRpcProxyOptions, JsonRpcTargetOptions, RpcMarshalableAttribute)[]
{
private static readonly IReadOnlyCollection<(Type ImplicitlyMarshaledType, JsonRpcProxyOptions ProxyOptions, JsonRpcTargetOptions TargetOptions, RpcMarshalableAttribute Attribute)> ImplicitlyMarshaledTypes =
[
(typeof(IDisposable), new JsonRpcProxyOptions { MethodNameTransform = CommonMethodNameTransforms.CamelCase }, new JsonRpcTargetOptions { MethodNameTransform = CommonMethodNameTransforms.CamelCase }, new RpcMarshalableAttribute()),

// IObserver<T> support requires special recognition of OnCompleted and OnError be considered terminating calls.
Expand Down Expand Up @@ -49,7 +49,7 @@ internal partial class MessageFormatterRpcMarshaledContextTracker
},
new JsonRpcTargetOptions { MethodNameTransform = CommonMethodNameTransforms.CamelCase },
new RpcMarshalableAttribute()),
};
];

private static readonly ConcurrentDictionary<Type, (JsonRpcProxyOptions ProxyOptions, JsonRpcTargetOptions TargetOptions, RpcMarshalableAttribute Attribute)> MarshaledTypes = new();
private static readonly (JsonRpcProxyOptions ProxyOptions, JsonRpcTargetOptions TargetOptions) RpcMarshalableInterfaceDefaultOptions = (new JsonRpcProxyOptions(), new JsonRpcTargetOptions { NotifyClientOfEvents = false, DisposeOnDisconnect = true });
Expand Down
18 changes: 18 additions & 0 deletions test/StreamJsonRpc.Analyzer.Tests/JsonRpcContractAnalyzerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,24 @@ partial interface IMyRpc : IDisposable
""");
}

[Fact]
public async Task RpcMarshalable_DisallowedMembers()
{
await VerifyCS.VerifyAnalyzerAsync("""
[RpcMarshalable]
partial interface IMyRpc : IDisposable
{
event EventHandler {|StreamJsonRpc0012:Changed|};
event EventHandler<int> {|StreamJsonRpc0012:Updated|};
event CustomEvent {|StreamJsonRpc0012:Custom|};
int {|StreamJsonRpc0012:Count|} { get; }
void {|StreamJsonRpc0013:Add|}<T>(T item);
}

delegate void CustomEvent();
""");
}

[Fact]
public async Task RpcMarshalable_WithOptionalInterfaceAndNoAttribute()
{
Expand Down
Loading