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
Expand Up @@ -38,3 +38,4 @@ SM0048 | SimpleModule.Generator | Error | Feature field is not a const string
SM0049 | SimpleModule.Generator | Error | Multiple endpoints in a single file
SM0052 | SimpleModule.Generator | Error | Module assembly name does not follow naming convention
SM0053 | SimpleModule.Generator | Error | Module has no matching Contracts assembly
SM0054 | SimpleModule.Generator | Info | Endpoint missing Route const field
12 changes: 11 additions & 1 deletion framework/SimpleModule.Generator/Discovery/DiscoveryData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -200,13 +200,17 @@ public override int GetHashCode()
internal readonly record struct EndpointInfoRecord(
string FullyQualifiedName,
ImmutableArray<string> RequiredPermissions,
bool AllowAnonymous
bool AllowAnonymous,
string RouteTemplate,
string HttpMethod
)
{
public bool Equals(EndpointInfoRecord other)
{
return FullyQualifiedName == other.FullyQualifiedName
&& AllowAnonymous == other.AllowAnonymous
&& RouteTemplate == other.RouteTemplate
&& HttpMethod == other.HttpMethod
&& RequiredPermissions.SequenceEqual(other.RequiredPermissions);
}

Expand All @@ -215,6 +219,8 @@ public override int GetHashCode()
var hash = 17;
hash = HashHelper.Combine(hash, FullyQualifiedName.GetHashCode());
hash = HashHelper.Combine(hash, AllowAnonymous.GetHashCode());
hash = HashHelper.Combine(hash, (RouteTemplate ?? "").GetHashCode());
hash = HashHelper.Combine(hash, (HttpMethod ?? "").GetHashCode());
hash = HashHelper.HashArray(hash, RequiredPermissions);
return hash;
}
Expand All @@ -223,6 +229,7 @@ public override int GetHashCode()
internal readonly record struct ViewInfoRecord(
string FullyQualifiedName,
string Page,
string RouteTemplate,
SourceLocationRecord? Location
);

Expand Down Expand Up @@ -499,13 +506,16 @@ internal sealed class EndpointInfo
public string FullyQualifiedName { get; set; } = "";
public List<string> RequiredPermissions { get; set; } = new();
public bool AllowAnonymous { get; set; }
public string RouteTemplate { get; set; } = "";
public string HttpMethod { get; set; } = "";
}

internal sealed class ViewInfo
{
public string FullyQualifiedName { get; set; } = "";
public string? Page { get; set; }
public string InferredClassName { get; set; } = "";
public string RouteTemplate { get; set; } = "";
public SourceLocationRecord? Location { get; set; }
}

Expand Down
43 changes: 34 additions & 9 deletions framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -698,12 +698,15 @@ is not IAssemblySymbol assemblySymbol
m.Endpoints.Select(e => new EndpointInfoRecord(
e.FullyQualifiedName,
e.RequiredPermissions.ToImmutableArray(),
e.AllowAnonymous
e.AllowAnonymous,
e.RouteTemplate,
e.HttpMethod
))
.ToImmutableArray(),
m.Views.Select(v => new ViewInfoRecord(
v.FullyQualifiedName,
v.Page ?? "",
v.RouteTemplate,
v.Location
))
.ToImmutableArray(),
Expand Down Expand Up @@ -985,14 +988,16 @@ viewEndpointInterfaceSymbol is not null
else if (className.EndsWith("View", StringComparison.Ordinal))
className = className.Substring(0, className.Length - "View".Length);

views.Add(
new ViewInfo
{
FullyQualifiedName = fqn,
InferredClassName = className,
Location = GetSourceLocation(typeSymbol),
}
);
var viewInfo = new ViewInfo
{
FullyQualifiedName = fqn,
InferredClassName = className,
Location = GetSourceLocation(typeSymbol),
};

var (viewRoute, _) = ReadRouteConstFields(typeSymbol);
viewInfo.RouteTemplate = viewRoute;
views.Add(viewInfo);
}
else if (ImplementsInterface(typeSymbol, endpointInterfaceSymbol))
{
Expand Down Expand Up @@ -1035,13 +1040,33 @@ viewEndpointInterfaceSymbol is not null
}
}

var (epRoute, epMethod) = ReadRouteConstFields(typeSymbol);
info.RouteTemplate = epRoute;
info.HttpMethod = epMethod;
endpoints.Add(info);
}
}
}
}
}

private static (string route, string method) ReadRouteConstFields(INamedTypeSymbol typeSymbol)
{
var route = "";
var method = "";
foreach (var m in typeSymbol.GetMembers())
{
if (m is IFieldSymbol { IsConst: true, ConstantValue: string value } field)
{
if (field.Name == "Route")
route = value;
else if (field.Name == "Method")
method = value;
}
}
return (route, method);
}

private static bool ImplementsInterface(
INamedTypeSymbol typeSymbol,
INamedTypeSymbol interfaceSymbol
Expand Down
38 changes: 38 additions & 0 deletions framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,15 @@ internal sealed class DiagnosticEmitter : IEmitter
isEnabledByDefault: true
);

private static readonly DiagnosticDescriptor MissingEndpointRouteConst = new(
id: "SM0054",
title: "Endpoint missing Route const field",
messageFormat: "Endpoint '{0}' does not declare a 'public const string Route' field. Add a Route const so the source generator can emit type-safe route helpers.",
category: "SimpleModule.Generator",
defaultSeverity: DiagnosticSeverity.Info,
isEnabledByDefault: true
);

public void Emit(SourceProductionContext context, DiscoveryData data)
{
// SM0002: Empty module name
Expand Down Expand Up @@ -1147,6 +1156,35 @@ public void Emit(SourceProductionContext context, DiscoveryData data)
)
);
}

// SM0054: Endpoint missing Route const
foreach (var endpoint in module.Endpoints)
{
if (string.IsNullOrEmpty(endpoint.RouteTemplate))
{
context.ReportDiagnostic(
Diagnostic.Create(
MissingEndpointRouteConst,
Location.None,
Strip(endpoint.FullyQualifiedName)
)
);
}
}

foreach (var view in module.Views)
{
if (string.IsNullOrEmpty(view.RouteTemplate))
{
context.ReportDiagnostic(
Diagnostic.Create(
MissingEndpointRouteConst,
LocationHelper.ToLocation(view.Location),
Strip(view.FullyQualifiedName)
)
);
}
}
}
}

Expand Down
186 changes: 186 additions & 0 deletions framework/SimpleModule.Generator/Emitters/RoutesEmitter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

namespace SimpleModule.Generator;

internal sealed class RoutesEmitter : IEmitter
{
private static readonly Regex RouteParamRegex = new Regex(
@"\{[*?]*(\w+?)(?:[:?*].*?)?\}",
RegexOptions.Compiled
);

public void Emit(SourceProductionContext context, DiscoveryData data)
{
var modules = TopologicalSort.SortModules(data);

// Only emit if at least one endpoint has a route template
if (!modules.Any(m => HasAnyRouteTemplates(m)))
return;

var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("namespace SimpleModule.Core;");
sb.AppendLine();
sb.AppendLine("public static class ModuleRoutes");
sb.AppendLine("{");

foreach (var module in modules)
{
if (!HasAnyRouteTemplates(module))
continue;

sb.AppendLine($" public static class {module.ModuleName}");
sb.AppendLine(" {");

if (!string.IsNullOrEmpty(module.RoutePrefix))
sb.AppendLine($" public const string ApiPrefix = \"{module.RoutePrefix}\";");

if (!string.IsNullOrEmpty(module.ViewPrefix))
sb.AppendLine($" public const string ViewPrefix = \"{module.ViewPrefix}\";");

// API endpoints
var apiEndpoints = module.Endpoints.Where(e => e.RouteTemplate.Length > 0).ToList();
if (apiEndpoints.Count > 0)
{
sb.AppendLine();
sb.AppendLine(" public static class Api");
sb.AppendLine(" {");

foreach (var endpoint in apiEndpoints)
{
EmitRouteMethod(
sb,
endpoint.FullyQualifiedName,
module.RoutePrefix,
endpoint.RouteTemplate,
" "
);
}

sb.AppendLine(" }");
}

// View endpoints
var viewEndpoints = module.Views.Where(v => v.RouteTemplate.Length > 0).ToList();
if (viewEndpoints.Count > 0)
{
sb.AppendLine();
sb.AppendLine(" public static class Views");
sb.AppendLine(" {");

foreach (var view in viewEndpoints)
{
EmitRouteMethod(
sb,
view.FullyQualifiedName,
module.ViewPrefix,
view.RouteTemplate,
" "
);
}

sb.AppendLine(" }");
}

sb.AppendLine(" }");
sb.AppendLine();
}

sb.AppendLine("}");

context.AddSource("ModuleRoutes.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8));
}

internal static bool HasAnyRouteTemplates(ModuleInfoRecord module)
{
return module.Endpoints.Any(e => e.RouteTemplate.Length > 0)
|| module.Views.Any(v => v.RouteTemplate.Length > 0);
}

private static void EmitRouteMethod(
StringBuilder sb,
string fullyQualifiedName,
string prefix,
string routeTemplate,
string indent
)
{
var methodName = DeriveMethodName(fullyQualifiedName);
var fullRoute = CombineRoute(prefix, routeTemplate);
var parameters = ExtractRouteParameters(routeTemplate);

if (parameters.Count == 0)
{
var cleanRoute = StripRouteConstraints(fullRoute);
sb.AppendLine($"{indent}public static string {methodName}() => \"{cleanRoute}\";");
}
else
{
var paramList = string.Join(", ", parameters.Select(p => $"object {p}"));
var interpolated = BuildInterpolatedRoute(prefix, routeTemplate);
sb.AppendLine(
$"{indent}public static string {methodName}({paramList}) => $\"{interpolated}\";"
);
}
}

internal static string DeriveMethodName(string fullyQualifiedName)
{
var name = TypeMappingHelpers.StripGlobalPrefix(fullyQualifiedName);
// Take the last segment (class name)
var lastDot = name.LastIndexOf('.');
if (lastDot >= 0)
name = name.Substring(lastDot + 1);

// Strip common suffixes
if (name.EndsWith("Endpoint", StringComparison.Ordinal))
name = name.Substring(0, name.Length - "Endpoint".Length);
else if (name.EndsWith("View", StringComparison.Ordinal))
name = name.Substring(0, name.Length - "View".Length);

return name;
}

internal static string CombineRoute(string prefix, string template)
{
if (string.IsNullOrEmpty(prefix))
return template;
if (template == "/")
return prefix;

// Ensure no double slashes
var p = prefix.TrimEnd('/');
var t = template.StartsWith("/", StringComparison.Ordinal) ? template : "/" + template;
return p + t;
}

internal static List<string> ExtractRouteParameters(string routeTemplate)
{
var result = new List<string>();
foreach (Match match in RouteParamRegex.Matches(routeTemplate))
{
result.Add(match.Groups[1].Value);
}
return result;
}

/// <summary>
/// Strips ASP.NET route constraints from a route template.
/// E.g., "/{id:guid}" → "/{id}", "/{id:int}" → "/{id}", "/{**key}" → "/{key}"
/// </summary>
internal static string StripRouteConstraints(string route)
{
return RouteParamRegex.Replace(route, "{$1}");
}

private static string BuildInterpolatedRoute(string prefix, string routeTemplate)
{
return StripRouteConstraints(CombineRoute(prefix, routeTemplate));
}
}
Loading
Loading