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:
Ricardo Carneiro 2026-04-27 18:12:21 -03:00
parent 8e022bf5a5
commit e67234345b
17 changed files with 587 additions and 0 deletions

5
.gitignore vendored
View File

@ -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
View File

@ -0,0 +1,4 @@
bin/
obj/
publish/
*.user

View 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.

View 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

View 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);
}

View File

@ -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);
}

View File

@ -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 };
}
}

View File

@ -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" });
}

View File

@ -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; }
}
}

View 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
);

View 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
);

View 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;
}

View 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
);
}
}

View 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;
}
}

View 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>

View File

@ -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);
}
}

View 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": {}}