diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/IntegrationTests/CodeGenerationIntegrationTest.cs b/src/Razor/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/IntegrationTests/CodeGenerationIntegrationTest.cs index bd93fc0692149..0c33b79ac4eeb 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/IntegrationTests/CodeGenerationIntegrationTest.cs +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/IntegrationTests/CodeGenerationIntegrationTest.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. #nullable disable @@ -34,21 +34,6 @@ public CodeGenerationIntegrationTest() protected override RazorConfiguration Configuration => _configuration; - protected override void ConfigureProjectEngine(RazorProjectEngineBuilder builder) - { - base.ConfigureProjectEngine(builder); - - // Register the UTF-8 WriteLiteral feature with a pre-computed support map. - var supportMap = new DefaultUtf8WriteLiteralFeature.Utf8SupportMap( - ImmutableSortedDictionary.Empty, - ImmutableSortedDictionary.CreateRange(StringComparer.Ordinal, new[] - { - new KeyValuePair("MyUtf8PageBase", true), - new KeyValuePair("MyPageBase", false), - })); - builder.Features.Add(new DefaultUtf8WriteLiteralFeature { SupportMap = supportMap }); - } - #region Runtime [ConditionalFact(typeof(IsEnglishLocal))] @@ -961,82 +946,6 @@ public void InvalidCode_EmptyImplicitExpression() CompileToAssembly(generated, throwOnFailure: false, ignoreRazorDiagnostics: true); } - [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] - public void Utf8HtmlLiterals_AutoDetectedFromInherits() - { - // Arrange - _configuration = new(RazorLanguageVersion.Preview, "MVC-3.0", Extensions: []); - - AddCSharpSyntaxTree(""" - - using System; - using System.Threading.Tasks; - using Microsoft.AspNetCore.Mvc.Razor; - - public abstract class MyUtf8PageBase : RazorPage - { - public void WriteLiteral(ReadOnlySpan value) - { - WriteLiteral(System.Text.Encoding.UTF8.GetString(value)); - } - } - - """); - - // Act - var generated = CompileToCSharp(""" - @inherits MyUtf8PageBase - - - -

Hello World

-

This is UTF-8 encoded HTML content.

- - - """); - - // Assert - CompileToAssembly(generated); - - var generatedCode = generated.CodeDocument.GetImplCSharpDocument().Text.ToString(); - Assert.Contains("u8)", generatedCode); - } - - [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] - public void Utf8HtmlLiterals_WithoutOverload_UsesStringLiterals() - { - // Arrange - _configuration = new(RazorLanguageVersion.Preview, "MVC-3.0", Extensions: []); - - AddCSharpSyntaxTree(""" - - using System.Threading.Tasks; - using Microsoft.AspNetCore.Mvc.Razor; - - public abstract class MyPageBase : RazorPage - { - } - - """); - - // Act - var generated = CompileToCSharp(""" - @inherits MyPageBase - - - -

Hello World

- - - """); - - // Assert - CompileToAssembly(generated); - - var generatedCode = generated.CodeDocument.GetImplCSharpDocument().Text.ToString(); - Assert.DoesNotContain("u8)", generatedCode); - } - #endregion [Theory, CombinatorialData, WorkItem("https://github.com/dotnet/razor/issues/7286")] diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs index 9ba1fc6011516..5d19013a23232 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs @@ -46,6 +46,7 @@ private static void AssertDefaultPhases(RazorProjectEngine engine) phase => Assert.IsType(phase), phase => Assert.IsType(phase), phase => Assert.IsType(phase), + phase => Assert.IsType(phase), phase => Assert.IsType(phase)); } @@ -94,7 +95,6 @@ private static void AssertDefaultFeatures(RazorProjectEngine engine) feature => Assert.IsType(feature), feature => Assert.IsType(feature), feature => Assert.IsType(feature), - feature => Assert.IsType(feature), feature => Assert.IsType(feature)); } diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs deleted file mode 100644 index 0aed773412c9d..0000000000000 --- a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs +++ /dev/null @@ -1,290 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Diagnostics; -using System.Linq; -using System.Text; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.PooledObjects; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Razor; -using Microsoft.CodeAnalysis.Text; -using Microsoft.Extensions.Internal; - -namespace Microsoft.CodeAnalysis.Razor.Compiler.CSharp; - -/// -/// Default implementation of backed by a pre-computed -/// . The map is set by the source generator before code generation runs. -/// -/// -/// This type implements directly (rather than extending -/// ) because the same instance is shared across multiple -/// per-file instances in the source generator, and -/// does not allow re-initialization. -/// -internal sealed class DefaultUtf8WriteLiteralFeature : IUtf8WriteLiteralFeature -{ - private RazorEngine? _engine; - - /// - /// Information about an @inherits directive extracted from a parsed document. - /// - internal readonly record struct InheritsInfo(string FilePath, string BaseTypeName, ImmutableArray Usings); - - public RazorEngine Engine - { - get => _engine!; - init => _engine = value; - } - - public Utf8SupportMap SupportMap { get; set; } = Utf8SupportMap.Empty; - - public void Initialize(RazorEngine engine) - { - _engine = engine; - } - - public bool IsSupported(string? filePath, string baseTypeName) - => SupportMap.IsSupported(filePath, baseTypeName); - - /// - /// A value-comparable map that determines whether a file's @inherits base type supports - /// UTF-8 WriteLiteral. Uses a two-level lookup: - /// - /// Per-file: maps (filePath, rawInheritsText) to a fully-qualified type name - /// Per-type: maps fully-qualified type name to - /// - /// This handles cases where the same @inherits text resolves to different types - /// in different files (e.g., via @using aliases). - /// - internal sealed class Utf8SupportMap : IEquatable - { - public static readonly Utf8SupportMap Empty = new( - ImmutableSortedDictionary.Empty, - ImmutableSortedDictionary.Empty); - - // filePath -> fully-qualified type name - private readonly ImmutableSortedDictionary _fileToType; - // fully-qualified type name -> supports UTF-8 - private readonly ImmutableSortedDictionary _typeSupport; - - internal Utf8SupportMap( - ImmutableSortedDictionary fileToType, - ImmutableSortedDictionary typeSupport) - { - _fileToType = fileToType; - _typeSupport = typeSupport; - } - - /// - /// Builds a by resolving each file's @inherits to a - /// fully-qualified type name, then checking whether each unique type supports UTF-8. - /// - public static Utf8SupportMap Create(ImmutableArray inheritsInfos, Compilation compilation) - { - // UTF-8 string literals (e.g. "..."u8) require C# 11 or later. If the consuming - // compilation is targeting an older C# version (or isn't C# at all), generating - // u8 literals would produce uncompilable code, so opt every file out by returning - // an empty map. - if (compilation is not CSharpCompilation { LanguageVersion: >= LanguageVersion.CSharp11 }) - { - return Empty; - } - - var fileToType = ImmutableSortedDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); - var typeSupport = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); - - // First pass: resolve fully-qualified names via fast path, collect unresolved entries. - List<(int Index, InheritsInfo Info)>? unresolvedEntries = null; - - for (var i = 0; i < inheritsInfos.Length; i++) - { - var info = inheritsInfos[i]; - var type = compilation.GetTypeByMetadataName(info.BaseTypeName); - if (type is not null && type.TypeKind != TypeKind.Error) - { - var fqn = type.GetFullName(); - fileToType[info.FilePath] = fqn; - - if (!typeSupport.ContainsKey(fqn)) - { - typeSupport[fqn] = compilation.HasCallableUtf8WriteLiteralOverload(fqn); - } - } - else - { - unresolvedEntries ??= []; - unresolvedEntries.Add((i, info)); - } - } - - // Second pass: resolve remaining entries via a single augmented compilation. - if (unresolvedEntries is { Count: > 0 } && compilation is CSharpCompilation csharpCompilation) - { - var resolved = ResolveTypeNamesWithUsings(unresolvedEntries, csharpCompilation); - foreach (var (index, fqn) in resolved) - { - var info = inheritsInfos[index]; - fileToType[info.FilePath] = fqn; - - if (!typeSupport.ContainsKey(fqn)) - { - typeSupport[fqn] = compilation.HasCallableUtf8WriteLiteralOverload(fqn); - } - } - } - - return fileToType.Count == 0 - ? Empty - : new Utf8SupportMap(fileToType.ToImmutable(), typeSupport.ToImmutable()); - } - - /// - /// Resolves multiple short or partially-qualified type names in a single augmented - /// compilation. Each entry's usings are scoped to a unique namespace block to prevent - /// cross-contamination. - /// - private static List<(int Index, string Fqn)> ResolveTypeNamesWithUsings( - List<(int Index, InheritsInfo Info)> entries, - CSharpCompilation compilation) - { - var results = new List<(int, string)>(); - - // Build a single probe tree with namespace-scoped usings for each entry. - using var _ = StringBuilderPool.GetPooledObject(out var sb); - for (var i = 0; i < entries.Count; i++) - { - var info = entries[i].Info; - - sb.Append("namespace __Utf8Probe_").Append(i).AppendLine(" {"); - foreach (var u in info.Usings) - { - sb.Append(" using ").Append(u).AppendLine(";"); - } - - // Alias TModel to a known type so that the common MVC pattern - // `@inherits SomeBase` (which is normally rewritten to the actual - // model type by ModelDirective.Pass during code generation) still binds in - // the probe compilation. WriteLiteral overloads don't depend on the model - // type argument, so binding to is sufficient for detection. - sb.AppendLine(" using TModel = global::System.Object;"); - - sb.Append(" class __Probe__ : ").Append(info.BaseTypeName).AppendLine(" { }"); - sb.AppendLine("}"); - } - - var parseOptions = compilation.SyntaxTrees.FirstOrDefault()?.Options as CSharpParseOptions - ?? CSharpParseOptions.Default; - var probeText = SourceText.From(sb.ToString()); - var probeTree = CSharpSyntaxTree.ParseText(probeText, parseOptions); - var augmented = compilation.AddSyntaxTrees(probeTree); - var semanticModel = augmented.GetSemanticModel(probeTree); - - // Query each probe class's base type. The probe tree has a known shallow shape: - // CompilationUnit -> NamespaceDeclaration (one per entry, in order) -> ClassDeclaration - // so we walk only direct children at each level rather than realizing the whole tree, - // and rely on entry order to map back to the original index. - var namespaceDecls = probeTree.GetRoot().ChildNodes().OfType(); - - var entryIndex = 0; - foreach (var namespaceDecl in namespaceDecls) - { - var classDecl = namespaceDecl.ChildNodes().OfType().FirstOrDefault(); - var baseTypeSyntax = classDecl?.BaseList?.Types.FirstOrDefault(); - if (baseTypeSyntax is not null) - { - var symbol = semanticModel.GetSymbolInfo(baseTypeSyntax.Type).Symbol as INamedTypeSymbol; - if (symbol is not null && symbol.TypeKind != TypeKind.Error) - { - results.Add((entries[entryIndex].Index, GetFullMetadataName(symbol))); - } - } - - entryIndex++; - } - - return results; - } - - /// - /// Builds a fully-qualified metadata name for a type symbol, suitable for - /// . Unlike GetFullName() - /// which produces C# display syntax, this uses CLR metadata conventions: - /// backtick arity for generics and + for nested types. - /// - private static string GetFullMetadataName(INamedTypeSymbol symbol) - { - var typePart = symbol.MetadataName; - - if (symbol.ContainingType is not null) - { - // Walk containing types to build Outer`1+Inner chain. - var parts = new List { typePart }; - for (var current = symbol.ContainingType; current is not null; current = current.ContainingType) - { - parts.Add(current.MetadataName); - } - - parts.Reverse(); - typePart = string.Join("+", parts); - } - - return symbol.ContainingNamespace is { IsGlobalNamespace: false } ns - ? $"{ns.GetFullName()}.{typePart}" - : typePart; - } - - public bool IsSupported(string? filePath, string baseTypeName) - { - if (filePath is not null && _fileToType.TryGetValue(filePath, out var fqn)) - { - return _typeSupport.TryGetValue(fqn, out var supported) && supported; - } - - // Fallback: try the raw name directly as a fully-qualified name. - return _typeSupport.TryGetValue(baseTypeName, out var fallback) && fallback; - } - - public bool Equals(Utf8SupportMap? other) - { - if (other is null) - { - return false; - } - - if (ReferenceEquals(this, other)) - { - return true; - } - - return _fileToType.SequenceEqual(other._fileToType) && - _typeSupport.SequenceEqual(other._typeSupport); - } - - public override bool Equals(object? obj) => Equals(obj as Utf8SupportMap); - - public override int GetHashCode() - { - var hash = HashCodeCombiner.Start(); - - foreach (var kvp in _fileToType) - { - hash.Add(kvp.Key, StringComparer.OrdinalIgnoreCase); - hash.Add(kvp.Value, StringComparer.Ordinal); - } - - foreach (var kvp in _typeSupport) - { - hash.Add(kvp.Key, StringComparer.Ordinal); - hash.Add(kvp.Value); - } - - return hash; - } - } -} diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/IUtf8WriteLiteralFeature.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/IUtf8WriteLiteralFeature.cs deleted file mode 100644 index 0ed068859213a..0000000000000 --- a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/IUtf8WriteLiteralFeature.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.AspNetCore.Razor.Language; - -namespace Microsoft.CodeAnalysis.Razor.Compiler.CSharp; - -/// -/// An that determines whether a given base type -/// supports calling WriteLiteral(ReadOnlySpan<byte>) for UTF-8 HTML literal emission. -/// -internal interface IUtf8WriteLiteralFeature : IRazorEngineFeature -{ - /// - /// Returns if the base type used by the specified file has a callable - /// WriteLiteral(ReadOnlySpan<byte>) overload. - /// - /// The file path of the Razor document. - /// The raw @inherits value from the document. - bool IsSupported(string? filePath, string baseTypeName); -} diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8SupportMap.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8SupportMap.cs new file mode 100644 index 0000000000000..01ce83facfb55 --- /dev/null +++ b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8SupportMap.cs @@ -0,0 +1,279 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.AspNetCore.Razor.PooledObjects; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Razor; +using Microsoft.CodeAnalysis.Text; +using Microsoft.Extensions.Internal; + +namespace Microsoft.CodeAnalysis.Razor.Compiler.CSharp; + +/// +/// A value-comparable map that determines whether a file's @inherits base type supports +/// UTF-8 WriteLiteral. Uses a two-level lookup: +/// +/// Per-file: maps filePath to a fully-qualified type name +/// Per-type: maps fully-qualified type name to +/// +/// This handles cases where the same @inherits text resolves to different types +/// in different files (e.g., via @using aliases). +/// +internal sealed class Utf8SupportMap : IEquatable +{ + /// + /// Information about an @inherits directive extracted from a parsed document. + /// + internal readonly record struct InheritsInfo(string FilePath, string BaseTypeName, ImmutableArray Usings); + + public static readonly Utf8SupportMap Empty = new( + ImmutableSortedDictionary.Empty, + ImmutableSortedDictionary.Empty); + + // filePath -> fully-qualified type name + private readonly ImmutableSortedDictionary _fileToType; + // fully-qualified type name -> supports UTF-8 + private readonly ImmutableSortedDictionary _typeSupport; + + internal Utf8SupportMap( + ImmutableSortedDictionary fileToType, + ImmutableSortedDictionary typeSupport) + { + _fileToType = fileToType; + _typeSupport = typeSupport; + } + + /// + /// Builds a by resolving each file's @inherits to a + /// fully-qualified type name, then checking whether each unique type supports UTF-8. + /// + public static Utf8SupportMap Create(ImmutableArray inheritsInfos, Compilation compilation) + { + // UTF-8 string literals (e.g. "..."u8) require C# 11 or later. If the consuming + // compilation is targeting an older C# version (or isn't C# at all), generating + // u8 literals would produce uncompilable code, so opt every file out by returning + // an empty map. + if (compilation is not CSharpCompilation { LanguageVersion: >= LanguageVersion.CSharp11 }) + { + return Empty; + } + + var fileToType = ImmutableSortedDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); + var typeSupport = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); + + // First pass: resolve fully-qualified names via fast path, collect unresolved entries. + using var _1 = ListPool<(int Index, InheritsInfo Info)>.GetPooledObject(out var unresolvedEntries); + + for (var i = 0; i < inheritsInfos.Length; i++) + { + var info = inheritsInfos[i]; + var type = compilation.GetTypeByMetadataName(info.BaseTypeName); + if (type is not null && type.TypeKind != TypeKind.Error) + { + var fqn = type.GetFullName(); + fileToType[info.FilePath] = fqn; + + if (!typeSupport.ContainsKey(fqn)) + { + // Use the resolved symbol directly rather than round-tripping the display name + // back through GetTypeByMetadataName (which expects metadata syntax). + typeSupport[fqn] = compilation.HasCallableUtf8WriteLiteralOverload(type); + } + } + else + { + unresolvedEntries.Add((i, info)); + } + } + + // Second pass: resolve remaining entries via a single augmented compilation. + if (unresolvedEntries.Count > 0 && compilation is CSharpCompilation csharpCompilation) + { + using var _2 = ListPool<(int Index, string Fqn)>.GetPooledObject(out var resolved); + ResolveTypeNamesWithUsings(unresolvedEntries, csharpCompilation, resolved); + foreach (var (index, fqn) in resolved) + { + var info = inheritsInfos[index]; + fileToType[info.FilePath] = fqn; + + if (!typeSupport.ContainsKey(fqn)) + { + typeSupport[fqn] = compilation.HasCallableUtf8WriteLiteralOverload(fqn); + } + } + } + + return fileToType.Count == 0 + ? Empty + : new Utf8SupportMap(fileToType.ToImmutable(), typeSupport.ToImmutable()); + } + + /// + /// Resolves multiple short or partially-qualified type names in a single augmented + /// compilation. Each entry's usings are scoped to a unique namespace block to prevent + /// cross-contamination. + /// + private static void ResolveTypeNamesWithUsings( + List<(int Index, InheritsInfo Info)> entries, + CSharpCompilation compilation, + List<(int Index, string Fqn)> results) + { + // Build a single probe tree with namespace-scoped usings for each entry. + using var _ = StringBuilderPool.GetPooledObject(out var sb); + for (var i = 0; i < entries.Count; i++) + { + var info = entries[i].Info; + + sb.Append("namespace __Utf8Probe_").Append(i).AppendLine(" {"); + foreach (var u in info.Usings) + { + sb.Append(" using ").Append(u).AppendLine(";"); + } + + // Alias TModel to a known type so that the common MVC pattern + // `@inherits SomeBase` (which is normally rewritten to the actual + // model type by ModelDirective.Pass during code generation) still binds in + // the probe compilation. WriteLiteral overloads don't depend on the model + // type argument, so binding to is sufficient for detection. + sb.AppendLine(" using TModel = global::System.Object;"); + + sb.Append(" class __Probe__ : ").Append(info.BaseTypeName).AppendLine(" { }"); + sb.AppendLine("}"); + } + + var parseOptions = compilation.SyntaxTrees.FirstOrDefault()?.Options as CSharpParseOptions + ?? CSharpParseOptions.Default; + var probeText = SourceText.From(sb.ToString()); + var probeTree = CSharpSyntaxTree.ParseText(probeText, parseOptions); + var augmented = compilation.AddSyntaxTrees(probeTree); + var semanticModel = augmented.GetSemanticModel(probeTree); + + // Query each probe class's base type. The probe tree has a known shallow shape: + // CompilationUnit -> NamespaceDeclaration (one per entry, in order) -> ClassDeclaration + // so we walk only direct children at each level rather than realizing the whole tree, + // and rely on entry order to map back to the original index. + var namespaceDecls = probeTree.GetRoot().ChildNodes().OfType(); + + var entryIndex = 0; + foreach (var namespaceDecl in namespaceDecls) + { + var classDecl = namespaceDecl.ChildNodes().OfType().FirstOrDefault(); + var baseTypeSyntax = classDecl?.BaseList?.Types.FirstOrDefault(); + if (baseTypeSyntax is not null) + { + var symbol = semanticModel.GetSymbolInfo(baseTypeSyntax.Type).Symbol as INamedTypeSymbol; + if (symbol is not null && symbol.TypeKind != TypeKind.Error) + { + results.Add((entries[entryIndex].Index, GetFullMetadataName(symbol))); + } + } + + entryIndex++; + } + } + + /// + /// Builds a fully-qualified metadata name for a type symbol, suitable for + /// . Unlike GetFullName() + /// which produces C# display syntax, this uses CLR metadata conventions: + /// backtick arity for generics and + for nested types. + /// + private static string GetFullMetadataName(INamedTypeSymbol symbol) + { + string typePart; + + if (symbol.ContainingType is null) + { + typePart = symbol.MetadataName; + } + else + { + // Walk containing types inner -> outer, prepending each name (and a separating '+') + // to build the Outer`1+Inner chain. + using var _ = StringBuilderPool.GetPooledObject(out var builder); + builder.Append(symbol.MetadataName); + for (var current = symbol.ContainingType; current is not null; current = current.ContainingType) + { + builder.Insert(0, '+').Insert(0, current.MetadataName); + } + + typePart = builder.ToString(); + } + + return symbol.ContainingNamespace is { IsGlobalNamespace: false } ns + ? $"{ns.GetFullName()}.{typePart}" + : typePart; + } + + public bool IsSupported(string? filePath, string baseTypeName) + { + // Create() stores keys as (Source.FilePath ?? string.Empty); normalize null the same way + // so a document without a file path still hits its per-file entry. + if (_fileToType.TryGetValue(filePath ?? string.Empty, out var fqn)) + { + return _typeSupport.TryGetValue(fqn, out var supported) && supported; + } + + // Fallback: try the raw name directly as a fully-qualified name. + return _typeSupport.TryGetValue(baseTypeName, out var fallback) && fallback; + } + + public bool Equals(Utf8SupportMap? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + // _fileToType keys are file paths compared case-insensitively (matching the builder's + // OrdinalIgnoreCase comparer and GetHashCode below), so compare via the dictionary's own + // lookup rather than SequenceEqual, which would compare keys case-sensitively. _typeSupport + // keys are ordinal fully-qualified names, so SequenceEqual is correct there. + if (_fileToType.Count != other._fileToType.Count) + { + return false; + } + + foreach (var (filePath, fqn) in _fileToType) + { + if (!other._fileToType.TryGetValue(filePath, out var otherFqn) || + !string.Equals(fqn, otherFqn, StringComparison.Ordinal)) + { + return false; + } + } + + return _typeSupport.SequenceEqual(other._typeSupport); + } + + public override bool Equals(object? obj) => Equals(obj as Utf8SupportMap); + + public override int GetHashCode() + { + var hash = HashCodeCombiner.Start(); + + foreach (var kvp in _fileToType) + { + hash.Add(kvp.Key, StringComparer.OrdinalIgnoreCase); + hash.Add(kvp.Value, StringComparer.Ordinal); + } + + foreach (var kvp in _typeSupport) + { + hash.Add(kvp.Key, StringComparer.Ordinal); + hash.Add(kvp.Value); + } + + return hash; + } +} diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8WriteLiteralDetectionPass.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8WriteLiteralDetectionPass.cs deleted file mode 100644 index cd4e981e7f212..0000000000000 --- a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8WriteLiteralDetectionPass.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Threading; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.Language.Intermediate; - -namespace Microsoft.CodeAnalysis.Razor.Compiler.CSharp; - -internal sealed class Utf8WriteLiteralDetectionPass : IntermediateNodePassBase, IRazorOptimizationPass -{ - private IUtf8WriteLiteralFeature? _utf8Feature; - - protected override void OnInitialized() - { - Engine.TryGetFeature(out _utf8Feature); - } - - protected override void ExecuteCore( - RazorCodeDocument codeDocument, - DocumentIntermediateNode documentNode, - CancellationToken cancellationToken) - { - if (_utf8Feature is null || - !codeDocument.FileKind.IsLegacy() || - documentNode.Options is null || - documentNode.Options.WriteHtmlUtf8StringLiterals) - { - return; - } - - var @class = documentNode.FindPrimaryClass(); - var baseType = @class?.BaseType; - if (baseType is null || string.IsNullOrWhiteSpace(baseType.BaseType.Content)) - { - // No explicit @inherits directive. The default Razor base classes don't currently - // support WriteLiteral(ReadOnlySpan). When they do, this check should be - // expanded to also probe the default base type. - return; - } - - var baseTypeName = baseType.BaseType.Content; - if (_utf8Feature.IsSupported(codeDocument.Source.FilePath, baseTypeName)) - { - documentNode.Options = documentNode.Options.WithFlags(writeHtmlUtf8StringLiterals: true); - } - } -} diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8WriteLiteralPhase.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8WriteLiteralPhase.cs new file mode 100644 index 0000000000000..b6c862dacb28a --- /dev/null +++ b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8WriteLiteralPhase.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Intermediate; + +namespace Microsoft.CodeAnalysis.Razor.Compiler.CSharp; + +/// +/// Records whether a legacy (.cshtml) document's @inherits base type has a callable +/// UTF-8 WriteLiteral(ReadOnlySpan<byte>) overload on the code-generation options, so +/// emission can choose byte literals. Runs after optimization and before C# lowering. +/// +/// +/// Only legacy (.cshtml) documents are considered; components and other file kinds are left +/// alone. A legacy document with no @inherits directive has no base type to probe. +/// +internal sealed class Utf8WriteLiteralPhase : RazorEnginePhaseBase +{ + protected override RazorCodeDocument ExecuteCore(RazorCodeDocument codeDocument, CancellationToken cancellationToken) + { + var documentNode = codeDocument.GetDocumentNode(); + ThrowForMissingDocumentDependency(documentNode); + + if (codeDocument.GetUtf8SupportMap() is { } supportMap && + documentNode.Options is { } options && + codeDocument.FileKind.IsLegacy()) + { + var baseTypeName = documentNode.FindPrimaryClass()?.BaseType?.BaseType.Content; + var supported = !string.IsNullOrWhiteSpace(baseTypeName) && + supportMap.IsSupported(codeDocument.Source.FilePath, baseTypeName!); + + documentNode.Options = options.WithFlags(writeHtmlUtf8StringLiterals: supported); + } + + return codeDocument.WithDocumentNode(documentNode); + } +} diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocument.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocument.cs index e6e0683698dec..30c510f00296d 100644 --- a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocument.cs +++ b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocument.cs @@ -9,6 +9,7 @@ using System.Runtime.InteropServices; using Microsoft.AspNetCore.Razor.Language.Intermediate; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Razor.Compiler.CSharp; namespace Microsoft.AspNetCore.Razor.Language; @@ -38,6 +39,7 @@ public sealed partial class RazorCodeDocument private readonly RazorCSharpDocument? _declCSharpDocument; private readonly DocumentIntermediateNode? _unresolvedDocumentNode; private readonly ImmutableArray _directiveTagHelperContributions; + private readonly Utf8SupportMap? _utf8SupportMap; private RazorCodeDocument( RazorSourceDocument source, @@ -54,7 +56,8 @@ private RazorCodeDocument( RazorCSharpDocument? csharpDocument, RazorCSharpDocument? declCSharpDocument, ImmutableArray directiveTagHelperContributions, - DocumentIntermediateNode? unresolvedDocumentNode) + DocumentIntermediateNode? unresolvedDocumentNode, + Utf8SupportMap? utf8SupportMap) { Source = source; Imports = imports.NullToEmpty(); @@ -73,6 +76,7 @@ private RazorCodeDocument( _declCSharpDocument = declCSharpDocument; _directiveTagHelperContributions = directiveTagHelperContributions.NullToEmpty(); _unresolvedDocumentNode = unresolvedDocumentNode; + _utf8SupportMap = utf8SupportMap; } public static RazorCodeDocument Create( @@ -104,7 +108,20 @@ public static RazorCodeDocument Create( csharpDocument: null, declCSharpDocument: null, directiveTagHelperContributions: default, - unresolvedDocumentNode: null); + unresolvedDocumentNode: null, + utf8SupportMap: null); + } + + internal Utf8SupportMap? GetUtf8SupportMap() + => _utf8SupportMap; + + internal RazorCodeDocument WithUtf8SupportMap(Utf8SupportMap? value) + { + if (Equals(value, _utf8SupportMap)) + { + return this; + } + return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, _tagHelpers, _referencedTagHelpers, _syntaxTree, _tagHelperRewrittenSyntaxTree, _importSyntaxTrees, _tagHelperContext, _documentNode, _csharpDocument, _declCSharpDocument, _directiveTagHelperContributions, _unresolvedDocumentNode, value); } internal bool TryGetTagHelpers([NotNullWhen(true)] out TagHelperCollection? result) @@ -125,7 +142,7 @@ internal RazorCodeDocument WithTagHelpers(TagHelperCollection? value) { return this; } - return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, value, _referencedTagHelpers, _syntaxTree, _tagHelperRewrittenSyntaxTree, _importSyntaxTrees, _tagHelperContext, _documentNode, _csharpDocument, _declCSharpDocument, _directiveTagHelperContributions, _unresolvedDocumentNode); + return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, value, _referencedTagHelpers, _syntaxTree, _tagHelperRewrittenSyntaxTree, _importSyntaxTrees, _tagHelperContext, _documentNode, _csharpDocument, _declCSharpDocument, _directiveTagHelperContributions, _unresolvedDocumentNode, _utf8SupportMap); } internal bool TryGetReferencedTagHelpers([NotNullWhen(true)] out TagHelperCollection? result) @@ -146,7 +163,7 @@ internal RazorCodeDocument WithReferencedTagHelpers(TagHelperCollection value) { return this; } - return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, _tagHelpers, value, _syntaxTree, _tagHelperRewrittenSyntaxTree, _importSyntaxTrees, _tagHelperContext, _documentNode, _csharpDocument, _declCSharpDocument, _directiveTagHelperContributions, _unresolvedDocumentNode); + return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, _tagHelpers, value, _syntaxTree, _tagHelperRewrittenSyntaxTree, _importSyntaxTrees, _tagHelperContext, _documentNode, _csharpDocument, _declCSharpDocument, _directiveTagHelperContributions, _unresolvedDocumentNode, _utf8SupportMap); } internal bool TryGetSyntaxTree([NotNullWhen(true)] out RazorSyntaxTree? result) @@ -168,7 +185,7 @@ internal RazorCodeDocument WithSyntaxTree(RazorSyntaxTree value) { return this; } - return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, _tagHelpers, _referencedTagHelpers, value, _tagHelperRewrittenSyntaxTree, _importSyntaxTrees, _tagHelperContext, _documentNode, _csharpDocument, _declCSharpDocument, _directiveTagHelperContributions, _unresolvedDocumentNode); + return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, _tagHelpers, _referencedTagHelpers, value, _tagHelperRewrittenSyntaxTree, _importSyntaxTrees, _tagHelperContext, _documentNode, _csharpDocument, _declCSharpDocument, _directiveTagHelperContributions, _unresolvedDocumentNode, _utf8SupportMap); } internal bool TryGetTagHelperRewrittenSyntaxTree([NotNullWhen(true)] out RazorSyntaxTree? result) @@ -190,7 +207,7 @@ internal RazorCodeDocument WithTagHelperRewrittenSyntaxTree(RazorSyntaxTree valu { return this; } - return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, _tagHelpers, _referencedTagHelpers, _syntaxTree, value, _importSyntaxTrees, _tagHelperContext, _documentNode, _csharpDocument, _declCSharpDocument, _directiveTagHelperContributions, _unresolvedDocumentNode); + return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, _tagHelpers, _referencedTagHelpers, _syntaxTree, value, _importSyntaxTrees, _tagHelperContext, _documentNode, _csharpDocument, _declCSharpDocument, _directiveTagHelperContributions, _unresolvedDocumentNode, _utf8SupportMap); } internal bool TryGetImportSyntaxTrees(out ImmutableArray result) @@ -217,7 +234,7 @@ internal RazorCodeDocument WithImportSyntaxTrees(ImmutableArray { return this; } - return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, _tagHelpers, _referencedTagHelpers, _syntaxTree, _tagHelperRewrittenSyntaxTree, value, _tagHelperContext, _documentNode, _csharpDocument, _declCSharpDocument, _directiveTagHelperContributions, _unresolvedDocumentNode); + return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, _tagHelpers, _referencedTagHelpers, _syntaxTree, _tagHelperRewrittenSyntaxTree, value, _tagHelperContext, _documentNode, _csharpDocument, _declCSharpDocument, _directiveTagHelperContributions, _unresolvedDocumentNode, _utf8SupportMap); } internal bool TryGetTagHelperContext([NotNullWhen(true)] out TagHelperDocumentContext? result) @@ -240,7 +257,7 @@ internal RazorCodeDocument WithTagHelperContext(TagHelperDocumentContext value) { return this; } - return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, _tagHelpers, _referencedTagHelpers, _syntaxTree, _tagHelperRewrittenSyntaxTree, _importSyntaxTrees, value, _documentNode, _csharpDocument, _declCSharpDocument, _directiveTagHelperContributions, _unresolvedDocumentNode); + return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, _tagHelpers, _referencedTagHelpers, _syntaxTree, _tagHelperRewrittenSyntaxTree, _importSyntaxTrees, value, _documentNode, _csharpDocument, _declCSharpDocument, _directiveTagHelperContributions, _unresolvedDocumentNode, _utf8SupportMap); } internal bool TryGetDocumentNode([NotNullWhen(true)] out DocumentIntermediateNode? result) @@ -262,7 +279,7 @@ internal RazorCodeDocument WithDocumentNode(DocumentIntermediateNode value) { return this; } - return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, _tagHelpers, _referencedTagHelpers, _syntaxTree, _tagHelperRewrittenSyntaxTree, _importSyntaxTrees, _tagHelperContext, value, _csharpDocument, _declCSharpDocument, _directiveTagHelperContributions, _unresolvedDocumentNode); + return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, _tagHelpers, _referencedTagHelpers, _syntaxTree, _tagHelperRewrittenSyntaxTree, _importSyntaxTrees, _tagHelperContext, value, _csharpDocument, _declCSharpDocument, _directiveTagHelperContributions, _unresolvedDocumentNode, _utf8SupportMap); } internal DocumentIntermediateNode? GetUnresolvedDocumentNode() @@ -275,7 +292,7 @@ internal RazorCodeDocument WithUnresolvedDocumentNode(DocumentIntermediateNode v { return this; } - return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, _tagHelpers, _referencedTagHelpers, _syntaxTree, _tagHelperRewrittenSyntaxTree, _importSyntaxTrees, _tagHelperContext, _documentNode, _csharpDocument, _declCSharpDocument, _directiveTagHelperContributions, value); + return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, _tagHelpers, _referencedTagHelpers, _syntaxTree, _tagHelperRewrittenSyntaxTree, _importSyntaxTrees, _tagHelperContext, _documentNode, _csharpDocument, _declCSharpDocument, _directiveTagHelperContributions, value, _utf8SupportMap); } internal RazorCSharpDocument? GetCSharpDocument(bool declarationDocument) @@ -291,7 +308,7 @@ internal RazorCodeDocument WithImplCSharpDocument(RazorCSharpDocument value) { return this; } - return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, _tagHelpers, _referencedTagHelpers, _syntaxTree, _tagHelperRewrittenSyntaxTree, _importSyntaxTrees, _tagHelperContext, _documentNode, value, _declCSharpDocument, _directiveTagHelperContributions, _unresolvedDocumentNode); + return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, _tagHelpers, _referencedTagHelpers, _syntaxTree, _tagHelperRewrittenSyntaxTree, _importSyntaxTrees, _tagHelperContext, _documentNode, value, _declCSharpDocument, _directiveTagHelperContributions, _unresolvedDocumentNode, _utf8SupportMap); } internal RazorCodeDocument WithDeclCSharpDocument(RazorCSharpDocument value) @@ -301,7 +318,7 @@ internal RazorCodeDocument WithDeclCSharpDocument(RazorCSharpDocument value) { return this; } - return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, _tagHelpers, _referencedTagHelpers, _syntaxTree, _tagHelperRewrittenSyntaxTree, _importSyntaxTrees, _tagHelperContext, _documentNode, _csharpDocument, value, _directiveTagHelperContributions, _unresolvedDocumentNode); + return new RazorCodeDocument(Source, Imports, ParserOptions, CodeGenerationOptions, _tagHelpers, _referencedTagHelpers, _syntaxTree, _tagHelperRewrittenSyntaxTree, _importSyntaxTrees, _tagHelperContext, _documentNode, _csharpDocument, value, _directiveTagHelperContributions, _unresolvedDocumentNode, _utf8SupportMap); } internal ImmutableArray GetDirectiveTagHelperContributions() @@ -314,7 +331,7 @@ internal RazorCodeDocument WithDirectiveTagHelperContributions(ImmutableArray.Builder p phases.Add(new DefaultTagHelperResolutionPhase()); phases.Add(new DefaultRazorTagHelperRewritePhase()); phases.Add(new DefaultRazorOptimizationPhase()); + phases.Add(new Utf8WriteLiteralPhase()); phases.Add(new DefaultRazorCSharpLoweringPhase()); } @@ -286,7 +287,6 @@ private static void AddDefaultFeatures(ImmutableArray.Builder fea // Intermediate Node Passes features.Add(new DefaultDocumentClassifierPass()); features.Add(new MetadataAttributePass()); - features.Add(new Utf8WriteLiteralDetectionPass()); features.Add(new DirectiveRemovalOptimizationPass()); features.Add(new DefaultTagHelperOptimizationPass()); features.Add(new PreallocatedTagHelperAttributeOptimizationPass()); diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.Helpers.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.Helpers.cs index bc0aee2599c0d..04c084c87fb26 100644 --- a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.Helpers.cs +++ b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.Helpers.cs @@ -258,8 +258,6 @@ private static SourceGeneratorProjectEngine GetGenerationProjectEngine( builder.CSharpParseOptions = razorSourceGeneratorOptions.CSharpParseOptions; }); - b.Features.Add(new DefaultUtf8WriteLiteralFeature()); - CompilerFeatures.Register(b); RazorExtensions.Register(b); diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.cs index 6817f4b2b69fc..f597714d744ca 100644 --- a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.cs +++ b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.cs @@ -383,14 +383,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return (codeDocument, InheritsValue: codeDocument.GetInheritsDirectiveValue()); }) .Where(static item => item.InheritsValue is not null) - .Select(static (item, _) => new DefaultUtf8WriteLiteralFeature.InheritsInfo( + .Select(static (item, _) => new Utf8SupportMap.InheritsInfo( item.codeDocument.Source.FilePath ?? string.Empty, item.InheritsValue!, item.codeDocument.GetUsingDirectives())) .Collect() .Combine(compilation) .Select(static (pair, _) => { var (inheritsInfos, compilation) = pair; - return DefaultUtf8WriteLiteralFeature.Utf8SupportMap.Create(inheritsInfos, compilation); + return Utf8SupportMap.Create(inheritsInfos, compilation); }) .WithTrackingName("Utf8SupportMap"); @@ -424,13 +424,43 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return (projectEngine, filePath, document); }) .WithTrackingName("CheckedAndRewrittenTagHelpers") + .Select(static (tuple, cancellationToken) => + { + var (projectEngine, filePath, document) = tuple; + + RazorSourceGeneratorEventSource.Log.RazorOptimizeStart(filePath); + document = projectEngine.ProcessOptimizationPasses(document, cancellationToken); + + RazorSourceGeneratorEventSource.Log.RazorOptimizeStop(filePath); + return (projectEngine, filePath, document); + }) + .WithTrackingName("OptimizedDocuments") + + // Run UTF-8 detection on its own so a support-map change doesn't replay the cached optimization passes. .Combine(utf8SupportMap) - .Select((pair, cancellationToken) => + .Select(static (pair, cancellationToken) => { var ((projectEngine, filePath, document), utf8SupportMap) = pair; + RazorSourceGeneratorEventSource.Log.RazorComputeUtf8LiteralsStart(filePath); + var useUtf8Literals = projectEngine.ProcessUtf8(document, utf8SupportMap, cancellationToken); + + RazorSourceGeneratorEventSource.Log.RazorComputeUtf8LiteralsStop(filePath); + return (projectEngine, filePath, document, useUtf8Literals); + }) + .WithLambdaComparer(static (a, b) => + a.filePath == b.filePath && + ReferenceEquals(a.document, b.document) && + a.useUtf8Literals == b.useUtf8Literals) + .WithTrackingName("Utf8Documents") + + // Generate the C# for each document. + .Select(static (tuple, cancellationToken) => + { + var (projectEngine, filePath, document, _) = tuple; + RazorSourceGeneratorEventSource.Log.RazorCodeGenerateStart(filePath); - document = projectEngine.ProcessRemaining(document, utf8SupportMap, cancellationToken); + document = projectEngine.ProcessCSharp(document, cancellationToken); RazorSourceGeneratorEventSource.Log.RazorCodeGenerateStop(filePath); return (filePath, document); diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGeneratorEventSource.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGeneratorEventSource.cs index 695f429c4501c..9618b9aa2bc2c 100644 --- a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGeneratorEventSource.cs +++ b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGeneratorEventSource.cs @@ -81,5 +81,21 @@ internal sealed partial class RazorSourceGeneratorEventSource : EventSource private const int CheckAndRewriteTagHelpersStopId = 20; [Event(CheckAndRewriteTagHelpersStopId, Level = EventLevel.Informational)] public void CheckAndRewriteTagHelpersStop(string file) => WriteEvent(CheckAndRewriteTagHelpersStopId, file); + + private const int RazorOptimizeStartId = 21; + [Event(RazorOptimizeStartId, Level = EventLevel.Informational)] + public void RazorOptimizeStart(string file) => WriteEvent(RazorOptimizeStartId, file); + + private const int RazorOptimizeStopId = 22; + [Event(RazorOptimizeStopId, Level = EventLevel.Informational)] + public void RazorOptimizeStop(string file) => WriteEvent(RazorOptimizeStopId, file); + + private const int RazorComputeUtf8LiteralsStartId = 23; + [Event(RazorComputeUtf8LiteralsStartId, Level = EventLevel.Informational)] + public void RazorComputeUtf8LiteralsStart(string file) => WriteEvent(RazorComputeUtf8LiteralsStartId, file); + + private const int RazorComputeUtf8LiteralsStopId = 24; + [Event(RazorComputeUtf8LiteralsStopId, Level = EventLevel.Informational)] + public void RazorComputeUtf8LiteralsStop(string file) => WriteEvent(RazorComputeUtf8LiteralsStopId, file); } } diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/SourceGeneratorProjectEngine.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/SourceGeneratorProjectEngine.cs index 25c38e05b0208..938402e38b9df 100644 --- a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/SourceGeneratorProjectEngine.cs +++ b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/SourceGeneratorProjectEngine.cs @@ -17,6 +17,8 @@ internal sealed class SourceGeneratorProjectEngine private readonly IRazorEnginePhase _discoveryPhase; private readonly int _discoveryPhaseIndex = -1; private readonly int _rewritePhaseIndex = -1; + private readonly int _utf8PhaseIndex = -1; + private readonly int _csharpLoweringPhaseIndex = -1; private ReadOnlySpan Phases => _projectEngine.Engine.Phases.AsSpan(); @@ -28,11 +30,6 @@ public SourceGeneratorProjectEngine(RazorProjectEngine projectEngine) foreach (var phase in Phases) { - if (_discoveryPhaseIndex >= 0 && _rewritePhaseIndex >= 0) - { - break; - } - switch (phase) { case DefaultRazorTagHelperContextDiscoveryPhase: @@ -43,6 +40,14 @@ public SourceGeneratorProjectEngine(RazorProjectEngine projectEngine) case DefaultRazorTagHelperRewritePhase: _rewritePhaseIndex = index; break; + + case Utf8WriteLiteralPhase: + _utf8PhaseIndex = index; + break; + + case DefaultRazorCSharpLoweringPhase: + _csharpLoweringPhaseIndex = index; + break; } index++; @@ -51,7 +56,11 @@ public SourceGeneratorProjectEngine(RazorProjectEngine projectEngine) Debug.Assert(_discoveryPhase is not null); Debug.Assert(_discoveryPhaseIndex >= 0); Debug.Assert(_rewritePhaseIndex >= 0); + Debug.Assert(_utf8PhaseIndex >= 0); + Debug.Assert(_csharpLoweringPhaseIndex >= 0); Debug.Assert(_discoveryPhaseIndex < _rewritePhaseIndex); + Debug.Assert(_rewritePhaseIndex < _utf8PhaseIndex); + Debug.Assert(_utf8PhaseIndex < _csharpLoweringPhaseIndex); } public SourceGeneratorRazorCodeDocument ProcessInitialParse(RazorProjectItem projectItem, CancellationToken cancellationToken) @@ -186,18 +195,34 @@ private static bool HasAnyNotIn(TagHelperCollection first, TagHelperCollection s return false; } - public SourceGeneratorRazorCodeDocument ProcessRemaining(SourceGeneratorRazorCodeDocument sgDocument, DefaultUtf8WriteLiteralFeature.Utf8SupportMap utf8SupportMap, CancellationToken cancellationToken) + public SourceGeneratorRazorCodeDocument ProcessOptimizationPasses(SourceGeneratorRazorCodeDocument sgDocument, CancellationToken cancellationToken) { var codeDocument = sgDocument.CodeDocument; Debug.Assert(codeDocument.GetReferencedTagHelpers() is not null); - if (_projectEngine.Engine.TryGetFeature(out var feature) && - feature is DefaultUtf8WriteLiteralFeature defaultFeature) - { - defaultFeature.SupportMap = utf8SupportMap; - } + codeDocument = ExecutePhases(Phases[(_rewritePhaseIndex + 1).._utf8PhaseIndex], codeDocument, cancellationToken); - codeDocument = ExecutePhases(Phases[(_rewritePhaseIndex + 1)..], codeDocument, cancellationToken); + return new SourceGeneratorRazorCodeDocument(codeDocument); + } + + /// + /// Runs the with the support map and returns the UTF-8 flag + /// it records on the document. + /// + /// + /// Kept as its own step so the support map stays out of the cached optimization passes. + /// + public bool ProcessUtf8(SourceGeneratorRazorCodeDocument sgDocument, Utf8SupportMap utf8SupportMap, CancellationToken cancellationToken) + { + var codeDocument = sgDocument.CodeDocument.WithUtf8SupportMap(utf8SupportMap); + codeDocument = ExecutePhases(Phases[_utf8PhaseIndex.._csharpLoweringPhaseIndex], codeDocument, cancellationToken); + + return codeDocument.GetDocumentNode()?.Options?.WriteHtmlUtf8StringLiterals ?? false; + } + + public SourceGeneratorRazorCodeDocument ProcessCSharp(SourceGeneratorRazorCodeDocument sgDocument, CancellationToken cancellationToken) + { + var codeDocument = ExecutePhases(Phases[_csharpLoweringPhaseIndex..], sgDocument.CodeDocument, cancellationToken); return new SourceGeneratorRazorCodeDocument(codeDocument); } diff --git a/src/Razor/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.UnitTests/RazorSourceGeneratorCshtmlTests.cs b/src/Razor/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.UnitTests/RazorSourceGeneratorCshtmlTests.cs index 5836e5979d09e..d6458f80801ef 100644 --- a/src/Razor/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.UnitTests/RazorSourceGeneratorCshtmlTests.cs +++ b/src/Razor/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.UnitTests/RazorSourceGeneratorCshtmlTests.cs @@ -386,6 +386,435 @@ public abstract class MyPageBase : RazorPage Assert.DoesNotContain("u8)", result.GeneratedSources[0].SourceText.ToString()); } + [Fact, WorkItem("https://devdiv.visualstudio.com/DevDiv/_workitems/edit/3050748")] + public async Task Utf8HtmlLiterals_IncrementalUpdate_DoesNotDuplicateComponentTypeInferenceMethods() + { + var project = CreateTestProject( + additionalSources: new() + { + ["Components/Page.razor"] = """ + @using MyApp + + """, + ["Pages/Index.cshtml"] = """ + @inherits MyApp.MyPageBase +

Hello World

+ """, + }, + sources: new() + { + ["GenericComponent.cs"] = """ + using Microsoft.AspNetCore.Components; + + namespace MyApp; + + public sealed class GenericComponent : ComponentBase + { + [Parameter] + public T Value { get; set; } = default!; + } + """, + ["MyPageBase.cs"] = """ + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp; + + public abstract class MyPageBase : RazorPage + { + } + """, + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + var result = RunGenerator(compilation!, ref driver); + var originalComponentSource = result.GeneratedSources + .Single(s => s.HintName.EndsWith("Page_razor.g.cs", StringComparison.Ordinal)) + .SourceText; + + var baseClassDocument = project.Documents.Single(d => d.Name == "MyPageBase.cs"); + project = baseClassDocument.WithText(SourceText.From(""" + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp; + + public abstract class MyPageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + """, Encoding.UTF8)).Project; + + compilation = await project.GetCompilationAsync(); + result = RunGenerator(compilation!, ref driver); + + var updatedComponentSource = result.GeneratedSources + .Single(s => s.HintName.EndsWith("Page_razor.g.cs", StringComparison.Ordinal)) + .SourceText; + + Assert.Equal(originalComponentSource.ToString(), updatedComponentSource.ToString()); + } + + [Fact, WorkItem("https://devdiv.visualstudio.com/DevDiv/_workitems/edit/3050748")] + public async Task IncrementalUpdate_WhenInheritsTypeChanges_DoesNotDuplicateComponentTypeInferenceMethods() + { + var project = CreateTestProject( + additionalSources: new() + { + ["Components/Page.razor"] = """ + @using MyApp + + """, + ["Pages/Index.cshtml"] = """ + @inherits PageBaseAlias +

Hello World

+ """, + }, + sources: new() + { + ["GenericComponent.cs"] = """ + using Microsoft.AspNetCore.Components; + + namespace MyApp; + + public sealed class GenericComponent : ComponentBase + { + [Parameter] + public T Value { get; set; } = default!; + } + """, + ["PageBases.cs"] = """ + global using PageBaseAlias = MyApp.FirstPageBase; + + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp; + + public abstract class FirstPageBase : RazorPage + { + } + + public abstract class SecondPageBase : RazorPage + { + } + """, + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + var result = RunGenerator(compilation!, ref driver); + var originalComponentSource = result.GeneratedSources + .Single(s => s.HintName.EndsWith("Page_razor.g.cs", StringComparison.Ordinal)) + .SourceText; + + var pageBasesDocument = project.Documents.Single(d => d.Name == "PageBases.cs"); + project = pageBasesDocument.WithText(SourceText.From(""" + global using PageBaseAlias = MyApp.SecondPageBase; + + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp; + + public abstract class FirstPageBase : RazorPage + { + } + + public abstract class SecondPageBase : RazorPage + { + } + """, Encoding.UTF8)).Project; + + compilation = await project.GetCompilationAsync(); + result = RunGenerator(compilation!, ref driver); + + var updatedComponentSource = result.GeneratedSources + .Single(s => s.HintName.EndsWith("Page_razor.g.cs", StringComparison.Ordinal)) + .SourceText; + + Assert.Equal(originalComponentSource.ToString(), updatedComponentSource.ToString()); + } + + [Fact, WorkItem("https://devdiv.visualstudio.com/DevDiv/_workitems/edit/3052471")] + public async Task Utf8HtmlLiterals_IncrementalUpdate_DoesNotDuplicateMetadataAttributes() + { + // A change to one .cshtml's @inherits base type flips the project-wide UTF-8 support map, + // which re-runs code generation for an unrelated bystander .cshtml. That bystander's + // [assembly: RazorCompiledItem(...)] metadata attribute (appended by an optimization pass) + // must not be duplicated by the re-run. + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Bystander.cshtml"] = """ + @inherits MyApp.MyPageBase +

Bystander

+ """, + ["Pages/Index.cshtml"] = """ + @inherits MyApp.MyPageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyPageBase.cs"] = """ + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp; + + public abstract class MyPageBase : RazorPage + { + } + """, + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + var result = RunGenerator(compilation!, ref driver, out _, _ => { }); + var originalBystanderSource = result.GeneratedSources + .Single(s => s.HintName.Contains("Bystander")) + .SourceText.ToString(); + Assert.Equal(1, CountOccurrences(originalBystanderSource, "RazorCompiledItemAttribute(")); + + var baseClassDocument = project.Documents.Single(d => d.Name == "MyPageBase.cs"); + project = baseClassDocument.WithText(SourceText.From(""" + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp; + + public abstract class MyPageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + """, Encoding.UTF8)).Project; + + compilation = await project.GetCompilationAsync(); + result = RunGenerator(compilation!, ref driver, out _, _ => { }); + + var updatedBystanderSource = result.GeneratedSources + .Single(s => s.HintName.Contains("Bystander")) + .SourceText.ToString(); + Assert.Equal(1, CountOccurrences(updatedBystanderSource, "RazorCompiledItemAttribute(")); + + static int CountOccurrences(string text, string value) + { + var count = 0; + for (var index = text.IndexOf(value, StringComparison.Ordinal); index >= 0; index = text.IndexOf(value, index + value.Length, StringComparison.Ordinal)) + { + count++; + } + return count; + } + } + + [Fact, WorkItem("https://devdiv.visualstudio.com/DevDiv/_workitems/edit/3050748")] + public async Task Utf8HtmlLiterals_IncrementalUpdate_SwitchesBackToStringLiteralsWhenOverloadRemoved() + { + // The same driver is reused across runs, and the UTF-8 overload is removed by editing the + // base class's .cs file rather than the .cshtml. The .cshtml document is therefore unchanged + // and its parsed/optimized output is cached; only the UTF-8 support map changes. Emission + // must recompute the decision so support that disappears turns byte literals back off. + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits MyApp.MyPageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyPageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp; + + public abstract class MyPageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + """, + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + var result = RunGenerator(compilation!, ref driver, out _, _ => { }); + Assert.Contains("u8)", result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString()); + + var baseClassDocument = project.Documents.Single(d => d.Name == "MyPageBase.cs"); + project = baseClassDocument.WithText(SourceText.From(""" + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp; + + public abstract class MyPageBase : RazorPage + { + } + """, Encoding.UTF8)).Project; + + compilation = await project.GetCompilationAsync(); + result = RunGenerator(compilation!, ref driver, out _, _ => { }); + + Assert.DoesNotContain("u8)", result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString()); + } + + [Fact, WorkItem("https://devdiv.visualstudio.com/DevDiv/_workitems/edit/3050748")] + public async Task Utf8HtmlLiterals_IncrementalUpdate_OnlyReRunsEmissionNotOptimization() + { + // Adding the UTF-8 overload edits a .cs source, not the .cshtml, so the document's parsed and + // optimized intermediate tree is unaffected and only the project-wide UTF-8 support map changes. + // The optimization step must stay cached (replaying it is what duplicated appended nodes); only + // read-only C# emission re-runs. + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits MyApp.MyPageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyPageBase.cs"] = """ + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp; + + public abstract class MyPageBase : RazorPage + { + } + """, + }); + + var compilation = await project.GetCompilationAsync(); + var (driver, _, _) = await GetDriverWithAdditionalTextAndProviderAsync(project, trackSteps: true); + + var result = RunGenerator(compilation!, ref driver, out _, _ => { }); + Assert.DoesNotContain("u8)", result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString()); + + var baseClassDocument = project.Documents.Single(d => d.Name == "MyPageBase.cs"); + project = baseClassDocument.WithText(SourceText.From(""" + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp; + + public abstract class MyPageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + """, Encoding.UTF8)).Project; + + compilation = await project.GetCompilationAsync(); + result = RunGenerator(compilation!, ref driver, out _, _ => { }); + Assert.Contains("u8)", result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString()); + + // The optimized intermediate tree is reused; the UTF-8 decision flips, so only C# emission + // re-runs. + result.VerifyIncrementalSteps("OptimizedDocuments", IncrementalStepRunReason.Cached); + result.VerifyIncrementalSteps("Utf8Documents", IncrementalStepRunReason.Modified); + result.VerifyIncrementalSteps("GeneratedCode", IncrementalStepRunReason.Modified); + } + + [Fact, WorkItem("https://devdiv.visualstudio.com/DevDiv/_workitems/edit/3050748")] + public async Task Utf8HtmlLiterals_IncrementalUpdate_DoesNotReEmitUnaffectedComponent() + { + // Editing the .cshtml base class flips only that page's UTF-8 decision. An unrelated generic + // component's optimized tree and UTF-8 flag are unchanged, so its C# emission stays cached -- + // it is neither re-lowered (which duplicated its type-inference methods) nor re-emitted. + var project = CreateTestProject( + additionalSources: new() + { + ["Components/Page.razor"] = """ + @using MyApp + + """, + ["Pages/Index.cshtml"] = """ + @inherits MyApp.MyPageBase +

Hello World

+ """, + }, + sources: new() + { + ["GenericComponent.cs"] = """ + using Microsoft.AspNetCore.Components; + + namespace MyApp; + + public sealed class GenericComponent : ComponentBase + { + [Parameter] + public T Value { get; set; } = default!; + } + """, + ["MyPageBase.cs"] = """ + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp; + + public abstract class MyPageBase : RazorPage + { + } + """, + }); + + var compilation = await project.GetCompilationAsync(); + var (driver, _, _) = await GetDriverWithAdditionalTextAndProviderAsync(project, trackSteps: true); + + RunGenerator(compilation!, ref driver, out _, _ => { }); + + var baseClassDocument = project.Documents.Single(d => d.Name == "MyPageBase.cs"); + project = baseClassDocument.WithText(SourceText.From(""" + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp; + + public abstract class MyPageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + """, Encoding.UTF8)).Project; + + compilation = await project.GetCompilationAsync(); + var result = RunGenerator(compilation!, ref driver, out _, _ => { }); + + // The .cshtml re-emits (its UTF-8 decision flipped); the unrelated component stays cached. + // Key the run reason by source file path so we verify *which* document re-emitted -- the + // "GeneratedCode" step output value is a (filePath, document) tuple; read the first element + // via ITuple to avoid naming the internal document type. + var reasonsByFile = result.TrackedSteps["GeneratedCode"] + .SelectMany(step => step.Outputs) + .ToDictionary( + output => (string)((System.Runtime.CompilerServices.ITuple)output.Value)[0]!, + output => output.Reason); + + Assert.Equal(2, reasonsByFile.Count); + var componentReason = reasonsByFile.Single(kvp => kvp.Key.EndsWith(".razor", StringComparison.Ordinal)).Value; + var pageReason = reasonsByFile.Single(kvp => kvp.Key.EndsWith(".cshtml", StringComparison.Ordinal)).Value; + Assert.NotEqual(IncrementalStepRunReason.Modified, componentReason); + Assert.Equal(IncrementalStepRunReason.Modified, pageReason); + } + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] public async Task Utf8HtmlLiterals_NoInheritsDirective_UsesStringLiterals() {