feat(roslyn-helper): C# subprocess helper with JSON-RPC over stdin/stdout
Implements Prompt 3. Newline-delimited JSON-RPC dispatcher (ping, loadSolution, projectSummary) with pure XML/.sln parsing — no Roslyn or MSBuild NuGet packages (irreconcilable .NET 10 SDK API mismatches). XDocument parses .csproj for frameworks/packages/refs; regex parses .sln project entries; filesystem walk counts .cs/.fs/.vb documents. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8e022bf5a5
commit
e67234345b
5
.gitignore
vendored
5
.gitignore
vendored
@ -27,6 +27,11 @@ coverage.html
|
|||||||
# Claude Code local settings
|
# Claude Code local settings
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Roslyn helper artifacts
|
||||||
|
tools/roslyn-helper/**/bin/
|
||||||
|
tools/roslyn-helper/**/obj/
|
||||||
|
tools/roslyn-helper/publish/
|
||||||
|
|
||||||
# OS artifacts
|
# OS artifacts
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
4
tools/roslyn-helper/.gitignore
vendored
Normal file
4
tools/roslyn-helper/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
publish/
|
||||||
|
*.user
|
||||||
62
tools/roslyn-helper/README.md
Normal file
62
tools/roslyn-helper/README.md
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# ctx-roslyn-helper
|
||||||
|
|
||||||
|
C# subprocess invoked by `ctx csharp` commands. Loads .NET solutions with
|
||||||
|
Roslyn and answers JSON-RPC queries over stdin/stdout.
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
|
||||||
|
One JSON object per line, UTF-8 without BOM.
|
||||||
|
|
||||||
|
**Request:** `{"id": 1, "method": "ping", "params": {}}`
|
||||||
|
**Success:** `{"id": 1, "result": {...}}`
|
||||||
|
**Error:** `{"id": 1, "error": {"code": "E_NOT_FOUND", "message": "..."}}`
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
| Method | Params | Description |
|
||||||
|
|------------------|---------------------------|------------------------------------|
|
||||||
|
| `ping` | `{}` | Health check, returns version |
|
||||||
|
| `loadSolution` | `{"path": "C:\\...\\x.sln"}` | Load solution into workspace |
|
||||||
|
| `projectSummary` | `{}` | Summary of loaded solution |
|
||||||
|
| `shutdown` | `{}` | Exit cleanly (no response written) |
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Requires: .NET 8+ SDK.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dotnet build src/RoslynHelper -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publish
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dotnet publish src/RoslynHelper -c Release -r win-x64 --self-contained false -o publish/
|
||||||
|
```
|
||||||
|
|
||||||
|
Output: `publish/ctx-roslyn-helper.exe`
|
||||||
|
|
||||||
|
## Manual test
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd publish
|
||||||
|
./ctx-roslyn-helper.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
Then type line by line:
|
||||||
|
|
||||||
|
```
|
||||||
|
{"id": 1, "method": "ping", "params": {}}
|
||||||
|
{"id": 2, "method": "loadSolution", "params": {"path": "C:\\path\\to\\My.sln"}}
|
||||||
|
{"id": 3, "method": "projectSummary", "params": {}}
|
||||||
|
{"id": 99, "method": "shutdown", "params": {}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Does NOT need to be self-contained. Requires .NET 8+ runtime on the target machine.
|
||||||
|
Users of `ctx csharp` are .NET developers — the runtime is already there.
|
||||||
|
- MSBuild warnings logged to stderr are informational (missing SDK targets, etc.).
|
||||||
|
Only `WorkspaceFailed` events with `Failure` kind indicate real problems.
|
||||||
|
- The helper is spawned once per `ctx` invocation and kept alive for all queries
|
||||||
|
in that session. ctx Go manages the subprocess lifecycle.
|
||||||
39
tools/roslyn-helper/RoslynHelper.sln
Normal file
39
tools/roslyn-helper/RoslynHelper.sln
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RoslynHelper", "src\RoslynHelper\RoslynHelper.csproj", "{CE832F0D-696C-4830-977F-D694DDCBA532}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
95
tools/roslyn-helper/src/RoslynHelper/JsonRpc/Dispatcher.cs
Normal file
95
tools/roslyn-helper/src/RoslynHelper/JsonRpc/Dispatcher.cs
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using RoslynHelper.JsonRpc.Handlers;
|
||||||
|
using RoslynHelper.Workspace;
|
||||||
|
|
||||||
|
namespace RoslynHelper.JsonRpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads newline-delimited JSON requests from stdin, dispatches to registered
|
||||||
|
/// handlers, and writes responses to stdout. One request per line, one response
|
||||||
|
/// per line, UTF-8 without BOM.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Dispatcher
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = false,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
// Allow literal single quotes and em dashes; Go's json.Unmarshal handles them fine.
|
||||||
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly Dictionary<string, IHandler> _handlers;
|
||||||
|
|
||||||
|
public Dispatcher(WorkspaceManager workspace)
|
||||||
|
{
|
||||||
|
_handlers = new Dictionary<string, IHandler>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["ping"] = new PingHandler(),
|
||||||
|
["loadSolution"] = new LoadSolutionHandler(workspace),
|
||||||
|
["projectSummary"] = new ProjectSummaryHandler(workspace),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RunAsync(TextReader stdin, TextWriter stdout)
|
||||||
|
{
|
||||||
|
string? line;
|
||||||
|
while ((line = await stdin.ReadLineAsync()) is not null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||||
|
|
||||||
|
var response = await DispatchAsync(line);
|
||||||
|
if (response is null) return; // shutdown
|
||||||
|
|
||||||
|
await stdout.WriteLineAsync(response);
|
||||||
|
await stdout.FlushAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> DispatchAsync(string line)
|
||||||
|
{
|
||||||
|
Request? req;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
req = JsonSerializer.Deserialize<Request>(line, JsonOpts);
|
||||||
|
if (req is null || string.IsNullOrEmpty(req.Method))
|
||||||
|
return Err(0, "E_INVALID_REQUEST", "request must have id and method");
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
return Err(0, "E_INVALID_REQUEST", $"invalid JSON: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.Method.Equals("shutdown", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return null; // signal caller to exit
|
||||||
|
|
||||||
|
if (!_handlers.TryGetValue(req.Method, out var handler))
|
||||||
|
return Err(req.Id, "E_UNKNOWN_METHOD", $"method '{req.Method}' not found");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await handler.HandleAsync(req.Params);
|
||||||
|
return Ok(req.Id, result);
|
||||||
|
}
|
||||||
|
catch (KnownException kex)
|
||||||
|
{
|
||||||
|
return Err(req.Id, kex.Code, kex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return ErrWithData(req.Id, "E_INTERNAL", ex.Message, new { stackTrace = ex.StackTrace });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Ok(int id, object result) =>
|
||||||
|
JsonSerializer.Serialize(new { id, result }, JsonOpts);
|
||||||
|
|
||||||
|
private string Err(int id, string code, string message) =>
|
||||||
|
JsonSerializer.Serialize(new { id, error = new { code, message } }, JsonOpts);
|
||||||
|
|
||||||
|
private string ErrWithData(int id, string code, string message, object data) =>
|
||||||
|
JsonSerializer.Serialize(new { id, error = new { code, message, data } }, JsonOpts);
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace RoslynHelper.JsonRpc.Handlers;
|
||||||
|
|
||||||
|
/// <summary>Contract for a JSON-RPC method handler.</summary>
|
||||||
|
public interface IHandler
|
||||||
|
{
|
||||||
|
/// <summary>Execute the handler and return the result object (serialized as the 'result' field).</summary>
|
||||||
|
Task<object> HandleAsync(JsonElement? @params);
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using RoslynHelper.Workspace;
|
||||||
|
|
||||||
|
namespace RoslynHelper.JsonRpc.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads a .sln file into the Roslyn workspace.
|
||||||
|
/// Idempotent — if the same path is already loaded, reloads it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LoadSolutionHandler(WorkspaceManager workspace) : IHandler
|
||||||
|
{
|
||||||
|
public async Task<object> HandleAsync(JsonElement? @params)
|
||||||
|
{
|
||||||
|
if (@params is not { } p)
|
||||||
|
throw new KnownException("E_INVALID_REQUEST", "params required for loadSolution");
|
||||||
|
|
||||||
|
if (!p.TryGetProperty("path", out var pathEl) || pathEl.ValueKind != JsonValueKind.String)
|
||||||
|
throw new KnownException("E_INVALID_REQUEST", "params.path (string) is required");
|
||||||
|
|
||||||
|
var path = pathEl.GetString()!;
|
||||||
|
var (projectCount, documentCount) = await workspace.LoadAsync(path);
|
||||||
|
return new { loaded = true, projectCount, documentCount };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace RoslynHelper.JsonRpc.Handlers;
|
||||||
|
|
||||||
|
/// <summary>Health-check handler. Returns version string.</summary>
|
||||||
|
public sealed class PingHandler : IHandler
|
||||||
|
{
|
||||||
|
public Task<object> HandleAsync(JsonElement? @params) =>
|
||||||
|
Task.FromResult<object>(new { pong = true, version = "0.1.0" });
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using RoslynHelper.Models;
|
||||||
|
using RoslynHelper.Workspace;
|
||||||
|
|
||||||
|
namespace RoslynHelper.JsonRpc.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a compact summary of the loaded solution: projects, target frameworks,
|
||||||
|
/// package references, and project-to-project dependencies.
|
||||||
|
/// Parses .csproj XML directly — no Roslyn workspace required.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProjectSummaryHandler(WorkspaceManager workspace) : IHandler
|
||||||
|
{
|
||||||
|
public Task<object> HandleAsync(JsonElement? @params)
|
||||||
|
{
|
||||||
|
var solution = workspace.GetCurrentSolution();
|
||||||
|
var solutionPath = workspace.SolutionPath!;
|
||||||
|
var solutionName = Path.GetFileNameWithoutExtension(solutionPath);
|
||||||
|
var solutionDir = Path.GetDirectoryName(solutionPath) ?? string.Empty;
|
||||||
|
|
||||||
|
// Document counts are tracked per-project in the WorkspaceManager load.
|
||||||
|
// Re-compute here from file system for simplicity.
|
||||||
|
var projects = solution.Projects
|
||||||
|
.OrderBy(p => p.Name)
|
||||||
|
.Select(p =>
|
||||||
|
{
|
||||||
|
var docCount = CountDocuments(p.ProjectPath);
|
||||||
|
return ProjectSummaryBuilder.Build(p, solutionDir, docCount);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Task.FromResult<object>(new SolutionSummary(solutionPath, solutionName, projects));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountDocuments(string projPath)
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(projPath);
|
||||||
|
if (dir is null || !Directory.Exists(dir)) return 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories).Count()
|
||||||
|
+ Directory.EnumerateFiles(dir, "*.fs", SearchOption.AllDirectories).Count()
|
||||||
|
+ Directory.EnumerateFiles(dir, "*.vb", SearchOption.AllDirectories).Count();
|
||||||
|
}
|
||||||
|
catch { return 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
11
tools/roslyn-helper/src/RoslynHelper/JsonRpc/Request.cs
Normal file
11
tools/roslyn-helper/src/RoslynHelper/JsonRpc/Request.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace RoslynHelper.JsonRpc;
|
||||||
|
|
||||||
|
/// <summary>Incoming JSON-RPC request from ctx (Go).</summary>
|
||||||
|
public sealed record Request(
|
||||||
|
[property: JsonPropertyName("id")] int Id,
|
||||||
|
[property: JsonPropertyName("method")] string Method,
|
||||||
|
[property: JsonPropertyName("params")] JsonElement? Params
|
||||||
|
);
|
||||||
10
tools/roslyn-helper/src/RoslynHelper/JsonRpc/Response.cs
Normal file
10
tools/roslyn-helper/src/RoslynHelper/JsonRpc/Response.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace RoslynHelper.JsonRpc;
|
||||||
|
|
||||||
|
/// <summary>Error detail embedded in a JSON-RPC error response.</summary>
|
||||||
|
public sealed record ErrorDetail(
|
||||||
|
[property: JsonPropertyName("code")] string Code,
|
||||||
|
[property: JsonPropertyName("message")] string Message,
|
||||||
|
[property: JsonPropertyName("data")] object? Data = null
|
||||||
|
);
|
||||||
10
tools/roslyn-helper/src/RoslynHelper/KnownException.cs
Normal file
10
tools/roslyn-helper/src/RoslynHelper/KnownException.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
namespace RoslynHelper;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An expected error with a standardized error code.
|
||||||
|
/// Throw from handlers to produce structured JSON-RPC error responses.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class KnownException(string code, string message) : Exception(message)
|
||||||
|
{
|
||||||
|
public string Code { get; } = code;
|
||||||
|
}
|
||||||
102
tools/roslyn-helper/src/RoslynHelper/Models/ProjectSummary.cs
Normal file
102
tools/roslyn-helper/src/RoslynHelper/Models/ProjectSummary.cs
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using RoslynHelper.Workspace;
|
||||||
|
|
||||||
|
namespace RoslynHelper.Models;
|
||||||
|
|
||||||
|
public sealed record PackageRef(
|
||||||
|
[property: JsonPropertyName("name")] string Name,
|
||||||
|
[property: JsonPropertyName("version")] string Version
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record ProjectSummary(
|
||||||
|
[property: JsonPropertyName("name")] string Name,
|
||||||
|
[property: JsonPropertyName("path")] string Path,
|
||||||
|
[property: JsonPropertyName("type")] string Type,
|
||||||
|
[property: JsonPropertyName("targetFrameworks")] string[] TargetFrameworks,
|
||||||
|
[property: JsonPropertyName("outputType")] string OutputType,
|
||||||
|
[property: JsonPropertyName("rootNamespace")] string RootNamespace,
|
||||||
|
[property: JsonPropertyName("documentCount")] int DocumentCount,
|
||||||
|
[property: JsonPropertyName("projectReferences")] List<string> ProjectReferences,
|
||||||
|
[property: JsonPropertyName("packageReferences")] List<PackageRef> PackageReferences
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record SolutionSummary(
|
||||||
|
[property: JsonPropertyName("solutionPath")] string SolutionPath,
|
||||||
|
[property: JsonPropertyName("solutionName")] string SolutionName,
|
||||||
|
[property: JsonPropertyName("projects")] List<ProjectSummary> Projects
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a <see cref="ProjectSummary"/> from a <see cref="ProjectEntry"/>
|
||||||
|
/// by parsing the .csproj XML for MSBuild properties.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ProjectSummaryBuilder
|
||||||
|
{
|
||||||
|
public static ProjectSummary Build(ProjectEntry entry, string solutionDir, int documentCount)
|
||||||
|
{
|
||||||
|
var projPath = entry.ProjectPath;
|
||||||
|
|
||||||
|
string[] targetFrameworks = [];
|
||||||
|
string outputType = "Library";
|
||||||
|
string rootNamespace = entry.Name;
|
||||||
|
List<PackageRef> packageRefs = [];
|
||||||
|
List<string> projRefs = [];
|
||||||
|
|
||||||
|
if (File.Exists(projPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var doc = XDocument.Load(projPath);
|
||||||
|
|
||||||
|
var tf = doc.Descendants("TargetFramework").FirstOrDefault()?.Value?.Trim();
|
||||||
|
var tfs = doc.Descendants("TargetFrameworks").FirstOrDefault()?.Value?.Trim();
|
||||||
|
|
||||||
|
targetFrameworks = tfs is not null
|
||||||
|
? tfs.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
: tf is not null ? [tf] : [];
|
||||||
|
|
||||||
|
outputType = doc.Descendants("OutputType").FirstOrDefault()?.Value?.Trim() ?? "Library";
|
||||||
|
rootNamespace = doc.Descendants("RootNamespace").FirstOrDefault()?.Value?.Trim() ?? entry.Name;
|
||||||
|
|
||||||
|
packageRefs = doc.Descendants("PackageReference")
|
||||||
|
.Select(el => new PackageRef(
|
||||||
|
el.Attribute("Include")?.Value ?? string.Empty,
|
||||||
|
el.Attribute("Version")?.Value ?? el.Element("Version")?.Value ?? string.Empty))
|
||||||
|
.Where(pr => !string.IsNullOrEmpty(pr.Name))
|
||||||
|
.OrderBy(pr => pr.Name)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
projRefs = doc.Descendants("ProjectReference")
|
||||||
|
.Select(el =>
|
||||||
|
{
|
||||||
|
var include = el.Attribute("Include")?.Value ?? string.Empty;
|
||||||
|
return Path.GetFileNameWithoutExtension(include);
|
||||||
|
})
|
||||||
|
.Where(n => !string.IsNullOrEmpty(n))
|
||||||
|
.OrderBy(n => n)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[summary] warn: could not parse {projPath}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var type = outputType.Equals("Exe", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
outputType.Equals("WinExe", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "exe" : "lib";
|
||||||
|
|
||||||
|
return new ProjectSummary(
|
||||||
|
entry.Name,
|
||||||
|
entry.RelativePath,
|
||||||
|
type,
|
||||||
|
targetFrameworks,
|
||||||
|
outputType,
|
||||||
|
rootNamespace,
|
||||||
|
documentCount,
|
||||||
|
projRefs,
|
||||||
|
packageRefs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
tools/roslyn-helper/src/RoslynHelper/Program.cs
Normal file
20
tools/roslyn-helper/src/RoslynHelper/Program.cs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
using RoslynHelper.JsonRpc;
|
||||||
|
using RoslynHelper.Workspace;
|
||||||
|
|
||||||
|
namespace RoslynHelper;
|
||||||
|
|
||||||
|
public static class Program
|
||||||
|
{
|
||||||
|
public static async Task<int> Main(string[] args)
|
||||||
|
{
|
||||||
|
// UTF-8 without BOM on both ends of the pipe.
|
||||||
|
Console.InputEncoding = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
|
||||||
|
Console.OutputEncoding = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
|
||||||
|
|
||||||
|
using var workspace = new WorkspaceManager();
|
||||||
|
var dispatcher = new Dispatcher(workspace);
|
||||||
|
|
||||||
|
await dispatcher.RunAsync(Console.In, Console.Out);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
tools/roslyn-helper/src/RoslynHelper/RoslynHelper.csproj
Normal file
16
tools/roslyn-helper/src/RoslynHelper/RoslynHelper.csproj
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<AssemblyName>ctx-roslyn-helper</AssemblyName>
|
||||||
|
<RootNamespace>RoslynHelper</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<!-- No external NuGet dependencies: System.Text.Json and System.Xml.Linq
|
||||||
|
are included in the net10.0 framework. MSBuild/Roslyn are not used
|
||||||
|
for the MVP commands (projectSummary) — csproj/sln files are parsed
|
||||||
|
directly. Roslyn semantic workspace will be added in a later prompt
|
||||||
|
once SDK version alignment is established. -->
|
||||||
|
</Project>
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
|
namespace RoslynHelper.Workspace;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses .sln and .csproj files directly without a Roslyn/MSBuild workspace.
|
||||||
|
/// This avoids tight version coupling between MSBuild NuGet packages and the
|
||||||
|
/// installed SDK. For MVP commands (projectSummary), structural parsing is sufficient.
|
||||||
|
/// Roslyn semantic APIs will be integrated in a later phase.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WorkspaceManager : IDisposable
|
||||||
|
{
|
||||||
|
public void Dispose() { } // reserved for future Roslyn workspace disposal
|
||||||
|
private SolutionData? _solution;
|
||||||
|
private string? _solutionPath;
|
||||||
|
|
||||||
|
public string? SolutionPath => _solutionPath;
|
||||||
|
|
||||||
|
public Task<(int projectCount, int documentCount)> LoadAsync(string path)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
throw new KnownException("E_NOT_FOUND", $"solution file not found: {path}");
|
||||||
|
|
||||||
|
SolutionData sln;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
sln = SolutionParser.Parse(path);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new KnownException("E_LOAD_FAILED", $"failed to load solution: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
_solution = sln;
|
||||||
|
_solutionPath = path;
|
||||||
|
|
||||||
|
var documentCount = sln.Projects.Sum(p => CountDocuments(p.ProjectPath));
|
||||||
|
return Task.FromResult((sln.Projects.Count, documentCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
public SolutionData GetCurrentSolution()
|
||||||
|
{
|
||||||
|
if (_solution is null)
|
||||||
|
throw new KnownException("E_NOT_FOUND", "no solution loaded — call loadSolution first");
|
||||||
|
return _solution;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Counts .cs/.fs/.vb source files in the project directory.</summary>
|
||||||
|
private static int CountDocuments(string projPath)
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(projPath);
|
||||||
|
if (dir is null || !Directory.Exists(dir)) return 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories).Count()
|
||||||
|
+ Directory.EnumerateFiles(dir, "*.fs", SearchOption.AllDirectories).Count()
|
||||||
|
+ Directory.EnumerateFiles(dir, "*.vb", SearchOption.AllDirectories).Count();
|
||||||
|
}
|
||||||
|
catch { return 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lightweight data model for a loaded solution.</summary>
|
||||||
|
public sealed class SolutionData(string solutionPath, List<ProjectEntry> projects)
|
||||||
|
{
|
||||||
|
public string SolutionPath { get; } = solutionPath;
|
||||||
|
public List<ProjectEntry> Projects { get; } = projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One project entry from the .sln file.</summary>
|
||||||
|
public sealed class ProjectEntry(string name, string relativePath, string absolutePath)
|
||||||
|
{
|
||||||
|
public string Name { get; } = name;
|
||||||
|
public string RelativePath { get; } = relativePath;
|
||||||
|
public string ProjectPath { get; } = absolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the classic Visual Studio .sln format to extract project entries.
|
||||||
|
/// Handles both SDK-style and legacy projects.
|
||||||
|
/// </summary>
|
||||||
|
internal static class SolutionParser
|
||||||
|
{
|
||||||
|
// Matches: Project("{type-guid}") = "Name", "path\to\project.csproj", "{project-guid}"
|
||||||
|
private static readonly Regex ProjectLine = new(
|
||||||
|
@"Project\(""\{[^}]+\}""\)\s*=\s*""([^""]+)""\s*,\s*""([^""]+)""\s*,",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static readonly HashSet<string> ProjectExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
".csproj", ".fsproj", ".vbproj", ".pyproj", ".vcxproj"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static SolutionData Parse(string slnPath)
|
||||||
|
{
|
||||||
|
var slnDir = Path.GetDirectoryName(slnPath) ?? string.Empty;
|
||||||
|
var content = File.ReadAllText(slnPath, System.Text.Encoding.UTF8);
|
||||||
|
var projects = new List<ProjectEntry>();
|
||||||
|
|
||||||
|
foreach (Match m in ProjectLine.Matches(content))
|
||||||
|
{
|
||||||
|
var name = m.Groups[1].Value;
|
||||||
|
var relPath = m.Groups[2].Value.Replace('/', Path.DirectorySeparatorChar);
|
||||||
|
var ext = Path.GetExtension(relPath);
|
||||||
|
|
||||||
|
// Skip solution folders (no file extension) and non-code projects.
|
||||||
|
if (string.IsNullOrEmpty(ext) || !ProjectExtensions.Contains(ext))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var absPath = Path.GetFullPath(Path.Combine(slnDir, relPath));
|
||||||
|
if (!File.Exists(absPath)) continue; // skip phantom entries
|
||||||
|
|
||||||
|
projects.Add(new ProjectEntry(name, relPath.Replace('\\', '/'), absPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SolutionData(slnPath, projects);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
tools/roslyn-helper/test-input.txt
Normal file
3
tools/roslyn-helper/test-input.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{"id": 1, "method": "loadSolution", "params": {"path": "C:\\gocode\\ctx\\tools\\roslyn-helper\\RoslynHelper.sln"}}
|
||||||
|
{"id": 2, "method": "projectSummary", "params": {}}
|
||||||
|
{"id": 3, "method": "shutdown", "params": {}}
|
||||||
Loading…
Reference in New Issue
Block a user