From 3bf145480efabb05208b3845b7d475b85acc5295 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 12 Jul 2025 01:35:51 +0000
Subject: [PATCH 1/3] Initial plan
From 052dc25ffdf474e81bdc6927dff2967753611f41 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 12 Jul 2025 19:11:20 +0000
Subject: [PATCH 2/3] Implement comprehensive LSP server support with 7 core
commands
Co-authored-by: AdamFrisby <114041+AdamFrisby@users.noreply.github.com>
---
Cast.Tool/Cast.Tool.csproj | 3 +
Cast.Tool/Commands/LspCodeActionsCommand.cs | 126 +++++++
.../Commands/LspDocumentSymbolsCommand.cs | 78 +++++
.../Commands/LspFindReferencesCommand.cs | 79 +++++
.../Commands/LspFormatDocumentCommand.cs | 203 +++++++++++
.../Commands/LspGoToDefinitionCommand.cs | 75 +++++
Cast.Tool/Commands/LspHoverCommand.cs | 125 +++++++
.../Commands/LspWorkspaceSymbolsCommand.cs | 74 +++++
Cast.Tool/Core/BaseLspCommand.cs | 247 ++++++++++++++
Cast.Tool/Core/LspClient.cs | 314 ++++++++++++++++++
Cast.Tool/Core/LspHelper.cs | 230 +++++++++++++
Cast.Tool/Program.cs | 22 ++
LSP_COMMANDS.md | 179 ++++++++++
13 files changed, 1755 insertions(+)
create mode 100644 Cast.Tool/Commands/LspCodeActionsCommand.cs
create mode 100644 Cast.Tool/Commands/LspDocumentSymbolsCommand.cs
create mode 100644 Cast.Tool/Commands/LspFindReferencesCommand.cs
create mode 100644 Cast.Tool/Commands/LspFormatDocumentCommand.cs
create mode 100644 Cast.Tool/Commands/LspGoToDefinitionCommand.cs
create mode 100644 Cast.Tool/Commands/LspHoverCommand.cs
create mode 100644 Cast.Tool/Commands/LspWorkspaceSymbolsCommand.cs
create mode 100644 Cast.Tool/Core/BaseLspCommand.cs
create mode 100644 Cast.Tool/Core/LspClient.cs
create mode 100644 Cast.Tool/Core/LspHelper.cs
create mode 100644 LSP_COMMANDS.md
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