diff --git a/Cast.Tool.Tests/LspTests.cs b/Cast.Tool.Tests/LspTests.cs new file mode 100644 index 0000000..ac30571 --- /dev/null +++ b/Cast.Tool.Tests/LspTests.cs @@ -0,0 +1,236 @@ +using Xunit; +using Cast.Tool.Core; +using System.IO; +using System.Threading.Tasks; + +namespace Cast.Tool.Tests; + +public class LspTests : IDisposable +{ + private readonly LspHelper _helper; + private readonly string _testFilePath; + + public LspTests() + { + _helper = new LspHelper(); + + // Create a temporary C# test file + _testFilePath = Path.GetTempFileName(); + File.WriteAllText(_testFilePath, @" +using System; + +namespace TestNamespace +{ + public class TestClass + { + public string TestProperty { get; set; } + + public void TestMethod() + { + Console.WriteLine(""Hello World""); + } + } +}"); + + // Rename to .cs extension + var csFilePath = Path.ChangeExtension(_testFilePath, ".cs"); + File.Move(_testFilePath, csFilePath); + _testFilePath = csFilePath; + } + + public void Dispose() + { + if (File.Exists(_testFilePath)) + { + File.Delete(_testFilePath); + } + } + + [Fact] + public void LspHelper_ValidateInputs_ValidFile_ShouldNotThrow() + { + // Arrange + var settings = new LspSettings { FilePath = _testFilePath }; + + // Act & Assert - Should not throw + _helper.ValidateInputs(settings); + } + + [Fact] + public void LspHelper_ValidateInputs_InvalidFile_ShouldThrow() + { + // Arrange + var settings = new LspSettings { FilePath = "nonexistent.cs" }; + + // Act & Assert + Assert.Throws(() => _helper.ValidateInputs(settings)); + } + + [Fact] + public void LspHelper_ValidateInputs_NegativeLineNumber_ShouldThrow() + { + // Arrange + var settings = new LspSettings { FilePath = _testFilePath, LineNumber = -1 }; + + // Act & Assert + Assert.Throws(() => _helper.ValidateInputs(settings)); + } + + [Fact] + public void LspHelper_ValidateInputs_NegativeColumnNumber_ShouldThrow() + { + // Arrange + var settings = new LspSettings { FilePath = _testFilePath, ColumnNumber = -1 }; + + // Act & Assert + Assert.Throws(() => _helper.ValidateInputs(settings)); + } + + [Fact] + public void LspHelper_CreatePosition_ShouldReturnCorrectPosition() + { + // Arrange + var settings = new LspSettings { LineNumber = 5, ColumnNumber = 10 }; + + // Act + var position = _helper.CreatePosition(settings); + + // Assert + Assert.Equal(5, position.Line); + Assert.Equal(10, position.Character); + } + + [Fact] + public void LspHelper_CreateDocumentUri_ShouldReturnValidUri() + { + // Act + var uri = _helper.CreateDocumentUri(_testFilePath); + + // Assert + Assert.StartsWith("file://", uri); + Assert.Contains(Path.GetFileName(_testFilePath), uri); + } + + [Fact] + public void LspHelper_GetLanguageId_CSharpFile_ShouldReturnCSharp() + { + // Act + var languageId = _helper.GetLanguageId(_testFilePath); + + // Assert + Assert.Equal("csharp", languageId); + } + + [Fact] + public void LspHelper_GetLanguageId_TypeScriptFile_ShouldReturnTypeScript() + { + // Act + var languageId = _helper.GetLanguageId("test.ts"); + + // Assert + Assert.Equal("typescript", languageId); + } + + [Fact] + public void LspHelper_GetLanguageId_JavaScriptFile_ShouldReturnJavaScript() + { + // Act + var languageId = _helper.GetLanguageId("test.js"); + + // Assert + Assert.Equal("javascript", languageId); + } + + [Fact] + public void LspHelper_GetLanguageId_PythonFile_ShouldReturnPython() + { + // Act + var languageId = _helper.GetLanguageId("test.py"); + + // Assert + Assert.Equal("python", languageId); + } + + [Fact] + public void LspHelper_GetLanguageId_UnknownFile_ShouldReturnPlainText() + { + // Act + var languageId = _helper.GetLanguageId("test.unknown"); + + // Assert + Assert.Equal("plaintext", languageId); + } + + [Fact] + public void LspHelper_GetDefaultServerPath_CSharpFile_ShouldReturnCSharpServerOrNull() + { + // Act + var serverPath = _helper.GetDefaultServerPath(_testFilePath); + + // Assert + // Should return null if no server is installed, or a path if found + // This test just verifies the method doesn't throw + Assert.True(serverPath == null || serverPath.Length > 0); + } + + [Fact] + public void LspPosition_Constructor_ShouldSetCorrectValues() + { + // Act + var position = new LspPosition(10, 5); + + // Assert + Assert.Equal(10, position.Line); + Assert.Equal(5, position.Character); + } + + [Fact] + public void LspRange_Constructor_ShouldSetCorrectValues() + { + // Arrange + var start = new LspPosition(1, 0); + var end = new LspPosition(2, 10); + + // Act + var range = new LspRange(start, end); + + // Assert + Assert.Equal(start, range.Start); + Assert.Equal(end, range.End); + Assert.Equal(1, range.Start.Line); + Assert.Equal(0, range.Start.Character); + Assert.Equal(2, range.End.Line); + Assert.Equal(10, range.End.Character); + } + + [Fact] + public async Task LspHelper_CreateLspClientAsync_WithInvalidServer_ShouldReturnNull() + { + // Arrange + var settings = new LspSettings + { + FilePath = _testFilePath, + ServerPath = "nonexistent-server" + }; + + // Act + var client = await _helper.CreateLspClientAsync(settings); + + // Assert + Assert.Null(client); + } + + [Fact] + public void LspSettings_DefaultValues_ShouldBeCorrect() + { + // Act + var settings = new LspSettings(); + + // Assert + Assert.Equal(0, settings.LineNumber); + Assert.Equal(0, settings.ColumnNumber); + Assert.Null(settings.ServerPath); + Assert.Null(settings.ServerArgs); + Assert.Null(settings.Query); + } +} \ No newline at end of file diff --git a/Cast.Tool/Cast.Tool.csproj b/Cast.Tool/Cast.Tool.csproj index df229ff..bcfea8b 100644 --- a/Cast.Tool/Cast.Tool.csproj +++ b/Cast.Tool/Cast.Tool.csproj @@ -11,6 +11,9 @@ + + + diff --git a/Cast.Tool/Commands/LspCodeActionsCommand.cs b/Cast.Tool/Commands/LspCodeActionsCommand.cs new file mode 100644 index 0000000..2a27af0 --- /dev/null +++ b/Cast.Tool/Commands/LspCodeActionsCommand.cs @@ -0,0 +1,126 @@ +using Cast.Tool.Core; +using Spectre.Console; +using Spectre.Console.Cli; +using System.ComponentModel; +using Newtonsoft.Json.Linq; + +namespace Cast.Tool.Commands; + +public class LspCodeActionsCommand : Command +{ + public class Settings : LspSettings + { + [CommandOption("--end-line")] + [Description("End line number (0-based) for range selection")] + public int? EndLineNumber { get; init; } + + [CommandOption("--end-column")] + [Description("End column number (0-based) for range selection")] + public int? EndColumnNumber { get; init; } + } + + public override int Execute(CommandContext context, Settings settings) + { + return ExecuteAsync(context, settings).GetAwaiter().GetResult(); + } + + public async Task ExecuteAsync(CommandContext context, Settings settings) + { + var helper = new LspHelper(); + try + { + helper.ValidateInputs(settings); + + using var client = await helper.CreateLspClientAsync(settings); + if (client == null) return 1; + + var documentUri = helper.CreateDocumentUri(settings.FilePath); + var languageId = helper.GetLanguageId(settings.FilePath); + var fileContent = await File.ReadAllTextAsync(settings.FilePath); + + // Open the document + await client.DidOpenAsync(documentUri, languageId, fileContent); + + // Give the server time to analyze the document + await Task.Delay(1000); + + // Create range for code actions + var startPosition = helper.CreatePosition(settings); + var endPosition = settings.EndLineNumber.HasValue || settings.EndColumnNumber.HasValue + ? new LspPosition(settings.EndLineNumber ?? settings.LineNumber, settings.EndColumnNumber ?? settings.ColumnNumber) + : startPosition; + + var range = new LspRange(startPosition, endPosition); + + // Request code actions + var result = await client.GetCodeActionsAsync(documentUri, range); + + if (result == null) + { + AnsiConsole.WriteLine("[yellow]No code actions available for the specified range.[/]"); + return 0; + } + + if (result is JArray actionsArray) + { + AnsiConsole.WriteLine($"[green]Available code actions ({actionsArray.Count}):[/]"); + + var actionIndex = 1; + foreach (var action in actionsArray) + { + var title = action["title"]?.ToString(); + var kind = action["kind"]?.ToString(); + var command = action["command"]; + var edit = action["edit"]; + var diagnostics = action["diagnostics"] as JArray; + + if (!string.IsNullOrEmpty(title)) + { + AnsiConsole.WriteLine($"{actionIndex}. [cyan]{title}[/]"); + + if (!string.IsNullOrEmpty(kind)) + { + AnsiConsole.WriteLine($" Kind: {kind}"); + } + + if (diagnostics != null && diagnostics.Count > 0) + { + AnsiConsole.WriteLine($" Fixes {diagnostics.Count} diagnostic(s)"); + } + + if (edit?["changes"] != null) + { + var changes = edit["changes"] as JObject; + if (changes != null) + { + AnsiConsole.WriteLine($" Affects {changes.Count} file(s)"); + } + } + + if (command != null) + { + var commandName = command["command"]?.ToString(); + if (!string.IsNullOrEmpty(commandName)) + { + AnsiConsole.WriteLine($" Command: {commandName}"); + } + } + } + + actionIndex++; + AnsiConsole.WriteLine(); + } + } + + // Close the document + await client.DidCloseAsync(documentUri); + + return 0; + } + catch (Exception ex) + { + AnsiConsole.WriteLine($"[red]Error: {ex.Message}[/]"); + return 1; + } + } +} \ No newline at end of file diff --git a/Cast.Tool/Commands/LspDocumentSymbolsCommand.cs b/Cast.Tool/Commands/LspDocumentSymbolsCommand.cs new file mode 100644 index 0000000..a0e517e --- /dev/null +++ b/Cast.Tool/Commands/LspDocumentSymbolsCommand.cs @@ -0,0 +1,78 @@ +using Cast.Tool.Core; +using Spectre.Console; +using Spectre.Console.Cli; +using Newtonsoft.Json.Linq; + +namespace Cast.Tool.Commands; + +public class LspDocumentSymbolsCommand : Command +{ + public override int Execute(CommandContext context, LspSettings settings) + { + return ExecuteAsync(context, settings).GetAwaiter().GetResult(); + } + + public async Task ExecuteAsync(CommandContext context, LspSettings settings) + { + var helper = new LspHelper(); + try + { + helper.ValidateInputs(settings); + + using var client = await helper.CreateLspClientAsync(settings); + if (client == null) return 1; + + var documentUri = helper.CreateDocumentUri(settings.FilePath); + var languageId = helper.GetLanguageId(settings.FilePath); + var fileContent = await File.ReadAllTextAsync(settings.FilePath); + + // Open the document + await client.DidOpenAsync(documentUri, languageId, fileContent); + + // Give the server time to analyze the document + await Task.Delay(1000); + + // Request document symbols + var result = await client.GetDocumentSymbolsAsync(documentUri); + + if (result == null) + { + AnsiConsole.WriteLine("[yellow]No document symbols found.[/]"); + return 0; + } + + AnsiConsole.WriteLine($"[green]Document symbols for {Path.GetFileName(settings.FilePath)}:[/]"); + + if (result is JArray symbolArray) + { + foreach (var symbol in symbolArray) + { + // Check if it's a DocumentSymbol (hierarchical) or SymbolInformation (flat) + if (symbol["children"] != null || symbol["selectionRange"] != null) + { + helper.OutputDocumentSymbol(symbol); + } + else + { + helper.OutputSymbol(symbol); + } + } + + if (symbolArray.Count == 0) + { + AnsiConsole.WriteLine("[yellow]No document symbols found.[/]"); + } + } + + // Close the document + await client.DidCloseAsync(documentUri); + + return 0; + } + catch (Exception ex) + { + AnsiConsole.WriteLine($"[red]Error: {ex.Message}[/]"); + return 1; + } + } +} \ No newline at end of file diff --git a/Cast.Tool/Commands/LspFindReferencesCommand.cs b/Cast.Tool/Commands/LspFindReferencesCommand.cs new file mode 100644 index 0000000..f91201d --- /dev/null +++ b/Cast.Tool/Commands/LspFindReferencesCommand.cs @@ -0,0 +1,79 @@ +using Cast.Tool.Core; +using Spectre.Console; +using Spectre.Console.Cli; +using System.ComponentModel; +using Newtonsoft.Json.Linq; + +namespace Cast.Tool.Commands; + +public class LspFindReferencesCommand : Command +{ + public class Settings : LspSettings + { + [CommandOption("--include-declaration")] + [Description("Include the declaration in the results")] + [DefaultValue(true)] + public bool IncludeDeclaration { get; init; } = true; + } + + public override int Execute(CommandContext context, Settings settings) + { + return ExecuteAsync(context, settings).GetAwaiter().GetResult(); + } + + public async Task ExecuteAsync(CommandContext context, Settings settings) + { + var helper = new LspHelper(); + try + { + helper.ValidateInputs(settings); + + using var client = await helper.CreateLspClientAsync(settings); + if (client == null) return 1; + + var documentUri = helper.CreateDocumentUri(settings.FilePath); + var position = helper.CreatePosition(settings); + var languageId = helper.GetLanguageId(settings.FilePath); + var fileContent = await File.ReadAllTextAsync(settings.FilePath); + + // Open the document + await client.DidOpenAsync(documentUri, languageId, fileContent); + + // Give the server time to analyze the document + await Task.Delay(1000); + + // Request references + var result = await client.FindReferencesAsync(documentUri, position, settings.IncludeDeclaration); + + if (result == null) + { + AnsiConsole.WriteLine("[yellow]No references found at the specified position.[/]"); + return 0; + } + + if (result is JArray array) + { + AnsiConsole.WriteLine($"[green]Found {array.Count} reference(s):[/]"); + foreach (var reference in array) + { + helper.OutputLocation(reference); + } + + if (array.Count == 0) + { + AnsiConsole.WriteLine("[yellow]No references found at the specified position.[/]"); + } + } + + // Close the document + await client.DidCloseAsync(documentUri); + + return 0; + } + catch (Exception ex) + { + AnsiConsole.WriteLine($"[red]Error: {ex.Message}[/]"); + return 1; + } + } +} \ No newline at end of file diff --git a/Cast.Tool/Commands/LspFormatDocumentCommand.cs b/Cast.Tool/Commands/LspFormatDocumentCommand.cs new file mode 100644 index 0000000..f720232 --- /dev/null +++ b/Cast.Tool/Commands/LspFormatDocumentCommand.cs @@ -0,0 +1,203 @@ +using Cast.Tool.Core; +using Spectre.Console; +using Spectre.Console.Cli; +using System.ComponentModel; +using Newtonsoft.Json.Linq; + +namespace Cast.Tool.Commands; + +public class LspFormatDocumentCommand : Command +{ + public class Settings : LspSettings + { + [CommandOption("--tab-size")] + [Description("Tab size for formatting")] + [DefaultValue(4)] + public int TabSize { get; init; } = 4; + + [CommandOption("--insert-spaces")] + [Description("Use spaces instead of tabs")] + [DefaultValue(true)] + public bool InsertSpaces { get; init; } = true; + + [CommandOption("--output")] + [Description("Output formatted content to a file (default: overwrite original)")] + public string? OutputFile { get; init; } + + [CommandOption("--dry-run")] + [Description("Show formatting changes without applying them")] + public bool DryRun { get; init; } + } + + public override int Execute(CommandContext context, Settings settings) + { + return ExecuteAsync(context, settings).GetAwaiter().GetResult(); + } + + public async Task ExecuteAsync(CommandContext context, Settings settings) + { + var helper = new LspHelper(); + try + { + helper.ValidateInputs(settings); + + using var client = await helper.CreateLspClientAsync(settings); + if (client == null) return 1; + + var documentUri = helper.CreateDocumentUri(settings.FilePath); + var languageId = helper.GetLanguageId(settings.FilePath); + var originalContent = await File.ReadAllTextAsync(settings.FilePath); + + // Open the document + await client.DidOpenAsync(documentUri, languageId, originalContent); + + // Give the server time to analyze the document + await Task.Delay(1000); + + // Request document formatting + var result = await client.FormatDocumentAsync(documentUri); + + if (result == null) + { + AnsiConsole.WriteLine("[yellow]No formatting changes suggested by the LSP server.[/]"); + return 0; + } + + if (result is JArray editsArray) + { + if (editsArray.Count == 0) + { + AnsiConsole.WriteLine("[yellow]No formatting changes suggested by the LSP server.[/]"); + return 0; + } + + // Apply the text edits + var formattedContent = ApplyTextEdits(originalContent, editsArray); + + if (settings.DryRun) + { + AnsiConsole.WriteLine("[green]Formatting changes (dry run):[/]"); + AnsiConsole.WriteLine($"[cyan]Original length:[/] {originalContent.Length} characters"); + AnsiConsole.WriteLine($"[cyan]Formatted length:[/] {formattedContent.Length} characters"); + AnsiConsole.WriteLine($"[cyan]Number of edits:[/] {editsArray.Count}"); + + // Show a diff preview (simplified) + if (originalContent != formattedContent) + { + AnsiConsole.WriteLine("\n[yellow]Content will be changed[/]"); + } + else + { + AnsiConsole.WriteLine("\n[green]No changes needed[/]"); + } + } + else + { + var outputPath = settings.OutputFile ?? settings.FilePath; + await File.WriteAllTextAsync(outputPath, formattedContent); + + AnsiConsole.WriteLine($"[green]Document formatted successfully.[/]"); + AnsiConsole.WriteLine($"[cyan]Output written to:[/] {outputPath}"); + AnsiConsole.WriteLine($"[cyan]Applied {editsArray.Count} edit(s)[/]"); + } + } + + // Close the document + await client.DidCloseAsync(documentUri); + + return 0; + } + catch (Exception ex) + { + AnsiConsole.WriteLine($"[red]Error: {ex.Message}[/]"); + return 1; + } + } + + private string ApplyTextEdits(string originalText, JArray edits) + { + // Sort edits by range (from end to beginning) to avoid offset issues + var sortedEdits = edits.OrderByDescending(e => e["range"]?["start"]?["line"]?.Value() ?? 0) + .ThenByDescending(e => e["range"]?["start"]?["character"]?.Value() ?? 0) + .ToList(); + + var lines = originalText.Split('\n'); + + foreach (var edit in sortedEdits) + { + try + { + var range = edit["range"]; + var newText = edit["newText"]?.ToString() ?? ""; + + if (range == null) continue; + + var startLine = range["start"]?["line"]?.Value() ?? 0; + var startChar = range["start"]?["character"]?.Value() ?? 0; + var endLine = range["end"]?["line"]?.Value() ?? 0; + var endChar = range["end"]?["character"]?.Value() ?? 0; + + if (startLine == endLine) + { + // Single line edit + if (startLine < lines.Length) + { + var line = lines[startLine]; + if (startChar <= line.Length && endChar <= line.Length) + { + lines[startLine] = line.Substring(0, startChar) + newText + line.Substring(endChar); + } + } + } + else + { + // Multi-line edit - simplified approach + if (startLine < lines.Length && endLine < lines.Length) + { + var newLines = new List(); + + // Add lines before the edit + for (int i = 0; i < startLine; i++) + { + newLines.Add(lines[i]); + } + + // Add the edited content + var startLinePrefix = startChar < lines[startLine].Length ? lines[startLine].Substring(0, startChar) : lines[startLine]; + var endLineSuffix = endChar < lines[endLine].Length ? lines[endLine].Substring(endChar) : ""; + + var editLines = newText.Split('\n'); + if (editLines.Length == 1) + { + newLines.Add(startLinePrefix + editLines[0] + endLineSuffix); + } + else + { + newLines.Add(startLinePrefix + editLines[0]); + for (int i = 1; i < editLines.Length - 1; i++) + { + newLines.Add(editLines[i]); + } + newLines.Add(editLines[editLines.Length - 1] + endLineSuffix); + } + + // Add lines after the edit + for (int i = endLine + 1; i < lines.Length; i++) + { + newLines.Add(lines[i]); + } + + lines = newLines.ToArray(); + } + } + } + catch (Exception) + { + // Skip problematic edits + continue; + } + } + + return string.Join('\n', lines); + } +} \ No newline at end of file diff --git a/Cast.Tool/Commands/LspGoToDefinitionCommand.cs b/Cast.Tool/Commands/LspGoToDefinitionCommand.cs new file mode 100644 index 0000000..97660b8 --- /dev/null +++ b/Cast.Tool/Commands/LspGoToDefinitionCommand.cs @@ -0,0 +1,75 @@ +using Cast.Tool.Core; +using Spectre.Console; +using Spectre.Console.Cli; +using Newtonsoft.Json.Linq; + +namespace Cast.Tool.Commands; + +public class LspGoToDefinitionCommand : Command +{ + public override int Execute(CommandContext context, LspSettings settings) + { + return ExecuteAsync(context, settings).GetAwaiter().GetResult(); + } + + public async Task ExecuteAsync(CommandContext context, LspSettings settings) + { + var helper = new LspHelper(); + try + { + helper.ValidateInputs(settings); + + using var client = await helper.CreateLspClientAsync(settings); + if (client == null) return 1; + + var documentUri = helper.CreateDocumentUri(settings.FilePath); + var position = helper.CreatePosition(settings); + var languageId = helper.GetLanguageId(settings.FilePath); + var fileContent = await File.ReadAllTextAsync(settings.FilePath); + + // Open the document + await client.DidOpenAsync(documentUri, languageId, fileContent); + + // Give the server time to analyze the document + await Task.Delay(1000); + + // Request definition + var result = await client.GoToDefinitionAsync(documentUri, position); + + if (result == null) + { + AnsiConsole.WriteLine("[yellow]No definition found at the specified position.[/]"); + return 0; + } + + AnsiConsole.WriteLine("[green]Definitions found:[/]"); + + if (result is JArray array) + { + foreach (var definition in array) + { + helper.OutputLocation(definition); + } + + if (array.Count == 0) + { + AnsiConsole.WriteLine("[yellow]No definition found at the specified position.[/]"); + } + } + else if (result is JObject obj) + { + helper.OutputLocation(obj); + } + + // Close the document + await client.DidCloseAsync(documentUri); + + return 0; + } + catch (Exception ex) + { + AnsiConsole.WriteLine($"[red]Error: {ex.Message}[/]"); + return 1; + } + } +} \ No newline at end of file diff --git a/Cast.Tool/Commands/LspHoverCommand.cs b/Cast.Tool/Commands/LspHoverCommand.cs new file mode 100644 index 0000000..60a7d6a --- /dev/null +++ b/Cast.Tool/Commands/LspHoverCommand.cs @@ -0,0 +1,125 @@ +using Cast.Tool.Core; +using Spectre.Console; +using Spectre.Console.Cli; +using Newtonsoft.Json.Linq; + +namespace Cast.Tool.Commands; + +public class LspHoverCommand : Command +{ + public override int Execute(CommandContext context, LspSettings settings) + { + return ExecuteAsync(context, settings).GetAwaiter().GetResult(); + } + + public async Task ExecuteAsync(CommandContext context, LspSettings settings) + { + var helper = new LspHelper(); + try + { + helper.ValidateInputs(settings); + + using var client = await helper.CreateLspClientAsync(settings); + if (client == null) return 1; + + var documentUri = helper.CreateDocumentUri(settings.FilePath); + var position = helper.CreatePosition(settings); + var languageId = helper.GetLanguageId(settings.FilePath); + var fileContent = await File.ReadAllTextAsync(settings.FilePath); + + // Open the document + await client.DidOpenAsync(documentUri, languageId, fileContent); + + // Give the server time to analyze the document + await Task.Delay(1000); + + // Request hover information + var result = await client.GetHoverInfoAsync(documentUri, position); + + if (result == null) + { + AnsiConsole.WriteLine("[yellow]No hover information available at the specified position.[/]"); + return 0; + } + + AnsiConsole.WriteLine("[green]Hover Information:[/]"); + + if (result is JObject hoverObj) + { + var contents = hoverObj["contents"]; + if (contents != null) + { + if (contents is JArray contentArray) + { + foreach (var content in contentArray) + { + OutputHoverContent(content); + } + } + else if (contents is JObject contentObj) + { + OutputHoverContent(contentObj); + } + else if (contents is JValue contentValue) + { + AnsiConsole.WriteLine(contentValue.ToString()); + } + } + + var range = hoverObj["range"]; + if (range != null) + { + var startLine = (range["start"]?["line"]?.Value() ?? 0) + 1; + var startCol = (range["start"]?["character"]?.Value() ?? 0) + 1; + var endLine = (range["end"]?["line"]?.Value() ?? 0) + 1; + var endCol = (range["end"]?["character"]?.Value() ?? 0) + 1; + AnsiConsole.WriteLine($"[grey]Range: {startLine}:{startCol} - {endLine}:{endCol}[/]"); + } + } + + // Close the document + await client.DidCloseAsync(documentUri); + + return 0; + } + catch (Exception ex) + { + AnsiConsole.WriteLine($"[red]Error: {ex.Message}[/]"); + return 1; + } + } + + private void OutputHoverContent(JToken content) + { + try + { + if (content is JObject obj) + { + var kind = obj["kind"]?.ToString(); + var value = obj["value"]?.ToString(); + var language = obj["language"]?.ToString(); + + if (!string.IsNullOrEmpty(language)) + { + AnsiConsole.WriteLine($"[grey]Language: {language}[/]"); + } + if (!string.IsNullOrEmpty(kind)) + { + AnsiConsole.WriteLine($"[grey]({kind})[/]"); + } + if (!string.IsNullOrEmpty(value)) + { + AnsiConsole.WriteLine(value); + } + } + else if (content is JValue val) + { + AnsiConsole.WriteLine(val.ToString()); + } + } + catch (Exception) + { + AnsiConsole.WriteLine($"Raw content: {content}"); + } + } +} \ No newline at end of file diff --git a/Cast.Tool/Commands/LspWorkspaceSymbolsCommand.cs b/Cast.Tool/Commands/LspWorkspaceSymbolsCommand.cs new file mode 100644 index 0000000..e3cb23d --- /dev/null +++ b/Cast.Tool/Commands/LspWorkspaceSymbolsCommand.cs @@ -0,0 +1,74 @@ +using Cast.Tool.Core; +using Spectre.Console; +using Spectre.Console.Cli; +using System.ComponentModel; +using Newtonsoft.Json.Linq; + +namespace Cast.Tool.Commands; + +public class LspWorkspaceSymbolsCommand : Command +{ + public class Settings : LspSettings + { + [CommandArgument(0, "[QUERY]")] + [Description("Search query for workspace symbols (overrides --query)")] + public string? QueryArgument { get; init; } + + [CommandOption("-w|--workspace")] + [Description("Workspace root directory")] + public string? WorkspaceRoot { get; init; } + } + + public override int Execute(CommandContext context, Settings settings) + { + return ExecuteAsync(context, settings).GetAwaiter().GetResult(); + } + + public async Task ExecuteAsync(CommandContext context, Settings settings) + { + var helper = new LspHelper(); + try + { + var query = settings.QueryArgument ?? settings.Query ?? ""; + + using var client = await helper.CreateLspClientAsync(settings); + if (client == null) return 1; + + // Give the server time to initialize + await Task.Delay(1000); + + // Request workspace symbols + var result = await client.GetWorkspaceSymbolsAsync(query); + + if (result == null) + { + AnsiConsole.WriteLine($"[yellow]No workspace symbols found{(string.IsNullOrEmpty(query) ? "" : $" for query '{query}'")}.[/]"); + return 0; + } + + if (result is JArray symbolArray) + { + AnsiConsole.WriteLine($"[green]Found {symbolArray.Count} workspace symbol(s):{(string.IsNullOrEmpty(query) ? "" : $" for '{query}'")}[/]"); + + // Group symbols by kind for better organization + var groupedSymbols = symbolArray.GroupBy(s => s["kind"]?.ToString() ?? "Unknown").OrderBy(g => g.Key); + + foreach (var group in groupedSymbols) + { + AnsiConsole.WriteLine($"\n[cyan]{group.Key}:[/]"); + foreach (var symbol in group.OrderBy(s => s["name"]?.ToString())) + { + helper.OutputSymbol(symbol); + } + } + } + + return 0; + } + catch (Exception ex) + { + AnsiConsole.WriteLine($"[red]Error: {ex.Message}[/]"); + return 1; + } + } +} \ No newline at end of file diff --git a/Cast.Tool/Core/BaseLspCommand.cs b/Cast.Tool/Core/BaseLspCommand.cs new file mode 100644 index 0000000..1a97684 --- /dev/null +++ b/Cast.Tool/Core/BaseLspCommand.cs @@ -0,0 +1,247 @@ +using System.ComponentModel; +using Spectre.Console; +using Spectre.Console.Cli; +using Newtonsoft.Json.Linq; + +namespace Cast.Tool.Core; + +public abstract class BaseLspCommand : Command +{ + public class Settings : CommandSettings + { + [CommandArgument(0, "")] + [Description("The source file to analyze")] + public string FilePath { get; init; } = string.Empty; + + [CommandOption("-l|--line")] + [Description("Line number (0-based) for position")] + [DefaultValue(0)] + public int LineNumber { get; init; } = 0; + + [CommandOption("-c|--column")] + [Description("Column number (0-based) for position")] + [DefaultValue(0)] + public int ColumnNumber { get; init; } = 0; + + [CommandOption("-s|--server")] + [Description("LSP server executable path")] + public string? ServerPath { get; init; } + + [CommandOption("--server-args")] + [Description("Arguments to pass to the LSP server")] + public string? ServerArgs { get; init; } + + [CommandOption("-q|--query")] + [Description("Query string for search operations")] + public string? Query { get; init; } + } + + public override int Execute(CommandContext context, Settings settings) + { + return ExecuteAsync(context, settings).GetAwaiter().GetResult(); + } + + public abstract Task ExecuteAsync(CommandContext context, Settings settings); + + protected void ValidateInputs(Settings settings) + { + if (!File.Exists(settings.FilePath)) + { + throw new FileNotFoundException($"File not found: {settings.FilePath}"); + } + + if (settings.LineNumber < 0) + { + throw new ArgumentException("Line number must be 0 or greater"); + } + + if (settings.ColumnNumber < 0) + { + throw new ArgumentException("Column number must be 0 or greater"); + } + } + + protected LspPosition CreatePosition(Settings settings) + { + return new LspPosition(settings.LineNumber, settings.ColumnNumber); + } + + protected string CreateDocumentUri(string filePath) + { + return new Uri(Path.GetFullPath(filePath)).ToString(); + } + + protected string GetLanguageId(string filePath) + { + var extension = Path.GetExtension(filePath).ToLowerInvariant(); + return extension switch + { + ".cs" => "csharp", + ".ts" => "typescript", + ".js" => "javascript", + ".py" => "python", + ".java" => "java", + ".cpp" or ".c" or ".h" => "cpp", + ".go" => "go", + ".rs" => "rust", + ".rb" => "ruby", + ".php" => "php", + _ => "plaintext" + }; + } + + protected async Task CreateLspClientAsync(Settings settings) + { + var serverPath = settings.ServerPath ?? GetDefaultServerPath(settings.FilePath); + if (string.IsNullOrEmpty(serverPath)) + { + AnsiConsole.WriteLine("[red]Error: No LSP server specified and no default server found for this file type.[/]"); + return null; + } + + var serverArgs = settings.ServerArgs?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty(); + var client = new LspClient(serverPath, serverArgs); + + if (!await client.StartAsync()) + { + AnsiConsole.WriteLine($"[red]Error: Failed to start LSP server: {serverPath}[/]"); + client.Dispose(); + return null; + } + + return client; + } + + protected string? GetDefaultServerPath(string filePath) + { + var extension = Path.GetExtension(filePath).ToLowerInvariant(); + return extension switch + { + ".cs" => FindExecutable("csharp-ls") ?? FindExecutable("omnisharp"), + ".ts" or ".js" => FindExecutable("typescript-language-server"), + ".py" => FindExecutable("pylsp") ?? FindExecutable("pyright"), + ".java" => FindExecutable("jdtls"), + ".cpp" or ".c" or ".h" => FindExecutable("clangd"), + ".go" => FindExecutable("gopls"), + ".rs" => FindExecutable("rust-analyzer"), + ".rb" => FindExecutable("solargraph"), + ".php" => FindExecutable("intelephense"), + _ => null + }; + } + + protected string? FindExecutable(string name) + { + var paths = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? Array.Empty(); + var extensions = Environment.OSVersion.Platform == PlatformID.Win32NT + ? new[] { ".exe", ".cmd", ".bat" } + : new[] { "" }; + + foreach (var path in paths) + { + foreach (var ext in extensions) + { + var fullPath = Path.Combine(path, name + ext); + if (File.Exists(fullPath)) + { + return fullPath; + } + } + } + + return null; + } + + protected void OutputLocation(JToken location, string? prefix = null) + { + try + { + var uri = location["uri"]?.ToString(); + var range = location["range"]; + if (uri != null && range != null) + { + var uriObj = new Uri(uri); + var filePath = uriObj.IsFile ? uriObj.LocalPath : uri; + var line = (range["start"]?["line"]?.Value() ?? 0) + 1; // Convert to 1-based for display + var column = (range["start"]?["character"]?.Value() ?? 0) + 1; // Convert to 1-based for display + + var prefixText = prefix != null ? $"{prefix}: " : ""; + Console.WriteLine($"{prefixText}{filePath}:{line}:{column}"); + } + } + catch (Exception) + { + Console.WriteLine($"Error parsing location: {location}"); + } + } + + protected void OutputSymbol(JToken symbol) + { + try + { + var name = symbol["name"]?.ToString(); + var kind = symbol["kind"]?.ToString(); + var location = symbol["location"]; + + if (location != null && name != null) + { + var uri = location["uri"]?.ToString(); + var range = location["range"]; + if (uri != null && range != null) + { + var uriObj = new Uri(uri); + var filePath = uriObj.IsFile ? uriObj.LocalPath : uri; + var line = (range["start"]?["line"]?.Value() ?? 0) + 1; + var column = (range["start"]?["character"]?.Value() ?? 0) + 1; + + Console.WriteLine($"{kind}: {name} - {filePath}:{line}:{column}"); + } + } + } + catch (Exception) + { + Console.WriteLine($"Error parsing symbol: {symbol}"); + } + } + + protected void OutputDocumentSymbol(JToken symbol, string indent = "") + { + try + { + var name = symbol["name"]?.ToString(); + var kind = symbol["kind"]?.ToString(); + var selectionRange = symbol["selectionRange"] ?? symbol["range"]; + + if (selectionRange != null && name != null) + { + var line = (selectionRange["start"]?["line"]?.Value() ?? 0) + 1; + var column = (selectionRange["start"]?["character"]?.Value() ?? 0) + 1; + + Console.WriteLine($"{indent}{kind}: {name} - Line {line}:{column}"); + + var children = symbol["children"]; + if (children is JArray childArray) + { + foreach (var child in childArray) + { + OutputDocumentSymbol(child, indent + " "); + } + } + } + } + catch (Exception) + { + Console.WriteLine($"Error parsing document symbol: {symbol}"); + } + } + + // Public helper methods for use by composition + public void ValidateInputsPublic(Settings settings) => ValidateInputs(settings); + public LspPosition CreatePositionPublic(Settings settings) => CreatePosition(settings); + public string CreateDocumentUriPublic(string filePath) => CreateDocumentUri(filePath); + public string GetLanguageIdPublic(string filePath) => GetLanguageId(filePath); + public Task CreateLspClientAsyncPublic(Settings settings) => CreateLspClientAsync(settings); + public void OutputLocationPublic(JToken location, string? prefix = null) => OutputLocation(location, prefix); + public void OutputSymbolPublic(JToken symbol) => OutputSymbol(symbol); + public void OutputDocumentSymbolPublic(JToken symbol, string indent = "") => OutputDocumentSymbol(symbol, indent); +} \ No newline at end of file diff --git a/Cast.Tool/Core/LspClient.cs b/Cast.Tool/Core/LspClient.cs new file mode 100644 index 0000000..143b2d9 --- /dev/null +++ b/Cast.Tool/Core/LspClient.cs @@ -0,0 +1,314 @@ +using System.Diagnostics; +using System.Text.Json; +using StreamJsonRpc; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Cast.Tool.Core; + +public class LspClient : IDisposable +{ + private JsonRpc? _jsonRpc; + private Process? _serverProcess; + private readonly string _serverExecutable; + private readonly string[] _serverArgs; + private bool _disposed; + private int _requestId = 1; + + public LspClient(string serverExecutable, params string[] serverArgs) + { + _serverExecutable = serverExecutable; + _serverArgs = serverArgs; + } + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + try + { + // Start the LSP server process + _serverProcess = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = _serverExecutable, + Arguments = string.Join(" ", _serverArgs), + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + } + }; + + if (!_serverProcess.Start()) + { + return false; + } + + // Initialize JsonRpc + _jsonRpc = JsonRpc.Attach(_serverProcess.StandardInput.BaseStream, _serverProcess.StandardOutput.BaseStream); + + // Initialize the LSP connection + var initializeParams = new + { + processId = Environment.ProcessId, + capabilities = new + { + textDocument = new + { + definition = new { dynamicRegistration = false }, + references = new { dynamicRegistration = false }, + hover = new { dynamicRegistration = false }, + documentSymbol = new { dynamicRegistration = false }, + codeAction = new { dynamicRegistration = false }, + formatting = new { dynamicRegistration = false } + }, + workspace = new + { + symbol = new { dynamicRegistration = false } + } + } + }; + + var result = await _jsonRpc.InvokeAsync("initialize", initializeParams, cancellationToken); + await _jsonRpc.NotifyAsync("initialized", new { }, cancellationToken); + + return true; + } + catch (Exception) + { + Dispose(); + return false; + } + } + + public async Task GoToDefinitionAsync(string documentUri, LspPosition position, CancellationToken cancellationToken = default) + { + if (_jsonRpc == null) return null; + + try + { + var @params = new + { + textDocument = new { uri = documentUri }, + position = new { line = position.Line, character = position.Character } + }; + + var result = await _jsonRpc.InvokeAsync("textDocument/definition", @params, cancellationToken); + return result; + } + catch (Exception) + { + return null; + } + } + + public async Task FindReferencesAsync(string documentUri, LspPosition position, bool includeDeclaration = true, CancellationToken cancellationToken = default) + { + if (_jsonRpc == null) return null; + + try + { + var @params = new + { + textDocument = new { uri = documentUri }, + position = new { line = position.Line, character = position.Character }, + context = new { includeDeclaration = includeDeclaration } + }; + + var result = await _jsonRpc.InvokeAsync("textDocument/references", @params, cancellationToken); + return result; + } + catch (Exception) + { + return null; + } + } + + public async Task GetHoverInfoAsync(string documentUri, LspPosition position, CancellationToken cancellationToken = default) + { + if (_jsonRpc == null) return null; + + try + { + var @params = new + { + textDocument = new { uri = documentUri }, + position = new { line = position.Line, character = position.Character } + }; + + var result = await _jsonRpc.InvokeAsync("textDocument/hover", @params, cancellationToken); + return result; + } + catch (Exception) + { + return null; + } + } + + public async Task GetWorkspaceSymbolsAsync(string query, CancellationToken cancellationToken = default) + { + if (_jsonRpc == null) return null; + + try + { + var @params = new { query = query }; + var result = await _jsonRpc.InvokeAsync("workspace/symbol", @params, cancellationToken); + return result; + } + catch (Exception) + { + return null; + } + } + + public async Task GetDocumentSymbolsAsync(string documentUri, CancellationToken cancellationToken = default) + { + if (_jsonRpc == null) return null; + + try + { + var @params = new { textDocument = new { uri = documentUri } }; + var result = await _jsonRpc.InvokeAsync("textDocument/documentSymbol", @params, cancellationToken); + return result; + } + catch (Exception) + { + return null; + } + } + + public async Task GetCodeActionsAsync(string documentUri, LspRange range, CancellationToken cancellationToken = default) + { + if (_jsonRpc == null) return null; + + try + { + var @params = new + { + textDocument = new { uri = documentUri }, + range = new + { + start = new { line = range.Start.Line, character = range.Start.Character }, + end = new { line = range.End.Line, character = range.End.Character } + }, + context = new { diagnostics = new object[0] } + }; + + var result = await _jsonRpc.InvokeAsync("textDocument/codeAction", @params, cancellationToken); + return result; + } + catch (Exception) + { + return null; + } + } + + public async Task FormatDocumentAsync(string documentUri, CancellationToken cancellationToken = default) + { + if (_jsonRpc == null) return null; + + try + { + var @params = new + { + textDocument = new { uri = documentUri }, + options = new + { + tabSize = 4, + insertSpaces = true + } + }; + + var result = await _jsonRpc.InvokeAsync("textDocument/formatting", @params, cancellationToken); + return result; + } + catch (Exception) + { + return null; + } + } + + public async Task DidOpenAsync(string documentUri, string languageId, string text, CancellationToken cancellationToken = default) + { + if (_jsonRpc == null) return; + + try + { + var @params = new + { + textDocument = new + { + uri = documentUri, + languageId = languageId, + version = 1, + text = text + } + }; + + await _jsonRpc.NotifyAsync("textDocument/didOpen", @params, cancellationToken); + } + catch (Exception) + { + // Ignore errors + } + } + + public async Task DidCloseAsync(string documentUri, CancellationToken cancellationToken = default) + { + if (_jsonRpc == null) return; + + try + { + var @params = new { textDocument = new { uri = documentUri } }; + await _jsonRpc.NotifyAsync("textDocument/didClose", @params, cancellationToken); + } + catch (Exception) + { + // Ignore errors + } + } + + public void Dispose() + { + if (_disposed) return; + + try + { + _jsonRpc?.Dispose(); + _serverProcess?.Kill(); + _serverProcess?.Dispose(); + } + catch (Exception) + { + // Ignore disposal errors + } + + _disposed = true; + } +} + +public class LspPosition +{ + public int Line { get; set; } + public int Character { get; set; } + + public LspPosition(int line, int character) + { + Line = line; + Character = character; + } +} + +public class LspRange +{ + public LspPosition Start { get; set; } + public LspPosition End { get; set; } + + public LspRange(LspPosition start, LspPosition end) + { + Start = start; + End = end; + } +} \ No newline at end of file diff --git a/Cast.Tool/Core/LspHelper.cs b/Cast.Tool/Core/LspHelper.cs new file mode 100644 index 0000000..d2124d8 --- /dev/null +++ b/Cast.Tool/Core/LspHelper.cs @@ -0,0 +1,230 @@ +using System.ComponentModel; +using Spectre.Console; +using Spectre.Console.Cli; +using Newtonsoft.Json.Linq; + +namespace Cast.Tool.Core; + +public class LspHelper +{ + public void ValidateInputs(LspSettings settings) + { + if (!File.Exists(settings.FilePath)) + { + throw new FileNotFoundException($"File not found: {settings.FilePath}"); + } + + if (settings.LineNumber < 0) + { + throw new ArgumentException("Line number must be 0 or greater"); + } + + if (settings.ColumnNumber < 0) + { + throw new ArgumentException("Column number must be 0 or greater"); + } + } + + public LspPosition CreatePosition(LspSettings settings) + { + return new LspPosition(settings.LineNumber, settings.ColumnNumber); + } + + public string CreateDocumentUri(string filePath) + { + return new Uri(Path.GetFullPath(filePath)).ToString(); + } + + public string GetLanguageId(string filePath) + { + var extension = Path.GetExtension(filePath).ToLowerInvariant(); + return extension switch + { + ".cs" => "csharp", + ".ts" => "typescript", + ".js" => "javascript", + ".py" => "python", + ".java" => "java", + ".cpp" or ".c" or ".h" => "cpp", + ".go" => "go", + ".rs" => "rust", + ".rb" => "ruby", + ".php" => "php", + _ => "plaintext" + }; + } + + public async Task CreateLspClientAsync(LspSettings settings) + { + var serverPath = settings.ServerPath ?? GetDefaultServerPath(settings.FilePath); + if (string.IsNullOrEmpty(serverPath)) + { + AnsiConsole.WriteLine("[red]Error: No LSP server specified and no default server found for this file type.[/]"); + return null; + } + + var serverArgs = settings.ServerArgs?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty(); + var client = new LspClient(serverPath, serverArgs); + + if (!await client.StartAsync()) + { + AnsiConsole.WriteLine($"[red]Error: Failed to start LSP server: {serverPath}[/]"); + client.Dispose(); + return null; + } + + return client; + } + + public string? GetDefaultServerPath(string filePath) + { + var extension = Path.GetExtension(filePath).ToLowerInvariant(); + return extension switch + { + ".cs" => FindExecutable("csharp-ls") ?? FindExecutable("omnisharp"), + ".ts" or ".js" => FindExecutable("typescript-language-server"), + ".py" => FindExecutable("pylsp") ?? FindExecutable("pyright"), + ".java" => FindExecutable("jdtls"), + ".cpp" or ".c" or ".h" => FindExecutable("clangd"), + ".go" => FindExecutable("gopls"), + ".rs" => FindExecutable("rust-analyzer"), + ".rb" => FindExecutable("solargraph"), + ".php" => FindExecutable("intelephense"), + _ => null + }; + } + + public string? FindExecutable(string name) + { + var paths = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? Array.Empty(); + var extensions = Environment.OSVersion.Platform == PlatformID.Win32NT + ? new[] { ".exe", ".cmd", ".bat" } + : new[] { "" }; + + foreach (var path in paths) + { + foreach (var ext in extensions) + { + var fullPath = Path.Combine(path, name + ext); + if (File.Exists(fullPath)) + { + return fullPath; + } + } + } + + return null; + } + + public void OutputLocation(JToken location, string? prefix = null) + { + try + { + var uri = location["uri"]?.ToString(); + var range = location["range"]; + if (uri != null && range != null) + { + var uriObj = new Uri(uri); + var filePath = uriObj.IsFile ? uriObj.LocalPath : uri; + var line = (range["start"]?["line"]?.Value() ?? 0) + 1; // Convert to 1-based for display + var column = (range["start"]?["character"]?.Value() ?? 0) + 1; // Convert to 1-based for display + + var prefixText = prefix != null ? $"{prefix}: " : ""; + Console.WriteLine($"{prefixText}{filePath}:{line}:{column}"); + } + } + catch (Exception) + { + Console.WriteLine($"Error parsing location: {location}"); + } + } + + public void OutputSymbol(JToken symbol) + { + try + { + var name = symbol["name"]?.ToString(); + var kind = symbol["kind"]?.ToString(); + var location = symbol["location"]; + + if (location != null && name != null) + { + var uri = location["uri"]?.ToString(); + var range = location["range"]; + if (uri != null && range != null) + { + var uriObj = new Uri(uri); + var filePath = uriObj.IsFile ? uriObj.LocalPath : uri; + var line = (range["start"]?["line"]?.Value() ?? 0) + 1; + var column = (range["start"]?["character"]?.Value() ?? 0) + 1; + + Console.WriteLine($"{kind}: {name} - {filePath}:{line}:{column}"); + } + } + } + catch (Exception) + { + Console.WriteLine($"Error parsing symbol: {symbol}"); + } + } + + public void OutputDocumentSymbol(JToken symbol, string indent = "") + { + try + { + var name = symbol["name"]?.ToString(); + var kind = symbol["kind"]?.ToString(); + var selectionRange = symbol["selectionRange"] ?? symbol["range"]; + + if (selectionRange != null && name != null) + { + var line = (selectionRange["start"]?["line"]?.Value() ?? 0) + 1; + var column = (selectionRange["start"]?["character"]?.Value() ?? 0) + 1; + + Console.WriteLine($"{indent}{kind}: {name} - Line {line}:{column}"); + + var children = symbol["children"]; + if (children is JArray childArray) + { + foreach (var child in childArray) + { + OutputDocumentSymbol(child, indent + " "); + } + } + } + } + catch (Exception) + { + Console.WriteLine($"Error parsing document symbol: {symbol}"); + } + } +} + +public class LspSettings : CommandSettings +{ + [CommandArgument(0, "")] + [Description("The source file to analyze")] + public string FilePath { get; init; } = string.Empty; + + [CommandOption("-l|--line")] + [Description("Line number (0-based) for position")] + [DefaultValue(0)] + public int LineNumber { get; init; } = 0; + + [CommandOption("-c|--column")] + [Description("Column number (0-based) for position")] + [DefaultValue(0)] + public int ColumnNumber { get; init; } = 0; + + [CommandOption("-s|--server")] + [Description("LSP server executable path")] + public string? ServerPath { get; init; } + + [CommandOption("--server-args")] + [Description("Arguments to pass to the LSP server")] + public string? ServerArgs { get; init; } + + [CommandOption("-q|--query")] + [Description("Query string for search operations")] + public string? Query { get; init; } +} \ No newline at end of file diff --git a/Cast.Tool/Program.cs b/Cast.Tool/Program.cs index 7f864ac..5dbf030 100644 --- a/Cast.Tool/Program.cs +++ b/Cast.Tool/Program.cs @@ -191,6 +191,28 @@ config.AddCommand("find-duplicate-code") .WithDescription("Find code that is substantially similar to existing code"); + + // LSP commands + config.AddCommand("lsp-goto-definition") + .WithDescription("Go to definition using LSP server"); + + config.AddCommand("lsp-find-references") + .WithDescription("Find references using LSP server"); + + config.AddCommand("lsp-hover") + .WithDescription("Get hover information using LSP server"); + + config.AddCommand("lsp-workspace-symbols") + .WithDescription("Search workspace symbols using LSP server"); + + config.AddCommand("lsp-document-symbols") + .WithDescription("Get document symbols using LSP server"); + + config.AddCommand("lsp-code-actions") + .WithDescription("Get available code actions using LSP server"); + + config.AddCommand("lsp-format-document") + .WithDescription("Format document using LSP server"); }); return app.Run(args); diff --git a/LSP_COMMANDS.md b/LSP_COMMANDS.md new file mode 100644 index 0000000..c8749d9 --- /dev/null +++ b/LSP_COMMANDS.md @@ -0,0 +1,179 @@ +# LSP Server Support + +This document describes the LSP (Language Server Protocol) commands available in Cast.Tool. These commands allow sophisticated code analysis by interfacing with existing LSP servers. + +## Available LSP Commands + +### lsp-goto-definition +Go to definition using an LSP server. + +**Usage:** +```bash +cast lsp-goto-definition --line --column +``` + +**Options:** +- `--line`, `-l`: Line number (0-based) for position +- `--column`, `-c`: Column number (0-based) for position +- `--server`, `-s`: LSP server executable path +- `--server-args`: Arguments to pass to the LSP server + +**Example:** +```bash +cast lsp-goto-definition MyClass.cs --line 10 --column 5 +``` + +### lsp-find-references +Find references using an LSP server. + +**Usage:** +```bash +cast lsp-find-references --line --column +``` + +**Options:** +- `--line`, `-l`: Line number (0-based) for position +- `--column`, `-c`: Column number (0-based) for position +- `--include-declaration`: Include the declaration in the results (default: true) +- `--server`, `-s`: LSP server executable path +- `--server-args`: Arguments to pass to the LSP server + +**Example:** +```bash +cast lsp-find-references MyClass.cs --line 10 --column 5 --include-declaration +``` + +### lsp-hover +Get hover information using an LSP server. + +**Usage:** +```bash +cast lsp-hover --line --column +``` + +**Options:** +- `--line`, `-l`: Line number (0-based) for position +- `--column`, `-c`: Column number (0-based) for position +- `--server`, `-s`: LSP server executable path +- `--server-args`: Arguments to pass to the LSP server + +**Example:** +```bash +cast lsp-hover MyClass.cs --line 10 --column 5 +``` + +### lsp-workspace-symbols +Search workspace symbols using an LSP server. + +**Usage:** +```bash +cast lsp-workspace-symbols [query] +``` + +**Options:** +- `query`: Search query for workspace symbols +- `--query`, `-q`: Alternative way to specify search query +- `--workspace`, `-w`: Workspace root directory +- `--server`, `-s`: LSP server executable path +- `--server-args`: Arguments to pass to the LSP server + +**Example:** +```bash +cast lsp-workspace-symbols MyClass.cs "MyClass" +``` + +### lsp-document-symbols +Get document symbols using an LSP server. + +**Usage:** +```bash +cast lsp-document-symbols +``` + +**Options:** +- `--server`, `-s`: LSP server executable path +- `--server-args`: Arguments to pass to the LSP server + +**Example:** +```bash +cast lsp-document-symbols MyClass.cs +``` + +### lsp-code-actions +Get available code actions using an LSP server. + +**Usage:** +```bash +cast lsp-code-actions --line --column +``` + +**Options:** +- `--line`, `-l`: Line number (0-based) for position +- `--column`, `-c`: Column number (0-based) for position +- `--end-line`: End line number (0-based) for range selection +- `--end-column`: End column number (0-based) for range selection +- `--server`, `-s`: LSP server executable path +- `--server-args`: Arguments to pass to the LSP server + +**Example:** +```bash +cast lsp-code-actions MyClass.cs --line 10 --column 5 --end-line 10 --end-column 20 +``` + +### lsp-format-document +Format document using an LSP server. + +**Usage:** +```bash +cast lsp-format-document +``` + +**Options:** +- `--tab-size`: Tab size for formatting (default: 4) +- `--insert-spaces`: Use spaces instead of tabs (default: true) +- `--output`: Output formatted content to a file (default: overwrite original) +- `--dry-run`: Show formatting changes without applying them +- `--server`, `-s`: LSP server executable path +- `--server-args`: Arguments to pass to the LSP server + +**Example:** +```bash +cast lsp-format-document MyClass.cs --dry-run +cast lsp-format-document MyClass.cs --output FormattedClass.cs +``` + +## Supported Language Servers + +Cast.Tool will automatically detect and use appropriate LSP servers for different file types: + +- **C#**: `csharp-ls`, `omnisharp` +- **TypeScript/JavaScript**: `typescript-language-server` +- **Python**: `pylsp`, `pyright` +- **Java**: `jdtls` +- **C/C++**: `clangd` +- **Go**: `gopls` +- **Rust**: `rust-analyzer` +- **Ruby**: `solargraph` +- **PHP**: `intelephense` + +## Manual Server Configuration + +You can specify a custom LSP server using the `--server` option: + +```bash +cast lsp-goto-definition MyClass.cs --line 10 --column 5 --server /path/to/my-language-server --server-args "--arg1 value1 --arg2" +``` + +## Installation Notes + +1. Make sure the appropriate LSP server is installed and available in your PATH +2. For C# development, install OmniSharp or the C# Language Server +3. For TypeScript/JavaScript, install typescript-language-server: `npm install -g typescript-language-server` +4. For Python, install python-lsp-server: `pip install python-lsp-server` + +## Error Handling + +- If no LSP server is found, Cast.Tool will display an error message +- If the LSP server fails to start, an error will be shown +- Network timeouts and communication errors are handled gracefully +- Malformed responses from LSP servers will be logged but won't crash the application \ No newline at end of file diff --git a/README.md b/README.md index 9ccec0e..9795d8b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ dotnet build ## Usage -The `cast` tool provides command-line access to 56 C# refactoring operations using the Roslyn compiler services. +The `cast` tool provides command-line access to 56 C# refactoring operations using the Roslyn compiler services, plus 7 additional LSP-powered commands for multi-language code analysis. ### Command Categories @@ -107,6 +107,17 @@ The `cast` tool provides command-line access to 56 C# refactoring operations usi - **find-dependencies** - Find dependencies and create a dependency graph from a type - **find-duplicate-code** - Find code that is substantially similar to existing code +#### Language Server Protocol (LSP) Commands +- **lsp-goto-definition** - Navigate to symbol definitions using LSP server +- **lsp-find-references** - Find all references to a symbol using LSP server +- **lsp-hover** - Get hover information and documentation using LSP server +- **lsp-workspace-symbols** - Search symbols across the workspace using LSP server +- **lsp-document-symbols** - Get hierarchical symbols in a document using LSP server +- **lsp-code-actions** - List available code actions and quick fixes using LSP server +- **lsp-format-document** - Format documents using LSP server + +**Supported Languages via LSP**: C#, TypeScript, JavaScript, Python, Java, C/C++, Go, Rust, Ruby, PHP + **Analysis Output Format**: All analysis tools output results in grep-style format: `Filename:Line ` ### Common Options @@ -162,11 +173,20 @@ cast find-references Calculator.cs --line 15 --column 12 cast find-usages Service.cs --line 8 --column 20 cast find-dependencies --type "Calculator" MyClass.cs cast find-duplicate-code LargeFile.cs + +# LSP-powered multi-language analysis +cast lsp-goto-definition Calculator.cs --line 10 --column 5 +cast lsp-find-references Service.cs --line 15 --column 8 +cast lsp-hover MyClass.cs --line 8 --column 12 +cast lsp-workspace-symbols MyProject.cs "MyClass" +cast lsp-document-symbols Calculator.cs +cast lsp-code-actions ErrorFile.cs --line 5 --column 10 +cast lsp-format-document MessyCode.cs --dry-run ``` ## Implemented Commands -✅ **61 Complete Commands** - All major C# refactoring operations plus powerful analysis tools are now implemented: +✅ **68 Complete Commands** - All major C# refactoring operations plus powerful analysis tools and multi-language LSP support are now implemented: **Code Analysis & Cleanup** (6 commands) **Symbol Refactoring** (4 commands) @@ -180,32 +200,38 @@ cast find-duplicate-code LargeFile.cs **Variable & Parameter Management** (4 commands) **Async & Debugging** (2 commands) **Code Analysis Tools** (5 commands) +**Language Server Protocol Commands** (7 commands) -The tool now provides comprehensive coverage of C# refactoring operations plus powerful analysis capabilities, making it ideal for coding agents and automated workflows that need safe, precise code transformations and deep code analysis. +The tool now provides comprehensive coverage of C# refactoring operations plus powerful analysis capabilities for multiple programming languages through LSP integration, making it ideal for coding agents and automated workflows that need safe, precise code transformations and deep code analysis across diverse codebases. ## Architecture The tool is built using: - **Roslyn** for C# code analysis and transformation +- **Language Server Protocol (LSP)** for multi-language code analysis via external language servers +- **StreamJsonRpc** for LSP client communication - **Spectre.Console.Cli** for command-line interface - **xUnit** for testing Each refactoring command follows a consistent pattern: 1. Parse and validate input arguments -2. Load and analyze the C# source file using Roslyn -3. Apply the requested transformation -4. Output the modified code +2. Load and analyze the source file using Roslyn (for C#) or LSP (for other languages) +3. Apply the requested transformation or analysis +4. Output the modified code or analysis results + +**LSP Integration**: The tool can automatically detect and use appropriate language servers for different file types, enabling sophisticated code analysis across multiple programming languages including TypeScript, Python, Java, Go, Rust, and more. ## Contributing -The core refactoring functionality is now complete with 56 commands implemented. To contribute additional features or improvements: +The core refactoring functionality is now complete with 61 commands implemented, plus 7 LSP-powered commands for multi-language support. To contribute additional features or improvements: 1. **Enhancement suggestions**: Open an issue to discuss new features or command improvements -2. **Bug fixes**: Create a new command class inheriting from `Command` -3. **New commands**: Implement additional refactoring logic using Roslyn APIs -4. **Testing**: Register the command in `Program.cs` and add comprehensive tests in `Cast.Tool.Tests` +2. **Bug fixes**: Create a new command class inheriting from `Command` (or use `LspHelper` for LSP commands) +3. **New commands**: Implement additional refactoring logic using Roslyn APIs or LSP protocol +4. **LSP integrations**: Add support for additional language servers or LSP features +5. **Testing**: Register the command in `Program.cs` and add comprehensive tests in `Cast.Tool.Tests` -The established pattern makes it straightforward to add specialized refactoring operations for specific use cases or domain-specific transformations. +The established pattern makes it straightforward to add specialized refactoring operations for specific use cases, domain-specific transformations, or new language server integrations. ## License