diff --git a/src/tools/illink/src/ILLink.CodeFix/ILLink.CodeFixProvider.csproj b/src/tools/illink/src/ILLink.CodeFix/ILLink.CodeFixProvider.csproj index 441acde0ff83a8..a166cdcfb04b66 100644 --- a/src/tools/illink/src/ILLink.CodeFix/ILLink.CodeFixProvider.csproj +++ b/src/tools/illink/src/ILLink.CodeFix/ILLink.CodeFixProvider.csproj @@ -25,4 +25,21 @@ + + + + + + + + true + + + + + + + + diff --git a/src/tools/illink/src/ILLink.CodeFix/RequiresUnsafeCodeFixProvider.cs b/src/tools/illink/src/ILLink.CodeFix/RequiresUnsafeCodeFixProvider.cs deleted file mode 100644 index fd9c7d22ad55cd..00000000000000 --- a/src/tools/illink/src/ILLink.CodeFix/RequiresUnsafeCodeFixProvider.cs +++ /dev/null @@ -1,574 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -#if DEBUG -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Composition; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using ILLink.CodeFixProvider; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CodeActions; -using Microsoft.CodeAnalysis.CodeFixes; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Editing; - -namespace ILLink.CodeFix -{ - [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(RequiresUnsafeCodeFixProvider)), Shared] - public sealed class RequiresUnsafeCodeFixProvider : Microsoft.CodeAnalysis.CodeFixes.CodeFixProvider - { - private const string WrapInUnsafeBlockTitle = "Wrap in unsafe block"; - - public const string UnsafeMemberOperationDiagnosticId = "CS9362"; - - public sealed override ImmutableArray FixableDiagnosticIds => [UnsafeMemberOperationDiagnosticId]; - - private static LocalizableString CodeFixTitle => new LocalizableResourceString(nameof(Resources.RequiresUnsafeCodeFixTitle), Resources.ResourceManager, typeof(Resources)); - - public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) - { - var document = context.Document; - var diagnostic = context.Diagnostics.First(); - var codeFixTitle = CodeFixTitle.ToString(); - - if (await document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false) is not { } root) - return; - - SyntaxNode targetNode = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); - - // Register "add unsafe modifier" code fix - var unsafeModifierTarget = GetUnsafeModifierTarget(targetNode); - if (unsafeModifierTarget is not null && !HasUnsafeModifier(unsafeModifierTarget)) - { - context.RegisterCodeFix(CodeAction.Create( - title: codeFixTitle, - createChangedDocument: ct => AddUnsafeModifierAsync(document, unsafeModifierTarget, ct), - equivalenceKey: codeFixTitle), diagnostic); - } - - // Register the "wrap in unsafe block" code fix - - // Find the statement containing the unsafe call - var containingStatement = targetNode.AncestorsAndSelf().OfType().FirstOrDefault(); - - // Check if this is a local function with expression body - treat it like expression-bodied member - if (containingStatement is LocalFunctionStatementSyntax localFunc && localFunc.ExpressionBody != null) - { - if (!HasDirectiveTrivia(localFunc.ExpressionBody)) - { - context.RegisterCodeFix(CodeAction.Create( - title: WrapInUnsafeBlockTitle, - createChangedDocument: ct => ConvertExpressionBodyToUnsafeBlockAsync(document, localFunc.ExpressionBody, ct), - equivalenceKey: WrapInUnsafeBlockTitle), diagnostic); - } - return; - } - - if (containingStatement is null || containingStatement is BlockSyntax) - { - // Try expression-bodied member - var arrowExpr = targetNode.AncestorsAndSelf().OfType().FirstOrDefault(); - if (arrowExpr != null && !HasDirectiveTrivia(arrowExpr)) - { - context.RegisterCodeFix(CodeAction.Create( - title: WrapInUnsafeBlockTitle, - createChangedDocument: ct => ConvertExpressionBodyToUnsafeBlockAsync(document, arrowExpr, ct), - equivalenceKey: WrapInUnsafeBlockTitle), diagnostic); - } - return; - } - - // Find the parent block containing this statement - var parentBlock = containingStatement.Parent as BlockSyntax; - if (parentBlock != null) - { - context.RegisterCodeFix(CodeAction.Create( - title: WrapInUnsafeBlockTitle, - createChangedDocument: ct => WrapStatementsInUnsafeBlockAsync(document, parentBlock, containingStatement, ct), - equivalenceKey: WrapInUnsafeBlockTitle), diagnostic); - return; - } - - // Handle switch case sections - var switchSection = containingStatement.Parent as SwitchSectionSyntax; - if (switchSection != null) - { - context.RegisterCodeFix(CodeAction.Create( - title: WrapInUnsafeBlockTitle, - createChangedDocument: ct => WrapSwitchSectionStatementInUnsafeBlockAsync(document, switchSection, containingStatement, ct), - equivalenceKey: WrapInUnsafeBlockTitle), diagnostic); - return; - } - - // Handle embedded statements (if/else/while/for without braces) - if (IsEmbeddedStatement(containingStatement)) - { - context.RegisterCodeFix(CodeAction.Create( - title: WrapInUnsafeBlockTitle, - createChangedDocument: ct => WrapEmbeddedStatementInUnsafeBlockAsync(document, containingStatement, ct), - equivalenceKey: WrapInUnsafeBlockTitle), diagnostic); - return; - } - } - - private static bool IsEmbeddedStatement(StatementSyntax statement) - { - // An embedded statement is a statement that is the direct child of a control flow statement - // without being wrapped in a block (e.g., "if (x) return;" instead of "if (x) { return; }") - return statement.Parent is IfStatementSyntax - || statement.Parent is ElseClauseSyntax - || statement.Parent is WhileStatementSyntax - || statement.Parent is ForStatementSyntax - || statement.Parent is ForEachStatementSyntax - || statement.Parent is DoStatementSyntax - || statement.Parent is UsingStatementSyntax - || statement.Parent is LockStatementSyntax - || statement.Parent is FixedStatementSyntax; - } - - private static async Task AddUnsafeModifierAsync( - Document document, - SyntaxNode declaration, - CancellationToken cancellationToken) - { - var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false); - var modifiers = editor.Generator.GetModifiers(declaration); - editor.SetModifiers(declaration, modifiers.WithIsUnsafe(true)); - return editor.GetChangedDocument(); - } - - private static SyntaxNode? GetUnsafeModifierTarget(SyntaxNode targetNode) - => targetNode.AncestorsAndSelf().FirstOrDefault(static node => node is MethodDeclarationSyntax - or ConstructorDeclarationSyntax - or DestructorDeclarationSyntax - or LocalFunctionStatementSyntax - or PropertyDeclarationSyntax - or IndexerDeclarationSyntax - or AccessorDeclarationSyntax); - - private static bool HasUnsafeModifier(SyntaxNode declaration) - => declaration switch - { - MethodDeclarationSyntax method => method.Modifiers.Any(SyntaxKind.UnsafeKeyword), - ConstructorDeclarationSyntax constructor => constructor.Modifiers.Any(SyntaxKind.UnsafeKeyword), - DestructorDeclarationSyntax destructor => destructor.Modifiers.Any(SyntaxKind.UnsafeKeyword), - LocalFunctionStatementSyntax localFunction => localFunction.Modifiers.Any(SyntaxKind.UnsafeKeyword), - PropertyDeclarationSyntax property => property.Modifiers.Any(SyntaxKind.UnsafeKeyword), - IndexerDeclarationSyntax indexer => indexer.Modifiers.Any(SyntaxKind.UnsafeKeyword), - AccessorDeclarationSyntax accessor => accessor.Modifiers.Any(SyntaxKind.UnsafeKeyword), - _ => false - }; - - private static async Task WrapStatementsInUnsafeBlockAsync( - Document document, - BlockSyntax parentBlock, - StatementSyntax triggerStatement, - CancellationToken cancellationToken) - { - var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); - if (semanticModel == null) - return document; - - // Find the trigger statement in the block - var statements = parentBlock.Statements; - int triggerIndex = statements.IndexOf(triggerStatement); - if (triggerIndex < 0) - return document; - - // Check if any statement has directive trivia that would be lost or mangled - var leadingTrivia = triggerStatement.GetLeadingTrivia(); - if (leadingTrivia.Any(t => t.IsDirective)) - { - // Skip the fix - directives in statement trivia would be lost or malformed - return document; - } - - var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false); - - // Create the TODO comment - var todoComment = SyntaxFactory.Comment("// TODO(unsafe): Baselining unsafe usage"); - var newLine = SyntaxFactory.ElasticCarriageReturnLineFeed; - - // Check if we can use forward declaration strategy - // This applies when the trigger is a local declaration with a single variable - // Ref locals (including ref readonly and scoped ref) cannot be forward-declared - LocalDeclarationStatementSyntax? forwardDecl = null; - StatementSyntax statementToWrap = triggerStatement; - int endIndex = triggerIndex; // For block expansion when forward decl not possible - - bool isRefOrScopedLocal = triggerStatement is LocalDeclarationStatementSyntax localDeclCheck && - (localDeclCheck.Declaration.Type is RefTypeSyntax || localDeclCheck.Declaration.Type is ScopedTypeSyntax); - - if (triggerStatement is LocalDeclarationStatementSyntax localDecl && - !localDecl.IsConst && - !isRefOrScopedLocal && - localDecl.Declaration.Variables.Count == 1 && - localDecl.Declaration.Variables[0].Initializer != null) - { - var variable = localDecl.Declaration.Variables[0]; - TypeSyntax? typeSyntax = localDecl.Declaration.Type; - - // If using 'var', resolve to explicit type - if (typeSyntax.IsVar) - { - var typeInfo = semanticModel.GetTypeInfo(typeSyntax, cancellationToken); - if (typeInfo.Type is not null and not IErrorTypeSymbol) - { - typeSyntax = SyntaxFactory.ParseTypeName(typeInfo.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)) - .WithTrailingTrivia(SyntaxFactory.Space); - } - else - { - // Can't resolve type, fall back to wrapping the whole declaration - typeSyntax = null; - } - } - - if (typeSyntax != null) - { - // Create forward declaration: Type varName; - var forwardDeclVariable = SyntaxFactory.VariableDeclarator(variable.Identifier); - var forwardDeclDeclaration = SyntaxFactory.VariableDeclaration(typeSyntax) - .AddVariables(forwardDeclVariable); - forwardDecl = SyntaxFactory.LocalDeclarationStatement(forwardDeclDeclaration) - .WithLeadingTrivia(triggerStatement.GetLeadingTrivia()) - .WithTrailingTrivia(SyntaxFactory.TriviaList(SyntaxFactory.ElasticCarriageReturnLineFeed)); - - // Create assignment: varName = initializer; - var assignment = SyntaxFactory.AssignmentExpression( - SyntaxKind.SimpleAssignmentExpression, - SyntaxFactory.IdentifierName(variable.Identifier), - variable.Initializer!.Value); - statementToWrap = SyntaxFactory.ExpressionStatement(assignment) - .WithTrailingTrivia(SyntaxFactory.TriviaList(SyntaxFactory.ElasticCarriageReturnLineFeed)); - } - } - else if (isRefOrScopedLocal) - { - // For ref/scoped locals, we must expand the block until no variables - // declared inside are used outside. This handles chains of dependencies - // where ref locals lead to regular locals that are also used later. - var localDeclStmt = (LocalDeclarationStatementSyntax)triggerStatement; - if (localDeclStmt.Declaration.Variables.Count == 1) - { - // Iteratively expand until no variables declared inside escape outside - bool expanded = true; - while (expanded && endIndex < statements.Count - 1) - { - expanded = false; - - // Collect all variables declared in the current range (using symbols for correct scoping) - var declaredVariableSymbols = new HashSet(SymbolEqualityComparer.Default); - for (int i = triggerIndex; i <= endIndex; i++) - { - if (statements[i] is LocalDeclarationStatementSyntax rangeLocalDecl) - { - foreach (var variable in rangeLocalDecl.Declaration.Variables) - { - var symbol = semanticModel.GetDeclaredSymbol(variable, cancellationToken); - if (symbol is not null) - declaredVariableSymbols.Add(symbol); - } - } - } - - // Check ALL remaining statements - does any use a declared variable? - for (int nextIndex = endIndex + 1; nextIndex < statements.Count; nextIndex++) - { - var stmt = statements[nextIndex]; - - bool usesAnyDeclaredVariable = stmt.DescendantNodes() - .OfType() - .Any(id => - { - var symbolInfo = semanticModel.GetSymbolInfo(id, cancellationToken); - return symbolInfo.Symbol is not null && declaredVariableSymbols.Contains(symbolInfo.Symbol); - }); - - if (usesAnyDeclaredVariable) - { - // Check for directive trivia that would be mangled - if (stmt.GetLeadingTrivia().Any(t => t.IsDirective)) - { - // Stop expansion here - don't include statements with directives - break; - } - - // Expand to include this statement - endIndex = nextIndex; - expanded = true; - break; // Restart the outer loop to recollect variables - } - } - } - } - } - - // Build the statements to wrap - List statementsToWrap; - if (forwardDecl != null) - { - statementsToWrap = new List { statementToWrap }; - } - else - { - statementsToWrap = statements.Skip(triggerIndex).Take(endIndex - triggerIndex + 1).ToList(); - } - - // Create the unsafe block - var wrappedStatements = statementsToWrap.Select(s => - s.WithoutTrivia() - .WithTrailingTrivia(SyntaxFactory.TriviaList(SyntaxFactory.ElasticCarriageReturnLineFeed))); - var unsafeBlock = SyntaxFactory.UnsafeStatement( - SyntaxFactory.Block(wrappedStatements)) - .WithLeadingTrivia(forwardDecl != null - ? SyntaxFactory.TriviaList(todoComment, newLine) - : triggerStatement.GetLeadingTrivia().InsertRange(0, new[] { todoComment, newLine })) - .WithTrailingTrivia(forwardDecl != null - ? triggerStatement.GetTrailingTrivia() - : statementsToWrap.Last().GetTrailingTrivia()); - - // Build the new list of statements - var newStatements = new List(); - for (int i = 0; i < statements.Count; i++) - { - if (i == triggerIndex) - { - if (forwardDecl != null) - { - newStatements.Add(forwardDecl); - } - newStatements.Add(unsafeBlock); - } - else if (i > triggerIndex && i <= endIndex) - { - // Skip - already included in unsafe block - } - else - { - newStatements.Add(statements[i]); - } - } - - var newBlock = parentBlock.WithStatements(SyntaxFactory.List(newStatements)); - editor.ReplaceNode(parentBlock, newBlock); - - return editor.GetChangedDocument(); - } - - private static async Task WrapSwitchSectionStatementInUnsafeBlockAsync( - Document document, - SwitchSectionSyntax _, - StatementSyntax triggerStatement, - CancellationToken cancellationToken) - { - var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false); - - // Create the TODO comment - var todoComment = SyntaxFactory.Comment("// TODO(unsafe): Baselining unsafe usage"); - var newLine = SyntaxFactory.CarriageReturnLineFeed; - - // Create the unsafe block wrapping just the trigger statement - var unsafeBlock = SyntaxFactory.UnsafeStatement( - SyntaxFactory.Block(triggerStatement.WithoutTrivia())) - .WithLeadingTrivia(triggerStatement.GetLeadingTrivia().InsertRange(0, new[] { todoComment, newLine })) - .WithTrailingTrivia(triggerStatement.GetTrailingTrivia()); - - // Replace the trigger statement with the unsafe block - editor.ReplaceNode(triggerStatement, unsafeBlock); - - return editor.GetChangedDocument(); - } - - private static async Task WrapEmbeddedStatementInUnsafeBlockAsync( - Document document, - StatementSyntax embeddedStatement, - CancellationToken cancellationToken) - { - var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false); - - // Create the TODO comment - var todoComment = SyntaxFactory.Comment("// TODO(unsafe): Baselining unsafe usage"); - var newLine = SyntaxFactory.CarriageReturnLineFeed; - - // Create the unsafe block wrapping the statement - var unsafeBlock = SyntaxFactory.UnsafeStatement( - SyntaxFactory.Block(embeddedStatement.WithoutTrivia())) - .WithLeadingTrivia(todoComment, newLine); - - // Wrap in a block to replace the embedded statement - var wrappingBlock = SyntaxFactory.Block(unsafeBlock) - .WithLeadingTrivia(embeddedStatement.GetLeadingTrivia()) - .WithTrailingTrivia(embeddedStatement.GetTrailingTrivia()); - - editor.ReplaceNode(embeddedStatement, wrappingBlock); - - return editor.GetChangedDocument(); - } - - private static async Task ConvertExpressionBodyToUnsafeBlockAsync( - Document document, - ArrowExpressionClauseSyntax arrowExpr, - CancellationToken cancellationToken) - { - var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false); - - // Create the TODO comment - var todoComment = SyntaxFactory.Comment("// TODO(unsafe): Baselining unsafe usage"); - var newLine = SyntaxFactory.CarriageReturnLineFeed; - - // Get the parent member to determine the return type - var parent = arrowExpr.Parent; - bool isVoid = false; - - if (parent is MethodDeclarationSyntax method) - { - isVoid = method.ReturnType is PredefinedTypeSyntax pts && pts.Keyword.IsKind(SyntaxKind.VoidKeyword); - } - else if (parent is LocalFunctionStatementSyntax localFunc) - { - isVoid = localFunc.ReturnType is PredefinedTypeSyntax pts && pts.Keyword.IsKind(SyntaxKind.VoidKeyword); - } - else if (parent is DestructorDeclarationSyntax) - { - isVoid = true; - } - else if (parent is AccessorDeclarationSyntax or PropertyDeclarationSyntax or IndexerDeclarationSyntax) - { - isVoid = false; - } - - // Create the statement that goes inside the unsafe block - StatementSyntax innerStatement = isVoid - ? SyntaxFactory.ExpressionStatement(arrowExpr.Expression.WithoutTrivia()) - : SyntaxFactory.ReturnStatement(arrowExpr.Expression.WithoutTrivia()); - - // Create the unsafe block - var unsafeBlock = SyntaxFactory.UnsafeStatement( - SyntaxFactory.Block(innerStatement)) - .WithLeadingTrivia(todoComment, newLine); - - // Create the block body - var blockBody = SyntaxFactory.Block(unsafeBlock); - - // Replace based on parent type - switch (parent) - { - case MethodDeclarationSyntax methodDecl: - var newMethod = methodDecl - .WithExpressionBody(null) - .WithSemicolonToken(default) - .WithBody(blockBody); - editor.ReplaceNode(methodDecl, newMethod); - break; - - case LocalFunctionStatementSyntax localFunc: - var newLocalFunc = localFunc - .WithExpressionBody(null) - .WithSemicolonToken(default) - .WithBody(blockBody); - editor.ReplaceNode(localFunc, newLocalFunc); - break; - - case DestructorDeclarationSyntax destructorDecl: - var newDestructor = destructorDecl - .WithExpressionBody(null) - .WithSemicolonToken(default) - .WithBody(blockBody); - editor.ReplaceNode(destructorDecl, newDestructor); - break; - - case PropertyDeclarationSyntax propDecl: - var getter = SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) - .WithBody(SyntaxFactory.Block(unsafeBlock)); - var accessorList = SyntaxFactory.AccessorList(SyntaxFactory.SingletonList(getter)); - var newProp = propDecl - .WithExpressionBody(null) - .WithSemicolonToken(default) - .WithAccessorList(accessorList); - editor.ReplaceNode(propDecl, newProp); - break; - - case AccessorDeclarationSyntax accessor: - var newAccessor = accessor - .WithExpressionBody(null) - .WithSemicolonToken(default) - .WithBody(SyntaxFactory.Block(unsafeBlock)); - editor.ReplaceNode(accessor, newAccessor); - break; - - case IndexerDeclarationSyntax indexerDecl: - var indexerGetter = SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) - .WithBody(SyntaxFactory.Block(unsafeBlock)); - var indexerAccessorList = SyntaxFactory.AccessorList(SyntaxFactory.SingletonList(indexerGetter)); - var newIndexer = indexerDecl - .WithExpressionBody(null) - .WithSemicolonToken(default) - .WithAccessorList(indexerAccessorList); - editor.ReplaceNode(indexerDecl, newIndexer); - break; - } - - return editor.GetChangedDocument(); - } - - /// - /// Checks if the arrow expression clause or its expression has preprocessor directive trivia. - /// Converting expression bodies with directives to block bodies is error-prone, so we skip the fix. - /// - private static bool HasDirectiveTrivia(ArrowExpressionClauseSyntax arrowExpr) - { - // Check the arrow expression clause's leading trivia (e.g., #if or #else before =>) - if (arrowExpr.GetLeadingTrivia().Any(t => t.IsDirective)) - return true; - - // Check the arrow token's trailing trivia (e.g., #if right after =>) - if (arrowExpr.ArrowToken.TrailingTrivia.Any(t => t.IsDirective)) - return true; - - // Check the expression's leading trivia (e.g., #if before the expression) - if (arrowExpr.Expression.GetLeadingTrivia().Any(t => t.IsDirective)) - return true; - - // Check the expression's trailing trivia (e.g., #endif after the expression) - if (arrowExpr.Expression.GetTrailingTrivia().Any(t => t.IsDirective)) - return true; - - // Check the arrow expression clause's trailing trivia - if (arrowExpr.GetTrailingTrivia().Any(t => t.IsDirective)) - return true; - - // Check the parent member for directives between the signature and the arrow - // e.g., method() #if FOO => expr1; #else => expr2; #endif - if (arrowExpr.Parent is MethodDeclarationSyntax method) - { - // Check trivia after the parameter list (where #if might appear) - if (method.ParameterList.GetTrailingTrivia().Any(t => t.IsDirective)) - return true; - // Check constraint clauses if present - foreach (var constraint in method.ConstraintClauses) - { - if (constraint.GetTrailingTrivia().Any(t => t.IsDirective)) - return true; - } - } - else if (arrowExpr.Parent is LocalFunctionStatementSyntax localFunc) - { - if (localFunc.ParameterList.GetTrailingTrivia().Any(t => t.IsDirective)) - return true; - } - else if (arrowExpr.Parent is PropertyDeclarationSyntax prop) - { - if (prop.Identifier.TrailingTrivia.Any(t => t.IsDirective)) - return true; - } - - return false; - } - } -} -#endif diff --git a/src/tools/illink/src/ILLink.CodeFix/Resources.resx b/src/tools/illink/src/ILLink.CodeFix/Resources.resx index 4753064074d672..24e0ded8bbfe88 100644 --- a/src/tools/illink/src/ILLink.CodeFix/Resources.resx +++ b/src/tools/illink/src/ILLink.CodeFix/Resources.resx @@ -126,8 +126,14 @@ Add RequiresDynamicCode attribute to parent method - - Add 'unsafe' to parent method + + Wrap in unsafe block + + + Wrap expression in unsafe context + + + Wrap entire body in unsafe Mark extern member 'unsafe' diff --git a/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/BlockEditFactory.cs b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/BlockEditFactory.cs new file mode 100644 index 00000000000000..6d4366d2e75a82 --- /dev/null +++ b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/BlockEditFactory.cs @@ -0,0 +1,309 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace ILLink.CodeFix.UnsafeContext +{ + /// + /// Creates block candidates for statements. A new block must keep every name visible where it was used, so the + /// range is closed over the locals and labels it declares: locals used later are split out of the block, or the + /// range grows to include their uses. + /// + internal static class BlockEditFactory + { + /// + /// Returns the block edits covering at least .., narrowest first: + /// one that splits declarations used after the block and one that grows over those uses instead. + /// + public static ImmutableArray CreateForRange(StatementList list, int first, int last, TargetSet targets, PlanningContext context) + { + var edits = ImmutableArray.CreateBuilder(); + + // An adjacent placeholder block is extended rather than given a sibling. + int extendedFirst = first > 0 && PlaceholderContexts.IsExtendableBlock(list[first - 1]) ? first - 1 : first; + int extendedLast = last < list.Count - 1 && PlaceholderContexts.IsExtendableBlock(list[last + 1]) ? last + 1 : last; + if ((extendedFirst != first || extendedLast != last) + && TryCreate(list, extendedFirst, extendedLast, targets, allowSplitting: true, context) is { } extended) + { + edits.Add(extended); + } + + UnsafeContextEdit? narrow = TryCreate(list, first, last, targets, allowSplitting: true, context); + if (narrow is not null) + edits.Add(narrow); + + if (narrow is null or StatementBlockEdit { HasSplits: true } + && TryCreate(list, first, last, targets, allowSplitting: false, context) is { } grown) + { + edits.Add(grown); + } + + return edits.ToImmutable(); + } + + /// Returns the block edit for the brace-less body of an if, else, loop, ... if any. + public static ImmutableArray CreateForEmbedded(StatementSyntax statement, TargetSet targets, PlanningContext context) + { + // The statement's leading trivia moves inside the new braces with it. + if (!CanEnclose(statement) + || !DirectiveFacts.AreBalanced(context.Root, TextSpan.FromBounds(statement.FullSpan.Start, statement.Span.End))) + { + return []; + } + + Coverage coverage = StatementShape.CountLeaves(statement, targets); + bool isProportionate = coverage.IsBalanced && !StatementShape.CoversBodyOnlyForHeader(statement, targets); + return EmbeddedBlockEdit.TryCreate(statement, context.Formatting, coverage, isProportionate) is { } edit ? [edit] : []; + } + + /// + /// Whether a statement may be moved into a new block. Lambdas and local functions would inherit the context, + /// yield cannot appear in it, and existing contexts are never nested (placeholder wrappers are merged). + /// + public static bool CanEnclose(StatementSyntax statement) => + !statement.DescendantNodesAndSelf().Any(static n => + n is AnonymousFunctionExpressionSyntax or LocalFunctionStatementSyntax or YieldStatementSyntax or UnsafeStatementSyntax) + && PlaceholderContexts.ContainsOnlyPlaceholderWrappers(statement); + + /// Counts the leaves a block range covers; an extended placeholder block only holds related leaves. + public static Coverage CountRangeLeaves(StatementSyntax statement, TargetSet targets) + { + Coverage leaves = StatementShape.CountLeaves(statement, targets); + return PlaceholderContexts.IsExtendableBlock(statement) ? new Coverage(leaves.Related + leaves.Unrelated, 0) : leaves; + } + + /// + /// Creates the block for .. after closing the range over its + /// declarations, or returns when no valid block exists. + /// + private static UnsafeContextEdit? TryCreate(StatementList list, int first, int last, TargetSet targets, bool allowSplitting, PlanningContext context) + { + ImmutableDictionary splits; + while (true) + { + // Placeholder blocks inside the range are merged into the new block. + if (!GetRange(list, first, last).All(static s => PlaceholderContexts.IsExtendableBlock(s) || CanEnclose(s))) + return null; + + // A using declaration keeps its original disposal point: the end of the enclosing scope. + if (last < list.Count - 1 && GetRange(list, first, last).Any(static s => s is LocalDeclarationStatementSyntax { UsingKeyword.RawKind: not 0 })) + { + last = list.Count - 1; + continue; + } + + if (!TryCloseRange(list, ref first, ref last, allowSplitting, context, out splits, out bool grew)) + return null; + + if (!grew) + break; + } + + Coverage coverage = default; + bool coversBodyOnlyForHeader = false; + for (int i = first; i <= last; i++) + { + // Declarations kept outside the block are not counted. + if (splits.TryGetValue(i, out DeclarationSplit? split) && split.Remaining.IsEmpty) + continue; + + coverage += CountRangeLeaves(list[i], targets); + coversBodyOnlyForHeader |= StatementShape.CoversBodyOnlyForHeader(list[i], targets); + } + + bool isProportionate = coverage.IsBalanced && !coversBodyOnlyForHeader; + StatementSyntax firstWrapped = StatementBlockEdit.GetFirstWrappedStatement(list[first]); + if (!context.Formatting.StartsLine(firstWrapped.GetFirstToken()) || !list[last].GetLastToken().EndsLine) + { + return first == 0 && last == list.Count - 1 && splits.IsEmpty && list.Owner is BlockSyntax block + ? SingleLineBlockEdit.TryCreate(block, context.Formatting, coverage, isProportionate) + : null; + } + + if (!DirectiveFacts.AreBalanced(context.Root, TextSpan.FromBounds(firstWrapped.SpanStart, list[last].Span.End))) + return null; + + return new StatementBlockEdit(list, first, last, splits, coverage, isProportionate); + } + + /// + /// Splits or grows over the declarations of the range that are referenced outside it. Returns + /// when a reference cannot be included, e.g. from another switch section. + /// + private static bool TryCloseRange( + StatementList list, + ref int first, + ref int last, + bool allowSplitting, + PlanningContext context, + out ImmutableDictionary splits, + out bool grew) + { + var splitBuilder = ImmutableDictionary.CreateBuilder(); + splits = splitBuilder.ToImmutable(); + grew = false; + int newFirst = first, newLast = last; + if (HidesInactiveDeclarations(list, first, last)) + return false; + + Dictionary> references = FindReferencesOutside(list, first, last, context); + List unsplit = []; + if (allowSplitting) + { + foreach (var group in references.Keys.OfType().GroupBy(local => GetDeclaringStatementIndex(list, local))) + { + // A split declaration takes the comments above it out of the block; directives must stay in place. + int index = group.Key; + if (index >= first && index <= last + && (index != first || list[index] is not LabeledStatementSyntax) + && (index == first || !list[index].GetLeadingTrivia().Any(static t => t.IsDirective)) + && DeclarationSplitter.TrySplit(list[index], group, context) is { } split) + { + splitBuilder[index] = split; + } + else + { + unsplit.AddRange(group); + } + } + + unsplit.AddRange(references.Keys.Where(static s => s is not ILocalSymbol)); + } + else + { + unsplit.AddRange(references.Keys); + } + + foreach (ISymbol symbol in unsplit) + { + List indices = references[symbol]; + if (indices.Contains(-1)) + return false; + + newFirst = Math.Min(newFirst, indices.Min()); + newLast = Math.Max(newLast, indices.Max()); + } + + grew = newFirst != first || newLast != last; + first = newFirst; + last = newLast; + splits = splitBuilder.ToImmutable(); + return true; + } + + /// + /// Finds the locals and labels declared in the range that are referenced outside it, with the indices of the + /// referencing statements: -1 for references the range cannot grow over, i.e. from another switch section or + /// an inactive #if arm. + /// + private static Dictionary> FindReferencesOutside(StatementList list, int first, int last, PlanningContext context) + { + SemanticModel model = context.Model; + Dictionary> declared = []; + foreach (StatementSyntax statement in GetRange(list, first, last)) + { + foreach (SyntaxNode node in statement.DescendantNodesAndSelf()) + { + ISymbol? symbol = node switch + { + VariableDeclaratorSyntax variable => model.GetDeclaredSymbol(variable, context.CancellationToken), + SingleVariableDesignationSyntax designation => model.GetDeclaredSymbol(designation, context.CancellationToken), + // A leading label stays outside the block. + LabeledStatementSyntax labeled when labeled != list[first] => model.GetDeclaredSymbol(labeled, context.CancellationToken), + _ => null, + }; + + if (symbol is not null) + { + if (!declared.TryGetValue(symbol.Name, out HashSet? symbols)) + declared[symbol.Name] = symbols = new HashSet(SymbolEqualityComparer.Default); + + symbols.Add(symbol); + } + } + } + + var references = new Dictionary>(SymbolEqualityComparer.Default); + if (declared.Count == 0) + return references; + + foreach (var (statement, index) in list.StatementsInScope) + { + if (index >= first && index <= last) + continue; + + foreach (IdentifierNameSyntax identifier in statement.DescendantNodes().OfType()) + { + if (!declared.TryGetValue(identifier.Identifier.ValueText, out HashSet? candidates)) + continue; + + ISymbol? symbol = model.GetSymbolInfo(identifier, context.CancellationToken).Symbol; + if (symbol is not null && candidates.Contains(symbol)) + AddReference(references, symbol, index); + } + } + + // Uses in inactive `#if` arms are invisible to the semantic model. A lexical match is a reference the range + // cannot grow over (-1), so the declaration must be split out or the block declined. Labels can also be + // targeted from before the range. + TextSpan range = GetRangeSpan(list, first, last); + HashSet usedAfter = [.. DirectiveFacts.GetDisabledIdentifiers(list.ScopeNode, TextSpan.FromBounds(range.End, list.ScopeNode.Span.End))]; + HashSet usedBefore = [.. DirectiveFacts.GetDisabledIdentifiers(list.ScopeNode, TextSpan.FromBounds(list.ScopeNode.SpanStart, range.Start))]; + foreach (var pair in declared) + { + foreach (ISymbol symbol in pair.Value) + { + if (usedAfter.Contains(pair.Key) || (symbol is ILabelSymbol && usedBefore.Contains(pair.Key))) + AddReference(references, symbol, -1); + } + } + + return references; + } + + private static IEnumerable GetRange(StatementList list, int first, int last) => + list.Statements.Skip(first).Take(last - first + 1); + + private static void AddReference(Dictionary> references, ISymbol symbol, int index) + { + if (!references.TryGetValue(symbol, out List? indices)) + references[symbol] = indices = []; + + indices.Add(index); + } + + /// The text moved into the block: from the first statement's first token to the last statement's end. + private static TextSpan GetRangeSpan(StatementList list, int first, int last) => + TextSpan.FromBounds(list[first].SpanStart, list[last].Span.End); + + /// + /// Whether an inactive #if arm inside the range declares a name that appears after it. In + /// that configuration the new block would hide the declaration, which no single-configuration check can see. + /// + private static bool HidesInactiveDeclarations(StatementList list, int first, int last) + { + SyntaxNode scope = list.ScopeNode; + TextSpan range = GetRangeSpan(list, first, last); + HashSet names = [.. DirectiveFacts.GetDisabledDeclarations(scope, range)]; + if (names.Count == 0) + return false; + + var after = TextSpan.FromBounds(range.End, scope.Span.End); + return scope.DescendantTokens(after).Any(t => t.IsKind(SyntaxKind.IdentifierToken) && names.Contains(t.ValueText)) + || DirectiveFacts.GetDisabledIdentifiers(scope, after).Any(names.Contains); + } + + private static int GetDeclaringStatementIndex(StatementList list, ILocalSymbol local) => + local.DeclaringSyntaxReferences.FirstOrDefault() is { } reference + ? list.IndexOfStatementContaining(reference.Span) + : -1; + } +} diff --git a/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/DeclarationSplitter.cs b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/DeclarationSplitter.cs new file mode 100644 index 00000000000000..0bf70ead29d8dd --- /dev/null +++ b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/DeclarationSplitter.cs @@ -0,0 +1,319 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace ILLink.CodeFix.UnsafeContext +{ + /// A statement whose declarations are hoisted out of a new unsafe block. + /// Typed declarations placed directly before the block, without trivia. + /// The statements kept inside the block: assignments, or the statement with lifted variables. + internal sealed record DeclarationSplit(ImmutableArray Declarations, ImmutableArray Remaining); + + /// + /// Splits declarations so that a narrow block does not hide locals used after it. The exact type of the original + /// local is kept, initialization stays at its original execution point, and no default value is added, which + /// preserves definite assignment. Local declarations, out variables and deconstruction declarations are split; + /// ref, const, using and pattern locals are not. + /// + internal static class DeclarationSplitter + { + private static readonly SymbolDisplayFormat s_typeFormat = SymbolDisplayFormat.MinimallyQualifiedFormat + .AddMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); + + /// Splits out of , or returns if any cannot be. + public static DeclarationSplit? TrySplit(StatementSyntax statement, IEnumerable locals, PlanningContext context) + { + // The split parts are not unwrapped, so they would keep nested `unsafe(...)` wrappers. + if (statement.DescendantNodes().Any(UnsafeContextFacts.IsUnsafeExpression)) + return null; + + HashSet pending = new(locals, SymbolEqualityComparer.Default); + var declarations = ImmutableArray.CreateBuilder(); + + // Out variables are declared before the block and passed by name. + Dictionary replacements = []; + foreach (DeclarationExpressionSyntax declaration in GetOutVariableDeclarations(statement)) + { + var designation = (SingleVariableDesignationSyntax)declaration.Designation; + if (context.Model.GetDeclaredSymbol(designation, context.CancellationToken) is not ILocalSymbol local || !pending.Remove(local)) + continue; + + if (UnsafeContextSemantics.IsCapturedByCallerArgumentExpression(declaration, context.Model, context.CancellationToken)) + return null; + + var call = declaration.Parent!.Parent!.Parent as ExpressionSyntax; + if (GetTypeSyntax(declaration.Type, local, call, statement, context) is not { } type) + return null; + + declarations.Add(CreateDeclaration(type, [SyntaxFactory.VariableDeclarator(designation.Identifier.WithoutTrivia())])); + replacements[declaration] = SyntaxFactory.IdentifierName(designation.Identifier.WithoutTrivia()).WithTriviaFrom(declaration); + } + + // A deconstruction declaration becomes a deconstruction assignment to declarations hoisted before the block. + if (statement is ExpressionStatementSyntax { Expression: AssignmentExpressionSyntax { Left: DeclarationExpressionSyntax or TupleExpressionSyntax } deconstruction } + && deconstruction.IsKind(SyntaxKind.SimpleAssignmentExpression)) + { + if (LiftDeconstructionTarget(deconstruction.Left, deconstruction.Right, pending, declarations, context) is not { } target) + return null; + + replacements[deconstruction.Left] = target.WithTriviaFrom(deconstruction.Left); + } + + StatementSyntax rewritten = replacements.Count == 0 ? statement : statement.ReplaceNodes(replacements.Keys, (original, _) => replacements[original]); + ImmutableArray remaining = [rewritten]; + + if (statement is LocalDeclarationStatementSyntax localDeclaration + && localDeclaration.Declaration.Variables.Any(v => context.Model.GetDeclaredSymbol(v, context.CancellationToken) is ILocalSymbol local && pending.Contains(local))) + { + var split = TrySplitLocalDeclaration(localDeclaration, (LocalDeclarationStatementSyntax)rewritten, context); + if (split is null) + return null; + + foreach (VariableDeclaratorSyntax variable in localDeclaration.Declaration.Variables) + pending.Remove((ILocalSymbol)context.Model.GetDeclaredSymbol(variable, context.CancellationToken)!); + + declarations.Add(split.Value.Declaration); + remaining = split.Value.Assignments; + } + + // Pattern variables and other declarations cannot be split by assignment. + return pending.Count == 0 ? new DeclarationSplit(declarations.ToImmutable(), remaining) : null; + } + + /// + /// Rewrites a deconstruction target: newly declared locals with a nameable type are hoisted and assigned by + /// name, existing targets are unchanged, and discards and other declarations keep their declaration form, + /// so that a discard never binds to a symbol named _. is the deconstructed value. + /// + private static ExpressionSyntax? LiftDeconstructionTarget( + ExpressionSyntax target, + ExpressionSyntax source, + HashSet pending, + ImmutableArray.Builder declarations, + PlanningContext context) + { + switch (target) + { + case TupleExpressionSyntax tuple: + List arguments = []; + foreach (ArgumentSyntax argument in tuple.Arguments) + { + if (LiftDeconstructionTarget(argument.Expression, source, pending, declarations, context) is not { } lifted) + return null; + + arguments.Add(argument.WithExpression(lifted.WithTriviaFrom(argument.Expression))); + } + + return tuple.WithArguments(SyntaxFactory.SeparatedList(arguments, tuple.Arguments.GetSeparators())); + case DeclarationExpressionSyntax declaration: + return LiftDesignation(declaration.Type, declaration.Designation, source, pending, declarations, context); + default: + return target; + } + } + + /// Hoists the local of a single designation, recursing into var (a, b)-style designations. + private static ExpressionSyntax? LiftDesignation( + TypeSyntax type, + VariableDesignationSyntax designation, + ExpressionSyntax source, + HashSet pending, + ImmutableArray.Builder declarations, + PlanningContext context) + { + if (designation is ParenthesizedVariableDesignationSyntax parenthesized) + { + // `var (a, b)` declares every element with the inferred type. + List elements = []; + foreach (VariableDesignationSyntax variable in parenthesized.Variables) + { + if (LiftDesignation(type, variable, source, pending, declarations, context) is not { } lifted) + return null; + + elements.Add(SyntaxFactory.Argument(lifted)); + } + + return SyntaxFactory.TupleExpression(SyntaxFactory.SeparatedList(elements, parenthesized.Variables.GetSeparators())); + } + + var inlineDeclaration = SyntaxFactory.DeclarationExpression(type.WithoutTrivia().WithTrailingTrivia(SyntaxFactory.Space), designation.WithoutTrivia()); + if (designation is not SingleVariableDesignationSyntax single + || context.Model.GetDeclaredSymbol(single, context.CancellationToken) is not ILocalSymbol local) + { + return inlineDeclaration; + } + + bool isPending = pending.Remove(local); + TypeSyntax? typeSyntax = local.Type.IsRefLikeType ? null : GetTypeSyntax(type, local, source, single, context); + if (typeSyntax is null) + return isPending ? null : inlineDeclaration; + + declarations.Add(CreateDeclaration(typeSyntax, [SyntaxFactory.VariableDeclarator(single.Identifier.WithoutTrivia())])); + return SyntaxFactory.IdentifierName(single.Identifier.WithoutTrivia()); + } + + // `out var x` / `out T x` arguments outside nested lambdas and local functions. + private static IEnumerable GetOutVariableDeclarations(StatementSyntax statement) => + statement + .DescendantNodes(static n => n is not AnonymousFunctionExpressionSyntax and not LocalFunctionStatementSyntax) + .OfType() + .Where(static d => d.Designation is SingleVariableDesignationSyntax + && d.Parent is ArgumentSyntax argument + && argument.RefKindKeyword.IsKind(SyntaxKind.OutKeyword)); + + /// Splits T a = x, b = y; into T a, b; and the assignments a = x; b = y;. + private static (StatementSyntax Declaration, ImmutableArray Assignments)? TrySplitLocalDeclaration( + LocalDeclarationStatementSyntax original, + LocalDeclarationStatementSyntax rewritten, + PlanningContext context) + { + if (original.IsConst || !original.UsingKeyword.IsKind(SyntaxKind.None)) + return null; + + TypeSyntax declaredType = original.Declaration.Type; + bool isScoped = declaredType is ScopedTypeSyntax; + if (declaredType is ScopedTypeSyntax scopedType) + declaredType = scopedType.Type; + + if (declaredType is RefTypeSyntax) + return null; + + var variables = original.Declaration.Variables; + var locals = variables.Select(v => context.Model.GetDeclaredSymbol(v, context.CancellationToken) as ILocalSymbol).ToList(); + if (locals.Any(static l => l is null || l.IsRef || l.IsConst || l.IsFixed || l.IsUsing) + || variables.Any(static v => v.Initializer?.Value is InitializerExpressionSyntax)) + { + return null; + } + + // A ref-like local would get a wider escape scope once separated from its initializer. Stack-allocated + // spans are the exception: they become scoped values, which keeps the stack memory assignable. + ITypeSymbol localType = locals[0]!.Type; + if (localType.IsRefLikeType || localType is ITypeParameterSymbol { AllowsRefLikeType: true }) + { + if (!variables.All(v => IsStackAllocatedSpan(locals[variables.IndexOf(v)]!, v, context))) + return null; + + isScoped = true; + } + else if (isScoped) + { + return null; + } + + ExpressionSyntax? initializer = variables.Count == 1 ? variables[0].Initializer?.Value : null; + if (GetTypeSyntax(declaredType, locals[0]!, initializer, original, context) is not { } type) + return null; + + if (isScoped) + type = SyntaxFactory.ScopedType(SyntaxFactory.Token(SyntaxKind.ScopedKeyword).WithTrailingTrivia(SyntaxFactory.Space), type); + + StatementSyntax declaration = CreateDeclaration(type, variables.Select(static v => SyntaxFactory.VariableDeclarator(v.Identifier.WithoutTrivia()))); + + // The last assignment keeps the original semicolon and its trivia; the others end their line. + SyntaxToken semicolon = SyntaxFactory.Token(default, SyntaxKind.SemicolonToken, [context.Formatting.EndOfLine]); + List initialized = [.. rewritten.Declaration.Variables.Where(static v => v.Initializer is not null)]; + ImmutableArray assignments = + [ + .. initialized.Select((variable, i) => SyntaxFactory.ExpressionStatement( + SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName(variable.Identifier.WithoutTrivia()).WithTrailingTrivia(SameLineOrSpace(variable.Identifier.TrailingTrivia)), + variable.Initializer!.EqualsToken, + variable.Initializer.Value), + i == initialized.Count - 1 ? rewritten.SemicolonToken : semicolon)), + ]; + + return (declaration, assignments); + } + + // Creates `T a, b;`; comments after the type on its line are kept. + private static StatementSyntax CreateDeclaration(TypeSyntax type, IEnumerable variables) + { + List list = [.. variables]; + var separators = Enumerable.Repeat(SyntaxFactory.Token(SyntaxKind.CommaToken).WithTrailingTrivia(SyntaxFactory.Space), list.Count - 1); + return SyntaxFactory.LocalDeclarationStatement( + SyntaxFactory.VariableDeclaration(type.WithoutTrivia().WithTrailingTrivia(SameLineOrSpace(type.GetTrailingTrivia())), SyntaxFactory.SeparatedList(list, separators))); + } + + // Keeps trailing trivia such as `/* note */ ` unless it is empty or ends the line, where a space is used instead. + private static SyntaxTriviaList SameLineOrSpace(SyntaxTriviaList trailing) => + trailing.Count == 0 || trailing.Any(static t => t.EndsLine) ? [SyntaxFactory.Space] : trailing; + + // Whether the local is a Span/ReadOnlySpan initialized with a stackalloc. + private static bool IsStackAllocatedSpan(ILocalSymbol local, VariableDeclaratorSyntax variable, PlanningContext context) + { + Compilation compilation = context.Model.Compilation; + ITypeSymbol definition = local.Type.OriginalDefinition; + return (SymbolEqualityComparer.Default.Equals(definition, compilation.GetTypeByMetadataName("System.Span`1")) + || SymbolEqualityComparer.Default.Equals(definition, compilation.GetTypeByMetadataName("System.ReadOnlySpan`1"))) + && variable.Initializer?.Value + .DescendantNodesAndSelf(static n => n is not AnonymousFunctionExpressionSyntax) + .Any(static n => n is StackAllocArrayCreationExpressionSyntax or ImplicitStackAllocArrayCreationExpressionSyntax) == true; + } + + /// + /// Returns the type for a split declaration: the explicit source type, or the exact inferred type of a + /// var local when it is nameable and cannot differ between build configurations. Trailing trivia + /// (e.g. a comment after the type) is kept. + /// + private static TypeSyntax? GetTypeSyntax(TypeSyntax? declaredType, ILocalSymbol local, ExpressionSyntax? initializer, SyntaxNode position, PlanningContext context) + { + if (declaredType is not null && !declaredType.IsVar) + return declaredType.WithoutLeadingTrivia(); + + if (!IsNameable(local.Type) || IsConfigurationDependent(local.Type, initializer, context)) + return null; + + TypeSyntax type = SyntaxFactory.ParseTypeName(local.Type.ToMinimalDisplayString(context.Model, position.SpanStart, s_typeFormat)); + return type.ContainsDiagnostics ? null : type.WithTrailingTrivia(declaredType?.GetTrailingTrivia() ?? default); + } + + // Whether the type can be written in source: no anonymous or error types, also as type arguments. + private static bool IsNameable(ITypeSymbol type) => + type switch + { + IErrorTypeSymbol => false, + IArrayTypeSymbol array => IsNameable(array.ElementType), + IPointerTypeSymbol pointer => IsNameable(pointer.PointedAtType), + INamedTypeSymbol named => !named.IsAnonymousType && named.TypeArguments.All(IsNameable), + _ => true, + }; + + /// + /// Whether an inferred type may differ in another build configuration: it (or a type argument) is declared in + /// an #if group or named through a using alias, the initializer contains directives (so its text + /// differs per configuration), or the initializer's member is in an #if group. + /// + private static bool IsConfigurationDependent(ITypeSymbol type, ExpressionSyntax? initializer, PlanningContext context) => + GetTypeParts(type).Any(part => + part.DeclaringSyntaxReferences.Any(context.IsConditionallyCompiled) + || context.AliasTargets.Contains(part, SymbolEqualityComparer.Default)) + || (initializer is not null + && (initializer.ContainsDirectives + || (context.Model.GetSymbolInfo(initializer, context.CancellationToken).Symbol is { } member + && member.DeclaringSyntaxReferences.Any(context.IsConditionallyCompiled)))); + + // The type itself and, recursively, its element, pointed-at and argument types. + private static IEnumerable GetTypeParts(ITypeSymbol type) + { + yield return type; + IEnumerable nested = type switch + { + IArrayTypeSymbol array => [array.ElementType], + IPointerTypeSymbol pointer => [pointer.PointedAtType], + INamedTypeSymbol named => named.TypeArguments, + _ => [], + }; + + foreach (ITypeSymbol part in nested.SelectMany(GetTypeParts)) + yield return part; + } + } +} diff --git a/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/DirectiveFacts.cs b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/DirectiveFacts.cs new file mode 100644 index 00000000000000..3032f9978dbe8a --- /dev/null +++ b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/DirectiveFacts.cs @@ -0,0 +1,91 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace ILLink.CodeFix.UnsafeContext +{ + /// + /// Preprocessor queries that keep new contexts within one conditional-compilation arm and avoid rewrites whose + /// meaning depends on code in inactive arms. + /// + internal static class DirectiveFacts + { + /// Whether the directives in form complete #if/#region groups. + public static bool AreBalanced(SyntaxNode root, TextSpan span) + { + int conditionalDepth = 0, regionDepth = 0; + foreach (SyntaxTrivia trivia in root.DescendantTrivia(span).Where(t => t.IsDirective && span.Contains(t.Span))) + { + switch (trivia.Kind()) + { + case SyntaxKind.IfDirectiveTrivia: + conditionalDepth++; + break; + case SyntaxKind.ElifDirectiveTrivia or SyntaxKind.ElseDirectiveTrivia when conditionalDepth == 0: + return false; + case SyntaxKind.EndIfDirectiveTrivia: + if (--conditionalDepth < 0) + return false; + break; + case SyntaxKind.RegionDirectiveTrivia: + regionDepth++; + break; + case SyntaxKind.EndRegionDirectiveTrivia: + if (--regionDepth < 0) + return false; + break; + } + } + + return conditionalDepth == 0 && regionDepth == 0; + } + + /// Returns the span of every #if ... #endif group in a tree. + public static ImmutableArray GetConditionalSpans(SyntaxTree tree) + { + var spans = ImmutableArray.CreateBuilder(); + Stack starts = []; + for (DirectiveTriviaSyntax? directive = ((CSharpSyntaxNode)tree.GetRoot()).GetFirstDirective(); directive is not null; directive = directive.GetNextDirective()) + { + if (directive.IsKind(SyntaxKind.IfDirectiveTrivia)) + starts.Push(directive.SpanStart); + else if (directive.IsKind(SyntaxKind.EndIfDirectiveTrivia) && starts.Count > 0) + spans.Add(TextSpan.FromBounds(starts.Pop(), directive.Span.End)); + } + + return spans.ToImmutable(); + } + + /// Returns the identifiers in the inactive #if arms of within . + public static IEnumerable GetDisabledIdentifiers(SyntaxNode node, TextSpan span) => + from text in GetDisabledText(node, span) + from token in SyntaxFactory.ParseTokens(text) + where token.IsKind(SyntaxKind.IdentifierToken) + select token.ValueText; + + /// Returns the locals and labels declared in the inactive #if arms within . + public static IEnumerable GetDisabledDeclarations(SyntaxNode node, TextSpan span) => + GetDisabledText(node, span) + .SelectMany(static text => SyntaxFactory.ParseStatement("{" + text + "\n}").DescendantNodes()) + .Select(static declaration => declaration switch + { + VariableDeclaratorSyntax variable => variable.Identifier.ValueText, + SingleVariableDesignationSyntax designation => designation.Identifier.ValueText, + LabeledStatementSyntax labeled => labeled.Identifier.ValueText, + _ => null, + }) + .OfType(); + + private static IEnumerable GetDisabledText(SyntaxNode node, TextSpan span) => + node.DescendantTrivia(span) + .Where(trivia => trivia.IsKind(SyntaxKind.DisabledTextTrivia) && span.Contains(trivia.Span)) + .Select(static trivia => trivia.ToFullString()); + } +} diff --git a/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/ExpressionWrapPlanner.cs b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/ExpressionWrapPlanner.cs new file mode 100644 index 00000000000000..782f7b31622941 --- /dev/null +++ b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/ExpressionWrapPlanner.cs @@ -0,0 +1,112 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace ILLink.CodeFix.UnsafeContext +{ + /// + /// Creates unsafe(...) candidates: one wrapper per clause covering every operation, from the narrowest + /// covering expression outwards. A bare wrapper may not cover a consumer's implicit operation (e.g. a conversion + /// or a property setter), so wider candidates are kept for validation to choose from. + /// + internal static class ExpressionWrapPlanner + { + /// Returns the wrapper candidates for targets of one clause, narrowest first. + public static ImmutableArray Create(IReadOnlyCollection targets, UnsafeScope scope, SyntaxNode boundary, PlanningContext context) + { + // Placeholder wrappers from earlier passes in the same clause are merged into the new one. + List nodes = [.. targets.Select(static t => t.Node)]; + if (GetCommonAncestor(nodes) is { } common && PlaceholderContexts.GetClause(common) is { } commonClause) + nodes.AddRange(PlaceholderContexts.GetWrappersInClause(commonClause)); + + var edits = ImmutableArray.CreateBuilder(); + for (SyntaxNode? node = GetCommonAncestor(nodes); node is not null && node != boundary; node = node.Parent) + { + if (node is StatementSyntax or MemberDeclarationSyntax or AccessorDeclarationSyntax) + break; + + if (node is ExpressionSyntax expression + && CanWrap(expression, scope, context) + && PlaceholderContexts.GetClause(expression) is { } clause + && PlaceholderContexts.GetWrappersInClause(clause).All(wrapper => expression.Span.Contains(wrapper.Span))) + { + edits.Add(new ExpressionWrapEdit(expression, clause)); + } + } + + return edits.ToImmutable(); + } + + private static SyntaxNode? GetCommonAncestor(IEnumerable nodes) => + nodes.Aggregate((SyntaxNode?)null, static (common, node) => + common is null ? node : common.AncestorsAndSelf().FirstOrDefault(a => a.Span.Contains(node.Span))); + + /// Whether unsafe(expression) is valid syntax with the same meaning, and puts nothing else in the context. + private static bool CanWrap(ExpressionSyntax expression, UnsafeScope scope, PlanningContext context) + { + if (expression is RefExpressionSyntax + or DeclarationExpressionSyntax + or ThrowExpressionSyntax + or InitializerExpressionSyntax + or BaseExpressionSyntax + or MemberBindingExpressionSyntax + or ElementBindingExpressionSyntax + or ImplicitElementAccessSyntax + or AnonymousFunctionExpressionSyntax + || UnsafeContextFacts.IsUnsafeExpression(expression) + || (expression is TypeSyntax type && SyntaxFacts.IsInTypeOnlyContext(type))) + { + return false; + } + + if (!IsValuePosition(expression, context.Model, context.CancellationToken) + || !DirectiveFacts.AreBalanced(context.Root, expression.Span)) + { + return false; + } + + // A lambda or local function inside would get an unsafe body; only placeholder wrappers may be nested. + if (expression.DescendantNodes().Any(static n => n is AnonymousFunctionExpressionSyntax or LocalFunctionStatementSyntax) + || !PlaceholderContexts.ContainsOnlyPlaceholderWrappers(expression)) + { + return false; + } + + if (expression == scope.Body + && scope.Kind == UnsafeScopeKind.ExpressionBody + && UnsafeContextSemantics.RequiresStatementExpressionBody(scope.Owner, context.Model, context.CancellationToken)) + { + return false; + } + + return !UnsafeContextSemantics.IsCapturedByCallerArgumentExpression(expression, context.Model, context.CancellationToken); + } + + /// + /// Whether the expression is used as a value: not a statement expression, an assignment or increment target, + /// a method group, the right side of a member access, or a type/namespace qualifier. + /// + private static bool IsValuePosition(ExpressionSyntax expression, SemanticModel model, CancellationToken cancellationToken) => + expression.Parent switch + { + ExpressionStatementSyntax or NameColonSyntax or NameEqualsSyntax or QualifiedNameSyntax or TypeArgumentListSyntax => false, + MemberAccessExpressionSyntax access when access.Name == expression => false, + MemberAccessExpressionSyntax access when access.Expression == expression => + model.GetSymbolInfo(expression, cancellationToken).Symbol is not (INamespaceOrTypeSymbol or IAliasSymbol), + InvocationExpressionSyntax invocation when invocation.Expression == expression => false, + AssignmentExpressionSyntax assignment when assignment.Left == expression => false, + ConditionalAccessExpressionSyntax conditionalAccess when conditionalAccess.WhenNotNull == expression => false, + PrefixUnaryExpressionSyntax prefix when prefix.Kind() is SyntaxKind.AddressOfExpression or SyntaxKind.PreIncrementExpression or SyntaxKind.PreDecrementExpression => false, + PostfixUnaryExpressionSyntax => false, + ArgumentSyntax { Parent.Parent: InvocationExpressionSyntax { Expression: IdentifierNameSyntax { Identifier.ValueText: "nameof" } } } => false, + _ => true, + }; + } +} diff --git a/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/PlaceholderContexts.cs b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/PlaceholderContexts.cs new file mode 100644 index 00000000000000..df483e67a4544d --- /dev/null +++ b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/PlaceholderContexts.cs @@ -0,0 +1,153 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace ILLink.CodeFix.UnsafeContext +{ + /// + /// Recognizes contexts introduced by earlier passes (exact placeholder marker) so that later passes merge with + /// them instead of nesting or duplicating contexts. Contexts with any other marker are audited and left alone. + /// + internal static class PlaceholderContexts + { + /// Whether the line directly above is the placeholder block marker. + public static bool HasBlockMarker(SyntaxToken token) => + token.LeadingTrivia.LastNonLayout is var last + && last.IsKind(SyntaxKind.SingleLineCommentTrivia) + && last.ToString() == UnsafeContextFacts.BlockMarker; + + /// Whether is an unsafe(...) directly preceded by the placeholder marker. + public static bool IsPlaceholderWrapper(SyntaxNode node) => + UnsafeContextFacts.IsUnsafeExpression(node) + && IsExpressionMarker((HasMarkerOnPreviousToken(node) ? node.GetFirstToken().GetPreviousToken().TrailingTrivia : node.GetLeadingTrivia()).LastNonLayout); + + /// + /// Whether the marker before is on the previous token: a comment after a token on the same + /// line is parsed as that token's trailing trivia, e.g. return /* SAFETY: ... */ unsafe(...). + /// + public static bool HasMarkerOnPreviousToken(SyntaxNode node) => + node.GetLeadingTrivia().IsLayoutOnly + && node.GetFirstToken().GetPreviousToken() is { RawKind: not 0, EndsLine: false } previous + && IsExpressionMarker(previous.TrailingTrivia.LastNonLayout); + + private static bool IsExpressionMarker(SyntaxTrivia trivia) => + trivia.IsKind(SyntaxKind.MultiLineCommentTrivia) && trivia.ToString() == UnsafeContextFacts.ExpressionMarker; + + /// + /// Whether is a placeholder block that may be extended; comments or directives + /// around its braces would be dropped, so such blocks are not. + /// + public static bool IsExtendableBlock(StatementSyntax statement) => + statement is UnsafeStatementSyntax { Block: var block } unsafeStatement + && HasBlockMarker(unsafeStatement.UnsafeKeyword) + && unsafeStatement.UnsafeKeyword.TrailingTrivia.IsLayoutOnly + && block.OpenBraceToken.LeadingTrivia.IsLayoutOnly + && block.OpenBraceToken.TrailingTrivia.IsLayoutOnly + && block.CloseBraceToken.LeadingTrivia.IsLayoutOnly + && block.CloseBraceToken.TrailingTrivia.IsLayoutOnly; + + /// + /// Returns the clause an expression belongs to: its statement, switch label, switch arm, catch clause, accessor + /// or member. A clause holds at most one unsafe(...). + /// + public static SyntaxNode? GetClause(SyntaxNode node) => + node.Ancestors().FirstOrDefault(static ancestor => ancestor + is (StatementSyntax and not BlockSyntax) + or SwitchExpressionArmSyntax + or CasePatternSwitchLabelSyntax + or CatchClauseSyntax + or AccessorDeclarationSyntax + or MemberDeclarationSyntax); + + /// Returns every unsafe(...), placeholder or audited, that belongs to . + public static IEnumerable GetWrappersInClause(SyntaxNode clause) => + clause.DescendantNodes().Where(UnsafeContextFacts.IsUnsafeExpression).Where(wrapper => GetClause(wrapper) == clause); + + /// Returns the spans of the placeholder unsafe(...) expressions in . + public static IEnumerable GetPlaceholderWrapperSpans(SyntaxNode node) => + node.DescendantNodes().Where(IsPlaceholderWrapper).Select(static wrapper => wrapper.Span); + + /// Whether every unsafe(...) in is a placeholder that may be merged. + public static bool ContainsOnlyPlaceholderWrappers(SyntaxNode node) => + node.DescendantNodes().Where(UnsafeContextFacts.IsUnsafeExpression).All(IsPlaceholderWrapper); + + /// + /// Returns with its placeholder unsafe(...) expressions and their markers removed. + /// A marker on a token before is outside it and stays. + /// + public static T Unwrap(T node) where T : SyntaxNode + { + List wrappers = [.. node.DescendantNodes().Where(IsPlaceholderWrapper)]; + List markedTokens = + [ + .. wrappers.Where(HasMarkerOnPreviousToken) + .Select(static wrapper => wrapper.GetFirstToken().GetPreviousToken()) + .Where(token => node.Span.Contains(token.Span)), + ]; + + return node.ReplaceSyntax( + wrappers, + static (original, rewritten) => UnwrapExpression((ExpressionSyntax)original, (ExpressionSyntax)rewritten), + markedTokens, + static (_, rewritten) => rewritten.WithTrailingTrivia(RemoveExpressionMarker(rewritten.TrailingTrivia)), + trivia: null, + computeReplacementTrivia: null); + } + + private static ExpressionSyntax UnwrapExpression(ExpressionSyntax original, ExpressionSyntax rewritten) + { + ExpressionSyntax operand = UnsafeContextFacts.GetUnsafeExpressionOperand(rewritten).WithoutTrivia(); + if (NeedsParentheses(original, operand)) + operand = SyntaxFactory.ParenthesizedExpression(operand); + + SyntaxTriviaList leading = rewritten.GetLeadingTrivia(); + return operand + .WithLeadingTrivia(HasMarkerOnPreviousToken(original) ? leading : RemoveExpressionMarker(leading)) + .WithTrailingTrivia(rewritten.GetTrailingTrivia()); + } + + // Drops the marker and the space after it. + private static SyntaxTriviaList RemoveExpressionMarker(SyntaxTriviaList trivia) + { + int index = trivia.IndexOf(trivia.LastNonLayout); + return index < 0 ? trivia : SyntaxFactory.TriviaList([.. trivia.Take(index), .. trivia.Skip(index + 1).SkipWhile(static t => t.IsKind(SyntaxKind.WhitespaceTrivia))]); + } + + /// Whether the unwrapped operand needs the parentheses that unsafe(...) provided. + private static bool NeedsParentheses(ExpressionSyntax wrapper, ExpressionSyntax operand) => + !IsPrimary(operand) && wrapper.Parent switch + { + ArgumentSyntax or EqualsValueClauseSyntax or ReturnStatementSyntax or ArrowExpressionClauseSyntax + or ParenthesizedExpressionSyntax or YieldStatementSyntax or InterpolationSyntax + or SwitchExpressionArmSyntax or WhenClauseSyntax or InitializerExpressionSyntax => false, + AssignmentExpressionSyntax assignment => assignment.Right != wrapper, + ConditionalExpressionSyntax conditional => conditional.Condition == wrapper, + _ => true, + }; + + private static bool IsPrimary(ExpressionSyntax expression) => + expression is InstanceExpressionSyntax + or LiteralExpressionSyntax + or SimpleNameSyntax + or MemberAccessExpressionSyntax + or InvocationExpressionSyntax + or ElementAccessExpressionSyntax + or ParenthesizedExpressionSyntax + or BaseObjectCreationExpressionSyntax + or ArrayCreationExpressionSyntax + or ImplicitArrayCreationExpressionSyntax + or TypeOfExpressionSyntax + or SizeOfExpressionSyntax + or DefaultExpressionSyntax + or CheckedExpressionSyntax + or InterpolatedStringExpressionSyntax + or TupleExpressionSyntax + or PostfixUnaryExpressionSyntax; + } +} diff --git a/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/PlanningContext.cs b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/PlanningContext.cs new file mode 100644 index 00000000000000..dd85a2d26cf86d --- /dev/null +++ b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/PlanningContext.cs @@ -0,0 +1,141 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; + +namespace ILLink.CodeFix.UnsafeContext +{ + /// The preferred shape of the introduced contexts. + internal enum UnsafeContextPolicy + { + /// Blocks first, falling back to unsafe(...) expressions (the default). + BlockFirst, + + /// unsafe(...) expressions first, falling back to blocks. + ExpressionFirst, + + /// One block around the whole body of each callable, falling back to the default plan. + BodyWide, + } + + /// Document-level state shared by the planners of one fix. + internal sealed class PlanningContext(SyntaxNode root, SemanticModel model, UnsafeContextPolicy policy, CancellationToken cancellationToken) + { + private readonly Dictionary> _conditionalSpans = []; + + public SyntaxNode Root { get; } = root; + + public SemanticModel Model { get; } = model; + + public UnsafeContextPolicy Policy { get; } = policy; + + public CancellationToken CancellationToken { get; } = cancellationToken; + + public UnsafeContextFormatting Formatting { get; } = new UnsafeContextFormatting(root); + + /// Whether a declaration lies inside an #if group of its file. + public bool IsConditionallyCompiled(SyntaxReference reference) + { + if (!_conditionalSpans.TryGetValue(reference.SyntaxTree, out ImmutableArray spans)) + _conditionalSpans[reference.SyntaxTree] = spans = DirectiveFacts.GetConditionalSpans(reference.SyntaxTree); + + return spans.Any(span => span.Contains(reference.Span.Start)); + } + + /// The types named by using aliases in this document. + public ImmutableArray AliasTargets + { + get + { + if (field.IsDefault) + { + field = [.. Root + .DescendantNodes(static n => n is CompilationUnitSyntax or BaseNamespaceDeclarationSyntax) + .OfType() + .Where(static u => u.Alias is not null) + .Select(u => Model.GetDeclaredSymbol(u, CancellationToken)?.Target) + .OfType()]; + } + + return field; + } + } + } + + /// Semantic checks shared by the declaration splitter and the expression planner. + internal static class UnsafeContextSemantics + { + /// + /// Returns the compiler diagnostics located in . The bodies of partial members are + /// bound with their defining part, so span-limited queries can miss them; those use the whole tree. + /// + public static ImmutableArray GetDiagnostics(SemanticModel model, MemberDeclarationSyntax member, CancellationToken cancellationToken) + { + if (!member.Modifiers.Any(SyntaxKind.PartialKeyword)) + return model.GetDiagnostics(member.Span, cancellationToken); + + return model.GetDiagnostics(cancellationToken: cancellationToken) + .Where(d => member.Span.Contains(d.Location.SourceSpan)) + .ToImmutableArray(); + } + + /// + /// Whether is inside an argument whose text a CallerArgumentExpression parameter + /// of the selected method captures; changing that text would change the captured string. + /// + public static bool IsCapturedByCallerArgumentExpression(SyntaxNode node, SemanticModel model, CancellationToken cancellationToken) + { + for (SyntaxNode? current = node; current is not null and not StatementSyntax and not MemberDeclarationSyntax; current = current.Parent) + { + if (current is not ArgumentSyntax argument || argument.Parent?.Parent is not { } call) + continue; + + ImmutableArray arguments = model.GetOperation(call, cancellationToken) switch + { + IInvocationOperation invocation => invocation.Arguments, + IObjectCreationOperation creation => creation.Arguments, + IPropertyReferenceOperation property => property.Arguments, + _ => [], + }; + + IParameterSymbol? parameter = arguments.FirstOrDefault(a => a.Syntax == argument)?.Parameter; + if (parameter is not null && arguments.Any(a => a.ArgumentKind == ArgumentKind.DefaultValue && Captures(a.Parameter, parameter.Name))) + return true; + } + + return false; + } + + /// + /// Whether an expression body must be a statement expression: void-returning callables and async callables + /// returning a non-generic task. unsafe(E) is not a statement expression. + /// + public static bool RequiresStatementExpressionBody(SyntaxNode owner, SemanticModel model, CancellationToken cancellationToken) + { + var method = owner switch + { + AnonymousFunctionExpressionSyntax function => model.GetSymbolInfo(function, cancellationToken).Symbol as IMethodSymbol, + BaseMethodDeclarationSyntax or LocalFunctionStatementSyntax or AccessorDeclarationSyntax => + model.GetDeclaredSymbol(owner, cancellationToken) as IMethodSymbol, + _ => null, + }; + + return method is not null + && (method.ReturnsVoid || (method.IsAsync && method.ReturnType is INamedTypeSymbol { Arity: 0 })); + } + + private static bool Captures(IParameterSymbol? capturing, string parameterName) => + capturing is not null && capturing.GetAttributes().Any(attribute => + attribute.AttributeClass?.Name == "CallerArgumentExpressionAttribute" + && attribute.ConstructorArguments.Length == 1 + && attribute.ConstructorArguments[0].Value as string == parameterName); + } +} diff --git a/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/StatementBlockEdit.cs b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/StatementBlockEdit.cs new file mode 100644 index 00000000000000..f9fca8ea822497 --- /dev/null +++ b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/StatementBlockEdit.cs @@ -0,0 +1,246 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace ILLink.CodeFix.UnsafeContext +{ + /// + /// Wraps a contiguous range of a statement list in a new unsafe block. Declarations used after the range are split + /// out before the block, and a leading label stays outside so that jumps to it remain valid. + /// + internal sealed class StatementBlockEdit( + StatementList list, + int first, + int last, + ImmutableDictionary splits, + Coverage coverage, + bool isProportionate) : UnsafeContextEdit + { + public bool HasSplits => !splits.IsEmpty; + + public override bool IsProportionate => isProportionate; + + public override int UnrelatedLeaves => coverage.Unrelated; + + public override TextSpan Span => TextSpan.FromBounds(list[first].SpanStart, list[last].Span.End); + + public override UnsafeContextForm Form => UnsafeContextForm.Block; + + public static StatementSyntax GetFirstWrappedStatement(StatementSyntax statement) => + statement is LabeledStatementSyntax labeled ? labeled.Statement : statement; + + public override void Apply(EditSession session, SyntaxAnnotation annotation) + { + UnsafeContextFormatting formatting = session.Formatting; + StatementSyntax firstStatement = list[first]; + string indentation = formatting.GetIndentation(GetFirstWrappedStatement(firstStatement).SpanStart); + var (outerLines, declarations, body) = BuildContents(formatting, indentation); + + // Comments and directives above the first statement stay above the split declarations and the marker. + if (declarations.Count > 0) + declarations[0] = declarations[0].WithLeadingTrivia(outerLines.AddRange(declarations[0].GetLeadingTrivia())); + + StatementSyntax unsafeStatement = BlockSyntaxFactory + .CreateUnsafe(formatting, indentation, body, declarations.Count == 0 ? outerLines : default) + .WithAdditionalAnnotations(annotation); + + if (declarations.Count > 0) + session.Editor.InsertBefore(firstStatement, declarations.ConvertAll(d => d.WithAdditionalAnnotations(annotation))); + + session.Editor.ReplaceNode(firstStatement, firstStatement is LabeledStatementSyntax labeled ? labeled.WithStatement(unsafeStatement) : unsafeStatement); + foreach (StatementSyntax statement in list.Statements.Skip(first + 1).Take(last - first)) + session.Editor.RemoveNode(statement, SyntaxRemoveOptions.KeepNoTrivia); + } + + /// Returns the trivia lines kept above the block, the split declarations and the block's statements. + private (SyntaxTriviaList OuterLines, List Declarations, List Body) BuildContents(UnsafeContextFormatting formatting, string indentation) + { + string inner = indentation + formatting.IndentUnit; + StatementSyntax firstWrapped = MergePlaceholderWrappers(GetFirstWrappedStatement(list[first])); + var (outerLines, firstInline) = UnsafeContextFormatting.SplitLeadingTrivia(firstWrapped.GetLeadingTrivia()); + + List declarations = [], body = []; + for (int i = first; i <= last; i++) + { + StatementSyntax statement = i == first ? firstWrapped : MergePlaceholderWrappers(list[i]); + var (lines, inline) = i == first ? (default, firstInline) : UnsafeContextFormatting.SplitLeadingTrivia(statement.GetLeadingTrivia()); + if (statement is UnsafeStatementSyntax placeholder && PlaceholderContexts.IsExtendableBlock(placeholder)) + { + // An extended placeholder block is unwrapped; its comments other than the marker are kept. + if (i == first) + outerLines = UnsafeContextFormatting.RemoveBlockMarker(outerLines); + + body.AddRange(Unwrap(placeholder, formatting.IndentLines(UnsafeContextFormatting.RemoveBlockMarker(lines), formatting.IndentUnit))); + } + else if (splits.TryGetValue(i, out DeclarationSplit? split)) + { + // Comments before a split declaration, above it or on its line, stay with the declaration. + declarations.AddRange(split.Declarations.Select((declaration, index) => declaration + .WithLeadingTrivia(index == 0 ? lines.AddRange(UnsafeContextFormatting.Whitespace(indentation)).AddRange(inline) : UnsafeContextFormatting.Whitespace(indentation)) + .WithTrailingTrivia(formatting.EndOfLine))); + body.AddRange(split.Remaining.Select(remaining => IndentFirstLine(formatting, remaining, inner, default))); + } + else + { + body.Add(i == first + ? IndentFirstLine(formatting, statement, inner, firstInline) + : formatting.Indent(statement, formatting.IndentUnit, includeFirstLine: true)); + } + } + + body[^1] = formatting.WithLineBreak(body[^1]); + return (outerLines, declarations, body); + } + + // Placeholder wrappers inside a statement the block now covers are merged into the block. + private static StatementSyntax MergePlaceholderWrappers(StatementSyntax statement) => + PlaceholderContexts.IsExtendableBlock(statement) ? statement : PlaceholderContexts.Unwrap(statement); + + private static StatementSyntax IndentFirstLine(UnsafeContextFormatting formatting, StatementSyntax statement, string indentation, SyntaxTriviaList inline) => + formatting.Indent(statement.WithLeadingTrivia(UnsafeContextFormatting.Whitespace(indentation).AddRange(inline)), formatting.IndentUnit, includeFirstLine: false); + + // The statements of a placeholder block are already at the new block's indentation. + private static IEnumerable Unwrap(UnsafeStatementSyntax placeholder, SyntaxTriviaList commentLines) => + placeholder.Block.Statements.Select((statement, index) => + index == 0 ? statement.WithLeadingTrivia(commentLines.AddRange(statement.GetLeadingTrivia())) : statement); + } + + /// + /// Wraps all statements of a single-line block such as void M() { Write(); }, laying it out on multiple + /// lines with the standard marker. + /// + internal sealed class SingleLineBlockEdit(BlockSyntax block, Coverage coverage, bool isProportionate) : UnsafeContextEdit + { + public override TextSpan Span => block.Span; + + public override UnsafeContextForm Form => UnsafeContextForm.Block; + + public override bool IsProportionate => isProportionate; + + public override int UnrelatedLeaves => coverage.Unrelated; + + /// Creates the edit when the block and its owner share one line and the block holds no comments. + public static SingleLineBlockEdit? TryCreate(BlockSyntax block, UnsafeContextFormatting formatting, Coverage coverage, bool isProportionate) + { + if (block.Parent is not { } owner || owner is BlockSyntax or SwitchSectionSyntax) + return null; + + SyntaxToken firstToken = owner.FirstTokenAfterAttributes; + bool isOwnLine = formatting.StartsLine(firstToken) + && block.CloseBraceToken.EndsLine + && !owner.DescendantTokens().SkipWhile(t => t != firstToken).TakeWhile(t => t != block.CloseBraceToken).Any(static t => t.EndsLine); + bool hasOnlyCode = block.DescendantTrivia(TextSpan.FromBounds(block.OpenBraceToken.Span.End, block.CloseBraceToken.SpanStart)) + .All(static t => t.IsKind(SyntaxKind.WhitespaceTrivia)); + return isOwnLine && hasOnlyCode ? new SingleLineBlockEdit(block, coverage, isProportionate) : null; + } + + public override void Apply(EditSession session, SyntaxAnnotation annotation) + { + UnsafeContextFormatting formatting = session.Formatting; + string indentation = formatting.GetIndentation(block.SpanStart); + string middle = indentation + formatting.IndentUnit; + + var statements = block.Statements.Select(s => s + .WithLeadingTrivia(UnsafeContextFormatting.Whitespace(middle + formatting.IndentUnit)) + .WithTrailingTrivia(formatting.EndOfLine)); + BlockSyntax expanded = BlockSyntaxFactory + .Create(formatting, indentation, [BlockSyntaxFactory.CreateUnsafe(formatting, middle, statements)], block.CloseBraceToken.TrailingTrivia) + .WithAdditionalAnnotations(annotation); + session.Editor.ReplaceNode(block, expanded); + + // The owner's line now ends before the opening brace. + session.Editor.ReplaceNode(block.Parent!, (current, _) => + { + SyntaxToken previous = current.GetAnnotatedNodes(annotation).First().GetFirstToken().GetPreviousToken(); + SyntaxTriviaList trailing = [.. previous.TrailingTrivia.Where(static t => !t.IsKind(SyntaxKind.WhitespaceTrivia)), formatting.EndOfLine]; + return current.ReplaceToken(previous, previous.WithTrailingTrivia(trailing)); + }); + } + } + + /// + /// Wraps the brace-less body of an if, else, loop, using, lock or fixed in + /// braces holding a new unsafe block, rather than emitting if (c) unsafe { ... }. + /// + /// The statement's indentation when it starts its own line; otherwise it follows its header. + internal sealed class EmbeddedBlockEdit(StatementSyntax statement, string outer, string? contentIndentation, Coverage coverage, bool isProportionate) : UnsafeContextEdit + { + public override TextSpan Span => statement.Span; + + public override UnsafeContextForm Form => UnsafeContextForm.Block; + + public override bool IsProportionate => isProportionate; + + public override int UnrelatedLeaves => coverage.Unrelated; + + /// Creates the edit when the statement starts its own line or is a one-liner after its header. + public static EmbeddedBlockEdit? TryCreate(StatementSyntax statement, UnsafeContextFormatting formatting, Coverage coverage, bool isProportionate) + { + string outer = formatting.GetIndentation(statement.GetFirstToken().GetPreviousToken().SpanStart); + if (formatting.StartsLine(statement.GetFirstToken())) + { + string current = formatting.GetIndentation(statement.SpanStart); + return (outer + formatting.IndentUnit + formatting.IndentUnit).StartsWith(current, StringComparison.Ordinal) + ? new EmbeddedBlockEdit(statement, outer, current, coverage, isProportionate) + : null; + } + + ImmutableArray tokens = [.. statement.DescendantTokens()]; + return tokens[..^1].Any(static t => t.EndsLine) ? null : new EmbeddedBlockEdit(statement, outer, contentIndentation: null, coverage, isProportionate); + } + + public override void Apply(EditSession session, SyntaxAnnotation annotation) + { + UnsafeContextFormatting formatting = session.Formatting; + string middle = outer + formatting.IndentUnit; + string inner = middle + formatting.IndentUnit; + + StatementSyntax source = PlaceholderContexts.Unwrap(statement); + var (lines, inline) = UnsafeContextFormatting.SplitLeadingTrivia(source.GetLeadingTrivia()); + StatementSyntax content = source.WithLeadingTrivia(UnsafeContextFormatting.Whitespace(inner).AddRange(inline)); + if (contentIndentation is not null) + content = formatting.Indent(content, inner[contentIndentation.Length..], includeFirstLine: false); + + BlockSyntax block = BlockSyntaxFactory + .Create(formatting, outer, [BlockSyntaxFactory.CreateUnsafe(formatting, middle, [formatting.WithLineBreak(content)], lines)], [formatting.EndOfLine]) + .WithAdditionalAnnotations(annotation); + + if (contentIndentation is not null) + { + session.Editor.ReplaceNode(statement, block); + return; + } + + // The statement shares its header's line, which now ends before the new brace. + session.Editor.ReplaceNode(statement.Parent!, (current, _) => + { + SyntaxToken previous = GetEmbeddedStatement(current).GetFirstToken().GetPreviousToken(); + current = current.ReplaceToken(previous, previous.WithTrailingTrivia(formatting.EndOfLine)); + return current.ReplaceNode(GetEmbeddedStatement(current), block); + }); + } + + private static StatementSyntax GetEmbeddedStatement(SyntaxNode parent) => + parent switch + { + IfStatementSyntax ifStatement => ifStatement.Statement, + ElseClauseSyntax elseClause => elseClause.Statement, + WhileStatementSyntax whileStatement => whileStatement.Statement, + DoStatementSyntax doStatement => doStatement.Statement, + ForStatementSyntax forStatement => forStatement.Statement, + CommonForEachStatementSyntax forEachStatement => forEachStatement.Statement, + UsingStatementSyntax usingStatement => usingStatement.Statement, + LockStatementSyntax lockStatement => lockStatement.Statement, + FixedStatementSyntax fixedStatement => fixedStatement.Statement, + _ => throw new InvalidOperationException(), + }; + } +} diff --git a/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/StatementList.cs b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/StatementList.cs new file mode 100644 index 00000000000000..ec2ca3f2705ce5 --- /dev/null +++ b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/StatementList.cs @@ -0,0 +1,98 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace ILLink.CodeFix.UnsafeContext +{ + /// The statements of a block or a switch section: the places a new unsafe block can be inserted into. + internal sealed class StatementList(SyntaxNode owner, SyntaxList statements) + { + public StatementList(BlockSyntax block) : this(block, block.Statements) { } + + public StatementList(SwitchSectionSyntax section) : this(section, section.Statements) { } + + public SyntaxNode Owner { get; } = owner; + + public SyntaxList Statements { get; } = statements; + + public int Count => Statements.Count; + + public StatementSyntax this[int index] => Statements[index]; + + /// The node spanning the declaration space of this list's locals: the whole switch for a section. + public SyntaxNode ScopeNode => Owner is SwitchSectionSyntax { Parent: SwitchStatementSyntax switchStatement } ? switchStatement : Owner; + + /// + /// The statements that may reference this list's locals, with their index here (-1 for other switch sections, + /// which share one declaration space). + /// + public IEnumerable<(StatementSyntax Statement, int Index)> StatementsInScope => + ScopeNode is SwitchStatementSyntax switchStatement + ? switchStatement.Sections.SelectMany(section => section.Statements.Select((s, i) => (s, section == Owner ? i : -1))) + : Statements.Select(static (s, i) => (s, i)); + + public int IndexOfStatementContaining(TextSpan span) + { + for (int i = 0; i < Statements.Count; i++) + { + if (Statements[i].FullSpan.Contains(span)) + return i; + } + + return -1; + } + + public static StatementList? ForContainer(SyntaxNode container) => + container switch + { + BlockSyntax block => new StatementList(block), + SwitchSectionSyntax section => new StatementList(section), + _ => null, + }; + } + + /// + /// The spans in one scope that need an unsafe context: the diagnosed operations and the placeholder + /// unsafe(...) expressions of earlier passes, ordered for fast containment queries. + /// + internal sealed class TargetSet(IEnumerable spans) + { + private readonly ImmutableArray _spans = [.. spans.OrderBy(static s => s.Start)]; + + /// Whether an operation lies within . + public bool AnyWithin(TextSpan span) + { + for (int i = FirstStartingAtOrAfter(span.Start); i < _spans.Length && _spans[i].Start < span.End; i++) + { + if (_spans[i].End <= span.End) + return true; + } + + return false; + } + + /// Whether an operation covers all of , e.g. an implicit enumeration. + public bool AnyCovering(TextSpan span) => _spans.Any(s => s.Contains(span)); + + private int FirstStartingAtOrAfter(int position) + { + int low = 0, high = _spans.Length; + while (low < high) + { + int mid = (low + high) / 2; + if (_spans[mid].Start < position) + low = mid + 1; + else + high = mid; + } + + return low; + } + } +} diff --git a/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/StatementShape.cs b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/StatementShape.cs new file mode 100644 index 00000000000000..7b3a08665f0815 --- /dev/null +++ b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/StatementShape.cs @@ -0,0 +1,175 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace ILLink.CodeFix.UnsafeContext +{ + /// + /// Related and unrelated leaves covered by a candidate block. Leaves are simple statements and compound-statement + /// headers; a leaf is related when it contains an operation that needs the context. + /// + internal readonly record struct Coverage(int Related, int Unrelated) + { + /// The coverage rule: unrelated leaves do not outnumber related ones. + public bool IsBalanced => Unrelated <= Related; + + public static Coverage Leaf(bool related) => related ? new(1, 0) : new(0, 1); + + public static Coverage operator +(Coverage left, Coverage right) => + new(left.Related + right.Related, left.Unrelated + right.Unrelated); + } + + /// + /// The header expressions a statement evaluates itself and the statement containers it runs. else if chains + /// and stacked fixed/using statements are flattened, since no block may be inserted between their parts. + /// + /// Blocks, switch sections or embedded statements. + internal readonly record struct StatementShape(ImmutableArray Headers, ImmutableArray Bodies) + { + public bool IsCompound => !Bodies.IsEmpty; + + public bool AnyBodyContains(TextSpan span) => Bodies.Any(b => BodyContains(b, span)); + + /// Whether is in a body's statements; switch labels and guards are headers. + public static bool BodyContains(SyntaxNode body, TextSpan span) => + body is SwitchSectionSyntax section + ? section.Statements.Any(s => s.FullSpan.Contains(span)) + : body.Span.Contains(span); + + public static StatementSyntax Unlabel(StatementSyntax statement) + { + while (statement is LabeledStatementSyntax labeled) + statement = labeled.Statement; + + return statement; + } + + public static StatementShape Of(StatementSyntax statement) => + Unlabel(statement) switch + { + BlockSyntax block => new([], [block]), + CheckedStatementSyntax checkedStatement => new([], [checkedStatement.Block]), + UnsafeStatementSyntax unsafeStatement => new([], [unsafeStatement.Block]), + IfStatementSyntax ifStatement => OfIfChain(ifStatement), + WhileStatementSyntax whileStatement => new([whileStatement.Condition], [whileStatement.Statement]), + DoStatementSyntax doStatement => new([doStatement.Condition], [doStatement.Statement]), + ForStatementSyntax forStatement => new(GetForHeaders(forStatement), [forStatement.Statement]), + CommonForEachStatementSyntax forEachStatement => new([forEachStatement.Expression], [forEachStatement.Statement]), + LockStatementSyntax lockStatement => new([lockStatement.Expression], [lockStatement.Statement]), + UsingStatementSyntax usingStatement => OfChain(usingStatement, static s => s is UsingStatementSyntax u ? ((SyntaxNode?)u.Declaration ?? u.Expression!, u.Statement) : null), + FixedStatementSyntax fixedStatement => OfChain(fixedStatement, static s => s is FixedStatementSyntax f ? (f.Declaration, f.Statement) : null), + SwitchStatementSyntax switchStatement => new( + [switchStatement.Expression, .. switchStatement.Sections.SelectMany(static s => s.Labels.OfType()).Select(static l => l.WhenClause).OfType()], + [.. switchStatement.Sections]), + TryStatementSyntax tryStatement => new( + [.. tryStatement.Catches.Select(static c => c.Filter).OfType()], + [tryStatement.Block, .. tryStatement.Catches.Select(static c => c.Block), .. tryStatement.Finally is { } f ? [f.Block] : ImmutableArray.Empty]), + _ => new([], []), + }; + + /// Counts the leaves of for the coverage rule. + public static Coverage CountLeaves(StatementSyntax statement, TargetSet targets) + { + statement = Unlabel(statement); + if (statement is LocalFunctionStatementSyntax) + return default; + + StatementShape shape = Of(statement); + if (!shape.IsCompound) + return Coverage.Leaf(targets.AnyWithin(statement.Span)); + + // A `for` header counts once; an operation covering the whole statement (e.g. enumeration) relates it all. + bool coveredWhole = targets.AnyCovering(statement.Span); + Coverage coverage = statement is ForStatementSyntax + ? Coverage.Leaf(coveredWhole || shape.Headers.Any(h => targets.AnyWithin(h.Span))) + : shape.Headers.Aggregate(default(Coverage), (sum, h) => sum + Coverage.Leaf(coveredWhole || targets.AnyWithin(h.Span))); + + if (coveredWhole && shape.Headers.IsEmpty) + coverage += Coverage.Leaf(related: true); + + return shape.Bodies.Aggregate(coverage, (sum, body) => sum + CountBody(body, targets)); + } + + /// Counts the leaves of a body: a block's or section's statements, or an embedded statement. + public static Coverage CountBody(SyntaxNode body, TargetSet targets) => + body switch + { + BlockSyntax block => CountLeaves(block.Statements, targets), + SwitchSectionSyntax section => CountLeaves(section.Statements, targets), + StatementSyntax statement => CountLeaves(statement, targets), + _ => default, + }; + + // Takes the original list: a collection expression would re-create it, losing the statements' positions. + private static Coverage CountLeaves(SyntaxList statements, TargetSet targets) => + statements.Aggregate(default(Coverage), (sum, statement) => sum + CountLeaves(statement, targets)); + + /// + /// Whether the statement owns its operations directly: a simple statement, or one whose operations are in its + /// headers or cover it whole (like an implicit enumeration), rather than only in its bodies. + /// + public static bool HasOwnOperations(StatementSyntax statement, TargetSet targets) + { + statement = Unlabel(statement); + StatementShape shape = Of(statement); + return !shape.IsCompound || targets.AnyCovering(statement.Span) || shape.Headers.Any(h => targets.AnyWithin(h.Span)); + } + + /// + /// Whether covering the statement would cover its bodies only because a header needs a context. A consumer of + /// the whole statement, such as an implicit enumeration, is exempt. + /// + public static bool CoversBodyOnlyForHeader(StatementSyntax statement, TargetSet targets) + { + statement = Unlabel(statement); + StatementShape shape = Of(statement); + if (!shape.IsCompound || targets.AnyCovering(statement.Span) || !shape.Headers.Any(h => targets.AnyWithin(h.Span))) + return false; + + Coverage bodies = shape.Bodies.Aggregate(default(Coverage), (sum, body) => sum + CountBody(body, targets)); + return bodies.Related == 0 && bodies.Unrelated > 0; + } + + private static StatementShape OfIfChain(IfStatementSyntax ifStatement) + { + List headers = [], bodies = []; + for (IfStatementSyntax? current = ifStatement; current is not null; current = current.Else?.Statement as IfStatementSyntax) + { + headers.Add(current.Condition); + bodies.Add(current.Statement); + if (current.Else is { Statement: not IfStatementSyntax } elseClause) + bodies.Add(elseClause.Statement); + } + + return new([.. headers], [.. bodies]); + } + + // Flattens stacked `using`/`fixed` statements into their headers and the innermost body. + private static StatementShape OfChain(StatementSyntax statement, Func unfold) + { + List headers = []; + while (unfold(statement) is (var header, var body)) + { + headers.Add(header); + statement = body; + } + + return new([.. headers], [statement]); + } + + private static ImmutableArray GetForHeaders(ForStatementSyntax forStatement) => + [ + .. forStatement.Declaration is { } declaration ? [declaration] : ImmutableArray.Empty, + .. forStatement.Initializers, + .. forStatement.Condition is { } condition ? [condition] : ImmutableArray.Empty, + .. forStatement.Incrementors, + ]; + } +} diff --git a/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/SyntaxLayoutExtensions.cs b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/SyntaxLayoutExtensions.cs new file mode 100644 index 00000000000000..b82417b0b46f69 --- /dev/null +++ b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/SyntaxLayoutExtensions.cs @@ -0,0 +1,56 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace ILLink.CodeFix.UnsafeContext +{ + /// Layout and declaration queries over syntax, shared by the planners and edits. + internal static class SyntaxLayoutExtensions + { + extension(SyntaxTrivia trivia) + { + /// Whitespace or a line break. + public bool IsLayout => trivia.IsKind(SyntaxKind.WhitespaceTrivia) || trivia.IsKind(SyntaxKind.EndOfLineTrivia); + + /// Line breaks, directives, disabled text and doc comments, which all end their line. + public bool EndsLine => + trivia.IsKind(SyntaxKind.EndOfLineTrivia) + || trivia.IsKind(SyntaxKind.DisabledTextTrivia) + || trivia.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia) + || trivia.IsDirective; + } + + extension(SyntaxTriviaList trivia) + { + public bool IsLayoutOnly => trivia.All(static t => t.IsLayout); + + /// The last trivia that is not whitespace or a line break, e.g. the comment directly above a token. + public SyntaxTrivia LastNonLayout => trivia.LastOrDefault(static t => !t.IsLayout); + } + + extension(SyntaxToken token) + { + /// Whether the token is the last one on its line. + public bool EndsLine => token.TrailingTrivia.Any(SyntaxKind.EndOfLineTrivia); + } + + extension(SyntaxNode node) + { + /// The first token of a declaration after its attribute lists, e.g. the first modifier. + public SyntaxToken FirstTokenAfterAttributes => + (node switch + { + MemberDeclarationSyntax member => member.AttributeLists, + AccessorDeclarationSyntax accessor => accessor.AttributeLists, + LocalFunctionStatementSyntax localFunction => localFunction.AttributeLists, + _ => default, + }) is { Count: > 0 } attributeLists + ? attributeLists[^1].GetLastToken().GetNextToken() + : node.GetFirstToken(); + } + } +} diff --git a/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextCodeFixProvider.cs b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextCodeFixProvider.cs new file mode 100644 index 00000000000000..b3e55d6399d78f --- /dev/null +++ b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextCodeFixProvider.cs @@ -0,0 +1,96 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Immutable; +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using ILLink.CodeFix.UnsafeContext; +using ILLink.CodeFixProvider; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; + +namespace ILLink.CodeFix +{ + /// + /// Introduces inner unsafe contexts for operations that require one under the updated memory-safety rules + /// (unsafe-v2): unsafe { } blocks preceded by a // SAFETY: To be audited line, or + /// /* SAFETY: To be audited */ unsafe(...) expressions. It never changes member modifiers, signatures or + /// project options. + /// + /// + /// Equivalence keys identify the planning policy rather than the generated form, so that Fix All (including + /// dotnet format, which uses the first registered action) applies one policy to every occurrence while + /// each occurrence still gets the form that policy selects for it. + /// + [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UnsafeContextCodeFixProvider)), Shared] + public sealed class UnsafeContextCodeFixProvider : Microsoft.CodeAnalysis.CodeFixes.CodeFixProvider + { + internal const string BlockFirstEquivalenceKey = nameof(UnsafeContextCodeFixProvider) + "." + nameof(UnsafeContextPolicy.BlockFirst); + internal const string ExpressionFirstEquivalenceKey = nameof(UnsafeContextCodeFixProvider) + "." + nameof(UnsafeContextPolicy.ExpressionFirst); + internal const string BodyWideEquivalenceKey = nameof(UnsafeContextCodeFixProvider) + "." + nameof(UnsafeContextPolicy.BodyWide); + + private static readonly FixAllProvider s_fixAllProvider = FixAllProvider.Create(async (context, document, diagnostics) => + { + var result = await UnsafeContextDocumentFixer.FixAsync( + document, + diagnostics, + GetPolicy(context.CodeActionEquivalenceKey), + focus: null, + context.CancellationToken).ConfigureAwait(false); + return result?.Document; + }); + + public override ImmutableArray FixableDiagnosticIds => UnsafeContextFacts.FixableDiagnosticIds; + + public override FixAllProvider GetFixAllProvider() => s_fixAllProvider; + + /// Registers the preferred fix, plus the expression-first and body-wide variants when they differ. + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + foreach (Diagnostic diagnostic in context.Diagnostics) + { + CancellationToken cancellationToken = context.CancellationToken; + UnsafeContextFixResult? preferred = await FixAsync(context.Document, diagnostic, UnsafeContextPolicy.BlockFirst, cancellationToken).ConfigureAwait(false); + if (preferred is null) + continue; + + string preferredTitle = preferred.Form == UnsafeContextForm.Block + ? Resources.WrapInUnsafeBlockCodeFixTitle + : Resources.WrapExpressionInUnsafeContextCodeFixTitle; + Register(context, diagnostic, preferredTitle, BlockFirstEquivalenceKey, preferred.Document); + + if (preferred.Form == UnsafeContextForm.Block + && await FixAsync(context.Document, diagnostic, UnsafeContextPolicy.ExpressionFirst, cancellationToken).ConfigureAwait(false) is { Form: UnsafeContextForm.Expression } expression) + { + Register(context, diagnostic, Resources.WrapExpressionInUnsafeContextCodeFixTitle, ExpressionFirstEquivalenceKey, expression.Document); + } + + if (await FixAsync(context.Document, diagnostic, UnsafeContextPolicy.BodyWide, cancellationToken).ConfigureAwait(false) is { } body + && !await HaveSameTextAsync(body.Document, preferred.Document, cancellationToken).ConfigureAwait(false)) + { + Register(context, diagnostic, Resources.WrapBodyInUnsafeBlockCodeFixTitle, BodyWideEquivalenceKey, body.Document); + } + } + } + + private static Task FixAsync(Document document, Diagnostic diagnostic, UnsafeContextPolicy policy, CancellationToken cancellationToken) => + UnsafeContextDocumentFixer.FixAsync(document, [diagnostic], policy, focus: diagnostic, cancellationToken); + + private static void Register(CodeFixContext context, Diagnostic diagnostic, string title, string equivalenceKey, Document document) => + context.RegisterCodeFix(CodeAction.Create(title, _ => Task.FromResult(document), equivalenceKey), diagnostic); + + private static async Task HaveSameTextAsync(Document left, Document right, CancellationToken cancellationToken) => + (await left.GetTextAsync(cancellationToken).ConfigureAwait(false)) + .ContentEquals(await right.GetTextAsync(cancellationToken).ConfigureAwait(false)); + + private static UnsafeContextPolicy GetPolicy(string? equivalenceKey) => + equivalenceKey switch + { + ExpressionFirstEquivalenceKey => UnsafeContextPolicy.ExpressionFirst, + BodyWideEquivalenceKey => UnsafeContextPolicy.BodyWide, + _ => UnsafeContextPolicy.BlockFirst, + }; + } +} diff --git a/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextDocumentFixer.cs b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextDocumentFixer.cs new file mode 100644 index 00000000000000..0ca4297e70ca8a --- /dev/null +++ b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextDocumentFixer.cs @@ -0,0 +1,201 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace ILLink.CodeFix.UnsafeContext +{ + /// The fixed document and the form of the context covering the requested operation. + internal sealed record UnsafeContextFixResult(Document Document, UnsafeContextForm Form); + + /// + /// Introduces unsafe contexts for the requested diagnostics of one document. Every member containing a requested + /// diagnostic is planned as a whole, so its operations share contexts however they were requested. Candidates are + /// validated in rounds on one forked compilation per round; failing regions fall back to their next candidate and + /// regions without a valid candidate are left unchanged. + /// + internal static class UnsafeContextDocumentFixer + { + /// Fixes the members containing ; returns if nothing could be fixed. + /// For a single code action, the diagnostic whose region is applied; for Fix All. + public static async Task FixAsync( + Document document, + IEnumerable diagnostics, + UnsafeContextPolicy policy, + Diagnostic? focus, + CancellationToken cancellationToken) + { + SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + SemanticModel? model = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + if (root is null || model is null || !UnsafeContextFacts.IsEnabled(document, root, model.Compilation)) + return null; + + // Every operation of a member is planned, so that its contexts are shared however they were requested. + Dictionary> baseline = []; + List targets = []; + foreach (MemberDeclarationSyntax member in diagnostics.Select(d => UnsafeTarget.Create(root, d)?.Scope.Member).OfType().Distinct()) + { + ImmutableArray memberDiagnostics = UnsafeContextSemantics.GetDiagnostics(model, member, cancellationToken); + baseline[member] = memberDiagnostics; + targets.AddRange(memberDiagnostics + .Select(d => UnsafeTarget.Create(root, d)) + .OfType() + .Where(t => t.Scope.Member == member)); + } + + if (targets.Count == 0) + return null; + + PlanningContext context = new(root, model, policy, cancellationToken); + UnsafeContextValidator validator = new(document, root, model.Compilation, context, baseline); + List regions = [.. new UnsafeContextPlanner(context).Plan(targets)]; + if (Converge(regions, validator, cancellationToken) is not var (newRoot, applied)) + return null; + + if (focus is null) + return new UnsafeContextFixResult(document.WithSyntaxRoot(newRoot), applied[0].CurrentEdit!.Form); + + // The requested operation is fixed by its own region or by a block that subsumed it. + List focused = [.. applied.Where(r => r.Targets.Any(t => t.Diagnostic.Id == focus.Id && t.Diagnostic.Location.SourceSpan == focus.Location.SourceSpan))]; + if (focused.Count == 0) + focused = [.. applied.Where(r => r.CurrentEdit!.Form == UnsafeContextForm.Block && r.CurrentEdit.Span.Contains(focus.Location.SourceSpan)).Take(1)]; + + if (focused.Count == 0) + return null; + + UnsafeContextForm form = focused[0].CurrentEdit!.Form; + if (focused.Count < applied.Count) + { + SyntaxNode focusedRoot = validator.Apply(focused); + if (validator.Validate(focusedRoot, focused).Count == 0) + return new UnsafeContextFixResult(document.WithSyntaxRoot(focusedRoot), form); + } + + return new UnsafeContextFixResult(document.WithSyntaxRoot(newRoot), form); + } + + /// + /// Validates the regions until every remaining region has a valid candidate. Returns the edited root and the + /// applied regions, or when no region could be fixed. Terminates because every failure + /// moves a region to a later candidate and each refinement is expanded at most once. + /// + private static (SyntaxNode Root, List Applied)? Converge(List regions, UnsafeContextValidator validator, CancellationToken cancellationToken) + { + Normalize(regions); + while (regions.Count > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + List applied = SelectApplicable(regions); + SyntaxNode newRoot = validator.Apply(applied); + HashSet failed = validator.Validate(newRoot, applied); + if (failed.Count == 0) + return (newRoot, applied); + + // A region may already be gone because an earlier failure fell back to its ancestor. + foreach (UnsafeContextRegion region in failed.Where(regions.Contains)) + Fail(regions, region); + + Normalize(regions); + } + + return null; + } + + /// + /// Returns the regions to apply in this round. A block that covers another region (e.g. after growing over a + /// ref local's uses) subsumes it; a block that only partially overlaps another region falls back to its + /// next candidate. Wrappers may nest around regions of lambdas they do not cover. + /// + private static List SelectApplicable(List regions) + { + while (true) + { + List applied = []; + UnsafeContextRegion? conflict = null; + foreach (UnsafeContextRegion region in regions.OrderByDescending(static r => r.CurrentEdit!.Span.Length)) + { + TextSpan span = region.CurrentEdit!.Span; + bool subsumed = false; + foreach (UnsafeContextRegion other in applied) + { + UnsafeContextEdit otherEdit = other.CurrentEdit!; + if (!otherEdit.Span.OverlapsWith(span)) + continue; + + if (!otherEdit.Span.Contains(span)) + { + conflict = otherEdit.Form == UnsafeContextForm.Block ? other : region; + break; + } + + subsumed |= otherEdit.Form == UnsafeContextForm.Block; + } + + if (conflict is not null) + break; + + if (!subsumed) + applied.Add(region); + } + + if (conflict is null) + return applied; + + Fail(regions, conflict); + Normalize(regions); + } + } + + /// Expands refinements and drops regions without candidates, until every region has an edit. + private static void Normalize(List regions) + { + for (int i = 0; i < regions.Count;) + { + UnsafeContextRegion region = regions[i]; + if (region.Current is null) + { + Fail(regions, region); + i = 0; + } + else if (region.Current.Refine is { } refine) + { + regions.RemoveAt(i); + regions.AddRange(refine(region)); + } + else + { + i++; + } + } + } + + /// + /// Moves a failing region to its next candidate. A region out of candidates is dropped; if it came from a + /// refinement whose parent has further candidates, the parent replaces all of its refined regions. + /// + private static void Fail(List regions, UnsafeContextRegion region) + { + if (region.MoveNext()) + return; + + regions.Remove(region); + for (UnsafeContextRegion? ancestor = region.Parent; ancestor is not null; ancestor = ancestor.Parent) + { + if (!ancestor.HasNext) + continue; + + regions.RemoveAll(r => r.IsDescendantOf(ancestor)); + ancestor.MoveNext(); + regions.Add(ancestor); + return; + } + } + } +} diff --git a/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextEdit.cs b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextEdit.cs new file mode 100644 index 00000000000000..cb965c4a4e2b1d --- /dev/null +++ b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextEdit.cs @@ -0,0 +1,185 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Editing; +using Microsoft.CodeAnalysis.Text; + +namespace ILLink.CodeFix.UnsafeContext +{ + internal enum UnsafeContextForm + { + Block, + Expression, + } + + /// + /// One way of introducing an unsafe context: an immutable plan over the original tree. All edits of a validation + /// round are applied together through one . + /// + internal abstract class UnsafeContextEdit + { + /// The original span this edit rewrites, used to attribute validation failures. + public abstract TextSpan Span { get; } + + public abstract UnsafeContextForm Form { get; } + + /// Whether the context satisfies the coverage rule; only blocks can cover too much. + public virtual bool IsProportionate => true; + + /// The leaves covered without needing the context, used to rank disproportionate blocks. + public virtual int UnrelatedLeaves => 0; + + /// Applies the edit, annotating the nodes it creates with . + public abstract void Apply(EditSession session, SyntaxAnnotation annotation); + } + + /// State shared by the edits applied in one validation round. + internal sealed record EditSession(SyntaxEditor Editor, UnsafeContextFormatting Formatting, CSharpParseOptions ParseOptions); + + /// + /// Wraps an expression in /* SAFETY: To be audited */ unsafe(...), merging the placeholder wrappers inside + /// it. Failures are attributed to the whole (e.g. the statement) the wrapper belongs to. + /// + internal sealed class ExpressionWrapEdit(ExpressionSyntax expression, SyntaxNode clause) : UnsafeContextEdit + { + public override TextSpan Span => clause.Span; + + public override UnsafeContextForm Form => UnsafeContextForm.Expression; + + public override void Apply(EditSession session, SyntaxAnnotation annotation) + { + // A placeholder marker on the token before the expression is outside it and stays, so it is not repeated. + bool withMarker = !PlaceholderContexts.HasMarkerOnPreviousToken(expression); + ExpressionSyntax merged = PlaceholderContexts.Unwrap(expression); + session.Editor.ReplaceNode(expression, (current, _) => + UnsafeContextFacts.CreateUnsafeExpression(merged == expression ? (ExpressionSyntax)current : merged, session.ParseOptions, withMarker) + .WithAdditionalAnnotations(annotation)); + session.Editor.ReplaceNode(clause, (current, _) => current.WithAdditionalAnnotations(annotation)); + } + } + + /// + /// Converts an expression body (=> expr) into a block body holding one unsafe block. Used when no wrapper + /// is valid, typically because the body must be a statement expression. + /// + internal sealed class ExpressionBodyBlockEdit(SyntaxNode owner, ExpressionSyntax body, bool asStatement, string indentation) : UnsafeContextEdit + { + public override TextSpan Span => body.Span; + + public override UnsafeContextForm Form => UnsafeContextForm.Block; + + public static bool CanConvert(SyntaxNode owner) => + owner is BaseMethodDeclarationSyntax or LocalFunctionStatementSyntax or AccessorDeclarationSyntax + or LambdaExpressionSyntax or PropertyDeclarationSyntax or IndexerDeclarationSyntax; + + public override void Apply(EditSession session, SyntaxAnnotation annotation) => + session.Editor.ReplaceNode(owner, (current, _) => Convert(current, session.Formatting).WithAdditionalAnnotations(annotation)); + + // Replaces the arrow clause with the block body; a property or indexer gets a `get` accessor. + private SyntaxNode Convert(SyntaxNode current, UnsafeContextFormatting formatting) + { + // The token before the body (`)` of a parameter list, `=>` of a lambda, ...) now ends its line. + SyntaxToken arrow = GetExpressionBody(current).GetFirstToken().GetPreviousToken(); + SyntaxToken previous = current is LambdaExpressionSyntax ? arrow : arrow.GetPreviousToken(); + current = current.ReplaceToken(previous, previous.WithTrailingTrivia(formatting.EndOfLine)); + + ExpressionSyntax expression = GetExpressionBody(current); + bool isProperty = current is BasePropertyDeclarationSyntax; + BlockSyntax CreateBody(SyntaxTriviaList closeBraceTrailingTrivia) => + CreateUnsafeBody(formatting, isProperty ? indentation + formatting.IndentUnit : indentation, expression, closeBraceTrailingTrivia); + + SyntaxTriviaList endOfLine = [formatting.EndOfLine]; + return current switch + { + LambdaExpressionSyntax lambda => lambda.WithExpressionBody(null).WithBlock(CreateBody(default)), + BaseMethodDeclarationSyntax method => method.WithExpressionBody(null).WithSemicolonToken(default).WithBody(CreateBody(method.SemicolonToken.TrailingTrivia)), + LocalFunctionStatementSyntax local => local.WithExpressionBody(null).WithSemicolonToken(default).WithBody(CreateBody(local.SemicolonToken.TrailingTrivia)), + AccessorDeclarationSyntax accessor => accessor.WithExpressionBody(null).WithSemicolonToken(default).WithBody(CreateBody(accessor.SemicolonToken.TrailingTrivia)), + PropertyDeclarationSyntax property => property.WithExpressionBody(null).WithSemicolonToken(default) + .WithAccessorList(CreateGetter(formatting, CreateBody(endOfLine), property.SemicolonToken.TrailingTrivia)), + IndexerDeclarationSyntax indexer => indexer.WithExpressionBody(null).WithSemicolonToken(default) + .WithAccessorList(CreateGetter(formatting, CreateBody(endOfLine), indexer.SemicolonToken.TrailingTrivia)), + _ => current, + }; + } + + // Builds `{ // SAFETY ... unsafe { return expr; } }`, keeping comments that preceded the expression. + private BlockSyntax CreateUnsafeBody(UnsafeContextFormatting formatting, string blockIndentation, ExpressionSyntax expression, SyntaxTriviaList closeBraceTrailingTrivia) + { + string middle = blockIndentation + formatting.IndentUnit; + string inner = middle + formatting.IndentUnit; + ExpressionSyntax value = expression.WithoutLeadingTrivia().WithoutTrailingTrivia(); + StatementSyntax statement = asStatement + ? SyntaxFactory.ExpressionStatement(value) + : SyntaxFactory.ReturnStatement(SyntaxFactory.Token(SyntaxKind.ReturnKeyword).WithTrailingTrivia(SyntaxFactory.Space), value, SyntaxFactory.Token(SyntaxKind.SemicolonToken)); + + // Comments between `=>` and the expression stay above it. + SyntaxTriviaList comments = []; + foreach (SyntaxTrivia comment in expression.GetLeadingTrivia().Where(static t => t.IsKind(SyntaxKind.SingleLineCommentTrivia) || t.IsKind(SyntaxKind.MultiLineCommentTrivia))) + comments = [.. comments, .. UnsafeContextFormatting.Whitespace(inner), comment, formatting.EndOfLine]; + + statement = formatting.Indent( + statement.WithLeadingTrivia(comments.AddRange(UnsafeContextFormatting.Whitespace(inner))).WithTrailingTrivia(formatting.EndOfLine), + formatting.IndentUnit, + includeFirstLine: false); + + return BlockSyntaxFactory.Create(formatting, blockIndentation, [BlockSyntaxFactory.CreateUnsafe(formatting, middle, [statement])], closeBraceTrailingTrivia); + } + + private AccessorListSyntax CreateGetter(UnsafeContextFormatting formatting, BlockSyntax body, SyntaxTriviaList closeBraceTrailingTrivia) + { + SyntaxTriviaList outer = UnsafeContextFormatting.Whitespace(indentation); + AccessorDeclarationSyntax getter = SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) + .WithKeyword(SyntaxFactory.Token(UnsafeContextFormatting.Whitespace(indentation + formatting.IndentUnit), SyntaxKind.GetKeyword, [formatting.EndOfLine])) + .WithBody(body); + return SyntaxFactory.AccessorList( + SyntaxFactory.Token(outer, SyntaxKind.OpenBraceToken, [formatting.EndOfLine]), + [getter], + SyntaxFactory.Token(outer, SyntaxKind.CloseBraceToken, closeBraceTrailingTrivia)); + } + + private static ExpressionSyntax GetExpressionBody(SyntaxNode owner) => + owner switch + { + LambdaExpressionSyntax lambda => lambda.ExpressionBody!, + BaseMethodDeclarationSyntax method => method.ExpressionBody!.Expression, + LocalFunctionStatementSyntax localFunction => localFunction.ExpressionBody!.Expression, + AccessorDeclarationSyntax accessor => accessor.ExpressionBody!.Expression, + BasePropertyDeclarationSyntax property => GetArrowClause(property)!.Expression, + _ => throw new InvalidOperationException(), + }; + + private static ArrowExpressionClauseSyntax? GetArrowClause(BasePropertyDeclarationSyntax property) => + property switch + { + PropertyDeclarationSyntax p => p.ExpressionBody, + IndexerDeclarationSyntax i => i.ExpressionBody, + _ => null, + }; + } + + /// Creates blocks in the multi-line layout used for every generated context. + internal static class BlockSyntaxFactory + { + public static BlockSyntax Create(UnsafeContextFormatting formatting, string indentation, IEnumerable statements, SyntaxTriviaList closeBraceTrailingTrivia) + { + SyntaxTriviaList indent = UnsafeContextFormatting.Whitespace(indentation); + return SyntaxFactory.Block( + SyntaxFactory.Token(indent, SyntaxKind.OpenBraceToken, [formatting.EndOfLine]), + [.. statements], + SyntaxFactory.Token(indent, SyntaxKind.CloseBraceToken, closeBraceTrailingTrivia)); + } + + /// Creates // SAFETY: To be audited + unsafe { statements } at . + public static UnsafeStatementSyntax CreateUnsafe(UnsafeContextFormatting formatting, string indentation, IEnumerable statements, SyntaxTriviaList extraLeadingLines = default) => + SyntaxFactory.UnsafeStatement( + SyntaxFactory.Token([.. extraLeadingLines, .. formatting.BlockMarkerLine(indentation)], SyntaxKind.UnsafeKeyword, [formatting.EndOfLine]), + Create(formatting, indentation, statements, [formatting.EndOfLine])); + } +} diff --git a/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextFacts.cs b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextFacts.cs new file mode 100644 index 00000000000000..9f94100af6eb04 --- /dev/null +++ b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextFacts.cs @@ -0,0 +1,111 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace ILLink.CodeFix.UnsafeContext +{ + /// + /// Diagnostic IDs, audit markers and the unsafe-evolution compiler APIs. The fixer builds against a Roslyn that + /// predates unsafe(...) and MemorySafetyRulesVersion, so both are reached at run time. + /// + internal static class UnsafeContextFacts + { + internal const string UnsafeOperationId = "CS9360"; + internal const string UninitializedStackallocId = "CS9361"; + internal const string UnsafeMemberOperationId = "CS9362"; + internal const string UnsafeMemberOperationCompatId = "CS9363"; + internal const string UnsafeConstructorConstraintId = "CS9376"; + + internal static readonly ImmutableArray FixableDiagnosticIds = + [ + UnsafeOperationId, + UninitializedStackallocId, + UnsafeMemberOperationId, + UnsafeMemberOperationCompatId, + UnsafeConstructorConstraintId, + ]; + + /// The placeholder audit comment on the line above every introduced unsafe { } block. + internal const string BlockMarker = "// SAFETY: To be audited"; + + /// The placeholder audit comment directly before every introduced unsafe(...) expression. + internal const string ExpressionMarker = "/* SAFETY: To be audited */"; + + private const int MemorySafetyRulesVersion2 = 2; + private const string UpdatedMemorySafetyRulesFeature = "updated-memory-safety-rules"; + + private static readonly SyntaxKind s_unsafeExpressionKind = + Enum.TryParse("UnsafeExpression", out SyntaxKind kind) ? kind : SyntaxKind.None; + + private static readonly Func? s_getMemorySafetyRulesVersion = + typeof(IModuleSymbol).GetProperty("MemorySafetyRulesVersion") is { } property ? module => property.GetValue(module) : null; + + internal static bool IsFixableDiagnosticId(string id) => FixableDiagnosticIds.Contains(id); + + internal static bool IsUnsafeExpression(SyntaxNode node) => + s_unsafeExpressionKind != SyntaxKind.None && node.IsKind(s_unsafeExpressionKind); + + /// Returns the expression inside unsafe(...). + internal static ExpressionSyntax GetUnsafeExpressionOperand(SyntaxNode unsafeExpression) => + unsafeExpression.ChildNodes().OfType().Single(); + + /// + /// Returns /* SAFETY: To be audited */ unsafe(expression) with the expression's outer trivia; without the + /// marker when is false, e.g. when the token before already carries it. + /// + internal static ExpressionSyntax CreateUnsafeExpression(ExpressionSyntax expression, CSharpParseOptions options, bool withMarker = true) + { + var template = SyntaxFactory.ParseExpression("unsafe(_)", options: options); + var wrapper = template.ReplaceNode(GetUnsafeExpressionOperand(template), expression.WithoutTrivia()); + SyntaxTriviaList leading = expression.GetLeadingTrivia(); + return wrapper + .WithLeadingTrivia(withMarker ? leading.AddRange([SyntaxFactory.Comment(ExpressionMarker), SyntaxFactory.Space]) : leading) + .WithTrailingTrivia(expression.GetTrailingTrivia()); + } + + /// + /// Whether the fixer may introduce contexts in a document: v2 memory-safety rules, unsafe code + /// allowed, a language version with unsafe(...), and no generated code. + /// + internal static bool IsEnabled(Document document, SyntaxNode root, Compilation compilation) => + document is not SourceGeneratedDocument + && compilation.Options is CSharpCompilationOptions { AllowUnsafe: true } + && root.SyntaxTree.Options is CSharpParseOptions parseOptions + && UsesUpdatedMemorySafetyRules(compilation, parseOptions) + && SupportsUnsafeExpressions(parseOptions) + && !IsGeneratedCode(root); + + // Hosts that predate the public API, such as the SDK's dotnet-format, read the compiler's feature flag instead. + private static bool UsesUpdatedMemorySafetyRules(Compilation compilation, CSharpParseOptions parseOptions) => + s_getMemorySafetyRulesVersion is null + ? parseOptions.Features.ContainsKey(UpdatedMemorySafetyRulesFeature) + : s_getMemorySafetyRulesVersion(compilation.SourceModule) is { } version && Convert.ToInt32(version) == MemorySafetyRulesVersion2; + + private static bool SupportsUnsafeExpressions(CSharpParseOptions options) => + SyntaxFactory.ParseExpression("unsafe(0)", options: options) is var expression + && IsUnsafeExpression(expression) + && !expression.ContainsDiagnostics; + + private static bool IsGeneratedCode(SyntaxNode root) + { + string fileName = Path.GetFileName(root.SyntaxTree.FilePath ?? string.Empty); + string[] generatedSuffixes = [".designer.cs", ".generated.cs", ".g.cs", ".g.i.cs"]; + if (fileName.StartsWith("TemporaryGeneratedFile_", StringComparison.OrdinalIgnoreCase) + || generatedSuffixes.Any(suffix => fileName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + return root.GetLeadingTrivia().Any(static trivia => + (trivia.IsKind(SyntaxKind.SingleLineCommentTrivia) || trivia.IsKind(SyntaxKind.MultiLineCommentTrivia)) + && trivia.ToString().IndexOf("= 0); + } + } +} diff --git a/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextFormatting.cs b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextFormatting.cs new file mode 100644 index 00000000000000..adee531ddc3e9c --- /dev/null +++ b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextFormatting.cs @@ -0,0 +1,197 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace ILLink.CodeFix.UnsafeContext +{ + /// + /// Layout helpers for generated contexts. Edits only add line breaks and indentation, so the rest of the document + /// keeps its formatting, comments, directives and disabled text. + /// + internal sealed class UnsafeContextFormatting + { + private const string DefaultIndentUnit = " "; + + private readonly SourceText _text; + + public UnsafeContextFormatting(SyntaxNode root) + { + _text = root.SyntaxTree.GetText(); + string newLine = root.DescendantTrivia().FirstOrDefault(static t => t.IsKind(SyntaxKind.EndOfLineTrivia)).ToFullString(); + EndOfLine = SyntaxFactory.EndOfLine(newLine.Length > 0 ? newLine : Environment.NewLine); + IndentUnit = DetectIndentUnit(root); + } + + public SyntaxTrivia EndOfLine { get; } + + /// One level of indentation, inferred from the document. + public string IndentUnit { get; } + + /// Returns the leading whitespace of the line containing . + public string GetIndentation(int position) + { + TextLine line = _text.Lines.GetLineFromPosition(position); + int end = line.Start; + while (end < line.End && _text[end] is ' ' or '\t') + end++; + + return _text.ToString(TextSpan.FromBounds(line.Start, end)); + } + + /// Whether only whitespace precedes on its line. + public bool StartsLine(SyntaxToken token) => + GetIndentation(token.SpanStart).Length == token.SpanStart - _text.Lines.GetLineFromPosition(token.SpanStart).Start; + + public static SyntaxTriviaList Whitespace(string indentation) => + indentation.Length == 0 ? default : [SyntaxFactory.Whitespace(indentation)]; + + /// Returns the block marker line followed by the indentation of the unsafe keyword below it. + public SyntaxTriviaList BlockMarkerLine(string indentation) => + [.. Whitespace(indentation), SyntaxFactory.Comment(UnsafeContextFacts.BlockMarker), EndOfLine, .. Whitespace(indentation)]; + + /// + /// Splits leading trivia into the complete lines above a token (comments, blank lines, directives) and the + /// non-whitespace trivia on the token's own line. + /// + public static (SyntaxTriviaList Lines, SyntaxTriviaList Inline) SplitLeadingTrivia(SyntaxTriviaList trivia) + { + int linesEnd = 0; + for (int i = 0; i < trivia.Count; i++) + { + if (trivia[i].EndsLine) + linesEnd = i + 1; + } + + return ([.. trivia.Take(linesEnd)], [.. trivia.Skip(linesEnd).SkipWhile(static t => t.IsKind(SyntaxKind.WhitespaceTrivia))]); + } + + /// Removes the last placeholder block marker line from leading trivia lines. + public static SyntaxTriviaList RemoveBlockMarker(SyntaxTriviaList lines) + { + for (int i = lines.Count - 1; i >= 0; i--) + { + if (!lines[i].IsKind(SyntaxKind.SingleLineCommentTrivia) || lines[i].ToString() != UnsafeContextFacts.BlockMarker) + continue; + + int start = i > 0 && lines[i - 1].IsKind(SyntaxKind.WhitespaceTrivia) ? i - 1 : i; + int end = i + 1 < lines.Count && lines[i + 1].IsKind(SyntaxKind.EndOfLineTrivia) ? i + 1 : i; + return [.. lines.Take(start), .. lines.Skip(end + 1)]; + } + + return lines; + } + + /// Adds to the indentation of complete trivia lines. + public SyntaxTriviaList IndentLines(SyntaxTriviaList lines, string addition) => + lines.Count == 0 ? lines : IndentTrivia(lines, atLineStart: true, addition, indentFollowingToken: false); + + /// Ensures that ends its line. + public T WithLineBreak(T node) where T : SyntaxNode + { + SyntaxToken last = node.GetLastToken(); + if (last.EndsLine) + return node; + + // Trailing whitespace before the new line break is dropped. + SyntaxTriviaList trailing = [.. last.TrailingTrivia.Reverse().SkipWhile(static t => t.IsKind(SyntaxKind.WhitespaceTrivia)).Reverse(), EndOfLine]; + return node.ReplaceToken(last, last.WithTrailingTrivia(trailing)); + } + + /// + /// Adds to the indentation of every line of (the first only + /// with ). Directive lines and multi-line token contents are unchanged. + /// + public T Indent(T node, string addition, bool includeFirstLine) where T : SyntaxNode + { + if (addition.Length == 0) + return node; + + var replacements = new Dictionary(); + bool atLineStart = includeFirstLine; + bool isFirst = true; + foreach (SyntaxToken token in node.DescendantTokens()) + { + if (!isFirst || includeFirstLine) + replacements[token] = token.WithLeadingTrivia(IndentTrivia(token.LeadingTrivia, atLineStart, addition, indentFollowingToken: true)); + + isFirst = false; + atLineStart = token.EndsLine; + } + + return node.ReplaceTokens(replacements.Keys, (original, _) => replacements[original]); + } + + private static SyntaxTriviaList IndentTrivia(SyntaxTriviaList trivia, bool atLineStart, string addition, bool indentFollowingToken) + { + List result = new(trivia.Count + 1); + for (int i = 0; i < trivia.Count; i++) + { + SyntaxTrivia current = trivia[i]; + if (atLineStart) + { + atLineStart = false; + if (current.IsKind(SyntaxKind.WhitespaceTrivia)) + { + bool isDirectiveLine = i + 1 < trivia.Count && trivia[i + 1].IsDirective; + result.Add(isDirectiveLine ? current : SyntaxFactory.Whitespace(addition + current.ToFullString())); + continue; + } + + if (!current.EndsLine) + result.Add(SyntaxFactory.Whitespace(addition)); + } + + result.Add(current.IsKind(SyntaxKind.DisabledTextTrivia) ? IndentDisabledText(current, addition) : current); + atLineStart = current.EndsLine; + } + + if (atLineStart && indentFollowingToken) + result.Add(SyntaxFactory.Whitespace(addition)); + + return [.. result]; + } + + // Inactive code is not parsed, so a line may be inside a multi-line string literal; such text is left as is. + private static SyntaxTrivia IndentDisabledText(SyntaxTrivia trivia, string addition) + { + string text = trivia.ToFullString(); + if (text.IndexOf('"') >= 0) + return trivia; + + var builder = new StringBuilder(text.Length); + for (int lineStart = 0, lineEnd; lineStart < text.Length; lineStart = lineEnd) + { + lineEnd = text.IndexOf('\n', lineStart) is int newLine and >= 0 ? newLine + 1 : text.Length; + string line = text[lineStart..lineEnd]; + builder.Append(line.Trim().Length > 0 ? addition + line : line); + } + + return SyntaxFactory.DisabledText(builder.ToString()); + } + + // Infers one level of indentation from the first block whose statements are on their own lines. + private string DetectIndentUnit(SyntaxNode root) + { + foreach (BlockSyntax block in root.DescendantNodes().OfType()) + { + if (block.Statements.Count == 0 || !StartsLine(block.OpenBraceToken) || !StartsLine(block.Statements[0].GetFirstToken())) + continue; + + string outer = GetIndentation(block.OpenBraceToken.SpanStart); + string inner = GetIndentation(block.Statements[0].SpanStart); + if (inner.Length > outer.Length && inner.StartsWith(outer, StringComparison.Ordinal)) + return inner[outer.Length..]; + } + + return DefaultIndentUnit; + } + } +} diff --git a/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextPlanner.cs b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextPlanner.cs new file mode 100644 index 00000000000000..32b9fd4498a609 --- /dev/null +++ b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextPlanner.cs @@ -0,0 +1,263 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace ILLink.CodeFix.UnsafeContext +{ + /// + /// Groups the operations of each scope into regions and orders each region's candidates by the coverage rule: + /// proportionate blocks first, then the alternatives (wrappers or finer regions), then disproportionate blocks + /// with the fewest unrelated leaves. + /// + internal sealed class UnsafeContextPlanner(PlanningContext context) + { + /// Returns the regions for , one or more per scope. + public ImmutableArray Plan(IEnumerable targets) + { + var regions = ImmutableArray.CreateBuilder(); + foreach (var group in targets.GroupBy(static t => t.Scope.Body)) + { + context.CancellationToken.ThrowIfCancellationRequested(); + regions.AddRange(PlanScope(group.First().Scope, [.. group])); + } + + return regions.ToImmutable(); + } + + private IEnumerable PlanScope(UnsafeScope scope, ImmutableArray targets) + { + if (scope.Kind != UnsafeScopeKind.Block) + return [PlanExpressionScope(scope, targets)]; + + // Placeholder wrappers count as operations: a block covering them replaces them. + TargetSet set = new([.. targets.Select(static t => t.Span), .. PlaceholderContexts.GetPlaceholderWrapperSpans(scope.Body)]); + StatementList list = new((BlockSyntax)scope.Body); + if (context.Policy == UnsafeContextPolicy.BodyWide && CreateBodyWideRegion(scope, list, targets, set) is { } bodyWide) + return [bodyWide]; + + return PlanList(scope, list, targets, set, parent: null); + } + + /// + /// Expression bodies and initializers keep their form: the narrowest wrapper first. An expression body is + /// converted to a block body only when no wrapper is valid. + /// + private UnsafeContextRegion PlanExpressionScope(UnsafeScope scope, ImmutableArray targets) + { + List candidates = [.. ExpressionWrapPlanner.Create(targets, scope, scope.Body.Parent!, context).Select(RegionCandidate.ForEdit)]; + + // A block body would put nested lambdas and local functions in the unsafe context, would separate + // directives between `=>` and the end of the member from their counterparts, and would nest any wrapper. + if (scope.Kind == UnsafeScopeKind.ExpressionBody + && ExpressionBodyBlockEdit.CanConvert(scope.Owner) + && !scope.Body.DescendantNodesAndSelf().Any(static n => n is AnonymousFunctionExpressionSyntax or LocalFunctionStatementSyntax || UnsafeContextFacts.IsUnsafeExpression(n)) + && !scope.Owner.DescendantTrivia(TextSpan.FromBounds(scope.Body.GetFirstToken().GetPreviousToken().SpanStart, scope.Owner.Span.End)).Any(static t => t.IsDirective)) + { + bool asStatement = UnsafeContextSemantics.RequiresStatementExpressionBody(scope.Owner, context.Model, context.CancellationToken); + int position = scope.Owner is LambdaExpressionSyntax ? scope.Owner.SpanStart : scope.Owner.FirstTokenAfterAttributes.SpanStart; + string indentation = context.Formatting.GetIndentation(position); + candidates.Add(RegionCandidate.ForEdit(new ExpressionBodyBlockEdit(scope.Owner, (ExpressionSyntax)scope.Body, asStatement, indentation))); + } + + return new UnsafeContextRegion(targets, [.. candidates], parent: null); + } + + /// Creates a region whose first candidates wrap the whole body, refining to the default plan. + private UnsafeContextRegion? CreateBodyWideRegion(UnsafeScope scope, StatementList list, ImmutableArray targets, TargetSet set) + { + // Trailing local functions stay outside the block. + int last = list.Count - 1; + while (last >= 0 && list[last] is LocalFunctionStatementSyntax) + last--; + + if (last < 0) + return null; + + var blocks = BlockEditFactory.CreateForRange(list, 0, last, set, context); + return blocks.IsEmpty + ? null + : new UnsafeContextRegion( + targets, + [.. blocks.Select(RegionCandidate.ForEdit), RegionCandidate.ForRefinement(region => PlanList(scope, list, targets, set, region))], + parent: null); + } + + /// + /// Plans the operations of a statement list. Statements that own operations (simple statements and compound + /// statements with operations in their headers) are anchors; adjacent anchors, and anchors separated by a gap + /// that keeps the merged block proportionate, form one region. Compound statements with operations only in + /// their bodies are planned inside those bodies unless a merged region covers them. + /// + private IEnumerable PlanList(UnsafeScope scope, StatementList list, ImmutableArray targets, TargetSet set, UnsafeContextRegion? parent) + { + var byStatement = targets + .GroupBy(t => list.IndexOfStatementContaining(t.Span)) + .Where(static g => g.Key >= 0) + .OrderBy(static g => g.Key) + .ToDictionary(static g => g.Key, static g => g.ToImmutableArray()); + + List regions = []; + HashSet covered = []; + foreach (var (first, last) in MergeAnchors(list, byStatement, set)) + { + ImmutableArray rangeTargets = [.. byStatement.Where(p => p.Key >= first && p.Key <= last).SelectMany(static p => p.Value)]; + covered.UnionWith(Enumerable.Range(first, last - first + 1)); + regions.Add(first == last + ? PlanStatement(scope, list, list[first], rangeTargets, set, parent) + : PlanRange(scope, list, first, last, rangeTargets, set, parent)); + } + + foreach (var pair in byStatement.Where(p => !covered.Contains(p.Key))) + regions.AddRange(PlanBodies(scope, list[pair.Key], pair.Value, set, parent)); + + return regions; + } + + /// Returns the ranges of anchor statements, merging each anchor into the previous range when allowed. + private IEnumerable<(int First, int Last)> MergeAnchors(StatementList list, Dictionary> byStatement, TargetSet set) + { + (int First, int Last)? range = null; + foreach (int index in byStatement.Keys.Where(i => StatementShape.HasOwnOperations(list[i], set)).OrderBy(static i => i)) + { + if (range is (int first, int last) && CanMerge(list, first, last, index, set)) + { + range = (first, index); + continue; + } + + if (range is { } previous) + yield return previous; + + range = (index, index); + } + + if (range is { } final) + yield return final; + } + + /// + /// Whether the anchor at joins the range: both sides must be enclosable on their own, + /// and a gap must need no declaration splitting or closure growth and keep the block proportionate. + /// + private bool CanMerge(StatementList list, int first, int last, int next, TargetSet set) + { + if (context.Policy == UnsafeContextPolicy.ExpressionFirst || !IsMergeable(list[last], set) || !IsMergeable(list[next], set)) + return false; + + Coverage coverage = default; + for (int i = first; i <= next; i++) + { + // Placeholder blocks in the gap are merged. + StatementSyntax statement = list[i]; + bool isGap = i > last && i < next; + if (isGap + && !PlaceholderContexts.IsExtendableBlock(statement) + && (statement is LocalDeclarationStatementSyntax or LabeledStatementSyntax || !BlockEditFactory.CanEnclose(statement))) + { + return false; + } + + coverage += BlockEditFactory.CountRangeLeaves(statement, set); + } + + return coverage.IsBalanced; + } + + private static bool IsMergeable(StatementSyntax statement, TargetSet set) => + BlockEditFactory.CanEnclose(statement) + && !StatementShape.CoversBodyOnlyForHeader(statement, set) + && StatementShape.CountLeaves(statement, set).IsBalanced; + + /// Plans a merged range: one block, refining to one region per statement. + private UnsafeContextRegion PlanRange(UnsafeScope scope, StatementList list, int first, int last, ImmutableArray targets, TargetSet set, UnsafeContextRegion? parent) + { + var blocks = BlockEditFactory.CreateForRange(list, first, last, set, context); + var refine = RegionCandidate.ForRefinement(region => targets + .GroupBy(t => list.IndexOfStatementContaining(t.Span)) + .SelectMany(g => StatementShape.HasOwnOperations(list[g.Key], set) + ? [PlanStatement(scope, list, list[g.Key], [.. g], set, region)] + : PlanBodies(scope, list[g.Key], [.. g], set, region))); + + return new UnsafeContextRegion(targets, Order(blocks, [refine]), parent); + } + + /// + /// Plans a statement that owns operations. A simple statement or a compound statement with operations only in + /// its headers falls back to unsafe(...) wrappers; a compound statement with operations in its bodies + /// falls back to planning its header and bodies separately. + /// + private UnsafeContextRegion PlanStatement(UnsafeScope scope, StatementList? list, StatementSyntax statement, ImmutableArray targets, TargetSet set, UnsafeContextRegion? parent) + { + var blocks = list is not null && list.Statements.IndexOf(statement) is var index + ? BlockEditFactory.CreateForRange(list, index, index, set, context) + : BlockEditFactory.CreateForEmbedded(statement, set, context); + + StatementShape shape = StatementShape.Of(statement); + bool hasBodyOperations = targets.Any(t => shape.AnyBodyContains(t.Span)); + IEnumerable alternatives = hasBodyOperations || GroupHeaderTargets(statement, targets).Count() > 1 + ? [RegionCandidate.ForRefinement(region => PlanHeaderAndBodies(scope, statement, targets, set, region))] + : ExpressionWrapPlanner.Create(targets, scope, statement, context).Select(RegionCandidate.ForEdit); + + return new UnsafeContextRegion(targets, Order(blocks, alternatives), parent); + } + + /// Plans one wrapper region per header group, then the bodies. + private IEnumerable PlanHeaderAndBodies(UnsafeScope scope, StatementSyntax statement, ImmutableArray targets, TargetSet set, UnsafeContextRegion parent) + { + StatementShape shape = StatementShape.Of(statement); + foreach (var group in GroupHeaderTargets(statement, targets.Where(t => !shape.AnyBodyContains(t.Span)))) + { + var wrappers = ExpressionWrapPlanner.Create([.. group], scope, statement, context); + yield return new UnsafeContextRegion([.. group], [.. wrappers.Select(RegionCandidate.ForEdit)], parent); + } + + foreach (UnsafeContextRegion region in PlanBodies(scope, statement, targets, set, parent)) + yield return region; + } + + /// + /// Groups header operations by clause: switch-label guards and catch filters each get their own wrapper, other + /// headers belong to the statement and so share one wrapper. + /// + private static IEnumerable> GroupHeaderTargets(StatementSyntax statement, IEnumerable targets) => + targets.GroupBy(t => t.Node.AncestorsAndSelf() + .TakeWhile(a => a != statement) + .FirstOrDefault(static a => a is CasePatternSwitchLabelSyntax or CatchClauseSyntax) ?? statement); + + /// Plans the operations inside the bodies of a compound statement. + private IEnumerable PlanBodies(UnsafeScope scope, StatementSyntax statement, ImmutableArray targets, TargetSet set, UnsafeContextRegion? parent) + { + foreach (SyntaxNode body in StatementShape.Of(statement).Bodies) + { + ImmutableArray bodyTargets = [.. targets.Where(t => StatementShape.BodyContains(body, t.Span))]; + if (bodyTargets.IsEmpty) + continue; + + IEnumerable regions = StatementList.ForContainer(body) is { } list + ? PlanList(scope, list, bodyTargets, set, parent) + : StatementShape.HasOwnOperations((StatementSyntax)body, set) + ? [PlanStatement(scope, list: null, (StatementSyntax)body, bodyTargets, set, parent)] + : PlanBodies(scope, (StatementSyntax)body, bodyTargets, set, parent); + + foreach (UnsafeContextRegion region in regions) + yield return region; + } + } + + /// Orders candidates by the coverage rule, or alternatives first for . + private ImmutableArray Order(ImmutableArray blocks, IEnumerable alternatives) + { + var proportionate = blocks.Where(static b => b.IsProportionate).Select(RegionCandidate.ForEdit); + var others = blocks.Where(static b => !b.IsProportionate).OrderBy(static b => b.UnrelatedLeaves).Select(RegionCandidate.ForEdit); + return context.Policy == UnsafeContextPolicy.ExpressionFirst + ? [.. alternatives, .. proportionate, .. others] + : [.. proportionate, .. alternatives, .. others]; + } + } +} diff --git a/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextRegion.cs b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextRegion.cs new file mode 100644 index 00000000000000..eff4ae8729c575 --- /dev/null +++ b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextRegion.cs @@ -0,0 +1,68 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace ILLink.CodeFix.UnsafeContext +{ + /// + /// One alternative for a region: either an edit, or a refinement that replaces the region with finer regions + /// (e.g. one per statement of a merged range, or the header and bodies of a compound statement). + /// + internal sealed class RegionCandidate + { + private RegionCandidate(UnsafeContextEdit? edit, Func>? refine) + { + Edit = edit; + Refine = refine; + } + + public UnsafeContextEdit? Edit { get; } + + public Func>? Refine { get; } + + public static RegionCandidate ForEdit(UnsafeContextEdit edit) => new(edit, null); + + public static RegionCandidate ForRefinement(Func> refine) => new(null, refine); + } + + /// + /// A group of operations covered by one context, with its candidates in order of preference. Validation advances + /// a failing region to its next candidate; a refined region whose children run out of candidates falls back to + /// the candidates its parent has after the refinement. + /// + internal sealed class UnsafeContextRegion(ImmutableArray targets, ImmutableArray candidates, UnsafeContextRegion? parent) + { + private int _index; + + public ImmutableArray Targets { get; } = targets; + + public UnsafeContextRegion? Parent { get; } = parent; + + /// Marks the nodes created by this region's edit, to find them again in the validated tree. + public SyntaxAnnotation Annotation { get; } = new("UnsafeContextRegion"); + + public RegionCandidate? Current => _index < candidates.Length ? candidates[_index] : null; + + public UnsafeContextEdit? CurrentEdit => Current?.Edit; + + public bool HasNext => _index + 1 < candidates.Length; + + /// Moves to the next candidate; returns when none is left. + public bool MoveNext() => ++_index < candidates.Length; + + public bool IsDescendantOf(UnsafeContextRegion region) + { + for (UnsafeContextRegion? current = Parent; current is not null; current = current.Parent) + { + if (current == region) + return true; + } + + return false; + } + } +} diff --git a/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextValidator.cs b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextValidator.cs new file mode 100644 index 00000000000000..8e77dade01510b --- /dev/null +++ b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeContextValidator.cs @@ -0,0 +1,149 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Editing; +using Microsoft.CodeAnalysis.Text; + +namespace ILLink.CodeFix.UnsafeContext +{ + /// + /// Applies the current candidate of every region to the document and rebinds the result on one forked + /// compilation. A member is valid when its diagnostics are unchanged except that the targeted unsafe-context + /// errors are gone: no new errors or warnings (ref safety, definite assignment, scoping, overloads...) and no + /// pre-existing diagnostic disappearing or changing severity, as an unsafe context may downgrade ref-safety errors. + /// + internal sealed class UnsafeContextValidator( + Document document, + SyntaxNode root, + Compilation compilation, + PlanningContext context, + IReadOnlyDictionary> baseline) + { + private readonly Dictionary _memberAnnotations = + baseline.Keys.ToDictionary(static member => member, static _ => new SyntaxAnnotation("UnsafeContextMember")); + + /// Applies the current edit of every region and returns the new root. + public SyntaxNode Apply(IReadOnlyCollection regions) + { + SyntaxEditor editor = new(root, document.Project.Solution.Services); + EditSession session = new(editor, context.Formatting, (CSharpParseOptions)root.SyntaxTree.Options); + foreach (UnsafeContextRegion region in regions) + region.CurrentEdit!.Apply(session, region.Annotation); + + // Registered last, so the callback sees the member with all of its edits. + foreach (MemberDeclarationSyntax member in regions.Select(GetMember).Distinct()) + editor.ReplaceNode(member, (current, _) => current.WithAdditionalAnnotations(_memberAnnotations[member])); + + return editor.GetChangedRoot(); + } + + /// Returns the regions whose edits made their member's diagnostics differ unexpectedly. + public HashSet Validate(SyntaxNode newRoot, IReadOnlyCollection regions) + { + SyntaxTree oldTree = root.SyntaxTree; + SyntaxTree newTree = oldTree.WithRootAndOptions(newRoot, oldTree.Options); + newRoot = newTree.GetRoot(context.CancellationToken); + SemanticModel model = compilation.ReplaceSyntaxTree(oldTree, newTree).GetSemanticModel(newTree); + + HashSet failed = []; + foreach (var group in regions.GroupBy(GetMember)) + { + var member = newRoot.GetAnnotatedNodes(_memberAnnotations[group.Key]).OfType().FirstOrDefault(); + var spans = group.Select(r => (Region: r, Old: r.CurrentEdit!.Span, New: GetNewSpan(newRoot, r))).ToList(); + if (member is null || spans.Any(static s => s.New is null)) + { + failed.UnionWith(group); + continue; + } + + List regionSpans = [.. spans.Select(static s => new RegionSpans(s.Region, s.Old, s.New!.Value))]; + ImmutableArray after = UnsafeContextSemantics.GetDiagnostics(model, member, context.CancellationToken); + Compare(baseline[group.Key], after, regionSpans, failed); + } + + return failed; + } + + private static MemberDeclarationSyntax GetMember(UnsafeContextRegion region) => region.Targets[0].Scope.Member; + + // The union of the spans of the nodes the region's edit annotated. + private static TextSpan? GetNewSpan(SyntaxNode root, UnsafeContextRegion region) => + root.GetAnnotatedNodes(region.Annotation).Aggregate((TextSpan?)null, static (span, node) => + span is { } current ? TextSpan.FromBounds(Math.Min(current.Start, node.SpanStart), Math.Max(current.End, node.Span.End)) : node.Span); + + /// Adds to the regions blamed for leftover or changed diagnostics. + private static void Compare(ImmutableArray before, ImmutableArray after, List regions, HashSet failed) + { + // An unsafe-context error left inside a region means its context did not cover the operation. + foreach (Diagnostic diagnostic in after.Where(static d => UnsafeContextFacts.IsFixableDiagnosticId(d.Id))) + { + if (Innermost(regions, diagnostic.Location.SourceSpan, static r => r.New) is { } region) + failed.Add(region); + } + + var oldByKey = before.Where(IsCompared).ToLookup(Key); + var newByKey = after.Where(IsCompared).ToLookup(Key); + foreach (var key in oldByKey.Select(static g => g.Key).Union(newByKey.Select(static g => g.Key))) + { + var oldDiagnostics = oldByKey[key].ToList(); + var newDiagnostics = newByKey[key].ToList(); + if (newDiagnostics.Count > oldDiagnostics.Count) + Blame(regions, newDiagnostics.Select(static d => d.Location.SourceSpan), static r => r.New, failed); + else if (oldDiagnostics.Count > newDiagnostics.Count) + Blame(regions, oldDiagnostics.Select(static d => d.Location.SourceSpan), static r => r.Old, failed); + } + } + + /// + /// Attributes changed diagnostics to the regions containing them; failing that, to the nearest preceding + /// region (a narrowed scope affects later code), or to every region of the member. + /// + private static void Blame(List regions, IEnumerable locations, Func span, HashSet failed) + { + List spans = [.. locations]; + List containing = [.. spans.Select(l => Innermost(regions, l, span)).OfType()]; + if (containing.Count > 0) + { + failed.UnionWith(containing); + return; + } + + foreach (TextSpan location in spans) + { + // Only a block narrows scopes or splits declarations, so it is the likely cause of a later change. + var preceding = regions + .Where(r => span(r).End <= location.Start) + .OrderByDescending(r => r.Region.CurrentEdit!.Form == UnsafeContextForm.Block) + .ThenByDescending(r => span(r).End) + .FirstOrDefault(); + if (preceding.Region is not null) + failed.Add(preceding.Region); + else + failed.UnionWith(regions.Select(static r => r.Region)); + } + } + + private static UnsafeContextRegion? Innermost(List regions, TextSpan location, Func span) => + regions.Where(r => span(r).Contains(location)).OrderBy(r => span(r).Length).FirstOrDefault().Region; + + // Only errors and warnings are compared; the fixable diagnostics are checked separately. + private static bool IsCompared(Diagnostic diagnostic) => + diagnostic.Severity >= DiagnosticSeverity.Warning + && !diagnostic.IsSuppressed + && !UnsafeContextFacts.IsFixableDiagnosticId(diagnostic.Id); + + private static (string Id, DiagnosticSeverity Severity, string Message) Key(Diagnostic diagnostic) => + (diagnostic.Id, diagnostic.Severity, diagnostic.GetMessage(CultureInfo.InvariantCulture)); + + /// A region's span before and after the edits. + private readonly record struct RegionSpans(UnsafeContextRegion Region, TextSpan Old, TextSpan New); + } +} diff --git a/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeTarget.cs b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeTarget.cs new file mode 100644 index 00000000000000..cb3573e2c3d110 --- /dev/null +++ b/src/tools/illink/src/ILLink.CodeFix/UnsafeContext/UnsafeTarget.cs @@ -0,0 +1,119 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace ILLink.CodeFix.UnsafeContext +{ + internal enum UnsafeScopeKind + { + /// A statement body of a method, accessor, local function or lambda. + Block, + + /// An expression body (=> expr) of a callable. + ExpressionBody, + + /// A position without statements: field/property initializers and constructor or base arguments. + Initializer, + } + + /// + /// The innermost callable body or initializer an operation is evaluated in. Lambdas and local functions are + /// separate scopes, so an outer context never covers their bodies. + /// + /// The declaration owning , e.g. a method, accessor, lambda or field. + /// The block of a statement body, otherwise the expression or argument list that may be wrapped. + internal sealed record UnsafeScope(UnsafeScopeKind Kind, SyntaxNode Owner, SyntaxNode Body) + { + /// The top-level member whose diagnostics validate edits made in this scope. + public MemberDeclarationSyntax Member { get; } = Owner.AncestorsAndSelf().OfType().First(); + + /// + /// Finds the scope is evaluated in, or where no inner context + /// can help, e.g. attribute arguments, parameter defaults and signatures. + /// + public static UnsafeScope? Find(SyntaxNode node) + { + for (SyntaxNode? current = node.Parent; current is not null; current = current.Parent) + { + switch (current) + { + case AnonymousFunctionExpressionSyntax function: + return FromBody(function, function.Block, function.ExpressionBody, node); + case LocalFunctionStatementSyntax localFunction: + return FromBody(localFunction, localFunction.Body, localFunction.ExpressionBody?.Expression, node); + case AccessorDeclarationSyntax accessor: + return FromBody(accessor, accessor.Body, accessor.ExpressionBody?.Expression, node); + case ConstructorDeclarationSyntax { Initializer.ArgumentList: var arguments } constructor when arguments.Span.Contains(node.Span): + return new UnsafeScope(UnsafeScopeKind.Initializer, constructor, arguments); + case BaseMethodDeclarationSyntax method: + return FromBody(method, method.Body, method.ExpressionBody?.Expression, node); + case PropertyDeclarationSyntax { Initializer.Value: var value } property when value.Span.Contains(node.Span): + return new UnsafeScope(UnsafeScopeKind.Initializer, property, value); + case PropertyDeclarationSyntax property: + return FromBody(property, body: null, property.ExpressionBody?.Expression, node); + case IndexerDeclarationSyntax indexer: + return FromBody(indexer, body: null, indexer.ExpressionBody?.Expression, node); + case VariableDeclaratorSyntax { Initializer.Value: var value, Parent.Parent: BaseFieldDeclarationSyntax field } when value.Span.Contains(node.Span): + return new UnsafeScope(UnsafeScopeKind.Initializer, field, value); + case PrimaryConstructorBaseTypeSyntax { ArgumentList: var arguments, Parent.Parent: TypeDeclarationSyntax type } when arguments.Span.Contains(node.Span): + return new UnsafeScope(UnsafeScopeKind.Initializer, type, arguments); + case MemberDeclarationSyntax or AttributeSyntax or ParameterSyntax: + return null; + } + } + + return null; + } + + private static UnsafeScope? FromBody(SyntaxNode owner, BlockSyntax? body, ExpressionSyntax? expressionBody, SyntaxNode node) => + body?.Span.Contains(node.Span) == true ? new UnsafeScope(UnsafeScopeKind.Block, owner, body) + : expressionBody?.Span.Contains(node.Span) == true ? new UnsafeScope(UnsafeScopeKind.ExpressionBody, owner, expressionBody) + : null; + } + + /// A diagnosed operation that needs an unsafe context, mapped to the syntax owning the unsafe check. + /// The operation, e.g. the element access of a diagnostic on its brackets. + internal sealed record UnsafeTarget(Diagnostic Diagnostic, SyntaxNode Node, UnsafeScope Scope) + { + public TextSpan Span => Node.Span; + + /// Maps a fixable diagnostic of to its operation and scope. + public static UnsafeTarget? Create(SyntaxNode root, Diagnostic diagnostic) + { + if (!UnsafeContextFacts.IsFixableDiagnosticId(diagnostic.Id) || diagnostic.Location.SourceTree != root.SyntaxTree) + return null; + + SyntaxNode node = GetOperationNode(root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true)); + return UnsafeScope.Find(node) is { } scope ? new UnsafeTarget(diagnostic, node, scope) : null; + } + + /// Climbs from the reported syntax to the operation that owns the unsafe check. + private static SyntaxNode GetOperationNode(SyntaxNode node) + { + // `p[i]` and invocations can be reported on their argument lists. + if (node is BaseArgumentListSyntax { Parent: ExpressionSyntax owner }) + node = owner; + + // CS9376 is reported on the constructed type, e.g. `new G()`; the creation is the checked operation. + if (node is TypeSyntax typeSyntax && SyntaxFacts.IsInTypeOnlyContext(typeSyntax)) + { + SyntaxNode type = node; + while (type.Parent is TypeSyntax or TypeArgumentListSyntax) + type = type.Parent; + + if (type.Parent is ExpressionSyntax consumer && (consumer is not TypeSyntax typeConsumer || !SyntaxFacts.IsInTypeOnlyContext(typeConsumer))) + node = consumer; + } + + while (node.Parent is MemberAccessExpressionSyntax access && access.Name == node) + node = access; + + return node.Parent is InvocationExpressionSyntax invocation && invocation.Expression == node ? invocation : node; + } + } +} diff --git a/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/RequiresUnsafeCodeFixTests.cs b/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/RequiresUnsafeCodeFixTests.cs deleted file mode 100644 index 01d24e40ad734d..00000000000000 --- a/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/RequiresUnsafeCodeFixTests.cs +++ /dev/null @@ -1,1675 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -#if DEBUG -using System; -using System.Threading.Tasks; -using ILLink.CodeFix; -using ILLink.Shared; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Testing; -using Microsoft.CodeAnalysis.Text; -using Xunit; -using VerifyCS = ILLink.RoslynAnalyzer.Tests.CSharpCodeFixVerifier< - ILLink.RoslynAnalyzer.DynamicallyAccessedMembersAnalyzer, - ILLink.CodeFix.RequiresUnsafeCodeFixProvider>; - -namespace ILLink.RoslynAnalyzer.Tests -{ - public class RequiresUnsafeCodeFixTests - { - static Solution SetOptions(Solution solution, ProjectId projectId) - { - var project = solution.GetProject(projectId)!; - var parseOptions = (CSharpParseOptions)project.ParseOptions!; - parseOptions = parseOptions.WithLanguageVersion(LanguageVersion.Preview) - .WithFeatures([.. parseOptions.Features, new("updated-memory-safety-rules", "")]); - var compilationOptions = (CSharpCompilationOptions)project.CompilationOptions!; - compilationOptions = compilationOptions.WithAllowUnsafe(true); - return solution.WithProjectParseOptions(projectId, parseOptions) - .WithProjectCompilationOptions(projectId, compilationOptions); - } - - static Task VerifyRequiresUnsafeCodeFix( - string source, - string fixedSource, - DiagnosticResult[] baselineExpected, - DiagnosticResult[] fixedExpected, - int? numberOfIterations = null, - int codeActionIndex = 1) - { - var test = new VerifyCS.Test - { - TestCode = source, - FixedCode = fixedSource, - CodeActionIndex = codeActionIndex - }; - test.ExpectedDiagnostics.AddRange(baselineExpected); - test.SolutionTransforms.Add(SetOptions); - if (numberOfIterations != null) - { - test.NumberOfIncrementalIterations = numberOfIterations; - test.NumberOfFixAllIterations = numberOfIterations; - } - test.FixedState.ExpectedDiagnostics.AddRange(fixedExpected); - return test.RunAsync(); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_SimpleStatement() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public void M2() - { - int x = M1(); - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public void M2() - { - int x; - // TODO(unsafe): Baselining unsafe usage - unsafe - { - x = M1(); - } - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 17, 9, 21) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_ExpressionStatement() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe void M1() { } - - public void M2() - { - M1(); - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe void M1() { } - - public void M2() - { - // TODO(unsafe): Baselining unsafe usage - unsafe - { - M1(); - } - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 9, 9, 13) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_ReturnStatement() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public int M2() - { - return M1(); - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public int M2() - { - // TODO(unsafe): Baselining unsafe usage - unsafe - { - return M1(); - } - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 16, 9, 20) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_IfStatement() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe bool M1() => true; - - public void M2() - { - if (M1()) - { - System.Console.WriteLine("yes"); - } - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe bool M1() => true; - - public void M2() - { - // TODO(unsafe): Baselining unsafe usage - unsafe - { - if (M1()) - { - System.Console.WriteLine("yes"); - } - } - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 13, 9, 17) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task NoWarning_InsideUnsafeBlock() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public void M2() - { - unsafe - { - int x = M1(); - } - } - } - """; - - // No diagnostics expected - already in unsafe block - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: test, // No change expected - baselineExpected: Array.Empty(), - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task CodeFix_InsideUnsafeMethod() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public unsafe void M2() - { - int x = M1(); - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public unsafe void M2() - { - int x; - // TODO(unsafe): Baselining unsafe usage - unsafe - { - x = M1(); - } - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 17, 9, 21) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty(), - codeActionIndex: 0); - } - - [Fact] - public async Task CodeFix_InsideUnsafeClass() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public void M2() - { - int x = M1(); - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public void M2() - { - int x; - // TODO(unsafe): Baselining unsafe usage - unsafe - { - x = M1(); - } - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 17, 9, 21) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task CodeFix_InsideUnsafeProperty() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public unsafe int P - { - get => M1(); - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public unsafe int P - { - get - { - // TODO(unsafe): Baselining unsafe usage - unsafe - { - return M1(); - } - } - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 16, 9, 20) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty(), - codeActionIndex: 1); - } - - [Fact] - public async Task CodeFix_InsideUnsafeLocalFunction() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public void M2() - { - unsafe int Local() => M1(); - - _ = Local(); - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public unsafe void M2() - { - unsafe int Local() - { - // TODO(unsafe): Baselining unsafe usage - unsafe - { - return M1(); - } - } - // TODO(unsafe): Baselining unsafe usage - - unsafe - { - _ = Local(); - } - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 31, 9, 35) - .WithArguments("C.M1()"), - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(11, 13, 11, 20) - .WithArguments("Local()") - }, - fixedExpected: Array.Empty(), - numberOfIterations: 3, - codeActionIndex: 0); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_ExpressionBodiedMethod() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public int M2() => M1(); - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public int M2() - { - // TODO(unsafe): Baselining unsafe usage - unsafe - { - return M1(); - } - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(7, 24, 7, 28) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_ExpressionBodiedVoidMethod() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe void M1() { } - - public void M2() => M1(); - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe void M1() { } - - public void M2() - { - // TODO(unsafe): Baselining unsafe usage - unsafe - { - M1(); - } - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(7, 25, 7, 29) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_ExpressionBodiedDestructor() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe void M1() { } - - ~C() => M1(); - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe void M1() { } - - ~C() - { - // TODO(unsafe): Baselining unsafe usage - unsafe - { - M1(); - } - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(7, 13, 7, 17) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_ExpressionBodiedProperty() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public int P => M1(); - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public int P - { - get - { - // TODO(unsafe): Baselining unsafe usage - unsafe - { - return M1(); - } - } - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(7, 21, 7, 25) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty(), - codeActionIndex: 1); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_ExpressionBodiedAccessor() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public int P - { - get => M1(); - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public int P - { - get - { - // TODO(unsafe): Baselining unsafe usage - unsafe - { - return M1(); - } - } - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 16, 9, 20) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty(), - codeActionIndex: 1); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_LocalFunction_ConvertsExpressionBody() - { - // Local functions with expression bodies are converted to block bodies - // with the unsafe block inside, preserving their scope. - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public void M2() - { - int Local() => M1(); - _ = Local(); - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public void M2() - { - int Local() - { - // TODO(unsafe): Baselining unsafe usage - unsafe - { - return M1(); - } - } - - _ = Local(); - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 24, 9, 28) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_ForwardDeclaration() - { - // When a variable is declared with unsafe code and used later, - // use forward declaration instead of expanding the unsafe block. - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public void M2() - { - int x = M1(); - int y = x + 1; - System.Console.WriteLine(y); - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public void M2() - { - int x; - // TODO(unsafe): Baselining unsafe usage - unsafe - { - x = M1(); - } - int y = x + 1; - System.Console.WriteLine(y); - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 17, 9, 21) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_UnusedVariable() - { - // When a variable declared with unsafe code is not used after, - // forward declaration still applies consistently. - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public void M2() - { - int x = M1(); - int y = 42; - System.Console.WriteLine(y); - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public void M2() - { - int x; - // TODO(unsafe): Baselining unsafe usage - unsafe - { - x = M1(); - } - int y = 42; - System.Console.WriteLine(y); - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 17, 9, 21) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_ForwardDeclaration_VarType() - { - // When using 'var', the forward declaration should use the explicit type. - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public void M2() - { - var x = M1(); - System.Console.WriteLine(x); - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public void M2() - { - int x; - // TODO(unsafe): Baselining unsafe usage - unsafe - { - x = M1(); - } - System.Console.WriteLine(x); - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 17, 9, 21) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_RefLocal_NoForwardDeclaration() - { - // Ref locals cannot use forward declaration (can't declare without initializer), - // so wrap the entire declaration in the unsafe block. - // Note: If the ref local is used after the declaration, those uses will be broken - // and need manual fixing. This test has no usage after the declaration. - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - private int _field; - - public static unsafe ref int M1(ref int x) => ref x; - - public void M2() - { - ref int x = ref M1(ref _field); - } - } - """; - - // ref locals can't be forward-declared, so wrap the whole declaration - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - private int _field; - - public static unsafe ref int M1(ref int x) => ref x; - - public void M2() - { - // TODO(unsafe): Baselining unsafe usage - unsafe - { - ref int x = ref M1(ref _field); - } - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(11, 25, 11, 39) - .WithArguments("C.M1(ref int)") - }, - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_RefLocalFromUnsafeAs_NoForwardDeclaration() - { - // Pattern matching real-world usage: ref byte x = ref Unsafe.As(ref source) - // The Unsafe.As call is unsafe, and the result is assigned to a ref local. - // When the ref local is used after the declaration, those statements must be included - // in the unsafe block since ref locals can't be forward-declared. - var test = """ - using System.Diagnostics.CodeAnalysis; - using System.Runtime.CompilerServices; - - public class C - { - public void M2() - { - char c = 'x'; - ref byte x = ref Unsafe.As(ref c); - int y = x + 1; - } - } - - namespace System.Runtime.CompilerServices - { - public static class Unsafe - { - public static unsafe ref TTo As(ref TFrom source) => throw null!; - } - } - """; - - // ref locals can't be forward-declared, so must expand block to include usages - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - using System.Runtime.CompilerServices; - - public class C - { - public void M2() - { - char c = 'x'; - // TODO(unsafe): Baselining unsafe usage - unsafe - { - ref byte x = ref Unsafe.As(ref c); - int y = x + 1; - } - } - } - - namespace System.Runtime.CompilerServices - { - public static class Unsafe - { - public static unsafe ref TTo As(ref TFrom source) => throw null!; - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 26, 9, 54) - .WithArguments("System.Runtime.CompilerServices.Unsafe.As(ref char)") - }, - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_ChainedRefLocals_ExpandsToIncludeAll() - { - // When a ref local is used to create another ref local, and that second ref local - // is used later, the unsafe block must expand to include ALL usages. - // This matches the pattern in CharUnicodeInfo.cs where rsStart is used to create rsDelta. - var test = """ - using System.Diagnostics.CodeAnalysis; - using System.Runtime.CompilerServices; - - public class C - { - public void M2() - { - byte b = 0; - ref ushort rsStart = ref Unsafe.As(ref b); - ref ushort rsDelta = ref Unsafe.Add(ref rsStart, 1); - int delta = rsDelta; - } - } - - namespace System.Runtime.CompilerServices - { - public static class Unsafe - { - public static unsafe ref TTo As(ref TFrom source) => throw null!; - public static ref T Add(ref T source, int elementOffset) => throw null!; - } - } - """; - - // Both ref locals and the usage of rsDelta must be inside the unsafe block - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - using System.Runtime.CompilerServices; - - public class C - { - public void M2() - { - byte b = 0; - // TODO(unsafe): Baselining unsafe usage - unsafe - { - ref ushort rsStart = ref Unsafe.As(ref b); - ref ushort rsDelta = ref Unsafe.Add(ref rsStart, 1); - int delta = rsDelta; - } - } - } - - namespace System.Runtime.CompilerServices - { - public static class Unsafe - { - public static unsafe ref TTo As(ref TFrom source) => throw null!; - public static ref T Add(ref T source, int elementOffset) => throw null!; - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 34, 9, 64) - .WithArguments("System.Runtime.CompilerServices.Unsafe.As(ref byte)") - }, - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_RefLocal_RegularVarEscapes_ExpandsToIncludeUsage() - { - // When a ref local block expansion pulls in a regular variable declaration, - // and that regular variable is used after the block, we must expand further. - // This matches the CharUnicodeInfo.cs pattern. - var test = """ - using System.Diagnostics.CodeAnalysis; - using System.Runtime.CompilerServices; - - public class C - { - public int M2() - { - byte b = 0; - ref ushort rsStart = ref Unsafe.As(ref b); - ref ushort rsDelta = ref Unsafe.Add(ref rsStart, 1); - int delta = rsDelta; - return delta + 1; - } - } - - namespace System.Runtime.CompilerServices - { - public static class Unsafe - { - public static unsafe ref TTo As(ref TFrom source) => throw null!; - public static ref T Add(ref T source, int elementOffset) => throw null!; - } - } - """; - - // Block must expand to include return statement since delta is used there - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - using System.Runtime.CompilerServices; - - public class C - { - public int M2() - { - byte b = 0; - // TODO(unsafe): Baselining unsafe usage - unsafe - { - ref ushort rsStart = ref Unsafe.As(ref b); - ref ushort rsDelta = ref Unsafe.Add(ref rsStart, 1); - int delta = rsDelta; - return delta + 1; - } - } - } - - namespace System.Runtime.CompilerServices - { - public static class Unsafe - { - public static unsafe ref TTo As(ref TFrom source) => throw null!; - public static ref T Add(ref T source, int elementOffset) => throw null!; - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 34, 9, 64) - .WithArguments("System.Runtime.CompilerServices.Unsafe.As(ref byte)") - }, - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_RefReadonlyLocal_NoForwardDeclaration() - { - // Ref readonly locals cannot use forward declaration, so wrap the declaration. - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe ref readonly int M1(in int x) => ref x; - - public void M2() - { - int value = 42; - ref readonly int x = ref M1(in value); - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe ref readonly int M1(in int x) => ref x; - - public void M2() - { - int value = 42; - // TODO(unsafe): Baselining unsafe usage - unsafe - { - ref readonly int x = ref M1(in value); - } - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(10, 34, 10, 46) - .WithArguments("C.M1(in int)") - }, - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_NotOfferedForStatementsWithPragmaDirectives() - { - // When statements to wrap have #pragma directives after the diagnostic statement, - // the unsafe block should not include the directive trivia. - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public void M2() - { - int x = M1(); - #pragma warning disable CS0168 - if (x > 0) - #pragma warning restore CS0168 - { - } - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public unsafe void M2() - { - int x; - // TODO(unsafe): Baselining unsafe usage - unsafe - { - x = M1(); - } - #pragma warning disable CS0168 - if (x > 0) - #pragma warning restore CS0168 - { - } - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 17, 9, 21) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty(), - numberOfIterations: 2, - codeActionIndex: 0); - } - - [Fact] - public async Task CodeFix_AddUnsafeModifier_Method() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public int M2() - { - return M1(); - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public unsafe int M2() - { - // TODO(unsafe): Baselining unsafe usage - unsafe - { - return M1(); - } - } - } - """; - - var addUnsafeTest = new VerifyCS.Test - { - TestCode = test, - FixedCode = fixedSource, - CodeActionIndex = 0, - NumberOfIncrementalIterations = 2, - NumberOfFixAllIterations = 2 - }; - addUnsafeTest.ExpectedDiagnostics.Add( - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 16, 9, 20) - .WithArguments("C.M1()")); - addUnsafeTest.SolutionTransforms.Add(SetOptions); - await addUnsafeTest.RunAsync(); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_Method() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public int M2() - { - return M1(); - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public int M2() - { - // TODO(unsafe): Baselining unsafe usage - unsafe - { - return M1(); - } - } - } - """; - - var addAttributeTest = new VerifyCS.Test - { - TestCode = test, - FixedCode = fixedSource, - CodeActionIndex = 1 - }; - addAttributeTest.ExpectedDiagnostics.Add( - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 16, 9, 20) - .WithArguments("C.M1()")); - addAttributeTest.SolutionTransforms.Add(SetOptions); - await addAttributeTest.RunAsync(); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_Constructor() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe void M1() { } - - public C() - { - M1(); - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe void M1() { } - - public C() - { - // TODO(unsafe): Baselining unsafe usage - unsafe - { - M1(); - } - } - } - """; - - var addAttributeTest = new VerifyCS.Test - { - TestCode = test, - FixedCode = fixedSource, - CodeActionIndex = 1 - }; - addAttributeTest.ExpectedDiagnostics.Add( - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(9, 9, 9, 13) - .WithArguments("C.M1()")); - addAttributeTest.SolutionTransforms.Add(SetOptions); - await addAttributeTest.RunAsync(); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_NotOfferedForExpressionBodyWithPreprocessorDirectives() - { - // When an expression-bodied member has preprocessor directives (#if/#else/#endif), - // the "Wrap in unsafe block" fix should NOT be offered because it would destroy - // the conditional compilation structure. - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public int M2() - #if SOME_DEFINE - => 42; - #else - => M1(); - #endif - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public unsafe int M2() - #if SOME_DEFINE - => 42; - #else - => M1(); - #endif - } - """; - - // The "Wrap in unsafe block" fix should NOT be available. - - var addAttributeTest = new VerifyCS.Test - { - TestCode = test, - FixedCode = fixedSource, - CodeActionIndex = 0, - NumberOfIncrementalIterations = 1, - NumberOfFixAllIterations = 1 - }; - addAttributeTest.ExpectedDiagnostics.Add( - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(11, 12, 11, 16) - .WithArguments("C.M1()")); - addAttributeTest.FixedState.ExpectedDiagnostics.Add( - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(11, 12, 11, 16) - .WithArguments("C.M1()")); - addAttributeTest.SolutionTransforms.Add(SetOptions); - await addAttributeTest.RunAsync(); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_SwitchCaseSection() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public int M2(int x) - { - switch (x) - { - case 1: - return M1(); - default: - return 0; - } - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public int M2(int x) - { - switch (x) - { - case 1: - // TODO(unsafe): Baselining unsafe usage - unsafe - { - return M1(); - } - default: - return 0; - } - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(12, 24, 12, 28) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_IfStatementWithoutBraces() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public int M2(bool condition) - { - if (condition) - return M1(); - return 0; - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public int M2(bool condition) - { - if (condition) - { - // TODO(unsafe): Baselining unsafe usage - unsafe - { - return M1(); - } - } - return 0; - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(10, 20, 10, 24) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty()); - } - - [Fact] - public async Task CodeFix_WrapInUnsafeBlock_ExpressionBodiedLocalFunction() - { - var test = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public int M2() - { - int x = 1; - static int LocalFunc() => M1(); - return LocalFunc() + x; - } - } - """; - - var fixedSource = """ - using System.Diagnostics.CodeAnalysis; - - public class C - { - public static unsafe int M1() => 0; - - public int M2() - { - int x = 1; - static int LocalFunc() - { - // TODO(unsafe): Baselining unsafe usage - unsafe - { - return M1(); - } - } - - return LocalFunc() + x; - } - } - """; - - await VerifyRequiresUnsafeCodeFix( - source: test, - fixedSource: fixedSource, - baselineExpected: new[] { - DiagnosticResult.CompilerError(RequiresUnsafeCodeFixProvider.UnsafeMemberOperationDiagnosticId) - .WithSpan(10, 35, 10, 39) - .WithArguments("C.M1()") - }, - fixedExpected: Array.Empty()); - } - } -} -#endif diff --git a/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/UnsafeContextCodeFixTests.Actions.cs b/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/UnsafeContextCodeFixTests.Actions.cs new file mode 100644 index 00000000000000..c4fac248d8728a --- /dev/null +++ b/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/UnsafeContextCodeFixTests.Actions.cs @@ -0,0 +1,452 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if DEBUG +using System.Threading.Tasks; +using ILLink.CodeFix; +using Microsoft.CodeAnalysis.Testing; +using Xunit; + +namespace ILLink.RoslynAnalyzer.Tests +{ + public partial class UnsafeContextCodeFixTests + { + private const string BodyWideKey = nameof(UnsafeContextCodeFixProvider) + ".BodyWide"; + private const string ExpressionFirstKey = nameof(UnsafeContextCodeFixProvider) + ".ExpressionFirst"; + + [Fact] + public Task BodyWideAction_WrapsWholeBody() + { + return VerifyAsync(""" + class C + { + static void Safe() { } + + void M(int* p) + { + {|CS9360:*|}p = 1; + Safe(); + Safe(); + Safe(); + {|CS9360:*|}p = 2; + } + } + """, """ + class C + { + static void Safe() { } + + void M(int* p) + { + // SAFETY: To be audited + unsafe + { + *p = 1; + Safe(); + Safe(); + Safe(); + *p = 2; + } + } + } + """, equivalenceKey: BodyWideKey); + } + + [Fact] + public Task SparseOperations_SeparateBlocksByDefault() + { + return VerifyAsync(""" + class C + { + static void Safe() { } + + void M(int* p) + { + {|CS9360:*|}p = 1; + Safe(); + Safe(); + Safe(); + {|CS9360:*|}p = 2; + } + } + """, """ + class C + { + static void Safe() { } + + void M(int* p) + { + // SAFETY: To be audited + unsafe + { + *p = 1; + } + Safe(); + Safe(); + Safe(); + // SAFETY: To be audited + unsafe + { + *p = 2; + } + } + } + """); + } + + [Fact] + public Task ExpressionFirstAction_UsesWrapper() + { + return VerifyAsync(""" + class C + { + static unsafe int Get() => 0; + + int M() + { + int value = {|CS9362:Get()|}; + return value; + } + } + """, """ + class C + { + static unsafe int Get() => 0; + + int M() + { + int value = /* SAFETY: To be audited */ unsafe(Get()); + return value; + } + } + """, equivalenceKey: ExpressionFirstKey); + } + + [Fact] + public Task CallerArgumentExpression_NeverWrapsCapturedArgument() + { + // The lambda rules out a block, and a wrapper would change the captured message: no fix is offered. + const string source = """ + using System; + using System.Runtime.CompilerServices; + + class C + { + static void Check(bool condition, Action callback, [CallerArgumentExpression(nameof(condition))] string message = "") { } + + void M(int* p) + { + Check({|CS9360:*|}p == 0, static () => { }); + } + } + """; + return VerifyAsync(source, source); + } + + [Fact] + public Task CallerArgumentExpression_InConstructorInitializer_NeverWrapsCapturedArgument() + { + // Initializer arguments are captured like any other, and no block can cover an initializer: no fix is offered. + const string source = """ + using System.Runtime.CompilerServices; + + class Base + { + public Base(int value, [CallerArgumentExpression(nameof(value))] string expression = "") { } + } + + class Derived : Base + { + static unsafe int Get() => 0; + + public Derived() + : base({|CS9362:Get()|}) + { + } + + public Derived(bool flag) + : this({|CS9362:Get()|}, flag) + { + } + + public Derived(int value, bool flag, [CallerArgumentExpression(nameof(value))] string expression = "") + : base(value) + { + } + } + """; + return VerifyAsync(source, source); + } + + [Fact] + public Task ExtendsAdjacentPlaceholderBlock() + { + return VerifyAsync(""" + class C + { + void M(int* p, int* q) + { + // Copies the value. + // SAFETY: To be audited + unsafe + { + *p = 1; + } + {|CS9360:*|}q = 2; + } + } + """, """ + class C + { + void M(int* p, int* q) + { + // Copies the value. + // SAFETY: To be audited + unsafe + { + *p = 1; + *q = 2; + } + } + } + """); + } + + [Fact] + public Task DoesNotExtendAuditedBlock() + { + return VerifyAsync(""" + class C + { + void M(int* p, int* q) + { + // SAFETY: p points to a live local. + unsafe + { + *p = 1; + } + {|CS9360:*|}q = 2; + } + } + """, """ + class C + { + void M(int* p, int* q) + { + // SAFETY: p points to a live local. + unsafe + { + *p = 1; + } + // SAFETY: To be audited + unsafe + { + *q = 2; + } + } + } + """); + } + + [Fact] + public Task OutVariableInIfCondition_IsLiftedBeforeStatement() + { + return VerifyAsync(""" + class C + { + static unsafe bool TryGet(out int value) { value = 0; return true; } + + int M(bool ready, int* p) + { + if (!ready || !{|CS9362:TryGet(out int value)|}) + return {|CS9360:*|}p; + + return value; + } + } + """, """ + class C + { + static unsafe bool TryGet(out int value) { value = 0; return true; } + + int M(bool ready, int* p) + { + int value; + // SAFETY: To be audited + unsafe + { + if (!ready || !TryGet(out value)) + return *p; + } + + return value; + } + } + """); + } + + [Fact] + public Task HeaderOnlyIf_WrapsCondition() + { + return VerifyAsync(""" + class C + { + static unsafe bool TryGet(out int value) { value = 0; return true; } + + int M(bool ready) + { + if (!ready || !{|CS9362:TryGet(out int value)|}) + return 0; + + return value; + } + } + """, """ + class C + { + static unsafe bool TryGet(out int value) { value = 0; return true; } + + int M(bool ready) + { + if (!ready || !/* SAFETY: To be audited */ unsafe(TryGet(out int value))) + return 0; + + return value; + } + } + """); + } + + [Fact] + public Task MixedDiagnostics_ShareOneBlock() + { + return VerifyAsync(""" + using System; + using System.Runtime.CompilerServices; + + [module: SkipLocalsInit] + + class C + { + static unsafe void Use(Span span) { } + + void M(byte* p) + { + {|CS9360:*|}p = 0; + {|CS9362:Use({|CS9361:stackalloc byte[16]|})|}; + } + } + """, """ + using System; + using System.Runtime.CompilerServices; + + [module: SkipLocalsInit] + + class C + { + static unsafe void Use(Span span) { } + + void M(byte* p) + { + // SAFETY: To be audited + unsafe + { + *p = 0; + Use(stackalloc byte[16]); + } + } + } + """); + } + + [Fact] + public Task GeneratedCode_NoFix() + { + const string source = """ + // + class C + { + void M(int* p) + { + {|CS9360:*|}p = 0; + } + } + """; + return VerifyAsync(source, source); + } + + [Fact] + public Task BaseConstructorInvocation_NoFix() + { + const string source = """ + class Base + { + public unsafe Base(int value) { } + } + + class Derived : Base + { + public Derived() + {|CS9362:: base(0)|} + { + } + } + """; + return VerifyAsync(source, source); + } + + [Fact] + public Task UnsafeCallTakingLambda_NoFix() + { + // The only covering context would also cover the lambda body. + const string source = """ + using System; + + class C + { + static unsafe int Run(Func callback) => callback(0); + + int M() => {|CS9362:Run(static x => x + 1)|}; + } + """; + return VerifyAsync(source, source); + } + + [Fact] + public Task ConditionalDirectiveInExpressionBody_NoFix() + { + // Converting the body to a block would separate `#if` from its `#else` and `#endif`. + const string source = """ + class C + { + static unsafe void A() { } + + void M() => + #if DEBUG + A(); + #else + {|CS9362:A()|}; + #endif + } + """; + return VerifyAsync(source, source); + } + + [Fact] + public Task NotUpdatedMemorySafetyRules_NoFix() + { + // Legacy rules also report CS9360 under the new language version, but only v2 code gets inner contexts. + const string source = """ + class C + { + void M(int* p) + { + {|CS9360:*|}p = 0; + } + } + """; + return VerifyAsync(source, source, updatedMemorySafetyRules: false); + } + } +} +#endif diff --git a/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/UnsafeContextCodeFixTests.CoreLibPatterns.cs b/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/UnsafeContextCodeFixTests.CoreLibPatterns.cs new file mode 100644 index 00000000000000..ddaf7382cbf368 --- /dev/null +++ b/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/UnsafeContextCodeFixTests.CoreLibPatterns.cs @@ -0,0 +1,217 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if DEBUG +using System.Threading.Tasks; +using Xunit; + +namespace ILLink.RoslynAnalyzer.Tests +{ + public partial class UnsafeContextCodeFixTests + { + [Fact] + public Task NestedOperandInExpressionBody_GetsInlineMarker() + { + // The marker goes directly before `unsafe(`, not above the member. + return VerifyAsync(""" + class C + { + static unsafe int Read(int* p) => 0; + + [System.Obsolete] + int M(int* p, int? cached, bool fast) => cached ?? (fast ? 0 : {|CS9362:Read(p)|} + 1); + } + """, """ + class C + { + static unsafe int Read(int* p) => 0; + + [System.Obsolete] + int M(int* p, int? cached, bool fast) => cached ?? (fast ? 0 : /* SAFETY: To be audited */ unsafe(Read(p)) + 1); + } + """); + } + + [Fact] + public Task PropertyExpressionBody_UnsafeGetter_ConvertsToGetAccessor() + { + // The getter of a wrapped property access is still checked outside `unsafe(...)`, so no wrapper is valid. + return VerifyAsync(""" + class Base + { + public virtual bool IsCollectible => false; + } + + class Other + { + public unsafe bool IsCollectible => true; + } + + class C : Base + { + Other ReflectedType => new Other(); + + public override bool IsCollectible => {|CS9362:ReflectedType.IsCollectible|}; + } + """, """ + class Base + { + public virtual bool IsCollectible => false; + } + + class Other + { + public unsafe bool IsCollectible => true; + } + + class C : Base + { + Other ReflectedType => new Other(); + + public override bool IsCollectible + { + get + { + // SAFETY: To be audited + unsafe + { + return ReflectedType.IsCollectible; + } + } + } + } + """); + } + + [Fact] + public Task ManyFailingWrappers_StillReachBodyConversion() + { + // Every parenthesized level is a wrapper candidate that fails as above; the conversion comes after all of them. + return VerifyAsync(""" + class Base + { + public virtual bool IsCollectible => false; + } + + class Other + { + public unsafe bool IsCollectible => true; + } + + class C : Base + { + Other ReflectedType => new Other(); + + public override bool IsCollectible => (((((((((((((({|CS9362:ReflectedType.IsCollectible|})))))))))))))); + } + """, """ + class Base + { + public virtual bool IsCollectible => false; + } + + class Other + { + public unsafe bool IsCollectible => true; + } + + class C : Base + { + Other ReflectedType => new Other(); + + public override bool IsCollectible + { + get + { + // SAFETY: To be audited + unsafe + { + return ((((((((((((((ReflectedType.IsCollectible)))))))))))))); + } + } + } + } + """); + } + + [Fact] + public Task SwitchCaseGuards_EachLabelIsAnAnchor() + { + return VerifyAsync(""" + class C + { + static unsafe bool Check(object o) => true; + + static void Use() { } + + int M(object o) + { + switch (o) + { + case string s when {|CS9362:Check(s)|}: + Use(); + return 1; + case { } when {|CS9362:Check(o)|}: + Use(); + return 2; + default: + Use(); + return 0; + } + } + } + """, """ + class C + { + static unsafe bool Check(object o) => true; + + static void Use() { } + + int M(object o) + { + switch (o) + { + case string s when /* SAFETY: To be audited */ unsafe(Check(s)): + Use(); + return 1; + case { } when /* SAFETY: To be audited */ unsafe(Check(o)): + Use(); + return 2; + default: + Use(); + return 0; + } + } + } + """); + } + + [Fact] + public Task SingleLineBody_IsExpanded() + { + return VerifyAsync(""" + class C + { + static unsafe void Write(int id, string message) { } + + public void Log(string message) { {|CS9362:Write(1, message)|}; } + } + """, """ + class C + { + static unsafe void Write(int id, string message) { } + + public void Log(string message) + { + // SAFETY: To be audited + unsafe + { + Write(1, message); + } + } + } + """); + } + } +} +#endif diff --git a/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/UnsafeContextCodeFixTests.Declarations.cs b/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/UnsafeContextCodeFixTests.Declarations.cs new file mode 100644 index 00000000000000..0eb6a730ea086a --- /dev/null +++ b/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/UnsafeContextCodeFixTests.Declarations.cs @@ -0,0 +1,604 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if DEBUG +using System.Threading.Tasks; +using Xunit; + +namespace ILLink.RoslynAnalyzer.Tests +{ + public partial class UnsafeContextCodeFixTests + { + [Fact] + public Task Stackalloc_ScopedSplit() + { + return VerifyAsync(""" + using System; + using System.Runtime.CompilerServices; + + [module: SkipLocalsInit] + + class C + { + static void Use(Span a, ReadOnlySpan b) { } + + void M(int length) + { + Span buffer = {|CS9361:stackalloc byte[256]|}; + ReadOnlySpan values = length < 100 ? {|CS9361:stackalloc int[100]|} : new int[length]; + Use(buffer, values); + } + } + """, """ + using System; + using System.Runtime.CompilerServices; + + [module: SkipLocalsInit] + + class C + { + static void Use(Span a, ReadOnlySpan b) { } + + void M(int length) + { + scoped Span buffer; + scoped ReadOnlySpan values; + // SAFETY: To be audited + unsafe + { + buffer = stackalloc byte[256]; + values = length < 100 ? stackalloc int[100] : new int[length]; + } + Use(buffer, values); + } + } + """); + } + + [Fact] + public Task Stackalloc_RefStructBuilderArgument_UsesExpression() + { + return VerifyAsync(""" + using System; + using System.Runtime.CompilerServices; + + [module: SkipLocalsInit] + + ref struct Builder + { + public Builder(Span buffer) { } + public void Append(char c) { } + } + + class C + { + void M() + { + var builder = new Builder({|CS9361:stackalloc char[256]|}); + builder.Append('a'); + builder.Append('b'); + } + } + """, """ + using System; + using System.Runtime.CompilerServices; + + [module: SkipLocalsInit] + + ref struct Builder + { + public Builder(Span buffer) { } + public void Append(char c) { } + } + + class C + { + void M() + { + var builder = new Builder(/* SAFETY: To be audited */ unsafe(stackalloc char[256])); + builder.Append('a'); + builder.Append('b'); + } + } + """); + } + + [Fact] + public Task Stackalloc_NestedScopeEscape_UsesClosedBlock() + { + // A scoped split would narrow `a` and break `outer = a`; the block containing `a` and its use is proportionate. + return VerifyAsync(""" + using System; + using System.Runtime.CompilerServices; + + [module: SkipLocalsInit] + + class C + { + static void Use(Span a) { } + + void M(int length, bool b) + { + scoped Span outer; + if (b) + { + Span a = length < 100 ? {|CS9361:stackalloc int[100]|} : new int[length]; + outer = a; + Use(outer); + } + } + } + """, """ + using System; + using System.Runtime.CompilerServices; + + [module: SkipLocalsInit] + + class C + { + static void Use(Span a) { } + + void M(int length, bool b) + { + scoped Span outer; + if (b) + { + // SAFETY: To be audited + unsafe + { + Span a = length < 100 ? stackalloc int[100] : new int[length]; + outer = a; + } + Use(outer); + } + } + } + """); + } + + [Fact] + public Task Stackalloc_NestedScopeEscape_KeepsDeclaration() + { + // Neither a scoped split nor a proportionate closed block preserves `a`, so its declaration is kept. + return VerifyAsync(""" + using System; + using System.Runtime.CompilerServices; + + [module: SkipLocalsInit] + + class C + { + static void Use(Span a) { } + + void M(int length, bool b) + { + scoped Span outer; + if (b) + { + Span a = length < 100 ? {|CS9361:stackalloc int[100]|} : new int[length]; + outer = a; + Use(a); + Use(a); + } + } + } + """, """ + using System; + using System.Runtime.CompilerServices; + + [module: SkipLocalsInit] + + class C + { + static void Use(Span a) { } + + void M(int length, bool b) + { + scoped Span outer; + if (b) + { + Span a = length < 100 ? /* SAFETY: To be audited */ unsafe(stackalloc int[100]) : new int[length]; + outer = a; + Use(a); + Use(a); + } + } + } + """); + } + + [Fact] + public Task OutVariable_InIterator() + { + return VerifyAsync(""" + using System.Collections.Generic; + + class C + { + static unsafe void Fill(out int value) => value = 0; + + IEnumerable M() + { + {|CS9362:Fill(out var value)|}; + yield return value; + } + } + """, """ + using System.Collections.Generic; + + class C + { + static unsafe void Fill(out int value) => value = 0; + + IEnumerable M() + { + int value; + // SAFETY: To be audited + unsafe + { + Fill(out value); + } + yield return value; + } + } + """); + } + + [Fact] + public Task YieldOperand_UsesExpression() + { + return VerifyAsync(""" + using System.Collections.Generic; + + class C + { + static unsafe int Get() => 0; + + IEnumerable M() + { + yield return {|CS9362:Get()|}; + } + } + """, """ + using System.Collections.Generic; + + class C + { + static unsafe int Get() => 0; + + IEnumerable M() + { + yield return /* SAFETY: To be audited */ unsafe(Get()); + } + } + """); + } + + [Fact] + public Task RefLocal_SafeLaterUses_UsesRefExpression() + { + return VerifyAsync(""" + class C + { + struct Entry + { + public int Value; + public int Next; + } + + void M(Entry* entries, int i, int value, int next) + { + ref Entry entry = ref entries{|CS9360:[|}i]; + entry.Value = value; + entry.Next = next; + } + } + """, """ + class C + { + struct Entry + { + public int Value; + public int Next; + } + + void M(Entry* entries, int i, int value, int next) + { + ref Entry entry = ref /* SAFETY: To be audited */ unsafe(entries[i]); + entry.Value = value; + entry.Next = next; + } + } + """); + } + + [Fact] + public Task RefLocals_RelatedLaterUses_ClosedBlock() + { + return VerifyAsync(""" + class C + { + static unsafe void Copy(ref byte destination, ref byte source) { } + + void M(byte* source, byte* destination) + { + ref byte src = ref {|CS9360:*|}source; + ref byte dst = ref {|CS9360:*|}destination; + {|CS9362:Copy(ref dst, ref src)|}; + } + } + """, """ + class C + { + static unsafe void Copy(ref byte destination, ref byte source) { } + + void M(byte* source, byte* destination) + { + // SAFETY: To be audited + unsafe + { + ref byte src = ref *source; + ref byte dst = ref *destination; + Copy(ref dst, ref src); + } + } + } + """); + } + + [Fact] + public Task MultiDeclarator_SplitsInOrder() + { + return VerifyAsync(""" + class C + { + int M(int* p) + { + int a = {|CS9360:*|}p, b = a + {|CS9360:*|}p; + return a + b; + } + } + """, """ + class C + { + int M(int* p) + { + int a, b; + // SAFETY: To be audited + unsafe + { + a = *p; + b = a + *p; + } + return a + b; + } + } + """); + } + + [Fact] + public Task SplitDeclaration_KeepsCommentsAroundIdentifier() + { + return VerifyAsync(""" + class C + { + static unsafe int Get() => 0; + + int M() + { + int value /* identifier */ = /* assignment */ {|CS9362:Get()|}; + return value; + } + } + """, """ + class C + { + static unsafe int Get() => 0; + + int M() + { + int value; + // SAFETY: To be audited + unsafe + { + value /* identifier */ = /* assignment */ Get(); + } + return value; + } + } + """); + } + + [Fact] + public Task SplitOutVariable_KeepsCommentAfterType() + { + return VerifyAsync(""" + class C + { + static unsafe void Get(out int value) { value = 0; } + + int M() + { + {|CS9362:Get(out int /* explanation */ value)|}; + return value; + } + } + """, """ + class C + { + static unsafe void Get(out int value) { value = 0; } + + int M() + { + int /* explanation */ value; + // SAFETY: To be audited + unsafe + { + Get(out value); + } + return value; + } + } + """); + } + + [Fact] + public Task SplitDeclaration_KeepsInlineComment() + { + return VerifyAsync(""" + class C + { + int M(int* p) + { + {|CS9360:*|}p = 0; + /* important */ int a = {|CS9360:*|}p; + return a; + } + } + """, """ + class C + { + int M(int* p) + { + /* important */ int a; + // SAFETY: To be audited + unsafe + { + *p = 0; + a = *p; + } + return a; + } + } + """); + } + + [Fact] + public Task VarWithDirectivesInInitializer_GrowsInsteadOfSplitting() + { + // The inferred type is `long` in the inactive arm, so hoisting `int value;` would break that configuration. + return VerifyAsync(""" + class C + { + static unsafe int GetInt() => 0; + static long GetLong() => 0; + + long M() + { + var value = ( + #if NEVER + GetLong() + #else + {|CS9362:GetInt()|} + #endif + ); + return value; + } + } + """, """ + class C + { + static unsafe int GetInt() => 0; + static long GetLong() => 0; + + long M() + { + // SAFETY: To be audited + unsafe + { + var value = ( + #if NEVER + GetLong() + #else + GetInt() + #endif + ); + return value; + } + } + } + """); + } + + [Fact] + public Task NullableVar_SplitKeepsAnnotation() + { + return VerifyAsync(""" + #nullable enable + class C + { + static unsafe string? Get() => null; + + int M() + { + var value = {|CS9362:Get()|}; + value = null; + return value?.Length ?? 0; + } + } + """, """ + #nullable enable + class C + { + static unsafe string? Get() => null; + + int M() + { + string? value; + // SAFETY: To be audited + unsafe + { + value = Get(); + } + value = null; + return value?.Length ?? 0; + } + } + """); + } + + [Fact] + public Task SwitchSection_SharedScope_SplitsDeclaration() + { + return VerifyAsync(""" + class C + { + int M(int kind, int* p) + { + switch (kind) + { + case 0: + int value = {|CS9360:*|}p; + return value; + default: + value = 1; + return value; + } + } + } + """, """ + class C + { + int M(int kind, int* p) + { + switch (kind) + { + case 0: + int value; + // SAFETY: To be audited + unsafe + { + value = *p; + } + return value; + default: + value = 1; + return value; + } + } + } + """); + } + } +} +#endif diff --git a/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/UnsafeContextCodeFixTests.MultiPass.cs b/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/UnsafeContextCodeFixTests.MultiPass.cs new file mode 100644 index 00000000000000..b31d1baa60c120 --- /dev/null +++ b/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/UnsafeContextCodeFixTests.MultiPass.cs @@ -0,0 +1,372 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if DEBUG +using System.Threading.Tasks; +using Xunit; + +namespace ILLink.RoslynAnalyzer.Tests +{ + /// + /// Later passes (e.g. under other build configurations) merge with the placeholder contexts of earlier passes and + /// account for code in inactive #if arms. + /// + public partial class UnsafeContextCodeFixTests + { + [Fact] + public Task SecondPass_ReplacesPlaceholderWrapperWithBlock() + { + return VerifyAsync(""" + class C + { + static unsafe int A() => 0; + static unsafe int B() => 0; + + int M() + { + return /* SAFETY: To be audited */ unsafe(A()) + {|CS9362:B()|}; + } + } + """, """ + class C + { + static unsafe int A() => 0; + static unsafe int B() => 0; + + int M() + { + // SAFETY: To be audited + unsafe + { + return A() + B(); + } + } + } + """); + } + + [Fact] + public Task SecondPass_WidensPlaceholderWrapper() + { + return VerifyAsync(""" + class C + { + static unsafe int A() => 0; + static unsafe int B() => 0; + + int P => /* SAFETY: To be audited */ unsafe(A()) * {|CS9362:B()|}; + } + """, """ + class C + { + static unsafe int A() => 0; + static unsafe int B() => 0; + + int P => /* SAFETY: To be audited */ unsafe(A() * B()); + } + """); + } + + [Fact] + public Task SecondPass_KeepsAuditedWrapper() + { + // Merging would broaden the audited claim and a clause holds one wrapper, so no fix is offered. + const string source = """ + class C + { + static unsafe int A() => 0; + static unsafe int B() => 0; + + int P => /* SAFETY: A has no preconditions. */ unsafe(A()) * {|CS9362:B()|}; + } + """; + return VerifyAsync(source, source); + } + + [Fact] + public Task SecondPass_MergesPlaceholderBlockBetweenOperations() + { + return VerifyAsync(""" + class C + { + void M(int* p) + { + {|CS9360:*|}p = 1; + // SAFETY: To be audited + unsafe + { + *p = 2; + } + {|CS9360:*|}p = 3; + } + } + """, """ + class C + { + void M(int* p) + { + // SAFETY: To be audited + unsafe + { + *p = 1; + *p = 2; + *p = 3; + } + } + } + """); + } + + [Fact] + public Task PlaceholderBlockWithInnerComment_IsNotExtended() + { + // Extending would drop the comment before the closing brace. + return VerifyAsync(""" + class C + { + void M(int* p, int* q) + { + // SAFETY: To be audited + unsafe + { + *p = 1; + // Trailing note. + } + {|CS9360:*|}q = 2; + } + } + """, """ + class C + { + void M(int* p, int* q) + { + // SAFETY: To be audited + unsafe + { + *p = 1; + // Trailing note. + } + // SAFETY: To be audited + unsafe + { + *q = 2; + } + } + } + """); + } + + [Fact] + public Task InactiveUseAfterBlock_SplitsDeclaration() + { + // `n` is only used in an inactive arm, so a block containing its declaration would break that configuration. + return VerifyAsync(""" + class C + { + static unsafe int Read() => 0; + + static void Log(int value) { } + + int M() + { + var n = {|CS9362:Read()|}; + #if NEVER + Log(n); + #endif + return 0; + } + } + """, """ + class C + { + static unsafe int Read() => 0; + + static void Log(int value) { } + + int M() + { + int n; + // SAFETY: To be audited + unsafe + { + n = Read(); + } + #if NEVER + Log(n); + #endif + return 0; + } + } + """); + } + + [Fact] + public Task InactiveDeclarationInRange_IsNotHidden() + { + // Merging both statements would move the inactive declaration of `x` into the block, away from its use. + return VerifyAsync(""" + class C + { + static void Use(int value) { } + + void M(int* p) + { + {|CS9360:*|}p = 1; + #if NEVER + int x = 0; + #endif + {|CS9360:*|}p = 2; + #if NEVER + Use(x); + #endif + } + } + """, """ + class C + { + static void Use(int value) { } + + void M(int* p) + { + // SAFETY: To be audited + unsafe + { + *p = 1; + } + #if NEVER + int x = 0; + #endif + // SAFETY: To be audited + unsafe + { + *p = 2; + } + #if NEVER + Use(x); + #endif + } + } + """); + } + + [Fact] + public Task InactiveArmInBlock_KeepsStringLiterals() + { + return VerifyAsync(""" + class C + { + static void Use(string value) { } + + void M(int* p) + { + if ({|CS9360:*|}p == 0) + { + #if NEVER + Use(@"first + second"); + #endif + {|CS9360:*|}p = 1; + } + } + } + """, """ + class C + { + static void Use(string value) { } + + void M(int* p) + { + // SAFETY: To be audited + unsafe + { + if (*p == 0) + { + #if NEVER + Use(@"first + second"); + #endif + *p = 1; + } + } + } + } + """); + } + + [Fact] + public Task DeconstructionFollowedByYield_SplitsDeclarations() + { + return VerifyAsync(""" + using System.Collections.Generic; + + class C + { + static unsafe (int, string) Get() => (0, ""); + + IEnumerable M() + { + var (first, second) = {|CS9362:Get()|}; + yield return first; + } + } + """, """ + using System.Collections.Generic; + + class C + { + static unsafe (int, string) Get() => (0, ""); + + IEnumerable M() + { + int first; + string second; + // SAFETY: To be audited + unsafe + { + (first, second) = Get(); + } + yield return first; + } + } + """); + } + + [Fact] + public Task MixedDeconstruction_KeepsDiscardsAndExistingTargets() + { + return VerifyAsync(""" + using System.Collections.Generic; + + class C + { + static unsafe (int, (string, long)) Get() => (0, ("", 0)); + + IEnumerable M(long existing) + { + (int first, (var _, existing)) = {|CS9362:Get()|}; + yield return first; + } + } + """, """ + using System.Collections.Generic; + + class C + { + static unsafe (int, (string, long)) Get() => (0, ("", 0)); + + IEnumerable M(long existing) + { + int first; + // SAFETY: To be audited + unsafe + { + (first, (var _, existing)) = Get(); + } + yield return first; + } + } + """); + } + } +} +#endif diff --git a/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/UnsafeContextCodeFixTests.Statements.cs b/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/UnsafeContextCodeFixTests.Statements.cs new file mode 100644 index 00000000000000..46a6bf30acf83c --- /dev/null +++ b/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/UnsafeContextCodeFixTests.Statements.cs @@ -0,0 +1,609 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if DEBUG +using System.Threading.Tasks; +using Xunit; + +namespace ILLink.RoslynAnalyzer.Tests +{ + public partial class UnsafeContextCodeFixTests + { + [Fact] + public Task HeaderOnlyCondition_UsesExpression() + { + return VerifyAsync(""" + class C + { + static void Use(int x) { } + + void M(int* p, int a, int b) + { + while ({|CS9360:*|}p != 0) + { + Use(a); + Use(b); + } + } + } + """, """ + class C + { + static void Use(int x) { } + + void M(int* p, int a, int b) + { + while (/* SAFETY: To be audited */ unsafe(*p) != 0) + { + Use(a); + Use(b); + } + } + } + """); + } + + [Fact] + public Task HeaderAndBody_WrapsWholeLoop() + { + return VerifyAsync(""" + class C + { + void M(int* p) + { + while ({|CS9360:*|}p != 0) + { + {|CS9360:*|}p = 0; + } + } + } + """, """ + class C + { + void M(int* p) + { + // SAFETY: To be audited + unsafe + { + while (*p != 0) + { + *p = 0; + } + } + } + } + """); + } + + [Fact] + public Task BodyOnly_WrapsInsideBody() + { + return VerifyAsync(""" + class C + { + void M(int* p, int length) + { + for (int i = 0; i < length; i++) + { + p{|CS9360:[|}i] = 0; + } + } + } + """, """ + class C + { + void M(int* p, int length) + { + for (int i = 0; i < length; i++) + { + // SAFETY: To be audited + unsafe + { + p[i] = 0; + } + } + } + } + """); + } + + [Fact] + public Task ElseIf_UsesExpressionAndKeepsChain() + { + return VerifyAsync(""" + class C + { + static void Use() { } + + void M(int* p, bool a) + { + if (a) + { + Use(); + } + else if ({|CS9360:*|}p == 0) + { + Use(); + } + } + } + """, """ + class C + { + static void Use() { } + + void M(int* p, bool a) + { + if (a) + { + Use(); + } + else if (/* SAFETY: To be audited */ unsafe(*p) == 0) + { + Use(); + } + } + } + """); + } + + [Fact] + public Task EmbeddedStatement_AddsBraces() + { + return VerifyAsync(""" + class C + { + void M(int* p, bool c) + { + if (c) + {|CS9360:*|}p = 0; + + if (c) {|CS9360:*|}p = 1; + } + } + """, """ + class C + { + void M(int* p, bool c) + { + if (c) + { + // SAFETY: To be audited + unsafe + { + *p = 0; + } + } + + if (c) + { + // SAFETY: To be audited + unsafe + { + *p = 1; + } + } + } + } + """); + } + + [Fact] + public Task StackedFixed_WrapsInnermostBody() + { + return VerifyAsync(""" + class C + { + static unsafe int Compare(char* a, char* b) => 0; + + int M(string a, string b) + { + fixed (char* pA = a) + fixed (char* pB = b) + { + return {|CS9362:Compare(pA, pB)|}; + } + } + } + """, """ + class C + { + static unsafe int Compare(char* a, char* b) => 0; + + int M(string a, string b) + { + fixed (char* pA = a) + fixed (char* pB = b) + { + // SAFETY: To be audited + unsafe + { + return Compare(pA, pB); + } + } + } + } + """); + } + + [Fact] + public Task SwitchExpressionArms_OneContext() + { + return VerifyAsync(""" + class C + { + int M(int kind, byte* data) + { + return kind switch + { + 1 => {|CS9360:*|}(sbyte*)data, + 2 => {|CS9360:*|}(short*)data, + _ => 0, + }; + } + } + """, """ + class C + { + int M(int kind, byte* data) + { + // SAFETY: To be audited + unsafe + { + return kind switch + { + 1 => *(sbyte*)data, + 2 => *(short*)data, + _ => 0, + }; + } + } + } + """); + } + + [Fact] + public Task StatementWithLambda_UsesExpressionOutsideLambda() + { + return VerifyAsync(""" + using System; + + class C + { + static unsafe object Get(object o) => o; + + static void Register(Func callback, object state) { } + + void M(object obj) + { + Register(static s => s, {|CS9362:Get(obj)|}); + } + } + """, """ + using System; + + class C + { + static unsafe object Get(object o) => o; + + static void Register(Func callback, object state) { } + + void M(object obj) + { + Register(static s => s, /* SAFETY: To be audited */ unsafe(Get(obj))); + } + } + """); + } + + [Fact] + public Task LambdaBody_PlannedInsideLambda() + { + return VerifyAsync(""" + using System; + + class C + { + static unsafe void Act() { } + + void M() + { + Action a = () => + { + {|CS9362:Act()|}; + }; + a(); + } + } + """, """ + using System; + + class C + { + static unsafe void Act() { } + + void M() + { + Action a = () => + { + // SAFETY: To be audited + unsafe + { + Act(); + } + }; + a(); + } + } + """); + } + + [Fact] + public Task CatchFilter_UsesExpression() + { + return VerifyAsync(""" + using System; + + class C + { + static unsafe bool Filter(Exception e) => true; + + static void Work() { } + + void M() + { + try + { + Work(); + } + catch (Exception e) when ({|CS9362:Filter(e)|}) + { + Work(); + } + } + } + """, """ + using System; + + class C + { + static unsafe bool Filter(Exception e) => true; + + static void Work() { } + + void M() + { + try + { + Work(); + } + catch (Exception e) when (/* SAFETY: To be audited */ unsafe(Filter(e))) + { + Work(); + } + } + } + """); + } + + [Fact] + public Task ImplicitEnumeration_WrapsWholeForEach() + { + return VerifyAsync(""" + using System.Collections.Generic; + + class Items + { + public unsafe IEnumerator GetEnumerator() => null!; + } + + class C + { + static void Use(int x) { } + + void M(Items items) + { + {|CS9362:foreach|} (int item in items) + { + Use(item); + } + } + } + """, """ + using System.Collections.Generic; + + class Items + { + public unsafe IEnumerator GetEnumerator() => null!; + } + + class C + { + static void Use(int x) { } + + void M(Items items) + { + // SAFETY: To be audited + unsafe + { + foreach (int item in items) + { + Use(item); + } + } + } + } + """); + } + + [Fact] + public Task LabelStaysOutsideBlock() + { + return VerifyAsync(""" + class C + { + void M(int* p, int n) + { + Retry: + {|CS9360:*|}p = n; + if (n-- > 0) + goto Retry; + } + } + """, """ + class C + { + void M(int* p, int n) + { + Retry: + // SAFETY: To be audited + unsafe + { + *p = n; + } + if (n-- > 0) + goto Retry; + } + } + """); + } + + [Fact] + public Task ConstructorInitializerArgument() + { + return VerifyAsync(""" + class Base + { + public Base(int value) { } + } + + class Derived : Base + { + static unsafe int Get() => 0; + + public Derived() + : base({|CS9362:Get()|}) + { + } + } + """, """ + class Base + { + public Base(int value) { } + } + + class Derived : Base + { + static unsafe int Get() => 0; + + public Derived() + : base(/* SAFETY: To be audited */ unsafe(Get())) + { + } + } + """); + } + + [Fact] + public Task ConstructorConstraint() + { + return VerifyAsync(""" + class U + { + public unsafe U() { } + } + + class G where T : new() { } + + class C + { + object M() => new {|CS9376:G|}(); + } + """, """ + class U + { + public unsafe U() { } + } + + class G where T : new() { } + + class C + { + object M() => /* SAFETY: To be audited */ unsafe(new G()); + } + """); + } + + [Fact] + public Task AwaitInsideBlock() + { + // `await` is allowed in unsafe contexts under the updated rules. + return VerifyAsync(""" + using System.Threading.Tasks; + + class C + { + static unsafe int Get(int value) => value; + + async Task M() + { + int x = {|CS9362:Get(await Task.FromResult(1))|}; + return x; + } + } + """, """ + using System.Threading.Tasks; + + class C + { + static unsafe int Get(int value) => value; + + async Task M() + { + int x; + // SAFETY: To be audited + unsafe + { + x = Get(await Task.FromResult(1)); + } + return x; + } + } + """); + } + + [Fact] + public Task MethodGroupConversion_ConvertsToGetAccessor() + { + // `unsafe(M)` is still an error: the conversion to `Action` happens outside the unsafe expression. + return VerifyAsync(""" + using System; + + class C + { + unsafe void M() { } + Action P => {|CS9362:M|}; + } + """, """ + using System; + + class C + { + unsafe void M() { } + Action P + { + get + { + // SAFETY: To be audited + unsafe + { + return M; + } + } + } + } + """); + } + } +} +#endif diff --git a/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/UnsafeContextCodeFixTests.cs b/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/UnsafeContextCodeFixTests.cs new file mode 100644 index 00000000000000..ca8c2b3e9442aa --- /dev/null +++ b/src/tools/illink/test/ILLink.RoslynAnalyzer.Tests/UnsafeContextCodeFixTests.cs @@ -0,0 +1,223 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if DEBUG +using System.Threading.Tasks; +using ILLink.CodeFix; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Testing; +using Xunit; + +namespace ILLink.RoslynAnalyzer.Tests +{ + /// + /// Verifies that introduces audited inner unsafe contexts for the + /// unsafe-v2 diagnostics (CS9360, CS9361, CS9362, CS9363 and CS9376). + /// + public partial class UnsafeContextCodeFixTests + { + private static Task VerifyAsync( + string source, + string fixedSource, + string? equivalenceKey = null, + bool updatedMemorySafetyRules = true, + int? iterations = null, + CodeFixTestBehaviors behaviors = CodeFixTestBehaviors.None) + { + var test = new CSharpCodeFixVerifier.Test + { + TestCode = source, + FixedCode = fixedSource, + CodeActionEquivalenceKey = equivalenceKey, + CodeFixTestBehaviors = behaviors, + }; + if (iterations is not null) + { + test.NumberOfIncrementalIterations = iterations; + test.NumberOfFixAllIterations = iterations; + } + + test.SolutionTransforms.Add((solution, projectId) => SetOptions(solution, projectId, updatedMemorySafetyRules)); + return test.RunAsync(); + } + + private static Solution SetOptions(Solution solution, ProjectId projectId, bool updatedMemorySafetyRules) + { + var project = solution.GetProject(projectId)!; + var parseOptions = ((CSharpParseOptions)project.ParseOptions!).WithLanguageVersion(LanguageVersion.Preview); + if (updatedMemorySafetyRules) + parseOptions = parseOptions.WithFeatures([.. parseOptions.Features, new("updated-memory-safety-rules", "")]); + + var compilationOptions = ((CSharpCompilationOptions)project.CompilationOptions!).WithAllowUnsafe(true); + return solution + .WithProjectParseOptions(projectId, parseOptions) + .WithProjectCompilationOptions(projectId, compilationOptions); + } + + [Fact] + public Task ExpressionStatement() + { + return VerifyAsync(""" + class C + { + static unsafe void M() { } + + void Caller() + { + // Leading comment. + {|CS9362:M()|}; + } + } + """, """ + class C + { + static unsafe void M() { } + + void Caller() + { + // Leading comment. + // SAFETY: To be audited + unsafe + { + M(); + } + } + } + """); + } + + [Fact] + public Task SplitsDeclarationUsedAfterBlock() + { + return VerifyAsync(""" + class C + { + static unsafe int M() => 0; + + int Caller() + { + var value = {|CS9362:M()|}; + return value + 1; + } + } + """, """ + class C + { + static unsafe int M() => 0; + + int Caller() + { + int value; + // SAFETY: To be audited + unsafe + { + value = M(); + } + return value + 1; + } + } + """); + } + + [Fact] + public Task PointerDereferences_MergeAdjacentAndProportionateGap() + { + return VerifyAsync(""" + class C + { + void M(int* p, int offset) + { + {|CS9360:*|}p = 1; + offset += 4; + p{|CS9360:[|}offset] = 2; + } + } + """, """ + class C + { + void M(int* p, int offset) + { + // SAFETY: To be audited + unsafe + { + *p = 1; + offset += 4; + p[offset] = 2; + } + } + } + """); + } + + [Fact] + public Task ExpressionBodiedMember_KeepsExpressionBody() + { + return VerifyAsync(""" + class C + { + int* _p; + + int Value => {|CS9360:*|}_p; + } + """, """ + class C + { + int* _p; + + int Value => /* SAFETY: To be audited */ unsafe(*_p); + } + """); + } + + [Fact] + public Task VoidExpressionBody_ConvertsToBlockBody() + { + return VerifyAsync(""" + class C + { + static unsafe void M() { } + + void Caller() => {|CS9362:M()|}; + } + """, """ + class C + { + static unsafe void M() { } + + void Caller() + { + // SAFETY: To be audited + unsafe + { + M(); + } + } + } + """); + } + + [Fact] + public Task FieldInitializer() + { + return VerifyAsync(""" + class C + { + static unsafe int M() => 0; + + [System.Obsolete] + static int s_value = {|CS9362:M()|}; + } + """, """ + class C + { + static unsafe int M() => 0; + + [System.Obsolete] + static int s_value = /* SAFETY: To be audited */ unsafe(M()); + } + """); + } + } +} +#endif