Skip to content
Draft
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
120 changes: 120 additions & 0 deletions src/EditorFeatures/CSharpTest/Rename/CSharpRenamerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -410,4 +410,124 @@ public Task CSharp_RenameDocument_MappedDocumentHasNoResults()
<h3>Component1</h3>
@code {}
""", "Component1.razor", newDocumentName: "MyComponent.razor");

[Fact]
public Task CSharp_RenameDocument_NestedType_RenameInner()
=> TestRenameDocument(
"""
partial class Outer
{
class Inner {}
}
""",
"""
partial class Outer
{
class Foo {}
}
""",
documentName: "Outer.Inner.cs",
newDocumentName: "Outer.Foo.cs");

[Fact]
public Task CSharp_RenameDocument_NestedType_RenameOuter()
=> TestRenameDocument(
"""
partial class Outer
{
class Inner {}
}
""",
"""
partial class Bar
{
class Inner {}
}
""",
documentName: "Outer.Inner.cs",
newDocumentName: "Bar.Inner.cs");

[Fact]
public Task CSharp_RenameDocument_NestedType_ThreeLevels()
=> TestRenameDocument(
"""
class A
{
class B
{
class C {}
}
}
""",
"""
class A
{
class B
{
class D {}
}
}
""",
documentName: "A.B.C.cs",
newDocumentName: "A.B.D.cs");

[Fact]
public Task CSharp_RenameDocument_NestedType_ThreeLevels_RenameMiddle()
=> TestRenameDocument(
"""
class A
{
class B
{
class C {}
}
}
""",
"""
class A
{
class X
{
class C {}
}
}
""",
documentName: "A.B.C.cs",
newDocumentName: "A.X.C.cs");

[Fact]
public Task CSharp_RenameDocument_NestedType_NoRenameWhenNotMatching()
=> TestEmptyActionSet(
"""
partial class Outer
{
class DifferentInner {}
}
""",
documentName: "Outer.Inner.cs",
newDocumentName: "Outer.Foo.cs");

[Fact]
public Task CSharp_RenameDocument_NestedType_WithNamespace()
=> TestRenameDocument(
"""
namespace Test.Namespace
{
partial class Outer
{
class Inner {}
}
}
""",
"""
namespace Test.Namespace
{
partial class Outer
{
class Foo {}
}
}
""",
documentName: "Outer.Inner.cs",
newDocumentName: "Outer.Foo.cs");
}
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,19 @@ public InlineRenameFileRenameInfo GetFileRenameInfo()
// the name with the symbol name. If they match allow
// rename file rename as part of the symbol rename
var symbolSourceDocument = this.Document.Project.Solution.GetDocument(RenameSymbol.Locations.Single().SourceTree);
if (symbolSourceDocument != null && WorkspacePathUtilities.TypeNameMatchesDocumentName(symbolSourceDocument, RenameSymbol.Name))
if (symbolSourceDocument != null)
{
return InlineRenameFileRenameInfo.Allowed;
// First check if the simple type name matches (e.g., "Foo.cs" with type "Foo")
if (WorkspacePathUtilities.TypeNameMatchesDocumentName(symbolSourceDocument, RenameSymbol.Name))
{
return InlineRenameFileRenameInfo.Allowed;
}

// Also check if this is a nested type following the Outer.Inner.cs convention
if (WorkspacePathUtilities.SymbolMatchesDocumentName(symbolSourceDocument, RenameSymbol))
{
return InlineRenameFileRenameInfo.Allowed;
}
}

return InlineRenameFileRenameInfo.TypeDoesNotMatchFileName;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System;
using System.Globalization;
using System.Linq;
using System.Threading;
Expand Down Expand Up @@ -64,15 +65,38 @@

/// <summary>
/// Finds a matching type such that the display name of the type matches the name passed in, ignoring case. Case isn't used because
/// documents with name "Foo.cs" and "foo.cs" should still have the same type name
/// documents with name "Foo.cs" and "foo.cs" should still have the same type name.
/// Also supports nested types following the Outer.Inner.cs convention.
/// </summary>
private static async Task<SyntaxNode?> GetMatchingTypeDeclarationAsync(Document document, CancellationToken cancellationToken)
{
var syntaxRoot = await document.GetRequiredSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
var syntaxFacts = document.GetRequiredLanguageService<ISyntaxFactsService>();

var typeDeclarations = syntaxRoot.DescendantNodesAndSelf(n => !syntaxFacts.IsMethodBody(n)).Where(syntaxFacts.IsTypeDeclaration);
return typeDeclarations.FirstOrDefault(d => WorkspacePathUtilities.TypeNameMatchesDocumentName(document, d, syntaxFacts));

Check failure on line 77 in src/Workspaces/Core/Portable/Rename/Renamer.RenameSymbolDocumentAction.cs

View check run for this annotation

Azure Pipelines / roslyn-CI (Correctness Correctness_Analyzers)

src/Workspaces/Core/Portable/Rename/Renamer.RenameSymbolDocumentAction.cs#L77

src/Workspaces/Core/Portable/Rename/Renamer.RenameSymbolDocumentAction.cs(77,1): error IDE0055: (NETCORE_ENGINEERING_TELEMETRY=Build) Fix formatting (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0055)

Check failure on line 77 in src/Workspaces/Core/Portable/Rename/Renamer.RenameSymbolDocumentAction.cs

View check run for this annotation

Azure Pipelines / roslyn-CI

src/Workspaces/Core/Portable/Rename/Renamer.RenameSymbolDocumentAction.cs#L77

src/Workspaces/Core/Portable/Rename/Renamer.RenameSymbolDocumentAction.cs(77,1): error IDE0055: (NETCORE_ENGINEERING_TELEMETRY=Build) Fix formatting (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0055)
// First check for simple type name match (e.g., "Foo.cs" with type "Foo")
var simpleMatch = typeDeclarations.FirstOrDefault(d => WorkspacePathUtilities.TypeNameMatchesDocumentName(document, d, syntaxFacts));
if (simpleMatch != null)
return simpleMatch;

Check failure on line 82 in src/Workspaces/Core/Portable/Rename/Renamer.RenameSymbolDocumentAction.cs

View check run for this annotation

Azure Pipelines / roslyn-CI (Correctness Correctness_Analyzers)

src/Workspaces/Core/Portable/Rename/Renamer.RenameSymbolDocumentAction.cs#L82

src/Workspaces/Core/Portable/Rename/Renamer.RenameSymbolDocumentAction.cs(82,1): error IDE0055: (NETCORE_ENGINEERING_TELEMETRY=Build) Fix formatting (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0055)

Check failure on line 82 in src/Workspaces/Core/Portable/Rename/Renamer.RenameSymbolDocumentAction.cs

View check run for this annotation

Azure Pipelines / roslyn-CI

src/Workspaces/Core/Portable/Rename/Renamer.RenameSymbolDocumentAction.cs#L82

src/Workspaces/Core/Portable/Rename/Renamer.RenameSymbolDocumentAction.cs(82,1): error IDE0055: (NETCORE_ENGINEERING_TELEMETRY=Build) Fix formatting (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0055)
// Then check for nested type match (e.g., "Outer.Inner.cs" with type Inner inside Outer)
// Only do this check if the document name contains a dot (indicating potential nested type naming)
var documentTypeName = WorkspacePathUtilities.GetTypeNameFromDocumentName(document);
if (documentTypeName != null && documentTypeName.Contains('.'))
{
var semanticModel = await document.GetRequiredSemanticModelAsync(cancellationToken).ConfigureAwait(false);
foreach (var declaration in typeDeclarations)
{
var symbol = semanticModel.GetDeclaredSymbol(declaration, cancellationToken);
if (symbol != null && WorkspacePathUtilities.SymbolMatchesDocumentName(document, symbol))
{
return declaration;
}
}
}

Check failure on line 98 in src/Workspaces/Core/Portable/Rename/Renamer.RenameSymbolDocumentAction.cs

View check run for this annotation

Azure Pipelines / roslyn-CI (Correctness Correctness_Analyzers)

src/Workspaces/Core/Portable/Rename/Renamer.RenameSymbolDocumentAction.cs#L98

src/Workspaces/Core/Portable/Rename/Renamer.RenameSymbolDocumentAction.cs(98,1): error IDE0055: (NETCORE_ENGINEERING_TELEMETRY=Build) Fix formatting (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0055)

Check failure on line 98 in src/Workspaces/Core/Portable/Rename/Renamer.RenameSymbolDocumentAction.cs

View check run for this annotation

Azure Pipelines / roslyn-CI

src/Workspaces/Core/Portable/Rename/Renamer.RenameSymbolDocumentAction.cs#L98

src/Workspaces/Core/Portable/Rename/Renamer.RenameSymbolDocumentAction.cs(98,1): error IDE0055: (NETCORE_ENGINEERING_TELEMETRY=Build) Fix formatting (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0055)
return null;
}

public static async Task<RenameSymbolDocumentAction?> TryCreateAsync(Document document, string newName, CancellationToken cancellationToken)
Expand Down Expand Up @@ -106,7 +130,20 @@
var semanticModel = await document.GetRequiredSemanticModelAsync(cancellationToken).ConfigureAwait(false);
var symbol = semanticModel.GetDeclaredSymbol(matchingDeclaration, cancellationToken);

if (symbol is null || WorkspacePathUtilities.TypeNameMatchesDocumentName(documentWithNewName, symbol.Name))
if (symbol is null)
{
return null;
}

// Check if the type already matches the new document name (no rename needed)
// For simple types: "Foo.cs" -> type "Foo"
if (WorkspacePathUtilities.TypeNameMatchesDocumentName(documentWithNewName, symbol.Name))
{
return null;
}

// For nested types: "Outer.Inner.cs" -> type Inner inside Outer
if (WorkspacePathUtilities.SymbolMatchesDocumentName(documentWithNewName, symbol))
{
return null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
// See the LICENSE file in the project root for more information.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.Shared.Utilities;

Expand Down Expand Up @@ -43,4 +45,77 @@

return IOUtilities.PerformIO(() => Path.GetFileNameWithoutExtension(document.Name));
}

/// <summary>
/// Checks if a symbol (potentially nested) matches a document name using the Outer.Inner.cs convention.
/// For example, a nested type "Inner" within "Outer" would match a document named "Outer.Inner.cs".
/// </summary>
/// <param name="document">The document to check against</param>
/// <param name="symbol">The type symbol to match</param>
/// <returns>True if the symbol's fully qualified name matches the document name pattern</returns>
public static bool SymbolMatchesDocumentName(Document document, ISymbol symbol)
{
if (symbol is not INamedTypeSymbol typeSymbol)
return false;

var documentTypeName = GetTypeNameFromDocumentName(document);
if (documentTypeName is null)
return false;

// Get the type hierarchy (e.g., [Outer, Inner] for Outer.Inner)
var typeHierarchy = GetTypeHierarchy(typeSymbol);

Check failure on line 67 in src/Workspaces/Core/Portable/Utilities/WorkspacePathUtilities.cs

View check run for this annotation

Azure Pipelines / roslyn-CI (Correctness Correctness_Analyzers)

src/Workspaces/Core/Portable/Utilities/WorkspacePathUtilities.cs#L67

src/Workspaces/Core/Portable/Utilities/WorkspacePathUtilities.cs(67,1): error IDE0055: (NETCORE_ENGINEERING_TELEMETRY=Build) Fix formatting (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0055)

Check failure on line 67 in src/Workspaces/Core/Portable/Utilities/WorkspacePathUtilities.cs

View check run for this annotation

Azure Pipelines / roslyn-CI

src/Workspaces/Core/Portable/Utilities/WorkspacePathUtilities.cs#L67

src/Workspaces/Core/Portable/Utilities/WorkspacePathUtilities.cs(67,1): error IDE0055: (NETCORE_ENGINEERING_TELEMETRY=Build) Fix formatting (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0055)
// Join with dots to create the expected pattern
var fullTypeName = string.Join(".", typeHierarchy.Select(t => t.Name));

Check failure on line 70 in src/Workspaces/Core/Portable/Utilities/WorkspacePathUtilities.cs

View check run for this annotation

Azure Pipelines / roslyn-CI (Correctness Correctness_Analyzers)

src/Workspaces/Core/Portable/Utilities/WorkspacePathUtilities.cs#L70

src/Workspaces/Core/Portable/Utilities/WorkspacePathUtilities.cs(70,1): error IDE0055: (NETCORE_ENGINEERING_TELEMETRY=Build) Fix formatting (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0055)
return fullTypeName.Equals(documentTypeName, StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Gets the hierarchy of types from outermost to innermost for a nested type.
/// For example, for A.B.C, returns [A, B, C].
/// </summary>
private static List<INamedTypeSymbol> GetTypeHierarchy(INamedTypeSymbol typeSymbol)
{
var hierarchy = new List<INamedTypeSymbol>();
var current = typeSymbol;

while (current is not null)
{
hierarchy.Insert(0, current);
current = current.ContainingType;
}

return hierarchy;
}

/// <summary>
/// Gets the new document name when renaming a type that follows the Outer.Inner.cs convention.
/// </summary>
/// <param name="document">The current document</param>
/// <param name="symbol">The type symbol being renamed</param>
/// <param name="newName">The new name for the symbol</param>
/// <returns>The new document name, or null if the document doesn't follow the convention</returns>
public static string? GetNewDocumentNameForSymbolRename(Document document, ISymbol symbol, string newName)
{
if (symbol is not INamedTypeSymbol typeSymbol)
return null;

var documentTypeName = GetTypeNameFromDocumentName(document);
if (documentTypeName is null)
return null;

var typeHierarchy = GetTypeHierarchy(typeSymbol);

// Build the new type hierarchy by replacing the renamed symbol's name
var newTypeHierarchy = typeHierarchy.Select(t => SymbolEqualityComparer.Default.Equals(t, typeSymbol) ? newName : t.Name);
var newDocumentTypeName = string.Join(".", newTypeHierarchy);

// Get the file extension from the original document
var extension = IOUtilities.PerformIO(() => Path.GetExtension(document.Name));
if (extension is null)
return null;

return newDocumentTypeName + extension;
}
}
Loading