Skip to content
Open
Show file tree
Hide file tree
Changes from 20 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,9 @@ _book
/src/Butil/Bit.Butil/Scripts/**/*.js
/src/Butil/Bit.Butil/wwwroot/**/*.js

/src/Brouter/Bit.Brouter/Scripts/**/*.js
/src/Brouter/Bit.Brouter/wwwroot/**/*.js

/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/BitFileUploaderFiles/*.*
/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/wwwroot/scripts/app.js

Expand Down
32 changes: 32 additions & 0 deletions src/Brouter/Bit.Brouter.Generators/Bit.Brouter.Generators.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">

<Import Project="../../Bit.Build.props" />

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<!-- Roslyn source generator: the assembly ships as an analyzer, not a lib reference. -->
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IncludeBuildOutput>false</IncludeBuildOutput>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
<!-- netstandard2.0 has no nullable annotations in the BCL; keep nullable on for our own code
but don't warn about the BCL's unannotated surface. -->
<NoWarn>$(NoWarn);nullable</NoWarn>
<IsAotCompatible>false</IsAotCompatible>
</PropertyGroup>

<ItemGroup>
<!-- 4.8 keeps the generator loadable by every SDK/IDE Roslyn from .NET 8 tooling onwards. -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
<!-- Pack as an analyzer so `dotnet add package Bit.Brouter.Generators` just works. -->
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Bit.Brouter.Generators.Tests" PublicKey="00240000048000009400000006020000002400005253413100040000010001008D726FE8F1BED8CA2A003848640C872C6A5F2191C81EDA677B249A6E34BD9134EBA9FF7251582A5020CB3EEE83A61E7034CE712F5873D60F7C3C61F48592B256C560D7B3384E9889E4F81E4D406BC2B639915A4062D60751193AE66028D7BD4B9A3BF0823F1E38ABE5EADC3CD9615C6FF811974A9F6F89297DC2A722BF23D0BB" />
</ItemGroup>

</Project>
275 changes: 275 additions & 0 deletions src/Brouter/Bit.Brouter.Generators/BrouterRoutesGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

namespace Bit.Brouter.Generators;

/// <summary>
/// Generates <c>BrouterRoutes</c> - one compile-time-safe URL builder method per route declared in
/// the project's .razor files (<c>@page</c>, <c>@attribute [Route]</c> and literal
/// <c>&lt;Broute Path="..."&gt;</c> trees). Route parameters become typed method parameters using
/// the template's constraints (<c>{id:int}</c> → <c>int id</c>), optionals become optional
/// arguments, catch-alls become path strings, and values are escaped/formatted with the exact
/// rules the router itself uses - so a generated URL always round-trips through its template.
/// </summary>
[Generator]
public sealed class BrouterRoutesGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var routesPerFile = context.AdditionalTextsProvider
.Where(static text => text.Path.EndsWith(".razor", StringComparison.OrdinalIgnoreCase))
.Select(static (text, ct) =>
{
var content = text.GetText(ct)?.ToString() ?? string.Empty;
return new EquatableArray<RouteModel>(RazorRouteScanner.Scan(content).ToArray());
})
.Where(static routes => routes.Count > 0)
.Collect();

var rootNamespace = context.AnalyzerConfigOptionsProvider
.Select(static (provider, _) =>
provider.GlobalOptions.TryGetValue("build_property.RootNamespace", out var ns) && string.IsNullOrWhiteSpace(ns) is false
? ns
: "Bit.Brouter.Generated");

context.RegisterSourceOutput(routesPerFile.Combine(rootNamespace), static (spc, input) =>
{
var (perFile, ns) = input;
var source = Emit(perFile, ns);
if (source is not null)
{
spc.AddSource("BrouterRoutes.g.cs", SourceText.From(source, Encoding.UTF8));
}
});
}

private static string? Emit(ImmutableArray<EquatableArray<RouteModel>> perFile, string ns)
{
// Dedup by canonical template (case-insensitive; parameter names don't distinguish
// templates at runtime, but for generation the first declaration's names/types win -
// matching the router's "hand-declared shadows discovered" precedence closely enough).
// A named declaration is preferred over an unnamed duplicate so Names constants survive.
var byTemplate = new Dictionary<string, RouteModel>(StringComparer.OrdinalIgnoreCase);
foreach (var file in perFile)
{
foreach (var route in file.Items)
{
if (byTemplate.TryGetValue(route.Template, out var existing))
{
if (existing.Name is null && route.Name is not null) byTemplate[route.Template] = route;
continue;
}
byTemplate[route.Template] = route;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
msynk marked this conversation as resolved.
Outdated

if (byTemplate.Count == 0) return null;

var routes = byTemplate.Values.OrderBy(r => r.Template, StringComparer.Ordinal).ToList();

var sb = new StringBuilder();
sb.AppendLine("// <auto-generated by Bit.Brouter.Generators />");
sb.AppendLine("#nullable enable");
sb.AppendLine($"namespace {ns};");
sb.AppendLine();
sb.AppendLine("/// <summary>Compile-time-safe URL builders for this app's Brouter routes.</summary>");
sb.AppendLine("public static partial class BrouterRoutes");
sb.AppendLine("{");

// Method-name assignment happens in two passes so an explicitly named route always owns
// its own name: "Name=counter" must yield Counter(...) even when an unnamed "/counter"
// template would derive the same identifier (that one gets the numeric suffix instead).
var usedNames = new HashSet<string>(StringComparer.Ordinal);
var methodNames = new Dictionary<RouteModel, string>();
foreach (var route in routes)
{
if (route.Name is not null) methodNames[route] = MakeUnique(MethodNameFor(route), usedNames);
}
foreach (var route in routes)
{
if (route.Name is null) methodNames[route] = MakeUnique(MethodNameFor(route), usedNames);
}

var namedRoutes = new List<(string Constant, string Name)>();
foreach (var route in routes)
{
var methodName = methodNames[route];
if (route.Name is not null)
{
namedRoutes.Add((methodName, route.Name));
}
EmitMethod(sb, route, methodName);
}

if (namedRoutes.Count > 0)
{
sb.AppendLine();
sb.AppendLine(" /// <summary>The declared route names, for NavigateToName/ResolveUrl without magic strings.</summary>");
sb.AppendLine(" public static class Names");
sb.AppendLine(" {");
var usedConstants = new HashSet<string>(StringComparer.Ordinal);
foreach (var (constant, name) in namedRoutes)
{
var constantName = MakeUnique(constant, usedConstants);
sb.AppendLine($" public const string {constantName} = \"{Escape(name)}\";");
}
sb.AppendLine(" }");
}

EmitHelpers(sb);
sb.AppendLine("}");
return sb.ToString();
}

private static void EmitMethod(StringBuilder sb, RouteModel route, string methodName)
{
var parameters = new List<string>();
var body = new StringBuilder();

foreach (var segment in route.Segments.Items)
{
switch (segment.Kind)
{
case SegmentKind.Literal:
body.AppendLine($" sb.Append(\"/{Escape(segment.Value)}\");");
break;

case SegmentKind.Parameter:
var paramName = ParameterNameFor(segment.Value);
if (segment.IsOptional)
{
var optionalType = segment.ClrType == "string" ? "string?" : segment.ClrType + "?";
parameters.Add($"{optionalType} {paramName} = null");
// Trailing optionals: emit only while values are provided (the template
// parser guarantees optionals are trailing, so no literal follows).
body.AppendLine($" if ({paramName} is not null) sb.Append('/').Append(global::System.Uri.EscapeDataString(__Format({paramName})));");
}
else
{
parameters.Add($"{segment.ClrType} {paramName}");
body.AppendLine($" sb.Append('/').Append(global::System.Uri.EscapeDataString(__Format({paramName})));");
}
break;

case SegmentKind.CatchAll:
var catchAllName = ParameterNameFor(segment.Value);
parameters.Add($"string? {catchAllName} = null");
body.AppendLine($" if (!string.IsNullOrEmpty({catchAllName}))");
body.AppendLine(" {");
body.AppendLine($" foreach (var __seg in {catchAllName}!.Split(new[] {{ '/' }}, global::System.StringSplitOptions.RemoveEmptyEntries))");
body.AppendLine(" {");
body.AppendLine(" sb.Append('/').Append(global::System.Uri.EscapeDataString(__seg));");
body.AppendLine(" }");
body.AppendLine(" }");
break;
}
}

parameters.Add("string? query = null");

sb.AppendLine();
sb.AppendLine($" /// <summary>Builds a URL for the route template <c>\"/{Escape(route.Template)}\"</c>.</summary>");
sb.AppendLine($" public static string {methodName}({string.Join(", ", parameters)})");
sb.AppendLine(" {");
sb.AppendLine(" var sb = new global::System.Text.StringBuilder();");
sb.Append(body);
sb.AppendLine(" if (sb.Length == 0) sb.Append('/');");
sb.AppendLine(" if (!string.IsNullOrEmpty(query)) sb.Append(query![0] == '?' ? query : \"?\" + query);");
sb.AppendLine(" return sb.ToString();");
sb.AppendLine(" }");
}

private static void EmitHelpers(StringBuilder sb)
{
sb.AppendLine();
sb.AppendLine(" // Mirrors the router's invariant route-value formatting so generated URLs and");
sb.AppendLine(" // ResolveUrl-produced URLs are byte-identical for the same values.");
sb.AppendLine(" private static string __Format(object? value)");
sb.AppendLine(" {");
sb.AppendLine(" if (value is null) return string.Empty;");
sb.AppendLine(" if (value is string s) return s;");
sb.AppendLine(" if (value is bool b) return b ? \"true\" : \"false\";");
sb.AppendLine(" if (value is global::System.DateTime dt) return dt.ToString(\"o\", global::System.Globalization.CultureInfo.InvariantCulture);");
sb.AppendLine(" if (value is global::System.IFormattable f) return f.ToString(null, global::System.Globalization.CultureInfo.InvariantCulture);");
sb.AppendLine(" return value.ToString() ?? string.Empty;");
sb.AppendLine(" }");
}

/// <summary>Derives the method name: the explicit route Name when present, else the template's literals (fallback: parameter names).</summary>
private static string MethodNameFor(RouteModel route)
{
if (string.IsNullOrWhiteSpace(route.Name) is false)
{
return Pascalize(route.Name!);
}

var literals = new StringBuilder();
foreach (var segment in route.Segments.Items)
{
if (segment.Kind == SegmentKind.Literal) literals.Append(Pascalize(segment.Value));
}
if (literals.Length > 0)
{
// "/users/{id}" and "/users" both start from "Users"; parameterized templates get a
// "By{Param}" suffix so the pair reads naturally and rarely collides.
foreach (var segment in route.Segments.Items)
{
if (segment.Kind == SegmentKind.Parameter) literals.Append("By").Append(Pascalize(segment.Value));
}
return literals.ToString();
}

if (route.Segments.Count == 0) return "Root";

var fromParams = new StringBuilder("By");
foreach (var segment in route.Segments.Items)
{
if (segment.Kind is SegmentKind.Parameter or SegmentKind.CatchAll) fromParams.Append(Pascalize(segment.Value));
}
return fromParams.Length > 2 ? fromParams.ToString() : "Route";
}

private static string Pascalize(string value)
{
var sb = new StringBuilder(value.Length);
var upperNext = true;
foreach (var c in value)
{
if (char.IsLetterOrDigit(c) is false)
{
upperNext = true;
continue;
}
sb.Append(upperNext ? char.ToUpperInvariant(c) : c);
upperNext = false;
}
if (sb.Length == 0) return "Route";
if (char.IsDigit(sb[0])) sb.Insert(0, '_');
return sb.ToString();
}

private static string ParameterNameFor(string templateParamName)
{
// Route parameter names are already identifier-shaped (the parser validated them);
// lower-case the first letter for C# convention and keyword-proof with '@'.
var name = char.ToLowerInvariant(templateParamName[0]) + templateParamName.Substring(1);
return "@" + name;
}

private static string MakeUnique(string name, HashSet<string> used)
{
if (used.Add(name)) return name;
for (var i = 2; ; i++)
{
var candidate = name + i.ToString(System.Globalization.CultureInfo.InvariantCulture);
if (used.Add(candidate)) return candidate;
}
}

private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\"");
}
5 changes: 5 additions & 0 deletions src/Brouter/Bit.Brouter.Generators/IsExternalInit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Polyfill: records/init-only setters require this marker type, which netstandard2.0 lacks.
namespace System.Runtime.CompilerServices
{
internal static class IsExternalInit { }
}
Binary file not shown.
71 changes: 71 additions & 0 deletions src/Brouter/Bit.Brouter.Generators/RouteModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using System.Collections.Generic;

namespace Bit.Brouter.Generators;

/// <summary>
/// A route discovered in a .razor file, reduced to what URL generation needs:
/// <c>Template</c> is the full normalized template ("users/{id}/edit", no surrounding slashes),
/// <c>Name</c> the explicit route name (from a Broute <c>Name="..."</c>) or null, and
/// <c>Segments</c> the parsed segments in order. Records with a value-equatable segment list so
/// the incremental pipeline can cache on equality.
/// </summary>
internal sealed record RouteModel(string Template, string? Name, EquatableArray<RouteSegment> Segments);

/// <summary>
/// One template segment: <c>Value</c> is the literal text or parameter name; <c>ClrType</c> the C#
/// type keyword the (last) constraint maps to ("string" when unconstrained).
/// </summary>
internal sealed record RouteSegment(SegmentKind Kind, string Value, string ClrType, bool IsOptional);

internal enum SegmentKind
{
Literal,
Parameter,
CatchAll,
/// <summary>A literal '*' or '**' wildcard - the template can't be resolved into a URL.</summary>
Wildcard,
}

/// <summary>
/// Minimal immutable array with structural equality, so records holding lists stay cacheable by
/// the incremental generator infrastructure (plain arrays/ImmutableArray compare by reference).
/// </summary>
internal readonly struct EquatableArray<T> : System.IEquatable<EquatableArray<T>>
where T : notnull
{
private static readonly T[] _empty = new T[0];

private readonly T[]? _items;

public EquatableArray(T[] items) => _items = items;

public IReadOnlyList<T> Items => _items ?? _empty;

public int Count => _items?.Length ?? 0;

public T this[int index] => _items![index];

public bool Equals(EquatableArray<T> other)
{
var a = _items ?? _empty;
var b = other._items ?? _empty;
if (a.Length != b.Length) return false;
for (var i = 0; i < a.Length; i++)
{
if (EqualityComparer<T>.Default.Equals(a[i], b[i]) is false) return false;
}
return true;
}

public override bool Equals(object? obj) => obj is EquatableArray<T> other && Equals(other);

public override int GetHashCode()
{
var hash = 17;
foreach (var item in _items ?? _empty)
{
hash = unchecked(hash * 31 + item.GetHashCode());
}
return hash;
}
}
Loading
Loading