diff --git a/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/IntegrationTests/CodeGenerationIntegrationTest.cs b/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/IntegrationTests/CodeGenerationIntegrationTest.cs index 01c1f17d866..d23b687ad9f 100644 --- a/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/IntegrationTests/CodeGenerationIntegrationTest.cs +++ b/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/IntegrationTests/CodeGenerationIntegrationTest.cs @@ -3,11 +3,15 @@ #nullable disable +using System; +using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.IntegrationTests; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Razor.Compiler.CSharp; using Microsoft.CodeAnalysis.Test.Utilities; using Roslyn.Test.Utilities; using Xunit; @@ -30,6 +34,21 @@ 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 [Fact] @@ -926,6 +945,82 @@ public void InvalidCode_EmptyImplicitExpression_Runtime() CompileToAssembly(generated, throwOnFailure: false, ignoreRazorDiagnostics: true); } + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public void Utf8HtmlLiterals_AutoDetectedFromInherits_Runtime() + { + // 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.GetCSharpDocument().Text.ToString(); + Assert.Contains("u8)", generatedCode); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public void Utf8HtmlLiterals_WithoutOverload_UsesStringLiterals_Runtime() + { + // 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.GetCSharpDocument().Text.ToString(); + Assert.DoesNotContain("u8)", generatedCode); + } + #endregion #region DesignTime diff --git a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/CodeGeneration/RuntimeNodeWriterTest.cs b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/CodeGeneration/RuntimeNodeWriterTest.cs index 90bf233f38d..798ecbfad9a 100644 --- a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/CodeGeneration/RuntimeNodeWriterTest.cs +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/CodeGeneration/RuntimeNodeWriterTest.cs @@ -445,6 +445,52 @@ public void WriteHtmlContent_RendersContentCorrectly() ignoreLineEndingDifferences: true); } + [Fact] + public void WriteHtmlContent_Utf8_RendersContentCorrectly() + { + // Arrange + var writer = RuntimeNodeWriter.Instance; + using var context = TestCodeRenderingContext.CreateRuntime(writeHtmlUtf8StringLiterals: true); + + var node = new HtmlContentIntermediateNode(); + node.Children.Add(IntermediateNodeFactory.HtmlToken("SomeContent")); + + // Act + writer.WriteHtmlContent(context, node); + + // Assert + var csharp = context.CodeWriter.GetText().ToString(); + Assert.Equal( +@"WriteLiteral(""SomeContent""u8); +", + csharp, + ignoreLineEndingDifferences: true); + } + + [Fact] + public void WriteHtmlContent_Utf8_LargeStringLiteral_UsesMultipleWrites() + { + // Arrange + var writer = RuntimeNodeWriter.Instance; + using var context = TestCodeRenderingContext.CreateRuntime(writeHtmlUtf8StringLiterals: true); + + var node = new HtmlContentIntermediateNode(); + node.Children.Add(IntermediateNodeFactory.HtmlToken(new string('*', 2000))); + + // Act + writer.WriteHtmlContent(context, node); + + // Assert + var csharp = context.CodeWriter.GetText().ToString(); + Assert.Equal(string.Format( + CultureInfo.InvariantCulture, +@"WriteLiteral(@""{0}""u8); +WriteLiteral(@""{1}""u8); +", new string('*', 1024), new string('*', 976)), + csharp, + ignoreLineEndingDifferences: true); + } + [Fact] public void WriteHtmlContent_LargeStringLiteral_UsesMultipleWrites() { diff --git a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs index 7075d6e6ae1..b226f27da5a 100644 --- a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Razor.Language.Components; using Microsoft.AspNetCore.Razor.Language.Extensions; using Microsoft.AspNetCore.Razor.PooledObjects; +using Microsoft.CodeAnalysis.Razor.Compiler.CSharp; using Moq; using Xunit; @@ -91,6 +92,7 @@ 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/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/CompilationExtensions.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/CompilationExtensions.cs index dc5dcd44c86..546b039da08 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/CompilationExtensions.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/CompilationExtensions.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Generic; using System.Linq; namespace Microsoft.CodeAnalysis.Razor.Compiler.CSharp; @@ -16,4 +15,56 @@ public static bool HasAddComponentParameter(this Compilation compilation) t.GetMembers("AddComponentParameter") .Any(static m => m.DeclaredAccessibility == Accessibility.Public)); } + + /// + /// Determines whether the type identified by has a callable + /// instance WriteLiteral(ReadOnlySpan<byte>) overload accessible from that type. + /// + public static bool HasCallableUtf8WriteLiteralOverload(this Compilation compilation, string typeMetadataName) + { + var type = compilation.GetTypeByMetadataName(typeMetadataName); + if (type is null || type.TypeKind == TypeKind.Error) + { + return false; + } + + return compilation.HasCallableUtf8WriteLiteralOverload(type); + } + + /// + /// Determines whether the given has a callable + /// instance WriteLiteral(ReadOnlySpan<byte>) overload accessible from that type. + /// + public static bool HasCallableUtf8WriteLiteralOverload(this Compilation compilation, INamedTypeSymbol type) + { + var readOnlySpanType = compilation.GetTypeByMetadataName("System.ReadOnlySpan`1"); + var byteType = compilation.GetSpecialType(SpecialType.System_Byte); + if (readOnlySpanType is not INamedTypeSymbol readOnlySpanNamedType || + byteType.TypeKind == TypeKind.Error) + { + return false; + } + + var readOnlySpanOfByte = readOnlySpanNamedType.Construct(byteType); + + for (var currentType = type; currentType is not null; currentType = currentType.BaseType) + { + foreach (var member in currentType.GetMembers("WriteLiteral")) + { + if (member is IMethodSymbol + { + IsStatic: false, + ReturnsVoid: true, + Parameters: [{ Type: var paramType }] + } method && + SymbolEqualityComparer.Default.Equals(paramType, readOnlySpanOfByte) && + compilation.IsSymbolAccessibleWithin(method, type)) + { + return true; + } + } + } + + return false; + } } diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs new file mode 100644 index 00000000000..9b284d2febf --- /dev/null +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs @@ -0,0 +1,271 @@ +// 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; + +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) + { + 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(";"); + } + + sb.Append(" class __Probe__ : ").Append(info.BaseTypeName).AppendLine(" { }"); + sb.AppendLine("}"); + } + + var parseOptions = compilation.SyntaxTrees.FirstOrDefault()?.Options as CSharpParseOptions + ?? CSharpParseOptions.Default; + var probeTree = CSharpSyntaxTree.ParseText(sb.ToString(), parseOptions); + var augmented = compilation.AddSyntaxTrees(probeTree); + var semanticModel = augmented.GetSemanticModel(probeTree); + + // Query each probe class's base type. + var namespaceDecls = probeTree.GetRoot().DescendantNodes() + .OfType() + .ToArray(); + + for (var i = 0; i < namespaceDecls.Length; i++) + { + var classDecl = namespaceDecls[i].DescendantNodes() + .OfType() + .FirstOrDefault(); + var baseTypeSyntax = classDecl?.BaseList?.Types.FirstOrDefault(); + if (baseTypeSyntax is null) + { + continue; + } + + var symbol = semanticModel.GetSymbolInfo(baseTypeSyntax.Type).Symbol as INamedTypeSymbol; + if (symbol is not null && symbol.TypeKind != TypeKind.Error) + { + results.Add((entries[i].Index, GetFullMetadataName(symbol))); + } + } + + 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 = 17; + + foreach (var kvp in _fileToType) + { + hash = hash * 31 + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Key); + hash = hash * 31 + StringComparer.Ordinal.GetHashCode(kvp.Value); + } + + foreach (var kvp in _typeSupport) + { + hash = hash * 31 + StringComparer.Ordinal.GetHashCode(kvp.Key); + hash = hash * 31 + kvp.Value.GetHashCode(); + } + + return hash; + } + } +} diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/IUtf8WriteLiteralFeature.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/IUtf8WriteLiteralFeature.cs new file mode 100644 index 00000000000..0ed06885921 --- /dev/null +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/IUtf8WriteLiteralFeature.cs @@ -0,0 +1,21 @@ +// 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/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8WriteLiteralDetectionPass.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8WriteLiteralDetectionPass.cs new file mode 100644 index 00000000000..b649c230cf9 --- /dev/null +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8WriteLiteralDetectionPass.cs @@ -0,0 +1,49 @@ +// 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.DesignTime || + 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/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/CodeGeneration/CodeWriterExtensions.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/CodeGeneration/CodeWriterExtensions.cs index 6741b8799e5..945763d0345 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/CodeGeneration/CodeWriterExtensions.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/CodeGeneration/CodeWriterExtensions.cs @@ -265,18 +265,18 @@ public static CodeWriter WriteStartNewObject(this CodeWriter writer, string type return writer.Write("new ").Write(typeName).Write("("); } - public static CodeWriter WriteStringLiteral(this CodeWriter writer, string literal) - => writer.WriteStringLiteral(literal.AsMemory()); + public static CodeWriter WriteStringLiteral(this CodeWriter writer, string literal, bool utf8 = false) + => writer.WriteStringLiteral(literal.AsMemory(), utf8); - public static CodeWriter WriteStringLiteral(this CodeWriter writer, ReadOnlyMemory literal) + public static CodeWriter WriteStringLiteral(this CodeWriter writer, ReadOnlyMemory literal, bool utf8 = false) { if (literal.Length >= 256 && literal.Length <= 1500 && literal.Span.IndexOf('\0') == -1) { - WriteVerbatimStringLiteral(writer, literal); + WriteVerbatimStringLiteral(writer, literal, utf8); } else { - WriteCStyleStringLiteral(writer, literal); + WriteCStyleStringLiteral(writer, literal, utf8); } return writer; @@ -900,7 +900,7 @@ public static CodeWriter WriteSeparatedList(this CodeWriter writer, string se return writer; } - private static void WriteVerbatimStringLiteral(CodeWriter writer, ReadOnlyMemory literal) + private static void WriteVerbatimStringLiteral(CodeWriter writer, ReadOnlyMemory literal, bool utf8 = false) { writer.Write("@\""); @@ -926,10 +926,15 @@ private static void WriteVerbatimStringLiteral(CodeWriter writer, ReadOnlyMemory writer.Write("\""); + if (utf8) + { + writer.Write("u8"); + } + writer.CurrentIndent = oldIndent; } - private static void WriteCStyleStringLiteral(CodeWriter writer, ReadOnlyMemory literal) + private static void WriteCStyleStringLiteral(CodeWriter writer, ReadOnlyMemory literal, bool utf8 = false) { // From CSharpCodeGenerator.QuoteSnippetStringCStyle in CodeDOM writer.Write("\""); @@ -983,6 +988,11 @@ private static void WriteCStyleStringLiteral(CodeWriter writer, ReadOnlyMemory content) { context.CodeWriter .WriteStartMethodInvocation(WriteHtmlContentMethod) - .WriteStringLiteral(content) + .WriteStringLiteral(content, context.Options.WriteHtmlUtf8StringLiterals) .WriteEndMethodInvocation(); } } diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorCSharpLoweringPhase.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorCSharpLoweringPhase.cs index 00abcedbdb2..2b058c97f1b 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorCSharpLoweringPhase.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorCSharpLoweringPhase.cs @@ -41,7 +41,7 @@ private static RazorCSharpDocument WriteDocument(RazorCodeDocument codeDocument, codeTarget.CreateNodeWriter(), codeDocument.Source, documentNode, - codeDocument.CodeGenerationOptions); + documentNode.Options ?? codeDocument.CodeGenerationOptions); context.SetVisitor(new Visitor(context, codeTarget, cancellationToken)); diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocumentExtensions.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocumentExtensions.cs index 081928c989a..15782a6b171 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocumentExtensions.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocumentExtensions.cs @@ -2,10 +2,13 @@ // 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.Diagnostics.CodeAnalysis; using System.IO; using Microsoft.AspNetCore.Mvc.Razor.Extensions; +using Microsoft.AspNetCore.Razor.Language.Extensions; using Microsoft.AspNetCore.Razor.Language.Legacy; using Microsoft.AspNetCore.Razor.Language.Syntax; @@ -43,6 +46,108 @@ internal static bool IsImportsFile(this RazorCodeDocument codeDocument) => codeDocument.FileKind.IsComponentImport() || (codeDocument.FileKind.IsLegacy() && string.Equals(Path.GetFileName(codeDocument.Source.FilePath), MvcImportProjectFeature.ImportsFileName, StringComparison.OrdinalIgnoreCase)); + /// + /// Returns the content of the @inherits directive if present in a legacy .cshtml + /// document's syntax tree, or for non-legacy files or when absent. + /// + internal static string? GetInheritsDirectiveValue(this RazorCodeDocument codeDocument) + { + if (!codeDocument.FileKind.IsLegacy()) + { + return null; + } + + var syntaxTree = codeDocument.GetSyntaxTree(); + if (syntaxTree is null) + { + return null; + } + + // Check the main document first -- its @inherits overrides any from imports. + var inheritsValue = FindInheritsDirective(syntaxTree); + if (inheritsValue is not null) + { + return inheritsValue; + } + + // Check import syntax trees. The last import's @inherits wins (most specific scope). + if (codeDocument.TryGetImportSyntaxTrees(out var importSyntaxTrees)) + { + for (var i = importSyntaxTrees.Length - 1; i >= 0; i--) + { + inheritsValue = FindInheritsDirective(importSyntaxTrees[i]); + if (inheritsValue is not null) + { + return inheritsValue; + } + } + } + + return null; + + static string? FindInheritsDirective(RazorSyntaxTree tree) + { + foreach (var node in tree.Root.DescendantNodes()) + { + if (node is RazorDirectiveSyntax + { + DirectiveDescriptor: var descriptor, + Body: RazorDirectiveBodySyntax { CSharpCode: { } csharpCode } + } && + descriptor == InheritsDirective.Directive) + { + return csharpCode.GetContent()?.Trim(); + } + } + + return null; + } + } + + /// + /// Returns all @using directives from the document and its import files. + /// + internal static ImmutableArray GetUsingDirectives(this RazorCodeDocument codeDocument) + { + var syntaxTree = codeDocument.GetSyntaxTree(); + if (syntaxTree is null) + { + return []; + } + + var usings = new List(); + CollectUsings(syntaxTree, usings); + + if (codeDocument.TryGetImportSyntaxTrees(out var importSyntaxTrees)) + { + foreach (var importTree in importSyntaxTrees) + { + CollectUsings(importTree, usings); + } + } + + return [.. usings]; + + static void CollectUsings(RazorSyntaxTree tree, List usings) + { + foreach (var node in tree.Root.DescendantNodes()) + { + if (node is RazorUsingDirectiveSyntax usingDirective) + { + var content = usingDirective.Body?.GetContent()?.Trim(); + if (content is not null && content.StartsWith("using ", StringComparison.Ordinal)) + { + var ns = content.Substring("using ".Length).TrimEnd(';').Trim(); + if (ns.Length > 0) + { + usings.Add(ns); + } + } + } + } + } + } + /// /// Returns whether the directive specified was involved in tag helper binding /// diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.Builder.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.Builder.cs index abacfbde00d..f587cb4422f 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.Builder.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.Builder.cs @@ -163,6 +163,15 @@ public bool RemapLinePragmaPathsOnWindows set => _flags.UpdateFlag(Flags.RemapLinePragmaPathsOnWindows, value); } + /// + /// Gets or sets a value that determines if HTML literals should be written as C# UTF-8 string literals. + /// + public bool WriteHtmlUtf8StringLiterals + { + get => _flags.IsFlagSet(Flags.WriteHtmlUtf8StringLiterals); + set => _flags.UpdateFlag(Flags.WriteHtmlUtf8StringLiterals, value); + } + public RazorCodeGenerationOptions ToOptions() => new(IndentSize, NewLine, RootNamespace, CssScope, SuppressUniqueIds, RazorWarningLevel, _flags); } diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.Flags.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.Flags.cs index 71a3641b612..4ff206c8e8d 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.Flags.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.Flags.cs @@ -22,6 +22,7 @@ private enum Flags UseEnhancedLinePragma = 1 << 9, SuppressAddComponentParameter = 1 << 10, RemapLinePragmaPathsOnWindows = 1 << 11, + WriteHtmlUtf8StringLiterals = 1 << 12, DefaultFlags = UseEnhancedLinePragma, DefaultDesignTimeFlags = DesignTime | SuppressMetadataAttributes | UseEnhancedLinePragma | RemapLinePragmaPathsOnWindows diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.cs index eb190417843..64d977769b2 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.cs @@ -170,6 +170,12 @@ public bool SuppressAddComponentParameter public bool RemapLinePragmaPathsOnWindows => (_flags & Flags.RemapLinePragmaPathsOnWindows) == Flags.RemapLinePragmaPathsOnWindows; + /// + /// Gets a value that determines if HTML literals should be written as C# UTF-8 string literals. + /// + public bool WriteHtmlUtf8StringLiterals + => (_flags & Flags.WriteHtmlUtf8StringLiterals) == Flags.WriteHtmlUtf8StringLiterals; + public RazorCodeGenerationOptions WithIndentSize(int value) => IndentSize == value ? this @@ -212,7 +218,8 @@ public RazorCodeGenerationOptions WithFlags( Optional supportLocalizedComponentNames = default, Optional useEnhancedLinePragma = default, Optional suppressAddComponentParameter = default, - Optional remapLinePragmaPathsOnWindows = default) + Optional remapLinePragmaPathsOnWindows = default, + Optional writeHtmlUtf8StringLiterals = default) { var flags = _flags; @@ -276,6 +283,11 @@ public RazorCodeGenerationOptions WithFlags( flags.UpdateFlag(Flags.RemapLinePragmaPathsOnWindows, remapLinePragmaPathsOnWindows.Value); } + if (writeHtmlUtf8StringLiterals.HasValue) + { + flags.UpdateFlag(Flags.WriteHtmlUtf8StringLiterals, writeHtmlUtf8StringLiterals.Value); + } + return flags == _flags ? this : new(IndentSize, NewLine, RootNamespace, CssScope, SuppressUniqueIds, RazorWarningLevel, flags); diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorProjectEngine.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorProjectEngine.cs index abed1f4e30f..2dfac1de7d2 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorProjectEngine.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorProjectEngine.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Razor.Language.Extensions; using Microsoft.AspNetCore.Razor.Language.Intermediate; using Microsoft.AspNetCore.Razor.PooledObjects; +using Microsoft.CodeAnalysis.Razor.Compiler.CSharp; namespace Microsoft.AspNetCore.Razor.Language; @@ -359,6 +360,7 @@ 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 DesignTimeDirectivePass()); features.Add(new DirectiveRemovalOptimizationPass()); features.Add(new DefaultTagHelperOptimizationPass()); diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.Helpers.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.Helpers.cs index 6a8b216fa54..d5a11e9b3cd 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.Helpers.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.Helpers.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Razor; +using Microsoft.CodeAnalysis.Razor.Compiler.CSharp; namespace Microsoft.NET.Sdk.Razor.SourceGenerators { @@ -133,6 +134,8 @@ private static SourceGeneratorProjectEngine GetGenerationProjectEngine( builder.CSharpParseOptions = razorSourceGeneratorOptions.CSharpParseOptions; }); + b.Features.Add(new DefaultUtf8WriteLiteralFeature()); + CompilerFeatures.Register(b); RazorExtensions.Register(b); diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.cs index 4358c46193c..8026d7af953 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; using System.Linq; using Microsoft.AspNetCore.Razor; @@ -11,6 +12,7 @@ using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Razor.Compiler.CSharp; namespace Microsoft.NET.Sdk.Razor.SourceGenerators { @@ -249,7 +251,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .WithLambdaComparer((old, @new) => old.Left.Equals(@new.Left) && old.Right.SequenceEqual(@new.Right)) .Combine(razorSourceGeneratorOptions); - var csharpDocuments = withOptions + var parsedDocuments = withOptions .Select((pair, cancellationToken) => { var ((sourceItem, imports), razorSourceGeneratorOptions) = pair; @@ -263,7 +265,28 @@ public void Initialize(IncrementalGeneratorInitializationContext context) RazorSourceGeneratorEventSource.Log.ParseRazorDocumentStop(sourceItem.RelativePhysicalPath); return (projectEngine, sourceItem.RelativePhysicalPath, document); }) - .WithTrackingName("ParsedDocuments") + .WithTrackingName("ParsedDocuments"); + + // Build a map of which @inherits base types support UTF-8 WriteLiteral. + var utf8SupportMap = parsedDocuments + .Select(static (item, _) => + { + var codeDocument = item.Item3.CodeDocument; + return (codeDocument, InheritsValue: codeDocument.GetInheritsDirectiveValue()); + }) + .Where(static item => item.InheritsValue is not null) + .Select(static (item, _) => new DefaultUtf8WriteLiteralFeature.InheritsInfo( + item.codeDocument.Source.FilePath ?? string.Empty, item.InheritsValue!, item.codeDocument.GetUsingDirectives())) + .Collect() + .Combine(declCompilation) + .Select(static (pair, _) => + { + var (inheritsInfos, compilation) = pair; + return DefaultUtf8WriteLiteralFeature.Utf8SupportMap.Create(inheritsInfos, compilation); + }) + .WithTrackingName("Utf8SupportMap"); + + var csharpDocuments = parsedDocuments // Add the tag helpers in, but ignore if they've changed or not, only reprocessing the actual document changed .Combine(allTagHelpers) @@ -293,12 +316,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return (projectEngine, filePath, document); }) .WithTrackingName("CheckedAndRewrittenTagHelpers") + .Combine(utf8SupportMap) .Select((pair, cancellationToken) => { - var (projectEngine, filePath, document) = pair; + var ((projectEngine, filePath, document), utf8SupportMap) = pair; RazorSourceGeneratorEventSource.Log.RazorCodeGenerateStart(filePath); - document = projectEngine.ProcessRemaining(document, cancellationToken); + document = projectEngine.ProcessRemaining(document, utf8SupportMap, cancellationToken); RazorSourceGeneratorEventSource.Log.RazorCodeGenerateStop(filePath); return (filePath, document); diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/SourceGeneratorProjectEngine.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/SourceGeneratorProjectEngine.cs index b22443db84a..140a6a7174f 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/SourceGeneratorProjectEngine.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/SourceGeneratorProjectEngine.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis.Razor.Compiler.CSharp; using System; using System.Diagnostics; using System.Threading; @@ -165,11 +166,17 @@ private static bool HasAnyNotIn(TagHelperCollection first, TagHelperCollection s return false; } - public SourceGeneratorRazorCodeDocument ProcessRemaining(SourceGeneratorRazorCodeDocument sgDocument, CancellationToken cancellationToken) + public SourceGeneratorRazorCodeDocument ProcessRemaining(SourceGeneratorRazorCodeDocument sgDocument, DefaultUtf8WriteLiteralFeature.Utf8SupportMap utf8SupportMap, 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)..], codeDocument, cancellationToken); return new SourceGeneratorRazorCodeDocument(codeDocument); diff --git a/src/Compiler/perf/Microsoft.AspNetCore.Razor.Microbenchmarks.Generator/Microsoft.AspNetCore.Razor.Microbenchmarks.Generator.csproj b/src/Compiler/perf/Microsoft.AspNetCore.Razor.Microbenchmarks.Generator/Microsoft.AspNetCore.Razor.Microbenchmarks.Generator.csproj index 0f76248d930..21893ebf1d6 100644 --- a/src/Compiler/perf/Microsoft.AspNetCore.Razor.Microbenchmarks.Generator/Microsoft.AspNetCore.Razor.Microbenchmarks.Generator.csproj +++ b/src/Compiler/perf/Microsoft.AspNetCore.Razor.Microbenchmarks.Generator/Microsoft.AspNetCore.Razor.Microbenchmarks.Generator.csproj @@ -36,7 +36,7 @@ - + diff --git a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs index f800c9961c9..de40b467710 100644 --- a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs +++ b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs @@ -1,9 +1,15 @@ // 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.Immutable; +using System.IO; +using System.Linq; +using System.Text; using System.Threading.Tasks; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; using Roslyn.Test.Utilities; using Xunit; @@ -167,4 +173,988 @@ public async Task QuoteInAttributeName() """, html); } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_AutoDetectedFromInherits() + { + // Arrange + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits MyUtf8PageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedSources); + result.VerifyOutputsMatchBaseline(); + Assert.Contains("u8)", result.GeneratedSources[0].SourceText.ToString()); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_WithoutOverload_UsesStringLiterals() + { + // Arrange + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits MyPageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyPageBase.cs"] = """ + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyPageBase : RazorPage + { + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedSources); + result.VerifyOutputsMatchBaseline(); + Assert.DoesNotContain("u8)", result.GeneratedSources[0].SourceText.ToString()); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_MixedFiles_OnlyUtf8ForInheritsWithOverload() + { + // Arrange - one file inherits a base with the overload, the other inherits one without + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Utf8Page.cshtml"] = """ + @inherits MyUtf8PageBase +

UTF-8 Page

+ """, + ["Pages/RegularPage.cshtml"] = """ + @inherits MyRegularPageBase +

Regular Page

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + """, + ["MyRegularPageBase.cs"] = """ + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyRegularPageBase : RazorPage + { + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert + Assert.Empty(result.Diagnostics); + Assert.Equal(2, result.GeneratedSources.Length); + + var utf8Source = result.GeneratedSources.Single(s => s.HintName.Contains("Utf8Page")).SourceText.ToString(); + var regularSource = result.GeneratedSources.Single(s => s.HintName.Contains("RegularPage")).SourceText.ToString(); + + Assert.Contains("u8)", utf8Source); + Assert.DoesNotContain("u8)", regularSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_SwitchesWhenOverloadAddedOrRemoved() + { + // Arrange - start without the UTF-8 overload + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits MyPageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyPageBase.cs"] = """ + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyPageBase : RazorPage + { + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act 1 - verify string literals are used + var result = RunGenerator(compilation!, ref driver, out _); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedSources); + Assert.DoesNotContain("u8)", result.GeneratedSources[0].SourceText.ToString()); + + // Act 2 - add the UTF-8 overload to the base class + var baseClassDoc = project.Documents.Single(d => d.Name == "MyPageBase.cs"); + project = project.RemoveDocument(baseClassDoc.Id) + .AddDocument("MyPageBase.cs", SourceText.From(""" + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyPageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + """, Encoding.UTF8)).Project; + + compilation = await project.GetCompilationAsync(); + driver = await GetDriverAsync(project); + result = RunGenerator(compilation!, ref driver, out _, _ => { }); + + // Assert - should now use UTF-8 literals + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedSources); + Assert.Contains("u8)", result.GeneratedSources[0].SourceText.ToString()); + + // Act 3 - remove the UTF-8 overload from the base class + baseClassDoc = project.Documents.Single(d => d.Name == "MyPageBase.cs"); + project = project.RemoveDocument(baseClassDoc.Id) + .AddDocument("MyPageBase.cs", SourceText.From(""" + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyPageBase : RazorPage + { + } + """, Encoding.UTF8)).Project; + + compilation = await project.GetCompilationAsync(); + driver = await GetDriverAsync(project); + result = RunGenerator(compilation!, ref driver, out _, _ => { }); + + // Assert - should switch back to string literals + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedSources); + Assert.DoesNotContain("u8)", result.GeneratedSources[0].SourceText.ToString()); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_NoInheritsDirective_UsesStringLiterals() + { + // Arrange - no @inherits directive, default base class + var project = CreateTestProject(new() + { + ["Pages/Index.cshtml"] = """ + @page +

Hello World

+ """, + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert - default base classes don't support UTF-8 WriteLiteral + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedSources); + Assert.DoesNotContain("u8)", result.GeneratedSources[0].SourceText.ToString()); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_FullyQualifiedInherits() + { + // Arrange - @inherits with fully-qualified type name + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits MyApp.Infrastructure.MyUtf8PageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert - fully-qualified name resolves correctly + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedSources); + Assert.Contains("u8)", result.GeneratedSources[0].SourceText.ToString()); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_ShortNameInherits_WithUsing() + { + // Arrange - @inherits with short name, namespace imported via _ViewImports + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/_ViewImports.cshtml"] = """ + @using MyApp.Infrastructure + """, + ["Pages/Index.cshtml"] = """ + @inherits MyUtf8PageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert - short name resolves via augmented compilation with the @using directives + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_PartiallyQualifiedInherits() + { + // Arrange - @inherits with partially-qualified name + // Note: Razor requires fully-qualified names in @inherits, so this produces a compile error. + // This test documents that we gracefully handle this case (no UTF-8 detection). + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits Infrastructure.MyUtf8PageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _, _ => { }); + + // Assert - partially-qualified name won't match the metadata name. + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.DoesNotContain("u8)", indexSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_AliasedInherits_WithUsing() + { + // Arrange - @inherits with a type alias defined via @using alias in _ViewImports + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/_ViewImports.cshtml"] = """ + @using Utf8Base = MyApp.Infrastructure.MyUtf8PageBase + """, + ["Pages/Index.cshtml"] = """ + @inherits Utf8Base +

Hello World

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert - alias resolves via augmented compilation + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_AliasShadowsExistingType_GracefulFallback() + { + // Arrange - "MyPageBase" exists as a non-UTF-8 type in the global namespace. + // AliasedPage uses "@using MyPageBase = MyApp.Infrastructure.MyUtf8PageBase" + // which creates a C# compile error (CS0576: definition conflicting with alias). + // This test verifies we handle this gracefully — no crash, falls back to string literals. + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/RegularPage.cshtml"] = """ + @inherits MyPageBase +

Regular Page

+ """, + ["Pages/AliasedPage.cshtml"] = """ + @using MyPageBase = MyApp.Infrastructure.MyUtf8PageBase + @inherits MyPageBase +

Aliased Page

+ """, + }, + sources: new() + { + ["MyPageBase.cs"] = """ + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyPageBase : RazorPage + { + } + """, + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert - no generator crash. The alias conflicts with the global type (CS0576), + // so both files fall back to string literals. This is correct behavior since the + // user's code has a compile error. + Assert.Empty(result.Diagnostics); + + var regularSource = result.GeneratedSources.Single(s => s.HintName.Contains("RegularPage")).SourceText.ToString(); + var aliasedSource = result.GeneratedSources.Single(s => s.HintName.Contains("AliasedPage")).SourceText.ToString(); + + Assert.DoesNotContain("u8)", regularSource); + Assert.DoesNotContain("u8)", aliasedSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_AddingUsingMakesShortNameResolve() + { + // Arrange - @inherits uses a short name that doesn't resolve without a @using + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits MyUtf8PageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var (driver, additionalTexts, optionsProvider) = await GetDriverWithAdditionalTextAndProviderAsync(project); + + // Act 1 - short name doesn't resolve, falls back to string literals + var result = RunGenerator(compilation!, ref driver, out _, _ => { }); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedSources); + Assert.DoesNotContain("u8)", result.GeneratedSources[0].SourceText.ToString()); + + // Act 2 - add a _ViewImports with @using that makes the short name resolve + var viewImports = new TestAdditionalText("Pages/_ViewImports.cshtml", + SourceText.From("@using MyApp.Infrastructure", Encoding.UTF8)); + driver = driver.AddAdditionalTexts([viewImports]); + optionsProvider.AdditionalTextOptions[viewImports.Path] = new TestAnalyzerConfigOptions + { + ["build_metadata.AdditionalFiles.TargetPath"] = Convert.ToBase64String(Encoding.UTF8.GetBytes(viewImports.Path)) + }; + driver = driver.WithUpdatedAnalyzerConfigOptions(optionsProvider); + + result = RunGenerator(compilation!, ref driver, out _, _ => { }); + + // Assert - now the short name resolves and UTF-8 is used + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_InheritsFromViewImports() + { + // Arrange - @inherits is in _ViewImports.cshtml, not in the page itself + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/_ViewImports.cshtml"] = """ + @inherits MyUtf8PageBase + """, + ["Pages/Index.cshtml"] = """ +

Hello World

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert - @inherits from _ViewImports should be detected and UTF-8 used + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_InheritsFromViewImports_ShortNameWithUsing() + { + // Arrange - @inherits with short name + @using both in _ViewImports.cshtml. + // The page itself has neither directive. + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/_ViewImports.cshtml"] = """ + @using MyApp.Infrastructure + @inherits MyUtf8PageBase + """, + ["Pages/Index.cshtml"] = """ +

Hello World

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert - short name resolved via @using in _ViewImports should enable UTF-8 + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_CascadingViewImports_MostSpecificWins() + { + // Arrange - root _ViewImports has @inherits with UTF-8 support, + // but Pages/_ViewImports overrides with a base that does NOT support UTF-8. + // A page in Pages/Admin/ has its own @inherits that DOES support UTF-8. + var project = CreateTestProject( + additionalSources: new() + { + ["_ViewImports.cshtml"] = """ + @inherits MyUtf8PageBase + """, + ["Pages/_ViewImports.cshtml"] = """ + @inherits MyRegularPageBase + """, + ["Pages/Index.cshtml"] = """ +

Hello

+ """, + ["Pages/Admin/Dashboard.cshtml"] = """ + @inherits MyUtf8PageBase +

Hello

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + """, + ["MyRegularPageBase.cs"] = """ + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyRegularPageBase : RazorPage + { + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert + Assert.Empty(result.Diagnostics); + + // Pages/Index.cshtml inherits MyRegularPageBase (from Pages/_ViewImports) - no UTF-8 + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.DoesNotContain("u8)", indexSource); + + // Pages/Admin/Dashboard.cshtml has its own @inherits MyUtf8PageBase - UTF-8 + var dashboardSource = result.GeneratedSources.Single(s => s.HintName.Contains("Dashboard")).SourceText.ToString(); + Assert.Contains("u8)", dashboardSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_GenericBaseClass() + { + // Arrange - @inherits uses a generic base class that has the UTF-8 overload + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits MyUtf8PageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyModel.cs"] = """ + public class MyModel + { + public string Name { get; set; } = ""; + } + """, + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert - generic base class with UTF-8 overload should be detected + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_NonGenericBaseClass_ResolvedViaCSharpGlobalUsing() + { + // Arrange - @inherits uses a short name with no @using in Razor. + // The namespace is imported via a C# global using (e.g. from GlobalUsings.cs). + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits MyUtf8PageBase +

Hello World

+ """, + }, + sources: new() + { + ["GlobalUsings.cs"] = """ + global using MyApp.Infrastructure; + """, + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert - should detect UTF-8 even though the using comes from C# not Razor + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_GenericBaseClass_InNamespace() + { + // Arrange - @inherits uses a generic base class in a namespace, resolved via @using + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/_ViewImports.cshtml"] = """ + @using MyApp.Infrastructure + """, + ["Pages/Index.cshtml"] = """ + @inherits MyUtf8PageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyModel.cs"] = """ + public class MyModel { } + """, + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_GenericBaseClass_NestedInGenericClass() + { + // Arrange - @inherits uses a generic class nested inside another generic class in a namespace + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/_ViewImports.cshtml"] = """ + @using MyApp.Infrastructure + """, + ["Pages/Index.cshtml"] = """ + @inherits PageFactory.Utf8Page +

Hello World

+ """, + }, + sources: new() + { + ["MyModel.cs"] = """ + public class MyModel { } + """, + ["PageFactory.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public class PageFactory + { + public abstract class Utf8Page : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_GenericBaseClass_MultipleTypeParameters() + { + // Arrange - @inherits uses a generic base class with multiple type parameters + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits MyUtf8PageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_GenericBaseClass_FromMetadataReference() + { + // Arrange - the generic base class comes from a pre-compiled assembly (metadata reference), + // not from source in the same compilation. + var baseProject = CreateTestProject(new() { ["_dummy.cshtml"] = "" }, sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace ExternalLib + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var baseCompilation = await baseProject.GetCompilationAsync(); + using var peStream = new MemoryStream(); + var emitResult = baseCompilation!.Emit(peStream); + Assert.True(emitResult.Success); + peStream.Position = 0; + var metadataRef = MetadataReference.CreateFromStream(peStream); + + // Now create the actual test project that references the pre-compiled assembly + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/_ViewImports.cshtml"] = """ + @using ExternalLib + """, + ["Pages/Index.cshtml"] = """ + @inherits MyUtf8PageBase +

Hello World

+ """, + }); + + project = project.AddMetadataReference(metadataRef); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } } diff --git a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/TestFiles/RazorSourceGeneratorCshtmlTests/Utf8HtmlLiterals_AutoDetectedFromInherits/Pages/Index_cshtml.g.cs b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/TestFiles/RazorSourceGeneratorCshtmlTests/Utf8HtmlLiterals_AutoDetectedFromInherits/Pages/Index_cshtml.g.cs new file mode 100644 index 00000000000..33f758b023f --- /dev/null +++ b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/TestFiles/RazorSourceGeneratorCshtmlTests/Utf8HtmlLiterals_AutoDetectedFromInherits/Pages/Index_cshtml.g.cs @@ -0,0 +1,59 @@ +#pragma checksum "Pages/Index.cshtml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "9d8afc5c1b11134bb445da5b3415042369d58dd7" +// +#pragma warning disable 1591 +[assembly: global::Microsoft.AspNetCore.Razor.Hosting.RazorCompiledItemAttribute(typeof(AspNetCoreGeneratedDocument.Pages_Index), @"mvc.1.0.view", @"/Pages/Index.cshtml")] +namespace AspNetCoreGeneratedDocument +{ + #line default + using global::System; + using global::System.Collections.Generic; + using global::System.Linq; + using global::System.Threading.Tasks; + using global::Microsoft.AspNetCore.Mvc; + using global::Microsoft.AspNetCore.Mvc.Rendering; + using global::Microsoft.AspNetCore.Mvc.ViewFeatures; + #line default + #line hidden + [global::Microsoft.AspNetCore.Razor.Hosting.RazorCompiledItemMetadataAttribute("Identifier", "/Pages/Index.cshtml")] + [global::System.Runtime.CompilerServices.CreateNewOnMetadataUpdateAttribute] + #nullable restore + internal sealed class Pages_Index : +#nullable restore +#line (1,11)-(1,25) "Pages/Index.cshtml" +MyUtf8PageBase + +#line default +#line hidden +#nullable disable + + #nullable disable + { + #pragma warning disable 1998 + public async override global::System.Threading.Tasks.Task ExecuteAsync() + { + WriteLiteral("

Hello World

"u8); + } + #pragma warning restore 1998 + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.ViewFeatures.IModelExpressionProvider ModelExpressionProvider { get; private set; } = default!; + #nullable disable + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.IUrlHelper Url { get; private set; } = default!; + #nullable disable + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.IViewComponentHelper Component { get; private set; } = default!; + #nullable disable + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.Rendering.IJsonHelper Json { get; private set; } = default!; + #nullable disable + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper Html { get; private set; } = default!; + #nullable disable + } +} +#pragma warning restore 1591 diff --git a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/TestFiles/RazorSourceGeneratorCshtmlTests/Utf8HtmlLiterals_WithoutOverload_UsesStringLiterals/Pages/Index_cshtml.g.cs b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/TestFiles/RazorSourceGeneratorCshtmlTests/Utf8HtmlLiterals_WithoutOverload_UsesStringLiterals/Pages/Index_cshtml.g.cs new file mode 100644 index 00000000000..ed8f3e05886 --- /dev/null +++ b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/TestFiles/RazorSourceGeneratorCshtmlTests/Utf8HtmlLiterals_WithoutOverload_UsesStringLiterals/Pages/Index_cshtml.g.cs @@ -0,0 +1,59 @@ +#pragma checksum "Pages/Index.cshtml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "c62489988165175ecfbb0389a7f80500ae401ed2" +// +#pragma warning disable 1591 +[assembly: global::Microsoft.AspNetCore.Razor.Hosting.RazorCompiledItemAttribute(typeof(AspNetCoreGeneratedDocument.Pages_Index), @"mvc.1.0.view", @"/Pages/Index.cshtml")] +namespace AspNetCoreGeneratedDocument +{ + #line default + using global::System; + using global::System.Collections.Generic; + using global::System.Linq; + using global::System.Threading.Tasks; + using global::Microsoft.AspNetCore.Mvc; + using global::Microsoft.AspNetCore.Mvc.Rendering; + using global::Microsoft.AspNetCore.Mvc.ViewFeatures; + #line default + #line hidden + [global::Microsoft.AspNetCore.Razor.Hosting.RazorCompiledItemMetadataAttribute("Identifier", "/Pages/Index.cshtml")] + [global::System.Runtime.CompilerServices.CreateNewOnMetadataUpdateAttribute] + #nullable restore + internal sealed class Pages_Index : +#nullable restore +#line (1,11)-(1,21) "Pages/Index.cshtml" +MyPageBase + +#line default +#line hidden +#nullable disable + + #nullable disable + { + #pragma warning disable 1998 + public async override global::System.Threading.Tasks.Task ExecuteAsync() + { + WriteLiteral("

Hello World

"); + } + #pragma warning restore 1998 + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.ViewFeatures.IModelExpressionProvider ModelExpressionProvider { get; private set; } = default!; + #nullable disable + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.IUrlHelper Url { get; private set; } = default!; + #nullable disable + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.IViewComponentHelper Component { get; private set; } = default!; + #nullable disable + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.Rendering.IJsonHelper Json { get; private set; } = default!; + #nullable disable + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper Html { get; private set; } = default!; + #nullable disable + } +} +#pragma warning restore 1591 diff --git a/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/CodeGeneration/TestCodeRenderingContext.cs b/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/CodeGeneration/TestCodeRenderingContext.cs index 870132e673c..32c16f09e71 100644 --- a/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/CodeGeneration/TestCodeRenderingContext.cs +++ b/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/CodeGeneration/TestCodeRenderingContext.cs @@ -31,7 +31,8 @@ public static CodeRenderingContext CreateRuntime( string newLineString = null, string suppressUniqueIds = "test", RazorSourceDocument source = null, - IntermediateNodeWriter nodeWriter = null) + IntermediateNodeWriter nodeWriter = null, + bool writeHtmlUtf8StringLiterals = false) { nodeWriter ??= RuntimeNodeWriter.Instance; source ??= TestRazorSourceDocument.Create(); @@ -39,6 +40,11 @@ public static CodeRenderingContext CreateRuntime( var options = ConfigureOptions(RazorCodeGenerationOptions.Default, newLineString, suppressUniqueIds); + if (writeHtmlUtf8StringLiterals) + { + options = options.WithFlags(writeHtmlUtf8StringLiterals: true); + } + var context = new CodeRenderingContext(nodeWriter, source, documentNode, options); context.SetVisitor(new RenderChildrenVisitor(context.CodeWriter));