diff --git a/.gitignore b/.gitignore index e7350e4..8fad305 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/tools/roslyn-helper/.gitignore b/tools/roslyn-helper/.gitignore new file mode 100644 index 0000000..4d4cd45 --- /dev/null +++ b/tools/roslyn-helper/.gitignore @@ -0,0 +1,4 @@ +bin/ +obj/ +publish/ +*.user diff --git a/tools/roslyn-helper/README.md b/tools/roslyn-helper/README.md new file mode 100644 index 0000000..516718a --- /dev/null +++ b/tools/roslyn-helper/README.md @@ -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. diff --git a/tools/roslyn-helper/RoslynHelper.sln b/tools/roslyn-helper/RoslynHelper.sln new file mode 100644 index 0000000..c358667 --- /dev/null +++ b/tools/roslyn-helper/RoslynHelper.sln @@ -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 diff --git a/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Dispatcher.cs b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Dispatcher.cs new file mode 100644 index 0000000..aa410cf --- /dev/null +++ b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Dispatcher.cs @@ -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; + +/// +/// 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. +/// +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 _handlers; + + public Dispatcher(WorkspaceManager workspace) + { + _handlers = new Dictionary(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 DispatchAsync(string line) + { + Request? req; + try + { + req = JsonSerializer.Deserialize(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); +} diff --git a/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Handlers/IHandler.cs b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Handlers/IHandler.cs new file mode 100644 index 0000000..83a63fb --- /dev/null +++ b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Handlers/IHandler.cs @@ -0,0 +1,10 @@ +using System.Text.Json; + +namespace RoslynHelper.JsonRpc.Handlers; + +/// Contract for a JSON-RPC method handler. +public interface IHandler +{ + /// Execute the handler and return the result object (serialized as the 'result' field). + Task HandleAsync(JsonElement? @params); +} diff --git a/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Handlers/LoadSolutionHandler.cs b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Handlers/LoadSolutionHandler.cs new file mode 100644 index 0000000..ed6c916 --- /dev/null +++ b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Handlers/LoadSolutionHandler.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using RoslynHelper.Workspace; + +namespace RoslynHelper.JsonRpc.Handlers; + +/// +/// Loads a .sln file into the Roslyn workspace. +/// Idempotent — if the same path is already loaded, reloads it. +/// +public sealed class LoadSolutionHandler(WorkspaceManager workspace) : IHandler +{ + public async Task 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 }; + } +} diff --git a/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Handlers/PingHandler.cs b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Handlers/PingHandler.cs new file mode 100644 index 0000000..f3fc2a1 --- /dev/null +++ b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Handlers/PingHandler.cs @@ -0,0 +1,10 @@ +using System.Text.Json; + +namespace RoslynHelper.JsonRpc.Handlers; + +/// Health-check handler. Returns version string. +public sealed class PingHandler : IHandler +{ + public Task HandleAsync(JsonElement? @params) => + Task.FromResult(new { pong = true, version = "0.1.0" }); +} diff --git a/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Handlers/ProjectSummaryHandler.cs b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Handlers/ProjectSummaryHandler.cs new file mode 100644 index 0000000..2d9ce6e --- /dev/null +++ b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Handlers/ProjectSummaryHandler.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using RoslynHelper.Models; +using RoslynHelper.Workspace; + +namespace RoslynHelper.JsonRpc.Handlers; + +/// +/// 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. +/// +public sealed class ProjectSummaryHandler(WorkspaceManager workspace) : IHandler +{ + public Task 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(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; } + } +} diff --git a/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Request.cs b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Request.cs new file mode 100644 index 0000000..fe16bc4 --- /dev/null +++ b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Request.cs @@ -0,0 +1,11 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace RoslynHelper.JsonRpc; + +/// Incoming JSON-RPC request from ctx (Go). +public sealed record Request( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("method")] string Method, + [property: JsonPropertyName("params")] JsonElement? Params +); diff --git a/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Response.cs b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Response.cs new file mode 100644 index 0000000..b373360 --- /dev/null +++ b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Response.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace RoslynHelper.JsonRpc; + +/// Error detail embedded in a JSON-RPC error response. +public sealed record ErrorDetail( + [property: JsonPropertyName("code")] string Code, + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("data")] object? Data = null +); diff --git a/tools/roslyn-helper/src/RoslynHelper/KnownException.cs b/tools/roslyn-helper/src/RoslynHelper/KnownException.cs new file mode 100644 index 0000000..ebed571 --- /dev/null +++ b/tools/roslyn-helper/src/RoslynHelper/KnownException.cs @@ -0,0 +1,10 @@ +namespace RoslynHelper; + +/// +/// An expected error with a standardized error code. +/// Throw from handlers to produce structured JSON-RPC error responses. +/// +public sealed class KnownException(string code, string message) : Exception(message) +{ + public string Code { get; } = code; +} diff --git a/tools/roslyn-helper/src/RoslynHelper/Models/ProjectSummary.cs b/tools/roslyn-helper/src/RoslynHelper/Models/ProjectSummary.cs new file mode 100644 index 0000000..797e1a7 --- /dev/null +++ b/tools/roslyn-helper/src/RoslynHelper/Models/ProjectSummary.cs @@ -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 ProjectReferences, + [property: JsonPropertyName("packageReferences")] List PackageReferences +); + +public sealed record SolutionSummary( + [property: JsonPropertyName("solutionPath")] string SolutionPath, + [property: JsonPropertyName("solutionName")] string SolutionName, + [property: JsonPropertyName("projects")] List Projects +); + +/// +/// Builds a from a +/// by parsing the .csproj XML for MSBuild properties. +/// +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 packageRefs = []; + List 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 + ); + } +} diff --git a/tools/roslyn-helper/src/RoslynHelper/Program.cs b/tools/roslyn-helper/src/RoslynHelper/Program.cs new file mode 100644 index 0000000..affeeb9 --- /dev/null +++ b/tools/roslyn-helper/src/RoslynHelper/Program.cs @@ -0,0 +1,20 @@ +using RoslynHelper.JsonRpc; +using RoslynHelper.Workspace; + +namespace RoslynHelper; + +public static class Program +{ + public static async Task 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; + } +} diff --git a/tools/roslyn-helper/src/RoslynHelper/RoslynHelper.csproj b/tools/roslyn-helper/src/RoslynHelper/RoslynHelper.csproj new file mode 100644 index 0000000..ed0c4e5 --- /dev/null +++ b/tools/roslyn-helper/src/RoslynHelper/RoslynHelper.csproj @@ -0,0 +1,16 @@ + + + Exe + net10.0 + enable + enable + latest + ctx-roslyn-helper + RoslynHelper + + + diff --git a/tools/roslyn-helper/src/RoslynHelper/Workspace/WorkspaceManager.cs b/tools/roslyn-helper/src/RoslynHelper/Workspace/WorkspaceManager.cs new file mode 100644 index 0000000..e3346ec --- /dev/null +++ b/tools/roslyn-helper/src/RoslynHelper/Workspace/WorkspaceManager.cs @@ -0,0 +1,119 @@ +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace RoslynHelper.Workspace; + +/// +/// 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. +/// +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; + } + + /// Counts .cs/.fs/.vb source files in the project directory. + 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; } + } +} + +/// Lightweight data model for a loaded solution. +public sealed class SolutionData(string solutionPath, List projects) +{ + public string SolutionPath { get; } = solutionPath; + public List Projects { get; } = projects; +} + +/// One project entry from the .sln file. +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; +} + +/// +/// Parses the classic Visual Studio .sln format to extract project entries. +/// Handles both SDK-style and legacy projects. +/// +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 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(); + + 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); + } +} diff --git a/tools/roslyn-helper/test-input.txt b/tools/roslyn-helper/test-input.txt new file mode 100644 index 0000000..9c1bbe4 --- /dev/null +++ b/tools/roslyn-helper/test-input.txt @@ -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": {}}