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