diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index 64ea810b2938..629a38041d68 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -2,7 +2,7 @@ ## Analyzer Warnings -### ASP (`ASP0000-ASP0038`) +### ASP (`ASP0000-ASP0039`) | Diagnostic ID | Description | | :---------------- | :---------- | @@ -44,6 +44,7 @@ | __`ASP0036`__ | Validatable property or its type on an endpoint parameter type is not accessible | | __`ASP0037`__ | \[ValidatableType] cannot be used in generated code | | __`ASP0038`__ | \[ValidatableType] should not be used without a call to 'AddValidation' | +| __`ASP0039`__ | Do not access a captured RenderTreeBuilder from a local function | ### API (`API1000-API1003`) diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs index ac9a10caf47c..e1e9fc46a0db 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs @@ -222,6 +222,14 @@ internal static class DiagnosticDescriptors DiagnosticSeverity.Info, isEnabledByDefault: true); + internal static readonly DiagnosticDescriptor DoNotUseLocalFunctionsInMarkup = CreateDiagnosticDescriptor( + "ASP0039", + CreateLocalizableResourceString(nameof(Resources.Analyzer_DoNotUseLocalFunctionsInMarkup_Title)), + CreateLocalizableResourceString(nameof(Resources.Analyzer_DoNotUseLocalFunctionsInMarkup_Message)), + Usage, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + private static DiagnosticDescriptor CreateDiagnosticDescriptor( string id, LocalizableString title, diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs new file mode 100644 index 000000000000..3ba4463200a1 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/RenderTreeBuilder/DoNotUseLocalFunctionsInMarkupAnalyzer.cs @@ -0,0 +1,690 @@ +// 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.App.Analyzers.Infrastructure; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Microsoft.AspNetCore.Analyzers.RenderTreeBuilder; + +using WellKnownType = WellKnownTypeData.WellKnownType; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class DoNotUseLocalFunctionsInMarkupAnalyzer : DiagnosticAnalyzer +{ + private const string BuildRenderTreeMethodName = "BuildRenderTree"; + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( + DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(context => + { + var compilation = context.Compilation; + var wellKnownTypes = WellKnownTypes.GetOrCreate(compilation); + var componentBaseType = compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Components.ComponentBase"); + var renderTreeBuilderType = wellKnownTypes.Get(WellKnownType.Microsoft_AspNetCore_Components_Rendering_RenderTreeBuilder); + var buildRenderTreeMethod = componentBaseType? + .GetMembers(BuildRenderTreeMethodName) + .OfType() + .FirstOrDefault(method => + method.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, renderTreeBuilderType)); + if (componentBaseType is null || renderTreeBuilderType is null || buildRenderTreeMethod is null) + { + return; + } + + context.RegisterSymbolStartAction(context => + { + var type = (INamedTypeSymbol)context.Symbol; + if (!InheritsFromComponentBase(type, componentBaseType)) + { + return; + } + + context.RegisterOperationBlockAction(context => + { + if (context.OwningSymbol is not IMethodSymbol method || + !Overrides(method, buildRenderTreeMethod)) + { + return; + } + + var localFunctions = new Dictionary(SymbolEqualityComparer.Default); + foreach (var operationBlock in context.OperationBlocks) + { + CollectLocalFunctions(operationBlock, localFunctions); + } + + var walker = new OwningBuilderWalker(method.Parameters[0], renderTreeBuilderType, localFunctions); + foreach (var operationBlock in context.OperationBlocks) + { + walker.Visit(operationBlock); + } + + foreach (var localFunction in walker.LocalFunctionsUsingOwningBuilder) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup, + localFunction.Locations.First(), + localFunction.Name)); + } + }); + }, SymbolKind.NamedType); + }); + } + + private static bool InheritsFromComponentBase(INamedTypeSymbol type, INamedTypeSymbol componentBaseType) + { + for (var current = type; current is not null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(current, componentBaseType)) + { + return true; + } + } + + return false; + } + + private static bool Overrides(IMethodSymbol method, IMethodSymbol overriddenMethod) + { + for (var current = method; current is not null; current = current.OverriddenMethod) + { + if (SymbolEqualityComparer.Default.Equals(current, overriddenMethod)) + { + return true; + } + } + + return false; + } + + private static void CollectLocalFunctions( + IOperation operation, + Dictionary localFunctions) + { + if (operation is ILocalFunctionOperation localFunction) + { + localFunctions.Add(localFunction.Symbol, localFunction); + } + + foreach (var child in operation.ChildOperations) + { + CollectLocalFunctions(child, localFunctions); + } + } + + private sealed class OwningBuilderWalker : OperationWalker + { + private readonly INamedTypeSymbol _renderTreeBuilderType; + private readonly Dictionary _localFunctions; + private readonly HashSet _activeLocalFunctions = new(SymbolEqualityComparer.Default); + private readonly Dictionary _provenance = new(SymbolEqualityComparer.Default); + private readonly Stack _loopContexts = new(); + private IMethodSymbol? _currentLocalFunction; + private bool _pathTerminated; + + public OwningBuilderWalker( + IParameterSymbol owningBuilder, + INamedTypeSymbol renderTreeBuilderType, + Dictionary localFunctions) + { + _renderTreeBuilderType = renderTreeBuilderType; + _localFunctions = localFunctions; + _provenance.Add(owningBuilder, true); + } + + public HashSet LocalFunctionsUsingOwningBuilder { get; } = new(SymbolEqualityComparer.Default); + + public override void VisitLocalFunction(ILocalFunctionOperation operation) + { + } + + public override void VisitAnonymousFunction(IAnonymousFunctionOperation operation) + { + var previousProvenance = CloneProvenance(); + var previousPathTerminated = _pathTerminated; + _pathTerminated = false; + foreach (var parameter in operation.Symbol.Parameters) + { + _provenance[parameter] = false; + } + + Visit(operation.Body); + RestoreProvenance(previousProvenance); + _pathTerminated = previousPathTerminated; + } + + public override void VisitVariableDeclarator(IVariableDeclaratorOperation operation) + { + if (operation.Initializer is { } initializer) + { + Visit(initializer.Value); + _provenance[operation.Symbol] = HasOwningBuilderProvenance(initializer.Value); + } + else + { + _provenance[operation.Symbol] = false; + } + } + + public override void VisitSimpleAssignment(ISimpleAssignmentOperation operation) + { + Visit(operation.Value); + if (GetReferencedSymbol(operation.Target) is { } target) + { + _provenance[target] = HasOwningBuilderProvenance(operation.Value); + } + } + + public override void VisitBlock(IBlockOperation operation) + { + foreach (var child in operation.Operations) + { + Visit(child); + if (_pathTerminated) + { + break; + } + } + } + + public override void VisitBranch(IBranchOperation operation) + { + var correspondingOperation = operation.GetCorrespondingOperation(); + foreach (var loopContext in _loopContexts) + { + if (!ReferenceEquals(correspondingOperation, loopContext.Operation)) + { + continue; + } + + switch (operation.BranchKind) + { + case BranchKind.Break: + loopContext.BreakStates.Add(CloneProvenance()); + _pathTerminated = true; + break; + case BranchKind.Continue: + loopContext.ContinueStates.Add(CloneProvenance()); + _pathTerminated = true; + break; + } + + return; + } + } + + public override void VisitReturn(IReturnOperation operation) + { + Visit(operation.ReturnedValue); + _pathTerminated = true; + } + + public override void VisitConditional(IConditionalOperation operation) + { + Visit(operation.Condition); + var initialProvenance = CloneProvenance(); + + _pathTerminated = false; + Visit(operation.WhenTrue); + var whenTrueProvenance = CloneProvenance(); + var whenTrueTerminated = _pathTerminated; + + RestoreProvenance(initialProvenance); + _pathTerminated = false; + if (operation.WhenFalse is { } whenFalse) + { + Visit(whenFalse); + } + + var whenFalseProvenance = CloneProvenance(); + var whenFalseTerminated = _pathTerminated; + if (whenTrueTerminated && whenFalseTerminated) + { + _pathTerminated = true; + } + else if (whenTrueTerminated) + { + RestoreProvenance(whenFalseProvenance); + _pathTerminated = false; + } + else + { + RestoreProvenance(whenTrueProvenance); + if (!whenFalseTerminated) + { + MergeProvenance(whenFalseProvenance); + } + + _pathTerminated = false; + } + } + + public override void VisitSwitch(ISwitchOperation operation) + { + Visit(operation.Value); + var initialProvenance = CloneProvenance(); + Dictionary? mergedProvenance = operation.Cases.Any( + @case => @case.Clauses.Any(clause => clause.CaseKind == CaseKind.Default)) + ? null + : CloneProvenance(); + + foreach (var @case in operation.Cases) + { + RestoreProvenance(initialProvenance); + _pathTerminated = false; + Visit(@case); + if (_pathTerminated) + { + continue; + } + + var caseProvenance = CloneProvenance(); + mergedProvenance = MergeProvenance(mergedProvenance, caseProvenance); + } + + if (mergedProvenance is null) + { + RestoreProvenance(initialProvenance); + _pathTerminated = true; + } + else + { + RestoreProvenance(mergedProvenance); + _pathTerminated = false; + } + } + + public override void VisitSwitchCase(ISwitchCaseOperation operation) + { + foreach (var clause in operation.Clauses) + { + Visit(clause); + } + + foreach (var child in operation.Body) + { + Visit(child); + if (_pathTerminated) + { + break; + } + } + } + + public override void VisitWhileLoop(IWhileLoopOperation operation) + { + if (operation.ConditionIsTop) + { + VisitLoop( + operation, + () => + { + Visit(operation.Condition); + if (!_pathTerminated) + { + Visit(operation.Body); + } + }, + visitContinue: null, + () => Visit(operation.Condition), + executesAtLeastOnce: false); + } + else + { + VisitLoop( + operation, + () => + { + Visit(operation.Body); + if (!_pathTerminated) + { + Visit(operation.Condition); + } + }, + () => Visit(operation.Condition), + visitExit: null, + executesAtLeastOnce: true); + } + } + + public override void VisitForLoop(IForLoopOperation operation) + { + foreach (var before in operation.Before) + { + Visit(before); + } + + VisitLoop( + operation, + () => + { + Visit(operation.Condition); + if (!_pathTerminated) + { + Visit(operation.Body); + } + + if (!_pathTerminated) + { + VisitForLoopBottom(operation); + } + }, + () => VisitForLoopBottom(operation), + () => Visit(operation.Condition), + executesAtLeastOnce: false); + } + + public override void VisitForEachLoop(IForEachLoopOperation operation) + { + Visit(operation.Collection); + VisitLoop( + operation, + () => + { + Visit(operation.LoopControlVariable); + if (!_pathTerminated) + { + Visit(operation.Body); + } + + if (!_pathTerminated) + { + VisitForEachLoopBottom(operation); + } + }, + () => VisitForEachLoopBottom(operation), + visitExit: null, + executesAtLeastOnce: false); + } + + public override void VisitInvocation(IInvocationOperation operation) + { + Visit(operation.Instance); + foreach (var argument in operation.Arguments) + { + Visit(argument.Value); + } + + if (_currentLocalFunction is not null && + SymbolEqualityComparer.Default.Equals(operation.TargetMethod.ContainingType, _renderTreeBuilderType) && + HasOwningBuilderProvenance(operation.Instance)) + { + LocalFunctionsUsingOwningBuilder.Add(_currentLocalFunction); + } + + if (_localFunctions.TryGetValue(operation.TargetMethod, out var localFunction)) + { + VisitLocalFunctionInvocation(localFunction); + } + } + + public override void VisitMethodReference(IMethodReferenceOperation operation) + { + Visit(operation.Instance); + if (_localFunctions.TryGetValue(operation.Method, out var localFunction)) + { + VisitLocalFunctionInvocation(localFunction); + } + } + + private void VisitLocalFunctionInvocation(ILocalFunctionOperation localFunction) + { + if (localFunction.Symbol.IsStatic || + localFunction.Body is null || + !_activeLocalFunctions.Add(localFunction.Symbol)) + { + return; + } + + var previousLocalFunction = _currentLocalFunction; + var previousPathTerminated = _pathTerminated; + _currentLocalFunction = localFunction.Symbol; + _pathTerminated = false; + foreach (var parameter in localFunction.Symbol.Parameters) + { + _provenance[parameter] = false; + } + + Visit(localFunction.Body); + + _pathTerminated = previousPathTerminated; + _currentLocalFunction = previousLocalFunction; + _activeLocalFunctions.Remove(localFunction.Symbol); + } + + private void VisitForLoopBottom(IForLoopOperation operation) + { + foreach (var atLoopBottom in operation.AtLoopBottom) + { + Visit(atLoopBottom); + } + } + + private void VisitForEachLoopBottom(IForEachLoopOperation operation) + { + foreach (var nextVariable in operation.NextVariables) + { + Visit(nextVariable); + } + } + + private void VisitLoop( + ILoopOperation operation, + Action visitIteration, + Action? visitContinue, + Action? visitExit, + bool executesAtLeastOnce) + { + var loopContext = new LoopContext(operation); + _loopContexts.Push(loopContext); + try + { + Dictionary? loopStates; + if (executesAtLeastOnce) + { + loopStates = VisitLoopIteration(loopContext, visitIteration, visitContinue); + } + else + { + loopStates = CloneProvenance(); + } + + while (loopStates is not null) + { + RestoreProvenance(loopStates); + var iterationEnd = VisitLoopIteration(loopContext, visitIteration, visitContinue); + var mergedStates = MergeProvenance(loopStates, iterationEnd)!; + if (HasSameProvenance(loopStates, mergedStates)) + { + loopStates = mergedStates; + break; + } + + loopStates = mergedStates; + } + + Dictionary? exitStates = null; + if (loopStates is not null) + { + RestoreProvenance(loopStates); + _pathTerminated = false; + visitExit?.Invoke(); + if (!_pathTerminated) + { + exitStates = CloneProvenance(); + } + } + + exitStates = MergeProvenance(exitStates, MergeProvenance(loopContext.BreakStates)); + if (exitStates is not null) + { + RestoreProvenance(exitStates); + } + + _pathTerminated = exitStates is null; + } + finally + { + _loopContexts.Pop(); + } + } + + private Dictionary? VisitLoopIteration( + LoopContext loopContext, + Action visitIteration, + Action? visitContinue) + { + loopContext.ContinueStates.Clear(); + _pathTerminated = false; + visitIteration(); + + var iterationStates = new List>(); + if (!_pathTerminated) + { + iterationStates.Add(CloneProvenance()); + } + + foreach (var continueState in loopContext.ContinueStates) + { + RestoreProvenance(continueState); + _pathTerminated = false; + visitContinue?.Invoke(); + if (!_pathTerminated) + { + iterationStates.Add(CloneProvenance()); + } + } + + _pathTerminated = false; + return MergeProvenance(iterationStates); + } + + private bool HasOwningBuilderProvenance(IOperation? operation) + => operation switch + { + IConversionOperation conversion => HasOwningBuilderProvenance(conversion.Operand), + IParenthesizedOperation parenthesized => HasOwningBuilderProvenance(parenthesized.Operand), + ILocalReferenceOperation local => GetProvenance(local.Local), + IParameterReferenceOperation parameter => GetProvenance(parameter.Parameter), + IFieldReferenceOperation field => GetProvenance(field.Field), + IConditionalOperation conditional => HasOwningBuilderProvenance(conditional.WhenTrue) || + HasOwningBuilderProvenance(conditional.WhenFalse), + ICoalesceOperation coalesce => HasOwningBuilderProvenance(coalesce.Value) || + HasOwningBuilderProvenance(coalesce.WhenNull), + ISimpleAssignmentOperation assignment => HasOwningBuilderProvenance(assignment.Value), + _ => false, + }; + + private bool GetProvenance(ISymbol symbol) + => _provenance.TryGetValue(symbol, out var hasOwningBuilderProvenance) && + hasOwningBuilderProvenance; + + private static ISymbol? GetReferencedSymbol(IOperation operation) + => operation switch + { + IConversionOperation conversion => GetReferencedSymbol(conversion.Operand), + IParenthesizedOperation parenthesized => GetReferencedSymbol(parenthesized.Operand), + ILocalReferenceOperation local => local.Local, + IParameterReferenceOperation parameter => parameter.Parameter, + IFieldReferenceOperation field => field.Field, + _ => null, + }; + + private Dictionary CloneProvenance() + => new(_provenance, SymbolEqualityComparer.Default); + + private void RestoreProvenance(Dictionary provenance) + { + _provenance.Clear(); + foreach (var item in provenance) + { + _provenance.Add(item.Key, item.Value); + } + } + + private void MergeProvenance(Dictionary provenance) + { + foreach (var item in provenance) + { + if (item.Value) + { + _provenance[item.Key] = true; + } + } + } + + private static Dictionary? MergeProvenance( + Dictionary? left, + Dictionary? right) + { + if (left is null) + { + return right; + } + + if (right is null) + { + return left; + } + + var merged = new Dictionary(left, SymbolEqualityComparer.Default); + foreach (var item in right) + { + if (item.Value) + { + merged[item.Key] = true; + } + } + + return merged; + } + + private static Dictionary? MergeProvenance( + IEnumerable> states) + { + Dictionary? merged = null; + foreach (var state in states) + { + merged = MergeProvenance(merged, state); + } + + return merged; + } + + private static bool HasSameProvenance( + Dictionary left, + Dictionary right) + => left.All(item => !item.Value || GetProvenance(right, item.Key)) && + right.All(item => !item.Value || GetProvenance(left, item.Key)); + + private static bool GetProvenance(Dictionary provenance, ISymbol symbol) + => provenance.TryGetValue(symbol, out var hasOwningBuilderProvenance) && + hasOwningBuilderProvenance; + + private sealed class LoopContext + { + public LoopContext(ILoopOperation operation) + { + Operation = operation; + } + + public ILoopOperation Operation { get; } + + public List> BreakStates { get; } = []; + + public List> ContinueStates { get; } = []; + } + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx index 8c9397f5be64..2028ae0153f0 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx @@ -333,4 +333,10 @@ If the server does not specifically reject IPv6, IPAddress.IPv6Any is preferred over IPAddress.Any usage for safety and performance reasons. See https://aka.ms/aspnetcore-warnings/ASP0028 for more details. + + Do not access a captured RenderTreeBuilder from a local function + + + Local function '{0}' accesses RenderTreeBuilder from parent scope, which can cause incorrect rendering behavior. Consider making it a static method or regular instance method that takes RenderTreeBuilder as a parameter. + diff --git a/src/Framework/AspNetCoreAnalyzers/test/Components/DisallowNonLiteralSequenceNumbersTest.cs b/src/Framework/AspNetCoreAnalyzers/test/Components/DisallowNonLiteralSequenceNumbersTest.cs index e77605431efa..d7d077662054 100644 --- a/src/Framework/AspNetCoreAnalyzers/test/Components/DisallowNonLiteralSequenceNumbersTest.cs +++ b/src/Framework/AspNetCoreAnalyzers/test/Components/DisallowNonLiteralSequenceNumbersTest.cs @@ -89,4 +89,28 @@ public async Task RenderTreeBuilderInvocationWithInvocationArgument_ProducesDiag AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, diagnostic.Location); Assert.StartsWith("'ComputeSequenceNumber(0)' should not be used as a sequence number.", diagnostic.GetMessage(CultureInfo.InvariantCulture)); } + + [Fact] + public async Task RenderTreeBuilderInvocationInGeneratedCode_Works() + { + var source = @" +using System.CodeDom.Compiler; +using Microsoft.AspNetCore.Components.Rendering; + +_ = new TestComponent(); + +[GeneratedCode(""Razor"", ""1.0"")] +public class TestComponent +{ + public void BuildRenderTree(RenderTreeBuilder builder, int sequence) + { + builder.OpenElement(sequence, ""div""); + builder.CloseElement(); + } +} +"; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + Assert.Empty(diagnostics); + } } diff --git a/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs new file mode 100644 index 000000000000..cd924b8a63d4 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/test/Components/DoNotUseLocalFunctionsInMarkupTest.cs @@ -0,0 +1,1136 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Linq; +using Microsoft.AspNetCore.Analyzer.Testing; + +namespace Microsoft.AspNetCore.Analyzers.RenderTreeBuilder; + +public class DoNotUseLocalFunctionsInMarkupTest +{ + private TestDiagnosticAnalyzerRunner Runner { get; } = new(new DoNotUseLocalFunctionsInMarkupAnalyzer()); + + [Fact] + public async Task LocalFunctionWithRenderTreeBuilderCall_ProducesDiagnostic() + { + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + void /*MM*/LocalFunction() + { + builder.OpenElement(0, ""div""); + builder.CloseElement(); + } + + LocalFunction(); + } +} +"); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + Assert.Equal("ASP0039", analyzerDiagnostic.Id); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + Assert.StartsWith("Local function 'LocalFunction' accesses RenderTreeBuilder from parent scope", analyzerDiagnostic.GetMessage(CultureInfo.InvariantCulture)); + } + + [Fact] + public async Task LocalFunctionWithMultipleRenderTreeBuilderCalls_ProducesDiagnostic() + { + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + void /*MM*/LocalFunction() + { + builder.OpenElement(0, ""div""); + builder.AddContent(1, ""text""); + builder.CloseElement(); + } + + LocalFunction(); + } +} +"); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + Assert.StartsWith("Local function 'LocalFunction' accesses RenderTreeBuilder from parent scope", analyzerDiagnostic.GetMessage(CultureInfo.InvariantCulture)); + } + + [Fact] + public async Task LocalFunctionWithoutRenderTreeBuilderCall_NoDiagnostic() + { + var source = @" +void LocalFunction() +{ + var x = 5; + System.Console.WriteLine(x); +} + +LocalFunction(); +"; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionWithParameterRenderTreeBuilder_NoDiagnostic() + { + var source = @" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + void LocalFunction(RenderTreeBuilder builderParam) + { + builderParam.OpenElement(0, ""div""); + builderParam.CloseElement(); + } + + LocalFunction(builder); + } +} +"; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionWithLocalRenderTreeBuilder_NoDiagnostic() + { + var source = @" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + void LocalFunction() + { + var localBuilder = new RenderTreeBuilder(); + localBuilder.OpenElement(0, ""div""); + localBuilder.CloseElement(); + } + + LocalFunction(); + } +} +"; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionWithOwningBuilderAlias_ProducesDiagnostic() + { + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var capturedBuilder = builder; + + void /*MM*/LocalFunction() + { + capturedBuilder.OpenElement(0, ""div""); + capturedBuilder.CloseElement(); + } + + LocalFunction(); + } +} +"); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Fact] + public async Task LocalFunctionWithReassignedOwningBuilderAlias_NoDiagnostic() + { + var source = @" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var childBuilder = new RenderTreeBuilder(); + var alias = builder; + alias = childBuilder; + + void LocalFunction() + { + alias.OpenElement(0, ""div""); + alias.CloseElement(); + } + + LocalFunction(); + } +} +"; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionWithAliasReassignedToOwningBuilder_ProducesDiagnostic() + { + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var alias = new RenderTreeBuilder(); + alias = builder; + + void /*MM*/LocalFunction() + { + alias.OpenElement(0, ""div""); + alias.CloseElement(); + } + + LocalFunction(); + } +} +"); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Fact] + public async Task LocalFunctionWithChainedOwningBuilderAlias_ProducesDiagnostic() + { + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var alias = new RenderTreeBuilder(); + var another = alias = builder; + + void /*MM*/LocalFunction() + { + another.OpenElement(0, ""div""); + another.CloseElement(); + } + + LocalFunction(); + } +} +"); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Fact] + public async Task LocalFunctionUsedAsRenderFragmentMethodGroup_ProducesDiagnostic() + { + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + void /*MM*/LocalFunction(RenderTreeBuilder childBuilder) + { + builder.OpenElement(0, ""div""); + builder.CloseElement(); + } + + RenderFragment fragment = LocalFunction; + builder.AddContent(0, fragment); + } +} +"); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Fact] + public async Task LocalFunctionInIndependentSwitchCaseWithOwningBuilderAlias_ProducesDiagnostic() + { + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var childBuilder = new RenderTreeBuilder(); + var alias = builder; + var value = 2; + + switch (value) + { + case 1: + alias = childBuilder; + break; + case 2: + void /*MM*/LocalFunction() + { + alias.OpenElement(0, ""div""); + alias.CloseElement(); + } + + LocalFunction(); + break; + } + } +} +"); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Theory] + [InlineData(""" +while (GetCondition()) +{ + alias = childBuilder; +} +""")] + [InlineData(""" +for (var i = 0; i < GetCount(); i++) +{ + alias = childBuilder; +} +""")] + [InlineData(""" +foreach (var item in GetItems()) +{ + alias = childBuilder; +} +""")] + public async Task LocalFunctionWithOwningBuilderReassignedInZeroOrMoreLoop_ProducesDiagnostic(string loop) + { + var source = TestSource.Read($$""" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var childBuilder = new RenderTreeBuilder(); + var alias = builder; + {{loop}} + + void /*MM*/LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } + + private bool GetCondition() => false; + private int GetCount() => 0; + private int[] GetItems() => []; +} +"""); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Theory] + [InlineData(""" +while (GetCondition()) +{ + alias = builder; +} +""")] + [InlineData(""" +for (var i = 0; i < GetCount(); i++) +{ + alias = builder; +} +""")] + [InlineData(""" +foreach (var item in GetItems()) +{ + alias = builder; +} +""")] + public async Task LocalFunctionWithFreshBuilderReassignedToOwningInZeroOrMoreLoop_ProducesDiagnostic(string loop) + { + var source = TestSource.Read($$""" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var alias = new RenderTreeBuilder(); + {{loop}} + + void /*MM*/LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } + + private bool GetCondition() => false; + private int GetCount() => 0; + private int[] GetItems() => []; +} +"""); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Fact] + public async Task LocalFunctionWithOwningBuilderReassignedInDoLoop_NoDiagnostic() + { + var source = """ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var alias = builder; + do + { + alias = new RenderTreeBuilder(); + } + while (GetCondition()); + + void LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } + + private bool GetCondition() => false; +} +"""; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionWithFreshBuilderReassignedToOwningInDoLoop_ProducesDiagnostic() + { + var source = TestSource.Read(""" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var alias = new RenderTreeBuilder(); + do + { + alias = builder; + } + while (GetCondition()); + + void /*MM*/LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } + + private bool GetCondition() => false; +} +"""); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Fact] + public async Task LocalFunctionWithDefiniteReassignmentFromWhileCondition_NoDiagnostic() + { + var source = """ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var alias = builder; + + bool ShouldContinue() + { + alias = new RenderTreeBuilder(); + return false; + } + + while (ShouldContinue()) + { + } + + void LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } +} +"""; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionWithOwningBuilderAliasPropagatedAcrossLoopIterations_ProducesDiagnostic() + { + var source = TestSource.Read(""" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var first = new RenderTreeBuilder(); + var second = new RenderTreeBuilder(); + + while (GetCondition()) + { + first = second; + second = builder; + } + + void /*MM*/LocalFunction() + { + first.OpenElement(0, "div"); + first.CloseElement(); + } + + LocalFunction(); + } + + private bool GetCondition() => false; +} +"""); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Theory] + [InlineData("break;")] + [InlineData("continue;")] + public async Task LocalFunctionWithOwningBuilderOnLoopBranch_ProducesDiagnostic(string branch) + { + var source = TestSource.Read($$""" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var childBuilder = new RenderTreeBuilder(); + var alias = childBuilder; + while (GetCondition()) + { + if (GetCondition()) + { + alias = builder; + {{branch}} + } + + alias = childBuilder; + } + + void /*MM*/LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } + + private bool GetCondition() => false; +} +"""); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Theory] + [InlineData("break;")] + [InlineData("continue;")] + public async Task LocalFunctionWithFreshBuilderOnDoLoopBranch_NoDiagnostic(string branch) + { + var source = $$""" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var childBuilder = new RenderTreeBuilder(); + var alias = builder; + do + { + if (GetCondition()) + { + alias = childBuilder; + {{branch}} + } + + alias = childBuilder; + } + while (GetCondition()); + + void LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } + + private bool GetCondition() => false; +} +"""; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionWithOwningBuilderInUnreachableCodeAfterSwitch_NoDiagnostic() + { + var source = """ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var alias = new RenderTreeBuilder(); + while (GetCondition()) + { + switch (GetValue()) + { + case 0: + continue; + default: + continue; + } + +#pragma warning disable CS0162 + alias = builder; +#pragma warning restore CS0162 + } + + void LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } + + private bool GetCondition() => false; + private int GetValue() => 0; +} +"""; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionWithOwningBuilderOnlyOnReturningBranch_NoDiagnostic() + { + var source = """ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var alias = new RenderTreeBuilder(); + if (GetCondition()) + { + alias = builder; + return; + } + + void LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } + + private bool GetCondition() => false; +} +"""; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task ReturnInInvokedLocalFunctionDoesNotTerminateCaller_ProducesDiagnostic() + { + var source = TestSource.Read(""" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var alias = new RenderTreeBuilder(); + + void AssignOwningBuilder() + { + alias = builder; + return; + } + + AssignOwningBuilder(); + + void /*MM*/LocalFunction() + { + alias.OpenElement(0, "div"); + alias.CloseElement(); + } + + LocalFunction(); + } +} +"""); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Fact] + public async Task ReturnInAnonymousFunctionDoesNotTerminateCaller_ProducesDiagnostic() + { + var source = TestSource.Read(""" +using System; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + Action callback = () => + { + return; + }; + + void /*MM*/LocalFunction() + { + builder.OpenElement(0, "div"); + builder.CloseElement(); + } + + LocalFunction(); + } +} +"""); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Fact] + public async Task NestedLocalFunctionWithFreshCapturedBuilder_NoDiagnostic() + { + var source = @" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + void Outer() + { + var scratch = new RenderTreeBuilder(); + + void Inner() + { + scratch.OpenElement(0, ""div""); + scratch.CloseElement(); + } + + Inner(); + } + + Outer(); + } +} +"; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionWithNestedLambdaBuilderParameter_NoDiagnostic() + { + var source = @" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + RenderFragment LocalFunction() => childBuilder => + { + childBuilder.OpenElement(0, ""div""); + childBuilder.CloseElement(); + }; + + builder.AddContent(0, LocalFunction()); + } +} +"; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionWithNestedLambdaCapturedRenderTreeBuilder_ProducesDiagnostic() + { + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + RenderFragment /*MM*/LocalFunction() => childBuilder => + { + builder.OpenElement(0, ""div""); + builder.CloseElement(); + }; + + builder.AddContent(0, LocalFunction()); + } +} +"); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Fact] + public async Task NestedLocalFunctionWithRenderTreeBuilderCall_ProducesDiagnostic() + { + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + void OuterFunction() + { + void /*MM*/InnerFunction() + { + builder.OpenElement(0, ""div""); + builder.CloseElement(); + } + + InnerFunction(); + } + + OuterFunction(); + } +} +"); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup).ToList(); + var innerFunctionDiagnostic = Assert.Single(analyzerDiagnostics); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, innerFunctionDiagnostic.Location); + Assert.StartsWith("Local function 'InnerFunction' accesses RenderTreeBuilder from parent scope", innerFunctionDiagnostic.GetMessage(CultureInfo.InvariantCulture)); + } + + [Fact] + public async Task StaticLocalFunction_NoDiagnostic() + { + var source = @" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + static void LocalFunction(RenderTreeBuilder builderParam) + { + builderParam.OpenElement(0, ""div""); + builderParam.CloseElement(); + } + + LocalFunction(builder); + } +} +"; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionWithMethodInvocation_ProducesDiagnostic() + { + var source = TestSource.Read(@" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + void /*MM*/LocalFunction() + { + builder.AddMarkupContent(0, ""
Hello
""); + } + + LocalFunction(); + } +} +"); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + Assert.StartsWith("Local function 'LocalFunction' accesses RenderTreeBuilder from parent scope", analyzerDiagnostic.GetMessage(CultureInfo.InvariantCulture)); + } + + [Fact] + public async Task LocalFunctionOutsideComponentBase_NoDiagnostic() + { + var source = @" +using Microsoft.AspNetCore.Components.Rendering; + +public class NotAComponent +{ + public void SomeMethod() + { + var builder = new RenderTreeBuilder(); + + void LocalFunction() + { + builder.OpenElement(0, ""div""); + builder.CloseElement(); + } + + LocalFunction(); + } +} +"; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionInBuildRenderTreeOverload_NoDiagnostic() + { + var source = @" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + private void BuildRenderTree() + { + var builder = new RenderTreeBuilder(); + + void LocalFunction() + { + builder.OpenElement(0, ""div""); + builder.CloseElement(); + } + + LocalFunction(); + } +} +"; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } + + [Fact] + public async Task LocalFunctionInGeneratedBuildRenderTree_ProducesDiagnostic() + { + var source = TestSource.Read(@" +using System.CodeDom.Compiler; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +[GeneratedCode(""Razor"", ""1.0"")] +public class TestComponent : ComponentBase +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + void /*MM*/LocalFunction() + { + builder.OpenElement(0, ""div""); + builder.CloseElement(); + } + + LocalFunction(); + } +} +"); + var diagnostics = await Runner.GetDiagnosticsAsync(source.Source); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + AnalyzerAssert.DiagnosticLocation(source.DefaultMarkerLocation, analyzerDiagnostic.Location); + } + + [Fact] + public async Task LocalFunctionInRazorGeneratedBuildRenderTree_ProducesDiagnostic() + { + var generatedCode = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "IssueSample_razor.g.cs")); + + Assert.Contains("void RenderTree(int depth, int maxDepth)", generatedCode); + Assert.Contains("__builder.OpenComponent", generatedCode); + Assert.Contains("\"ChildContent\"", generatedCode); + Assert.Contains("RenderTree(depth + 1, maxDepth)", generatedCode); + + var diagnostics = await Runner.GetDiagnosticsAsync(generatedCode); + + var analyzerDiagnostic = Assert.Single(diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup)); + var mappedPath = analyzerDiagnostic.Location.GetMappedLineSpan().Path + .Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + Assert.Equal("IssueSample.razor", Path.GetFileName(mappedPath)); + } + + [Fact] + public async Task LocalFunctionInNonBuildRenderTreeMethod_NoDiagnostic() + { + var source = @" +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +public class TestComponent : ComponentBase +{ + private void SomeOtherMethod() + { + var builder = new RenderTreeBuilder(); + + void LocalFunction() + { + builder.OpenElement(0, ""div""); + builder.CloseElement(); + } + + LocalFunction(); + } +} +"; + var diagnostics = await Runner.GetDiagnosticsAsync(source); + + var analyzerDiagnostics = diagnostics.Where(d => d.Descriptor == DiagnosticDescriptors.DoNotUseLocalFunctionsInMarkup); + Assert.Empty(analyzerDiagnostics); + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj b/src/Framework/AspNetCoreAnalyzers/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj index ab3270d51175..426006209254 100644 --- a/src/Framework/AspNetCoreAnalyzers/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj +++ b/src/Framework/AspNetCoreAnalyzers/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj @@ -16,6 +16,7 @@ + @@ -51,6 +52,7 @@ + @@ -58,6 +60,12 @@ + + + + diff --git a/src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/DoNotUseLocalFunctionsInMarkup.csproj b/src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/DoNotUseLocalFunctionsInMarkup.csproj new file mode 100644 index 000000000000..6f0a7d26324e --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/DoNotUseLocalFunctionsInMarkup.csproj @@ -0,0 +1,14 @@ + + + + $(DefaultNetCoreTargetFramework) + true + $(IntermediateOutputPath)generated + enable + + + + + + + diff --git a/src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/FluentTreeItem.razor b/src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/FluentTreeItem.razor new file mode 100644 index 000000000000..ab0a8a7a3cd2 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/FluentTreeItem.razor @@ -0,0 +1,11 @@ +@using Microsoft.AspNetCore.Components + +
@Text @ChildContent
+ +@code { + [Parameter] + public string? Text { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } +} diff --git a/src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/IssueSample.razor b/src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/IssueSample.razor new file mode 100644 index 000000000000..33717918eefb --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/test/testassets/DoNotUseLocalFunctionsInMarkup/IssueSample.razor @@ -0,0 +1,15 @@ +@{ + void RenderTree(int depth, int maxDepth) + { + if (depth >= maxDepth) + { + return; + } + + + @{ RenderTree(depth + 1, maxDepth); } + + } + + RenderTree(0, 2); +}