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/
|
||||
|
||||
# Roslyn helper artifacts
|
||||
tools/roslyn-helper/**/bin/
|
||||
tools/roslyn-helper/**/obj/
|
||||
tools/roslyn-helper/publish/
|
||||
|
||||
# OS artifacts
|
||||
.DS_Store
|
||||
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