-
-
Notifications
You must be signed in to change notification settings - Fork 266
Apply Brouter improvements (#12559) #12560
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
msynk
wants to merge
21
commits into
bitfoundation:develop
Choose a base branch
from
msynk:12559-brouter-improvements
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
5371612
apply Brouter improvements #12559
msynk 2bf38b5
apply type renames
msynk 62d8c17
make guards preventive
msynk ce28f77
add route discovery and prerender support
msynk 5f2aa76
add focus management
msynk eca2b61
improve scroll handling
msynk 9e73832
improve BrouterLink
msynk 243df1e
fix param
msynk f7a2e90
fix comment
msynk 209bd24
fix method
msynk 977f1ce
improve matching performance
msynk 920cc0a
improve route uniqueness check
msynk b599fb8
rename incorrect ones back
msynk 06eb3f4
rename types back
msynk a5252e2
fix global static constraint registry
msynk 791955d
add pending UI feature
msynk 70fd776
add navigation type to context
msynk c484772
resolve review comments
msynk 7a28e64
resolve review comments II
msynk 448ed58
apply further improvements
msynk 1038fe2
resolve review comments III
msynk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
32 changes: 32 additions & 0 deletions
32
src/Brouter/Bit.Brouter.Generators/Bit.Brouter.Generators.csproj
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
275
src/Brouter/Bit.Brouter.Generators/BrouterRoutesGenerator.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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><Broute Path="..."></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; | ||
| } | ||
| } | ||
|
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("\"", "\\\""); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.