Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System.Collections.Immutable;
using System.Composition;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using HttpClient.Resilience.Analyzers.Diagnostics;
Expand Down Expand Up @@ -43,7 +42,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
declaration.Declaration.Variables.Count == 1)
{
var variableName = declaration.Declaration.Variables[0].Identifier.ValueText;
if (!VariableEscapesScope(node, variableName))
if (!UsingDeclarationEscapeGate.VariableEscapesScope(node, variableName))
{
context.RegisterCodeFix(
CodeAction.Create(
Expand All @@ -64,7 +63,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
out var assignmentStatement))
{
var mergedName = adjacentDeclaration.Declaration.Variables[0].Identifier.ValueText;
if (!VariableEscapesScope(node, mergedName))
if (!UsingDeclarationEscapeGate.VariableEscapesScope(node, mergedName))
{
context.RegisterCodeFix(
CodeAction.Create(
Expand All @@ -91,7 +90,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
out var topLevelAssignmentStatement))
{
var topLevelName = topLevelDeclaration.Declaration.Variables[0].Identifier.ValueText;
if (!VariableEscapesScope(node, topLevelName))
if (!UsingDeclarationEscapeGate.VariableEscapesScope(node, topLevelName))
{
context.RegisterCodeFix(
CodeAction.Create(
Expand Down Expand Up @@ -155,34 +154,6 @@ variables[0].Initializer is not null ||
assignmentStatement = statement;
return true;
}
private static bool VariableEscapesScope(SyntaxNode node, string variableName)
{
if (string.IsNullOrEmpty(variableName))
{
return false;
}

SyntaxNode? scope = node.FirstAncestorOrSelf<BlockSyntax>();
scope ??= node.FirstAncestorOrSelf<CompilationUnitSyntax>();
if (scope is null)
{
return false;
}

// Disposing at scope end breaks callers when the response outlives the block:
// returned directly or stored into a member or another container.
return scope.DescendantNodes()
.Any(descendant => descendant switch
{
ReturnStatementSyntax { Expression: IdentifierNameSyntax returned } =>
returned.Identifier.ValueText == variableName,
AssignmentExpressionSyntax assignment when assignment.Left is not IdentifierNameSyntax =>
assignment.Right.DescendantNodesAndSelf()
.OfType<IdentifierNameSyntax>()
.Any(identifier => identifier.Identifier.ValueText == variableName),
_ => false,
});
}

private static async Task<Document> AddUsingDeclarationAsync(
Document document,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace HttpClient.Resilience.Analyzers.CodeFixes;

/// <summary>
/// Decides whether a `using` declaration fix is safe for a disposable local.
/// Disposing at scope end breaks callers when the value outlives the block,
/// so no automatic fix is offered once the variable escapes.
/// </summary>
internal static class UsingDeclarationEscapeGate
{
internal static bool VariableEscapesScope(SyntaxNode node, string variableName)
{
if (string.IsNullOrEmpty(variableName))
{
return false;
}

SyntaxNode? scope = node.FirstAncestorOrSelf<BlockSyntax>() as SyntaxNode ??
node.FirstAncestorOrSelf<CompilationUnitSyntax>();
if (scope is null)
{
return false;
}

return scope.DescendantNodes()
.OfType<IdentifierNameSyntax>()
.Where(identifier => identifier.Identifier.ValueText == variableName)
.Any(IsTransferredOut);
}

private static bool IsTransferredOut(IdentifierNameSyntax identifier)
{
var child = (SyntaxNode)identifier;
for (var current = identifier.Parent; current is not null; child = current, current = current.Parent)
{
// Merely calling a member on the value keeps ownership local.
if (current is MemberAccessExpressionSyntax memberAccess &&
ReferenceEquals(memberAccess.Expression, child))
{
return false;
}

if (current is ArgumentSyntax)
{
return true;
}

if (current is ReturnStatementSyntax)
{
return true;
}

// Object, collection, array, and `with` initializers all store into
// the new aggregate (WithInitializerExpression shares this node type).
if (current is InitializerExpressionSyntax)
{
return true;
}

if (current is AssignmentExpressionSyntax assignment)
{
// Initializer members (`new Foo { Bar = value }`) store into the
// new aggregate even though the member reads as an identifier.
if (assignment.Parent is InitializerExpressionSyntax)
{
return true;
}

// `variable = ...` keeps ownership local; any other target
// (member, element, or container) leaks it.
return assignment.Left is not IdentifierNameSyntax;
}

if (current is not ParenthesizedExpressionSyntax and not EqualsValueClauseSyntax)
{
return false;
}
}

return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
using System.Linq;
using System.Threading.Tasks;
using HttpClient.Resilience.Analyzers.CodeFixes;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Xunit;

namespace HttpClient.Resilience.Analyzers.Tests.CodeFixes;

public sealed class UsingDeclarationEscapeGateTests
{
[Fact]
public async Task EmptyName_NeverEscapes()
{
var declaration = ParseResponseDeclaration("return Task.FromResult(0);");

Assert.False(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, string.Empty));
await Task.CompletedTask;
}

[Fact]
public async Task DetachedNode_NeverEscapes()
{
var detached = SyntaxFactory.IdentifierName("response");

Assert.False(UsingDeclarationEscapeGate.VariableEscapesScope(detached, "response"));
await Task.CompletedTask;
}

[Fact]
public async Task ReturnedIdentifier_Escapes()
{
var declaration = ParseResponseDeclaration("return response;");

Assert.True(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response"));
await Task.CompletedTask;
}

[Fact]
public async Task ReturnedTuple_Escapes()
{
var declaration = ParseResponseDeclaration("return (response, 1);");

Assert.True(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response"));
await Task.CompletedTask;
}

[Fact]
public async Task StoredIntoMember_Escapes()
{
var declaration = ParseResponseDeclaration("this.pending = response;");

Assert.True(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response"));
await Task.CompletedTask;
}

[Fact]
public async Task PassedAsArgument_Escapes()
{
var declaration = ParseResponseDeclaration("Takes(response);");

Assert.True(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response"));
await Task.CompletedTask;
}

[Fact]
public async Task ParenthesizedArgument_Escapes()
{
var declaration = ParseResponseDeclaration("Takes((response));");

Assert.True(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response"));
await Task.CompletedTask;
}
[Fact]
public async Task ObjectInitializerMember_Escapes()
{
var declaration = ParseResponseDeclaration("var holder = new Holder { Value = response };");

Assert.True(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response"));
await Task.CompletedTask;
}

[Fact]
public async Task CollectionInitializerElement_Escapes()
{
var declaration = ParseResponseDeclaration("var items = new System.Collections.Generic.List<object> { response };");

Assert.True(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response"));
await Task.CompletedTask;
}

[Fact]
public async Task MemberCall_DoesNotEscape()
{
var declaration = ParseResponseDeclaration("_ = response.Content.ReadAsStringAsync();");

Assert.False(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response"));
await Task.CompletedTask;
}

[Fact]
public async Task MemberAccessAsArgument_DoesNotEscape()
{
var declaration = ParseResponseDeclaration("Takes(response.Content);");

Assert.False(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response"));
await Task.CompletedTask;
}

[Fact]
public async Task LocalReassignment_DoesNotEscape()
{
var declaration = ParseResponseDeclaration("response = other;");

Assert.False(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response"));
await Task.CompletedTask;
}

[Fact]
public async Task TypeTest_DoesNotEscape()
{
var declaration = ParseResponseDeclaration("if (response is not null) { Takes(1); }");

Assert.False(UsingDeclarationEscapeGate.VariableEscapesScope(declaration, "response"));
await Task.CompletedTask;
}

private static LocalDeclarationStatementSyntax ParseResponseDeclaration(string tailStatement)
{
var root = CSharpSyntaxTree.ParseText(
"""
using System.Net.Http;
using System.Threading.Tasks;

public sealed class Client
{
private HttpResponseMessage? pending;

public async Task UseAsync(HttpClient client)
{
var response = await client.GetAsync("https://example.com");
"""
+ "\n " + tailStatement + "\n }\n }\n").GetCompilationUnitRoot();

return root.DescendantNodes()
.OfType<LocalDeclarationStatementSyntax>()
.First(declaration => declaration.Declaration.Variables.Any(variable => variable.Identifier.ValueText == "response"));
}
}
Loading