diff --git a/Cast.Tool.Tests/MultiFileEditingTests.cs b/Cast.Tool.Tests/MultiFileEditingTests.cs
new file mode 100644
index 0000000..fc60940
--- /dev/null
+++ b/Cast.Tool.Tests/MultiFileEditingTests.cs
@@ -0,0 +1,385 @@
+using System.IO;
+using System.Threading.Tasks;
+using Xunit;
+using Cast.Tool.Core;
+using Cast.Tool.Commands;
+
+namespace Cast.Tool.Tests;
+
+public class MultiFileEditingTests
+{
+ [Fact]
+ public async Task RenameCommand_ProjectWide_DryRun_ShouldShowMultipleFiles()
+ {
+ // Arrange - Create a temporary project directory
+ var projectDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(projectDir);
+
+ var file1Content = @"using System;
+
+namespace TestProject
+{
+ public class MyClass
+ {
+ public void DoSomething()
+ {
+ Console.WriteLine(""Hello from MyClass"");
+ }
+
+ public MyClass CreateInstance()
+ {
+ return new MyClass();
+ }
+ }
+}";
+
+ var file2Content = @"using System;
+
+namespace TestProject
+{
+ public class AnotherClass
+ {
+ public void UseMyClass()
+ {
+ var instance = new MyClass();
+ instance.DoSomething();
+
+ MyClass anotherInstance = instance.CreateInstance();
+ }
+ }
+}";
+
+ var file1Path = Path.Combine(projectDir, "TestClass.cs");
+ var file2Path = Path.Combine(projectDir, "AnotherClass.cs");
+ var projectPath = Path.Combine(projectDir, "TestProject.csproj");
+
+ await File.WriteAllTextAsync(file1Path, file1Content);
+ await File.WriteAllTextAsync(file2Path, file2Content);
+ await File.WriteAllTextAsync(projectPath, @"
+
+ net8.0
+
+");
+
+ try
+ {
+ // Act
+ var command = new RenameCommand();
+ var settings = new RenameCommand.Settings
+ {
+ FilePath = file1Path,
+ OldName = "MyClass",
+ NewName = "RenamedClass",
+ LineNumber = 5, // Line with class declaration
+ ProjectWide = true,
+ DryRun = true
+ };
+
+ var result = await command.ExecuteAsync(null!, settings);
+
+ // Assert
+ Assert.Equal(0, result);
+ // In a real test, we'd capture the console output to verify the multi-file diff display
+ // For now, just ensuring the command executes successfully
+ }
+ finally
+ {
+ // Cleanup
+ Directory.Delete(projectDir, true);
+ }
+ }
+ [Fact]
+ public async Task ExtractBaseClass_DryRun_ShouldShowMultipleFiles()
+ {
+ // Arrange
+ var testCode = @"using System;
+
+namespace MyProject
+{
+ public class Employee
+ {
+ public string Name { get; set; }
+ public int Age { get; set; }
+ public decimal Salary { get; set; }
+
+ public void DisplayInfo()
+ {
+ Console.WriteLine($""Name: {Name}, Age: {Age}"");
+ }
+ }
+}";
+
+ var tempFile = Path.GetTempFileName();
+ var csFile = Path.ChangeExtension(tempFile, ".cs");
+ File.Move(tempFile, csFile);
+ await File.WriteAllTextAsync(csFile, testCode);
+
+ try
+ {
+ // Act
+ var command = new ExtractBaseClassCommand();
+ var settings = new ExtractBaseClassCommandSettings
+ {
+ FilePath = csFile,
+ ClassName = "Employee",
+ BaseClassName = "Person",
+ Members = "Name,Age,DisplayInfo",
+ DryRun = true
+ };
+
+ var result = command.Execute(null!, settings);
+
+ // Assert
+ Assert.Equal(0, result);
+ // The actual output verification would require capturing console output
+ // For now, we verify the command executes successfully in dry-run mode
+ }
+ finally
+ {
+ // Cleanup
+ if (File.Exists(csFile))
+ File.Delete(csFile);
+ }
+ }
+
+ [Fact]
+ public async Task ExtractInterface_DryRun_ShouldShowMultipleFiles()
+ {
+ // Arrange
+ var testCode = @"using System;
+
+namespace MyProject
+{
+ public class Calculator
+ {
+ public int Add(int a, int b)
+ {
+ return a + b;
+ }
+
+ public int Subtract(int a, int b)
+ {
+ return a - b;
+ }
+
+ private void LogOperation(string operation)
+ {
+ Console.WriteLine($""Operation: {operation}"");
+ }
+ }
+}";
+
+ var tempFile = Path.GetTempFileName();
+ var csFile = Path.ChangeExtension(tempFile, ".cs");
+ File.Move(tempFile, csFile);
+ await File.WriteAllTextAsync(csFile, testCode);
+
+ try
+ {
+ // Act
+ var command = new ExtractInterfaceCommand();
+ var settings = new ExtractInterfaceCommandSettings
+ {
+ FilePath = csFile,
+ ClassName = "Calculator",
+ InterfaceName = "ICalculator",
+ DryRun = true
+ };
+
+ var result = command.Execute(null!, settings);
+
+ // Assert
+ Assert.Equal(0, result);
+ }
+ finally
+ {
+ // Cleanup
+ if (File.Exists(csFile))
+ File.Delete(csFile);
+ }
+ }
+
+ [Fact]
+ public async Task MoveTypeToMatchingFile_DryRun_ShouldShowFileCreation()
+ {
+ // Arrange
+ var testCode = @"using System;
+
+namespace MyProject
+{
+ public class MainClass
+ {
+ public void DoSomething() { }
+ }
+
+ public class HelperClass
+ {
+ public void Help() { }
+ }
+}";
+
+ var tempFile = Path.GetTempFileName();
+ var csFile = Path.ChangeExtension(tempFile, ".cs");
+ File.Move(tempFile, csFile);
+ await File.WriteAllTextAsync(csFile, testCode);
+
+ try
+ {
+ // Act
+ var command = new MoveTypeToMatchingFileCommand();
+ var settings = new MoveTypeToMatchingFileCommandSettings
+ {
+ FilePath = csFile,
+ TypeName = "HelperClass",
+ DryRun = true
+ };
+
+ var result = command.Execute(null!, settings);
+
+ // Assert
+ Assert.Equal(0, result);
+ }
+ finally
+ {
+ // Cleanup
+ if (File.Exists(csFile))
+ File.Delete(csFile);
+ }
+ }
+
+ [Fact]
+ public void DiffUtility_DisplayMultiFileDiff_ShouldHandleMultipleFiles()
+ {
+ // Arrange
+ var fileChanges = new Dictionary
+ {
+ ["Test1.cs"] = ("using System;", "using System;\nusing System.Collections.Generic;"),
+ ["Test2.cs"] = ("", "public class NewClass { }"),
+ ["Test3.cs"] = ("public class OldClass { }", "public class RenamedClass { }")
+ };
+
+ // Act & Assert - The method should execute without throwing
+ DiffUtility.DisplayMultiFileDiff(fileChanges);
+ DiffUtility.DisplayFileSummary(fileChanges);
+
+ // Test with empty changes
+ DiffUtility.DisplayMultiFileDiff(new Dictionary());
+ DiffUtility.DisplayFileSummary(new Dictionary());
+ }
+
+ [Fact]
+ public void RefactoringEngine_ResolveProjectPath_ShouldFindProjectFile()
+ {
+ // Arrange
+ var tempDir = Path.GetTempPath();
+ var projectDirName = Guid.NewGuid().ToString();
+ var projectDir = Path.Combine(tempDir, projectDirName);
+ Directory.CreateDirectory(projectDir);
+
+ var csprojFile = Path.Combine(projectDir, "TestProject.csproj");
+ var sourceFile = Path.Combine(projectDir, "Source.cs");
+
+ try
+ {
+ File.WriteAllText(csprojFile, "");
+ File.WriteAllText(sourceFile, "class Test {}");
+
+ // Act
+ var resolvedPath = RefactoringEngine.ResolveProjectPath(sourceFile);
+
+ // Assert
+ Assert.Equal(projectDir, resolvedPath);
+ }
+ finally
+ {
+ // Cleanup
+ if (Directory.Exists(projectDir))
+ Directory.Delete(projectDir, true);
+ }
+ }
+
+ [Fact]
+ public void RefactoringEngine_GetRelativePathFromProject_ShouldReturnCorrectPath()
+ {
+ // Arrange
+ var tempDir = Path.GetTempPath();
+ var projectDirName = Guid.NewGuid().ToString();
+ var projectDir = Path.Combine(tempDir, projectDirName);
+ var sourceDir = Path.Combine(projectDir, "Models");
+ Directory.CreateDirectory(sourceDir);
+
+ var csprojFile = Path.Combine(projectDir, "TestProject.csproj");
+ var sourceFile = Path.Combine(sourceDir, "User.cs");
+
+ try
+ {
+ File.WriteAllText(csprojFile, "");
+ File.WriteAllText(sourceFile, "class User {}");
+
+ // Act
+ var relativePath = RefactoringEngine.GetRelativePathFromProject(sourceFile, projectDir);
+
+ // Assert
+ Assert.Equal("Models" + Path.DirectorySeparatorChar + "User.cs", relativePath);
+ }
+ finally
+ {
+ // Cleanup
+ if (Directory.Exists(projectDir))
+ Directory.Delete(projectDir, true);
+ }
+ }
+
+ [Fact]
+ public async Task SyncNamespaceWithFolder_WithProjectPath_ShouldCalculateCorrectNamespace()
+ {
+ // Arrange
+ var tempDir = Path.GetTempPath();
+ var projectDirName = Guid.NewGuid().ToString();
+ var projectDir = Path.Combine(tempDir, projectDirName);
+ var modelsDir = Path.Combine(projectDir, "Models");
+ Directory.CreateDirectory(modelsDir);
+
+ var csprojFile = Path.Combine(projectDir, $"{projectDirName}.csproj");
+ var sourceFile = Path.Combine(modelsDir, "User.cs");
+ var testCode = @"using System;
+
+namespace WrongNamespace
+{
+ public class User
+ {
+ public string Name { get; set; }
+ }
+}";
+
+ try
+ {
+ File.WriteAllText(csprojFile, "");
+ await File.WriteAllTextAsync(sourceFile, testCode);
+
+ // Act
+ var command = new SyncNamespaceWithFolderCommand();
+ var settings = new SyncNamespaceWithFolderCommand.Settings
+ {
+ FilePath = sourceFile,
+ ProjectPath = projectDir,
+ DryRun = true
+ };
+
+ var result = await command.ExecuteAsync(null!, settings);
+
+ // Assert
+ Assert.Equal(0, result);
+
+ // Verify the file wasn't actually modified (dry run)
+ var content = await File.ReadAllTextAsync(sourceFile);
+ Assert.Contains("WrongNamespace", content);
+ }
+ finally
+ {
+ // Cleanup
+ if (Directory.Exists(projectDir))
+ Directory.Delete(projectDir, true);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Cast.Tool/Commands/ExtractBaseClassCommand.cs b/Cast.Tool/Commands/ExtractBaseClassCommand.cs
index 0734267..c1a70c0 100644
--- a/Cast.Tool/Commands/ExtractBaseClassCommand.cs
+++ b/Cast.Tool/Commands/ExtractBaseClassCommand.cs
@@ -94,17 +94,18 @@ public override int Execute(CommandContext context, ExtractBaseClassCommandSetti
AnsiConsole.MarkupLine("[yellow]Members to extract: {0}[/]", string.Join(", ", membersToExtract));
}
- // Show diff for the modified original file
+ // Use the new multi-file diff format
var dryRunModifiedCode = modifiedRoot.ToFullString();
- AnsiConsole.WriteLine();
- AnsiConsole.MarkupLine("[yellow]Changes to original file:[/]");
- DiffUtility.DisplayDiff(sourceCode, dryRunModifiedCode, settings.FilePath);
-
- // Show the new base class file that would be created
var dryRunBaseClassPath = Path.Combine(Path.GetDirectoryName(settings.FilePath) ?? ".", $"{settings.BaseClassName}.cs");
+
+ var fileChanges = new Dictionary
+ {
+ { settings.FilePath, (sourceCode, dryRunModifiedCode) },
+ { dryRunBaseClassPath, ("", baseClassCode) }
+ };
+
AnsiConsole.WriteLine();
- AnsiConsole.MarkupLine("[yellow]New base class file would be created: {0}[/]", dryRunBaseClassPath);
- DiffUtility.DisplayDiff("", baseClassCode, dryRunBaseClassPath);
+ DiffUtility.DisplayMultiFileDiff(fileChanges);
return 0;
}
diff --git a/Cast.Tool/Commands/ExtractInterfaceCommand.cs b/Cast.Tool/Commands/ExtractInterfaceCommand.cs
index 3b6dfcb..576afd3 100644
--- a/Cast.Tool/Commands/ExtractInterfaceCommand.cs
+++ b/Cast.Tool/Commands/ExtractInterfaceCommand.cs
@@ -94,17 +94,18 @@ public override int Execute(CommandContext context, ExtractInterfaceCommandSetti
AnsiConsole.MarkupLine("[yellow]Members to extract: {0}[/]", string.Join(", ", membersToExtract));
}
- // Show diff for the modified original file
+ // Use the new multi-file diff format
var dryRunModifiedCode = modifiedRoot.ToFullString();
- AnsiConsole.WriteLine();
- AnsiConsole.MarkupLine("[yellow]Changes to original file:[/]");
- DiffUtility.DisplayDiff(sourceCode, dryRunModifiedCode, settings.FilePath);
-
- // Show the new interface file that would be created
var dryRunInterfacePath = Path.Combine(Path.GetDirectoryName(settings.FilePath) ?? ".", $"{settings.InterfaceName}.cs");
+
+ var fileChanges = new Dictionary
+ {
+ { settings.FilePath, (sourceCode, dryRunModifiedCode) },
+ { dryRunInterfacePath, ("", interfaceCode) }
+ };
+
AnsiConsole.WriteLine();
- AnsiConsole.MarkupLine("[yellow]New interface file would be created: {0}[/]", dryRunInterfacePath);
- DiffUtility.DisplayDiff("", interfaceCode, dryRunInterfacePath);
+ DiffUtility.DisplayMultiFileDiff(fileChanges);
return 0;
}
diff --git a/Cast.Tool/Commands/MoveTypeToMatchingFileCommand.cs b/Cast.Tool/Commands/MoveTypeToMatchingFileCommand.cs
index 0558bb6..8279734 100644
--- a/Cast.Tool/Commands/MoveTypeToMatchingFileCommand.cs
+++ b/Cast.Tool/Commands/MoveTypeToMatchingFileCommand.cs
@@ -61,11 +61,17 @@ public override int Execute(CommandContext context, MoveTypeToMatchingFileComman
var originalContent = File.ReadAllText(settings.FilePath);
AnsiConsole.MarkupLine("[green]Would move type '{0}' to {1}[/]",
settings.TypeName, newFileName);
+
+ // Use the new multi-file diff format
+ var fileChanges = new Dictionary
+ {
+ { settings.FilePath, (originalContent, modifiedCode) },
+ { newFileName, ("", extractedTypeCode) }
+ };
+
AnsiConsole.WriteLine();
- AnsiConsole.MarkupLine("[yellow]Changes to original file:[/]");
- DiffUtility.DisplayDiff(originalContent, modifiedCode, settings.FilePath);
- AnsiConsole.WriteLine();
- AnsiConsole.MarkupLine("[yellow]New file would be created: {0}[/]", newFileName);
+ DiffUtility.DisplayMultiFileDiff(fileChanges);
+
return 0;
}
diff --git a/Cast.Tool/Commands/MoveTypeToNamespaceFolderCommand.cs b/Cast.Tool/Commands/MoveTypeToNamespaceFolderCommand.cs
index 1bde54f..660d02e 100644
--- a/Cast.Tool/Commands/MoveTypeToNamespaceFolderCommand.cs
+++ b/Cast.Tool/Commands/MoveTypeToNamespaceFolderCommand.cs
@@ -26,6 +26,10 @@ public class MoveTypeToNamespaceFolderCommandSettings : CommandSettings
[Description("Target folder path (default: namespace path relative to project)")]
public string? TargetFolder { get; set; }
+ [CommandOption("--project-path")]
+ [Description("Path to the project directory (for resolving target folder structure)")]
+ public string? ProjectPath { get; set; }
+
[CommandOption("--dry-run")]
[Description("Preview changes without applying them")]
public bool DryRun { get; set; }
@@ -69,16 +73,17 @@ public override int Execute(CommandContext context, MoveTypeToNamespaceFolderCom
AnsiConsole.MarkupLine("[green]Would move type '{0}' to namespace '{1}' in {2}[/]",
settings.TypeName, settings.TargetNamespace, newFileName);
- // Show diff for the original file (with type removed)
+ // Use the new multi-file diff format
var dryRunModifiedCode = modifiedRoot.ToFullString();
- AnsiConsole.WriteLine();
- AnsiConsole.MarkupLine("[yellow]Changes to original file:[/]");
- DiffUtility.DisplayDiff(sourceCode, dryRunModifiedCode, settings.FilePath);
- // Show the new file that would be created
+ var fileChanges = new Dictionary
+ {
+ { settings.FilePath, (sourceCode, dryRunModifiedCode) },
+ { newFileName, ("", extractedTypeCode) }
+ };
+
AnsiConsole.WriteLine();
- AnsiConsole.MarkupLine("[yellow]New file would be created: {0}[/]", newFileName);
- DiffUtility.DisplayDiff("", extractedTypeCode, newFileName);
+ DiffUtility.DisplayMultiFileDiff(fileChanges);
return 0;
}
diff --git a/Cast.Tool/Commands/RenameCommand.cs b/Cast.Tool/Commands/RenameCommand.cs
index c2af211..74bdf0e 100644
--- a/Cast.Tool/Commands/RenameCommand.cs
+++ b/Cast.Tool/Commands/RenameCommand.cs
@@ -3,6 +3,7 @@
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Rename;
+using Microsoft.CodeAnalysis.FindSymbols;
using Spectre.Console;
using Spectre.Console.Cli;
using Cast.Tool.Core;
@@ -39,6 +40,15 @@ public class Settings : CommandSettings
[Description("Output file path (defaults to overwriting the input file)")]
public string? OutputPath { get; init; }
+ [CommandOption("--project-path")]
+ [Description("Path to the project directory (for project-wide semantic renaming)")]
+ public string? ProjectPath { get; init; }
+
+ [CommandOption("--project-wide")]
+ [Description("Perform project-wide semantic renaming (finds and updates all references)")]
+ [DefaultValue(false)]
+ public bool ProjectWide { get; init; } = false;
+
[CommandOption("--dry-run")]
[Description("Show what changes would be made without applying them")]
[DefaultValue(false)]
@@ -52,72 +62,211 @@ public override int Execute(CommandContext context, Settings settings)
public async Task ExecuteAsync(CommandContext context, Settings settings)
{
- var renameSettings = settings;
-
try
{
ValidateInputs(settings);
- if (string.IsNullOrWhiteSpace(renameSettings.OldName))
+ if (string.IsNullOrWhiteSpace(settings.OldName))
{
AnsiConsole.WriteLine("[red]Error: Old name is required[/]");
return 1;
}
- if (string.IsNullOrWhiteSpace(renameSettings.NewName))
+ if (string.IsNullOrWhiteSpace(settings.NewName))
{
AnsiConsole.WriteLine("[red]Error: New name is required[/]");
return 1;
}
- var engine = new RefactoringEngine();
- var (document, tree, model) = await engine.LoadDocumentAsync(settings.FilePath);
-
- var position = engine.GetTextSpanFromPosition(tree, settings.LineNumber, settings.ColumnNumber);
- var root = await tree.GetRootAsync();
- var node = root.FindNode(position);
-
- // Find the symbol at the specified position
- var symbol = model.GetSymbolInfo(node).Symbol;
- if (symbol == null)
+ if (settings.ProjectWide)
{
- // Try to get declared symbol if it's a declaration
- symbol = model.GetDeclaredSymbol(node);
+ return await PerformProjectWideRename(settings);
}
-
- if (symbol == null)
+ else
{
- AnsiConsole.WriteLine($"[yellow]Warning: No symbol found at line {settings.LineNumber}, column {settings.ColumnNumber}[/]");
- return 1;
+ return await PerformSingleFileRename(settings);
}
+ }
+ catch (Exception ex)
+ {
+ AnsiConsole.WriteLine($"[red]Error: {ex.Message}[/]");
+ return 1;
+ }
+ }
- if (symbol.Name != renameSettings.OldName)
- {
- AnsiConsole.WriteLine($"[yellow]Warning: Found symbol '{symbol.Name}' but expected '{renameSettings.OldName}'[/]");
- return 1;
- }
+ private async Task PerformSingleFileRename(Settings settings)
+ {
+ var engine = new RefactoringEngine();
+ var (document, tree, model) = await engine.LoadDocumentAsync(settings.FilePath);
+
+ var position = engine.GetTextSpanFromPosition(tree, settings.LineNumber, settings.ColumnNumber);
+ var root = await tree.GetRootAsync();
+ var node = root.FindNode(position);
+
+ // Find the symbol at the specified position
+ var symbol = model.GetSymbolInfo(node).Symbol;
+ if (symbol == null)
+ {
+ // Try to get declared symbol if it's a declaration
+ symbol = model.GetDeclaredSymbol(node);
+ }
+
+ if (symbol == null)
+ {
+ AnsiConsole.WriteLine($"[yellow]Warning: No symbol found at line {settings.LineNumber}, column {settings.ColumnNumber}[/]");
+ return 1;
+ }
+
+ if (symbol.Name != settings.OldName)
+ {
+ AnsiConsole.WriteLine($"[yellow]Warning: Found symbol '{symbol.Name}' but expected '{settings.OldName}'[/]");
+ return 1;
+ }
- // Perform the rename to get the modified content
- var result = await PerformSimpleRename(settings.FilePath, renameSettings.OldName, renameSettings.NewName);
+ // Perform the rename to get the modified content
+ var result = await PerformSimpleRename(settings.FilePath, settings.OldName, settings.NewName);
+
+ if (settings.DryRun)
+ {
+ var originalContent = await File.ReadAllTextAsync(settings.FilePath);
+ DiffUtility.DisplayDiff(originalContent, result, settings.FilePath);
+ return 0;
+ }
+
+ var outputPath = settings.OutputPath ?? settings.FilePath;
+ await File.WriteAllTextAsync(outputPath, result);
+
+ AnsiConsole.WriteLine($"[green]Successfully renamed '{settings.OldName}' to '{settings.NewName}' in {outputPath}[/]");
+ return 0;
+ }
+
+ private async Task PerformProjectWideRename(Settings settings)
+ {
+ var projectPath = RefactoringEngine.ResolveProjectPath(settings.FilePath, settings.ProjectPath);
+ if (projectPath == null)
+ {
+ AnsiConsole.WriteLine("[red]Error: Could not find project directory. Use --project-path to specify explicitly.[/]");
+ return 1;
+ }
+
+ AnsiConsole.WriteLine($"[blue]Using project path: {projectPath}[/]");
+
+ // Find all C# files in the project
+ var csFiles = Directory.GetFiles(projectPath, "*.cs", SearchOption.AllDirectories)
+ .Where(f => !f.Contains("bin") && !f.Contains("obj"))
+ .ToList();
+
+ var targetFilePath = Path.GetFullPath(settings.FilePath);
+ if (!csFiles.Any(f => Path.GetFullPath(f) == targetFilePath))
+ {
+ AnsiConsole.WriteLine("[red]Error: Specified file is not part of the detected project.[/]");
+ AnsiConsole.WriteLine($"[yellow]Looking for: {targetFilePath}[/]");
+ AnsiConsole.WriteLine($"[yellow]Found files: {string.Join(", ", csFiles.Select(Path.GetFullPath))}[/]");
+ return 1;
+ }
+
+ AnsiConsole.WriteLine($"[blue]Found {csFiles.Count} C# files in project[/]");
+
+ // Build workspace with all project files
+ var workspace = await CreateWorkspaceAsync(projectPath, csFiles);
+ var project = workspace.CurrentSolution.Projects.First();
+
+ // Find the target document and symbol
+ var targetDocument = project.Documents.FirstOrDefault(d =>
+ d.FilePath != null && Path.GetFullPath(d.FilePath) == targetFilePath);
- if (settings.DryRun)
+ if (targetDocument == null)
+ {
+ AnsiConsole.WriteLine("[red]Error: Could not find target file in workspace.[/]");
+ AnsiConsole.WriteLine($"[yellow]Looking for: {targetFilePath}[/]");
+ AnsiConsole.WriteLine($"[yellow]Documents in workspace: {string.Join(", ", project.Documents.Select(d => d.FilePath ?? "null"))}[/]");
+ return 1;
+ }
+
+ var syntaxTree = await targetDocument.GetSyntaxTreeAsync();
+ var semanticModel = await targetDocument.GetSemanticModelAsync();
+
+ if (syntaxTree == null || semanticModel == null)
+ {
+ AnsiConsole.WriteLine("[red]Error: Could not load semantic model for target file.[/]");
+ return 1;
+ }
+
+ var position = new RefactoringEngine().GetTextSpanFromPosition(syntaxTree, settings.LineNumber, settings.ColumnNumber);
+ var root = await syntaxTree.GetRootAsync();
+ var node = root.FindNode(position);
+
+ // Find the symbol at the specified position
+ var symbol = semanticModel.GetSymbolInfo(node).Symbol;
+ if (symbol == null)
+ {
+ symbol = semanticModel.GetDeclaredSymbol(node);
+ }
+
+ if (symbol == null)
+ {
+ AnsiConsole.WriteLine($"[yellow]Warning: No symbol found at line {settings.LineNumber}, column {settings.ColumnNumber}[/]");
+ return 1;
+ }
+
+ if (symbol.Name != settings.OldName)
+ {
+ AnsiConsole.WriteLine($"[yellow]Warning: Found symbol '{symbol.Name}' but expected '{settings.OldName}'[/]");
+ return 1;
+ }
+
+ AnsiConsole.WriteLine($"[blue]Found symbol: {symbol.Name} of type {symbol.Kind}[/]");
+
+ // Find all references to the symbol across the project
+ var references = await SymbolFinder.FindReferencesAsync(symbol, workspace.CurrentSolution);
+ var changedFiles = new Dictionary();
+
+ // Process each file that contains references
+ foreach (var reference in references)
+ {
+ foreach (var location in reference.Locations)
{
- var originalContent = await File.ReadAllTextAsync(settings.FilePath);
- DiffUtility.DisplayDiff(originalContent, result, settings.FilePath);
- return 0;
+ var document = workspace.CurrentSolution.GetDocument(location.Document.Id);
+ if (document?.FilePath == null) continue;
+
+ var filePath = Path.GetFullPath(document.FilePath);
+
+ if (!changedFiles.ContainsKey(filePath))
+ {
+ var originalContent = await File.ReadAllTextAsync(filePath);
+ changedFiles[filePath] = (originalContent, originalContent);
+ }
+
+ // Apply simple text replacement for now
+ // In a more sophisticated implementation, this would use Roslyn's rename service
+ var (original, current) = changedFiles[filePath];
+ var modified = current.Replace(settings.OldName, settings.NewName);
+ changedFiles[filePath] = (original, modified);
}
-
- var outputPath = settings.OutputPath ?? settings.FilePath;
- await File.WriteAllTextAsync(outputPath, result);
-
- AnsiConsole.WriteLine($"[green]Successfully renamed '{renameSettings.OldName}' to '{renameSettings.NewName}' in {outputPath}[/]");
+ }
+
+ if (changedFiles.Count == 0)
+ {
+ AnsiConsole.WriteLine($"[yellow]No references to '{settings.OldName}' found in the project.[/]");
return 0;
}
- catch (Exception ex)
+
+ if (settings.DryRun)
{
- AnsiConsole.WriteLine($"[red]Error: {ex.Message}[/]");
- return 1;
+ AnsiConsole.MarkupLine($"[green]Would rename '{settings.OldName}' to '{settings.NewName}' across {changedFiles.Count} file(s)[/]");
+ AnsiConsole.WriteLine();
+ DiffUtility.DisplayMultiFileDiff(changedFiles);
+ return 0;
}
+
+ // Apply changes to all files
+ foreach (var (filePath, (_, modified)) in changedFiles)
+ {
+ await File.WriteAllTextAsync(filePath, modified);
+ }
+
+ AnsiConsole.WriteLine($"[green]Successfully renamed '{settings.OldName}' to '{settings.NewName}' across {changedFiles.Count} file(s)[/]");
+ return 0;
}
private async Task PerformSimpleRename(string filePath, string oldName, string newName)
@@ -133,6 +282,72 @@ private async Task PerformSimpleRename(string filePath, string oldName,
return newRoot.ToFullString();
}
+ private async Task CreateWorkspaceAsync(string projectPath, List csFiles)
+ {
+ var workspace = new Microsoft.CodeAnalysis.AdhocWorkspace();
+ var projectId = Microsoft.CodeAnalysis.ProjectId.CreateNewId();
+
+ var projectInfo = Microsoft.CodeAnalysis.ProjectInfo.Create(
+ projectId,
+ Microsoft.CodeAnalysis.VersionStamp.Create(),
+ "TempProject",
+ "TempProject",
+ Microsoft.CodeAnalysis.LanguageNames.CSharp,
+ metadataReferences: GetMetadataReferences(),
+ compilationOptions: new CSharpCompilationOptions(Microsoft.CodeAnalysis.OutputKind.DynamicallyLinkedLibrary));
+
+ var project = workspace.AddProject(projectInfo);
+
+ // Add all C# files to the project
+ foreach (var csFile in csFiles)
+ {
+ var sourceText = await File.ReadAllTextAsync(csFile);
+ var documentId = Microsoft.CodeAnalysis.DocumentId.CreateNewId(projectId);
+
+ project = project.AddDocument(
+ name: Path.GetFileName(csFile),
+ text: sourceText,
+ filePath: csFile).Project;
+ }
+
+ // Update the workspace with the final project
+ workspace.TryApplyChanges(project.Solution);
+
+ return workspace;
+ }
+
+ private static IEnumerable GetMetadataReferences()
+ {
+ var references = new List();
+
+ // Add basic .NET references
+ var dotnetAssemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location);
+ if (dotnetAssemblyPath != null)
+ {
+ var assemblyFiles = new[]
+ {
+ "System.Runtime.dll",
+ "System.Private.CoreLib.dll",
+ "System.Console.dll",
+ "System.Collections.dll",
+ "System.Linq.dll",
+ "System.Text.RegularExpressions.dll",
+ "System.Threading.dll"
+ };
+
+ foreach (var assemblyFile in assemblyFiles)
+ {
+ var path = Path.Combine(dotnetAssemblyPath, assemblyFile);
+ if (File.Exists(path))
+ {
+ references.Add(Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(path));
+ }
+ }
+ }
+
+ return references;
+ }
+
private class RenameRewriter : CSharpSyntaxRewriter
{
private readonly string _oldName;
diff --git a/Cast.Tool/Commands/SyncNamespaceWithFolderCommand.cs b/Cast.Tool/Commands/SyncNamespaceWithFolderCommand.cs
index 26ece5a..0fbfb92 100644
--- a/Cast.Tool/Commands/SyncNamespaceWithFolderCommand.cs
+++ b/Cast.Tool/Commands/SyncNamespaceWithFolderCommand.cs
@@ -24,6 +24,10 @@ public class Settings : CommandSettings
[Description("Output file path (defaults to overwriting the input file)")]
public string? OutputPath { get; init; }
+ [CommandOption("--project-path")]
+ [Description("Path to the project directory (for resolving root namespace)")]
+ public string? ProjectPath { get; init; }
+
[CommandOption("--dry-run")]
[Description("Show what changes would be made without applying them")]
[DefaultValue(false)]
@@ -56,7 +60,7 @@ public async Task ExecuteAsync(CommandContext context, Settings settings)
}
// Calculate expected namespace based on folder structure
- var expectedNamespace = CalculateExpectedNamespace(settings.FilePath, settings.RootNamespace);
+ var expectedNamespace = CalculateExpectedNamespace(settings.FilePath, settings.RootNamespace, settings.ProjectPath);
var currentNamespace = namespaceDeclaration.Name.ToString();
if (currentNamespace == expectedNamespace)
@@ -92,23 +96,31 @@ public async Task ExecuteAsync(CommandContext context, Settings settings)
}
}
- private static string CalculateExpectedNamespace(string filePath, string? rootNamespace)
+ private static string CalculateExpectedNamespace(string filePath, string? rootNamespace, string? projectPath)
{
- var fileInfo = new FileInfo(filePath);
- var projectRoot = FindProjectRoot(fileInfo.Directory!);
+ var resolvedProjectPath = RefactoringEngine.ResolveProjectPath(filePath, projectPath);
// Use provided root namespace or project directory name
- var baseNamespace = rootNamespace ?? projectRoot?.Name ?? "DefaultNamespace";
+ var baseNamespace = rootNamespace ??
+ (resolvedProjectPath != null ? Path.GetFileName(resolvedProjectPath) : "DefaultNamespace");
+
+ // Calculate relative path from project root to the file's directory
+ var fileDirectory = Path.GetDirectoryName(Path.GetFullPath(filePath));
+ string relativePath;
- // Calculate relative path from project root
- var relativePath = projectRoot != null
- ? Path.GetRelativePath(projectRoot.FullName, fileInfo.Directory!.FullName)
- : fileInfo.Directory!.Name;
+ if (resolvedProjectPath != null && fileDirectory != null)
+ {
+ relativePath = Path.GetRelativePath(resolvedProjectPath, fileDirectory);
+ }
+ else
+ {
+ relativePath = Path.GetFileName(fileDirectory ?? "");
+ }
// Build namespace from path segments
var segments = new List { baseNamespace };
- if (relativePath != ".")
+ if (relativePath != "." && !string.IsNullOrEmpty(relativePath))
{
var pathSegments = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
.Where(s => !string.IsNullOrEmpty(s) && IsValidNamespaceSegment(s));
diff --git a/Cast.Tool/Core/BaseRefactoringCommand.cs b/Cast.Tool/Core/BaseRefactoringCommand.cs
index 855e25e..92cadaa 100644
--- a/Cast.Tool/Core/BaseRefactoringCommand.cs
+++ b/Cast.Tool/Core/BaseRefactoringCommand.cs
@@ -29,6 +29,10 @@ public class Settings : CommandSettings
[Description("Show what changes would be made without applying them")]
[DefaultValue(false)]
public bool DryRun { get; init; } = false;
+
+ [CommandOption("--project-path")]
+ [Description("Path to the project directory (for namespace and folder resolution)")]
+ public string? ProjectPath { get; init; }
}
public override int Execute(CommandContext context, Settings settings)
diff --git a/Cast.Tool/Core/DiffUtility.cs b/Cast.Tool/Core/DiffUtility.cs
index c873b2b..9dee98a 100644
--- a/Cast.Tool/Core/DiffUtility.cs
+++ b/Cast.Tool/Core/DiffUtility.cs
@@ -92,6 +92,77 @@ public static void DisplayDiff(string originalContent, string modifiedContent, s
}
}
+ ///
+ /// Displays a multi-file diff with clear separation between files
+ ///
+ /// Dictionary of file paths to (original, modified) content tuples
+ public static void DisplayMultiFileDiff(Dictionary fileChanges)
+ {
+ if (fileChanges.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[yellow]No changes would be made[/]");
+ return;
+ }
+
+ bool isFirst = true;
+ foreach (var kvp in fileChanges)
+ {
+ if (!isFirst)
+ {
+ AnsiConsole.WriteLine(); // Add spacing between files
+ }
+
+ var filePath = kvp.Key;
+ var (original, modified) = kvp.Value;
+
+ if (string.IsNullOrEmpty(original))
+ {
+ AnsiConsole.MarkupLine($"[yellow]New file would be created: {filePath}[/]");
+ }
+ else
+ {
+ AnsiConsole.MarkupLine($"[yellow]Changes to file: {filePath}[/]");
+ }
+
+ DisplayDiff(original, modified, filePath);
+ isFirst = false;
+ }
+ }
+
+ ///
+ /// Display a summary of files that would be affected
+ ///
+ /// Dictionary of file paths to (original, modified) content tuples
+ public static void DisplayFileSummary(Dictionary fileChanges)
+ {
+ if (fileChanges.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[yellow]No files would be affected[/]");
+ return;
+ }
+
+ AnsiConsole.MarkupLine($"[cyan]Files that would be affected ({fileChanges.Count}):[/]");
+ foreach (var kvp in fileChanges)
+ {
+ var filePath = kvp.Key;
+ var (original, modified) = kvp.Value;
+
+ if (string.IsNullOrEmpty(original))
+ {
+ AnsiConsole.MarkupLine($" [green]+ {filePath}[/] (new file)");
+ }
+ else if (string.IsNullOrEmpty(modified))
+ {
+ AnsiConsole.MarkupLine($" [red]- {filePath}[/] (deleted)");
+ }
+ else
+ {
+ AnsiConsole.MarkupLine($" [yellow]~ {filePath}[/] (modified)");
+ }
+ }
+ AnsiConsole.WriteLine();
+ }
+
private static List ComputeDiff(string[] originalLines, string[] modifiedLines)
{
var hunks = new List();
diff --git a/Cast.Tool/Core/RefactoringEngine.cs b/Cast.Tool/Core/RefactoringEngine.cs
index 9a158d1..34444a1 100644
--- a/Cast.Tool/Core/RefactoringEngine.cs
+++ b/Cast.Tool/Core/RefactoringEngine.cs
@@ -73,4 +73,51 @@ private static IEnumerable GetMetadataReferences()
return references;
}
+
+ ///
+ /// Resolves the project path from a file path or explicit project path
+ ///
+ public static string? ResolveProjectPath(string filePath, string? explicitProjectPath = null)
+ {
+ if (!string.IsNullOrEmpty(explicitProjectPath))
+ {
+ return Path.GetFullPath(explicitProjectPath);
+ }
+
+ // Look for project files starting from the file's directory
+ var directory = Path.GetDirectoryName(Path.GetFullPath(filePath));
+ while (directory != null)
+ {
+ if (Directory.GetFiles(directory, "*.csproj").Any() ||
+ Directory.GetFiles(directory, "*.vbproj").Any() ||
+ Directory.GetFiles(directory, "*.fsproj").Any() ||
+ Directory.GetFiles(directory, "*.sln").Any())
+ {
+ return directory;
+ }
+
+ var parent = Directory.GetParent(directory);
+ directory = parent?.FullName;
+ }
+
+ // Fall back to the file's directory
+ return Path.GetDirectoryName(Path.GetFullPath(filePath));
+ }
+
+ ///
+ /// Gets the relative path from project root to the file
+ ///
+ public static string GetRelativePathFromProject(string filePath, string? projectPath = null)
+ {
+ var resolvedProjectPath = ResolveProjectPath(filePath, projectPath);
+ if (resolvedProjectPath == null)
+ {
+ return Path.GetFileName(filePath);
+ }
+
+ var fullFilePath = Path.GetFullPath(filePath);
+ var fullProjectPath = Path.GetFullPath(resolvedProjectPath);
+
+ return Path.GetRelativePath(fullProjectPath, fullFilePath);
+ }
}
\ No newline at end of file