feat(csharp): implement 'project' command via Roslyn helper
Replaces placeholder with full csharp@0.1.0 plugin. Adds helper/ package (locate, process, client, protocol) for JSON-RPC over stdio to ctx-roslyn-helper. project.go finds .sln (fallback: single .csproj), loads it, retrieves projectSummary, formats dense markdown with project details, reference graph, and multi-targeting section. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e67234345b
commit
59cb2b5ddb
@ -1,10 +1,7 @@
|
|||||||
// Package csharp implements the ctx csharp plugin.
|
// Package csharp implements the ctx csharp plugin — .NET solution analysis via Roslyn helper.
|
||||||
// Full implementation: prompts 4–6 (requires Roslyn helper from prompt 3).
|
|
||||||
package csharp
|
package csharp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/ricarneiro/ctx/internal/core"
|
"github.com/ricarneiro/ctx/internal/core"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@ -16,19 +13,19 @@ func init() {
|
|||||||
type csharpPlugin struct{}
|
type csharpPlugin struct{}
|
||||||
|
|
||||||
func (c *csharpPlugin) Name() string { return "csharp" }
|
func (c *csharpPlugin) Name() string { return "csharp" }
|
||||||
func (c *csharpPlugin) Version() string { return "0.0.1" }
|
func (c *csharpPlugin) Version() string { return "0.1.0" }
|
||||||
func (c *csharpPlugin) ShortDescription() string { return "C# / .NET project analysis via Roslyn" }
|
func (c *csharpPlugin) ShortDescription() string { return "C# / .NET project analysis via Roslyn" }
|
||||||
|
|
||||||
func (c *csharpPlugin) Command(ctx *core.Context) *cobra.Command {
|
func (c *csharpPlugin) Command(ctx *core.Context) *cobra.Command {
|
||||||
return &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "csharp",
|
Use: "csharp",
|
||||||
Short: c.ShortDescription(),
|
Short: c.ShortDescription(),
|
||||||
// placeholder=true tells ctx auto to show a placeholder message instead of
|
Long: `Analyze C# / .NET projects and emit compact markdown summaries
|
||||||
// attempting to invoke this plugin's subcommands.
|
optimized for Claude Code consumption.
|
||||||
Annotations: map[string]string{"placeholder": "true"},
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
Requires the Roslyn helper (ctx-roslyn-helper) to be built.
|
||||||
fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in prompt 4")
|
See 'ctx csharp project --help' for details.`,
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
cmd.AddCommand(projectCmd(ctx))
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
249
internal/plugins/csharp/format.go
Normal file
249
internal/plugins/csharp/format.go
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
package csharp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ricarneiro/ctx/internal/output"
|
||||||
|
"github.com/ricarneiro/ctx/internal/plugins/csharp/helper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteSummary formats a ProjectSummary as dense markdown.
|
||||||
|
func WriteSummary(w io.Writer, s *helper.ProjectSummary) error {
|
||||||
|
output.H1(w, "Solution: "+s.SolutionName)
|
||||||
|
writeOverview(w, s)
|
||||||
|
output.H2(w, "Projects")
|
||||||
|
writeProjects(w, s.Projects)
|
||||||
|
writeReferenceGraph(w, s.Projects)
|
||||||
|
writeMultiTargeting(w, s.Projects)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOverview(w io.Writer, s *helper.ProjectSummary) {
|
||||||
|
exeCount, libCount := 0, 0
|
||||||
|
totalDocs := 0
|
||||||
|
for _, p := range s.Projects {
|
||||||
|
if p.Type == "exe" {
|
||||||
|
exeCount++
|
||||||
|
} else {
|
||||||
|
libCount++
|
||||||
|
}
|
||||||
|
totalDocs += p.DocumentCount
|
||||||
|
}
|
||||||
|
|
||||||
|
projectSummary := fmt.Sprintf("%d", len(s.Projects))
|
||||||
|
parts := []string{}
|
||||||
|
if exeCount > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d exe", exeCount))
|
||||||
|
}
|
||||||
|
if libCount > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d lib", libCount))
|
||||||
|
}
|
||||||
|
if len(parts) > 0 {
|
||||||
|
projectSummary += " (" + strings.Join(parts, ", ") + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
output.KeyValue(w, "path", "`"+s.SolutionPath+"`")
|
||||||
|
output.KeyValue(w, "projects", projectSummary)
|
||||||
|
output.KeyValue(w, "documents", fmt.Sprintf("%d", totalDocs))
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeProjects(w io.Writer, projects []helper.ProjectInfo) {
|
||||||
|
for _, p := range projects {
|
||||||
|
output.H3(w, fmt.Sprintf("%s (%s)", p.Name, p.Type))
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "- **path:** `%s`\n", p.Path)
|
||||||
|
|
||||||
|
targets := strings.Join(p.TargetFrameworks, ", ")
|
||||||
|
if targets == "" {
|
||||||
|
targets = "_(unknown)_"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "- **target:** %s\n", targets)
|
||||||
|
fmt.Fprintf(w, "- **namespace:** `%s`\n", p.RootNamespace)
|
||||||
|
fmt.Fprintf(w, "- **documents:** %d\n", p.DocumentCount)
|
||||||
|
|
||||||
|
// Project references
|
||||||
|
if len(p.ProjectReferences) == 0 {
|
||||||
|
fmt.Fprintf(w, "- **references:** _(none)_\n")
|
||||||
|
} else {
|
||||||
|
refs := make([]string, len(p.ProjectReferences))
|
||||||
|
for i, r := range p.ProjectReferences {
|
||||||
|
refs[i] = "`" + r + "`"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "- **references:**\n")
|
||||||
|
for _, r := range refs {
|
||||||
|
fmt.Fprintf(w, " - %s\n", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Package references
|
||||||
|
if len(p.PackageReferences) == 0 {
|
||||||
|
fmt.Fprintf(w, "- **packages:** _(none)_\n")
|
||||||
|
} else {
|
||||||
|
pkgs := make([]string, len(p.PackageReferences))
|
||||||
|
for i, pkg := range p.PackageReferences {
|
||||||
|
pkgs[i] = pkg.Name + " " + pkg.Version
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "- **packages:** %s\n", strings.Join(pkgs, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeReferenceGraph(w io.Writer, projects []helper.ProjectInfo) {
|
||||||
|
// Build reverse dependency map: who depends on each project
|
||||||
|
dependents := map[string][]string{}
|
||||||
|
refMap := map[string][]string{}
|
||||||
|
nameSet := map[string]bool{}
|
||||||
|
|
||||||
|
for _, p := range projects {
|
||||||
|
nameSet[p.Name] = true
|
||||||
|
refMap[p.Name] = p.ProjectReferences
|
||||||
|
for _, ref := range p.ProjectReferences {
|
||||||
|
dependents[ref] = append(dependents[ref], p.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are any references at all
|
||||||
|
hasRefs := false
|
||||||
|
for _, p := range projects {
|
||||||
|
if len(p.ProjectReferences) > 0 {
|
||||||
|
hasRefs = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output.H2(w, "Reference graph")
|
||||||
|
|
||||||
|
if !hasRefs {
|
||||||
|
fmt.Fprintf(w, "No inter-project references.\n\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For complex graphs (>5 projects with references), use flat list
|
||||||
|
refsCount := 0
|
||||||
|
for _, p := range projects {
|
||||||
|
if len(p.ProjectReferences) > 0 {
|
||||||
|
refsCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(projects) > 5 && refsCount > 3 {
|
||||||
|
for _, p := range projects {
|
||||||
|
if len(p.ProjectReferences) > 0 {
|
||||||
|
refs := make([]string, len(p.ProjectReferences))
|
||||||
|
for i, r := range p.ProjectReferences {
|
||||||
|
refs[i] = "`" + r + "`"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "- `%s` → %s\n", p.Name, strings.Join(refs, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// DFS from roots (projects with no dependents)
|
||||||
|
roots := []string{}
|
||||||
|
for _, p := range projects {
|
||||||
|
if len(dependents[p.Name]) == 0 {
|
||||||
|
roots = append(roots, p.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(roots) == 0 {
|
||||||
|
// Cycle or all have dependents — fall back to flat list
|
||||||
|
for _, p := range projects {
|
||||||
|
if len(p.ProjectReferences) > 0 {
|
||||||
|
refs := make([]string, len(p.ProjectReferences))
|
||||||
|
for i, r := range p.ProjectReferences {
|
||||||
|
refs[i] = "`" + r + "`"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "- `%s` → %s\n", p.Name, strings.Join(refs, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
visited := map[string]bool{}
|
||||||
|
for _, root := range roots {
|
||||||
|
dfsRender(&sb, root, refMap, visited, 0)
|
||||||
|
}
|
||||||
|
output.CodeBlock(w, "", strings.TrimRight(sb.String(), "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func dfsRender(sb *strings.Builder, name string, refMap map[string][]string, visited map[string]bool, depth int) {
|
||||||
|
indent := strings.Repeat(" ", depth)
|
||||||
|
refs := refMap[name]
|
||||||
|
if len(refs) == 0 {
|
||||||
|
if depth == 0 {
|
||||||
|
fmt.Fprintf(sb, "%s%s (no deps)\n", indent, name)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(sb, "%s%s\n", indent, name)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if visited[name] {
|
||||||
|
fmt.Fprintf(sb, "%s%s (see above)\n", indent, name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
visited[name] = true
|
||||||
|
|
||||||
|
for i, ref := range refs {
|
||||||
|
if i == 0 {
|
||||||
|
fmt.Fprintf(sb, "%s%s → %s\n", indent, name, ref)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(sb, "%s%s └──→ %s\n", indent, strings.Repeat(" ", len(name)), ref)
|
||||||
|
}
|
||||||
|
dfsRender(sb, ref, refMap, visited, depth+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMultiTargeting(w io.Writer, projects []helper.ProjectInfo) {
|
||||||
|
output.H2(w, "Multi-targeting")
|
||||||
|
|
||||||
|
// Collect all unique frameworks
|
||||||
|
frameworkSets := map[string][]string{} // project name → frameworks
|
||||||
|
allFrameworks := map[string]bool{}
|
||||||
|
|
||||||
|
for _, p := range projects {
|
||||||
|
if len(p.TargetFrameworks) > 1 {
|
||||||
|
frameworkSets[p.Name] = p.TargetFrameworks
|
||||||
|
}
|
||||||
|
for _, tf := range p.TargetFrameworks {
|
||||||
|
allFrameworks[tf] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(frameworkSets) == 0 {
|
||||||
|
// All same framework, or single framework each
|
||||||
|
if len(allFrameworks) == 1 {
|
||||||
|
for tf := range allFrameworks {
|
||||||
|
fmt.Fprintf(w, "None — all projects target `%s`.\n\n", tf)
|
||||||
|
}
|
||||||
|
} else if len(allFrameworks) == 0 {
|
||||||
|
fmt.Fprintf(w, "No target frameworks detected.\n\n")
|
||||||
|
} else {
|
||||||
|
// Multiple different single targets
|
||||||
|
for _, p := range projects {
|
||||||
|
if len(p.TargetFrameworks) > 0 {
|
||||||
|
fmt.Fprintf(w, "- `%s` targets: `%s`\n", p.Name, strings.Join(p.TargetFrameworks, "`, `"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range projects {
|
||||||
|
if tfs, ok := frameworkSets[p.Name]; ok {
|
||||||
|
fmt.Fprintf(w, "- `%s` targets: `%s`\n", p.Name, strings.Join(tfs, "`, `"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
139
internal/plugins/csharp/helper/client.go
Normal file
139
internal/plugins/csharp/helper/client.go
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
package helper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is a high-level typed interface to the roslyn-helper subprocess.
|
||||||
|
type Client struct {
|
||||||
|
proc *Process
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient locates the helper binary, starts the process, and verifies it
|
||||||
|
// responds to ping. Returns an error if any step fails.
|
||||||
|
func NewClient() (*Client, error) {
|
||||||
|
helperPath, err := LocateHelper()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
proc, err := Start(helperPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("start roslyn helper: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Client{proc: proc}
|
||||||
|
if _, err := c.Ping(); err != nil {
|
||||||
|
_ = proc.Close()
|
||||||
|
return nil, fmt.Errorf("roslyn helper ping failed: %w", err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close shuts down the helper process.
|
||||||
|
func (c *Client) Close() error {
|
||||||
|
return c.proc.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Result types ---
|
||||||
|
|
||||||
|
// PingResult is returned by the ping method.
|
||||||
|
type PingResult struct {
|
||||||
|
Pong bool `json:"pong"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSolutionResult is returned by the loadSolution method.
|
||||||
|
type LoadSolutionResult struct {
|
||||||
|
Loaded bool `json:"loaded"`
|
||||||
|
ProjectCount int `json:"projectCount"`
|
||||||
|
DocumentCount int `json:"documentCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectSummary is the top-level result of the projectSummary method.
|
||||||
|
type ProjectSummary struct {
|
||||||
|
SolutionPath string `json:"solutionPath"`
|
||||||
|
SolutionName string `json:"solutionName"`
|
||||||
|
Projects []ProjectInfo `json:"projects"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectInfo describes a single project in the solution.
|
||||||
|
type ProjectInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
TargetFrameworks []string `json:"targetFrameworks"`
|
||||||
|
OutputType string `json:"outputType"`
|
||||||
|
RootNamespace string `json:"rootNamespace"`
|
||||||
|
DocumentCount int `json:"documentCount"`
|
||||||
|
ProjectReferences []string `json:"projectReferences"`
|
||||||
|
PackageReferences []PackageReference `json:"packageReferences"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackageReference is a NuGet package dependency.
|
||||||
|
type PackageReference struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Methods ---
|
||||||
|
|
||||||
|
// Ping sends a ping to the helper and returns the pong result.
|
||||||
|
func (c *Client) Ping() (*PingResult, error) {
|
||||||
|
raw, err := c.proc.Send("ping", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, wrapRpc("ping", err)
|
||||||
|
}
|
||||||
|
var r PingResult
|
||||||
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
|
return nil, fmt.Errorf("ping: decode response: %w", err)
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSolution instructs the helper to load the given .sln or .csproj file.
|
||||||
|
func (c *Client) LoadSolution(path string) (*LoadSolutionResult, error) {
|
||||||
|
params := map[string]string{"path": path}
|
||||||
|
raw, err := c.proc.Send("loadSolution", params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, wrapRpc("loadSolution", err)
|
||||||
|
}
|
||||||
|
var r LoadSolutionResult
|
||||||
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
|
return nil, fmt.Errorf("loadSolution: decode response: %w", err)
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectSummary retrieves the project summary for the currently loaded solution.
|
||||||
|
func (c *Client) ProjectSummary() (*ProjectSummary, error) {
|
||||||
|
raw, err := c.proc.Send("projectSummary", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, wrapRpc("projectSummary", err)
|
||||||
|
}
|
||||||
|
var r ProjectSummary
|
||||||
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
|
return nil, fmt.Errorf("projectSummary: decode response: %w", err)
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapRpc wraps RpcError values into user-friendly messages.
|
||||||
|
func wrapRpc(method string, err error) error {
|
||||||
|
var rpcErr *RpcError
|
||||||
|
if errors.As(err, &rpcErr) {
|
||||||
|
switch rpcErr.Code {
|
||||||
|
case "E_NOT_FOUND":
|
||||||
|
return fmt.Errorf("solution not found: %s", rpcErr.Message)
|
||||||
|
case "E_LOAD_FAILED":
|
||||||
|
return fmt.Errorf("failed to load solution: %s", rpcErr.Message)
|
||||||
|
case "E_INVALID_PARAMS":
|
||||||
|
return fmt.Errorf("invalid request: %s", rpcErr.Message)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%s failed: %s", method, rpcErr.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
86
internal/plugins/csharp/helper/locate.go
Normal file
86
internal/plugins/csharp/helper/locate.go
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
package helper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
const helperBinary = "ctx-roslyn-helper"
|
||||||
|
|
||||||
|
// ErrHelperNotFound is returned when the Roslyn helper binary cannot be located.
|
||||||
|
var ErrHelperNotFound = errors.New("roslyn helper not found")
|
||||||
|
|
||||||
|
// LocateHelper searches for the ctx-roslyn-helper binary using four strategies:
|
||||||
|
// 1. CTX_ROSLYN_HELPER environment variable
|
||||||
|
// 2. Same directory as the running ctx binary
|
||||||
|
// 3. PATH
|
||||||
|
// 4. tools/roslyn-helper/publish/ relative to working directory
|
||||||
|
//
|
||||||
|
// Returns the absolute path or ErrHelperNotFound with a diagnostic message.
|
||||||
|
func LocateHelper() (string, error) {
|
||||||
|
name := helperBinary
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
name += ".exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
tried := []string{}
|
||||||
|
|
||||||
|
// 1. Environment variable
|
||||||
|
if env := os.Getenv("CTX_ROSLYN_HELPER"); env != "" {
|
||||||
|
if fileExists(env) {
|
||||||
|
return env, nil
|
||||||
|
}
|
||||||
|
tried = append(tried, fmt.Sprintf("$CTX_ROSLYN_HELPER = %s (not found)", env))
|
||||||
|
} else {
|
||||||
|
tried = append(tried, "$CTX_ROSLYN_HELPER (not set)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Same directory as ctx binary
|
||||||
|
if exePath, err := os.Executable(); err == nil {
|
||||||
|
exeDir := filepath.Dir(exePath)
|
||||||
|
candidate := filepath.Join(exeDir, name)
|
||||||
|
if fileExists(candidate) {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
tried = append(tried, fmt.Sprintf("%s (not found)", exeDir))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. PATH
|
||||||
|
if found, err := exec.LookPath(name); err == nil {
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
tried = append(tried, "PATH (not found)")
|
||||||
|
|
||||||
|
// 4. Dev fallback: tools/roslyn-helper/publish/ relative to working dir
|
||||||
|
if cwd, err := os.Getwd(); err == nil {
|
||||||
|
candidate := filepath.Join(cwd, "tools", "roslyn-helper", "publish", name)
|
||||||
|
if fileExists(candidate) {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
tried = append(tried, fmt.Sprintf("tools/roslyn-helper/publish/ (not found)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("%w\n\nLooked in:\n - %s\n - %s\n - %s\n - %s\n\nTo build the helper, run:\n cd tools\\roslyn-helper\n dotnet publish src/RoslynHelper -c Release -r win-x64 --self-contained false -o publish/",
|
||||||
|
ErrHelperNotFound,
|
||||||
|
safeIdx(tried, 0, "$CTX_ROSLYN_HELPER (not set)"),
|
||||||
|
safeIdx(tried, 1, "ctx.exe directory"),
|
||||||
|
safeIdx(tried, 2, "PATH"),
|
||||||
|
safeIdx(tried, 3, "tools/roslyn-helper/publish/"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeIdx(s []string, i int, fallback string) string {
|
||||||
|
if i < len(s) {
|
||||||
|
return s[i]
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
108
internal/plugins/csharp/helper/process.go
Normal file
108
internal/plugins/csharp/helper/process.go
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
package helper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Process manages the lifetime of the roslyn-helper subprocess.
|
||||||
|
// Calls are sequential — no concurrency within a single Process.
|
||||||
|
type Process struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
stdin io.WriteCloser
|
||||||
|
stdout *bufio.Reader
|
||||||
|
nextID atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start launches the helper subprocess and returns a Process ready to use.
|
||||||
|
func Start(helperPath string) (*Process, error) {
|
||||||
|
cmd := exec.Command(helperPath)
|
||||||
|
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("helper stdin pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdoutPipe, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("helper stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stderrPipe, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("helper stderr pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, fmt.Errorf("helper start: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain stderr in background to prevent blocking.
|
||||||
|
go func() { _, _ = io.Copy(io.Discard, stderrPipe) }()
|
||||||
|
|
||||||
|
p := &Process{
|
||||||
|
cmd: cmd,
|
||||||
|
stdin: stdin,
|
||||||
|
stdout: bufio.NewReader(stdoutPipe),
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends a JSON-RPC request and returns the raw result JSON.
|
||||||
|
// Returns an error if the helper returns an RpcError or dies.
|
||||||
|
func (p *Process) Send(method string, params interface{}) (json.RawMessage, error) {
|
||||||
|
id := int(p.nextID.Add(1))
|
||||||
|
|
||||||
|
var rawParams json.RawMessage
|
||||||
|
if params != nil {
|
||||||
|
var err error
|
||||||
|
rawParams, err = json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal params: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := Request{ID: id, Method: method, Params: rawParams}
|
||||||
|
line, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
line = append(line, '\n')
|
||||||
|
|
||||||
|
if _, err := p.stdin.Write(line); err != nil {
|
||||||
|
return nil, fmt.Errorf("helper write (process may have crashed): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
respLine, err := p.stdout.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("helper read (process may have crashed): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp Response
|
||||||
|
if err := json.Unmarshal([]byte(respLine), &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.ID != id {
|
||||||
|
return nil, fmt.Errorf("response id mismatch: got %d, want %d", resp.ID, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Error != nil {
|
||||||
|
return nil, resp.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.Result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close sends a shutdown request, closes stdin, and waits for the process to exit.
|
||||||
|
func (p *Process) Close() error {
|
||||||
|
// Best-effort shutdown — ignore errors here.
|
||||||
|
_, _ = p.Send("shutdown", nil)
|
||||||
|
_ = p.stdin.Close()
|
||||||
|
_ = p.cmd.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
26
internal/plugins/csharp/helper/protocol.go
Normal file
26
internal/plugins/csharp/helper/protocol.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package helper
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// Request is a newline-delimited JSON-RPC request sent to the helper process.
|
||||||
|
type Request struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params json.RawMessage `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response is a newline-delimited JSON-RPC response from the helper process.
|
||||||
|
type Response struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
|
Error *RpcError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RpcError is a structured error from the helper process.
|
||||||
|
type RpcError struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data json.RawMessage `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RpcError) Error() string { return e.Code + ": " + e.Message }
|
||||||
111
internal/plugins/csharp/project.go
Normal file
111
internal/plugins/csharp/project.go
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
package csharp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/ricarneiro/ctx/internal/core"
|
||||||
|
"github.com/ricarneiro/ctx/internal/plugins/csharp/helper"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errExit is a sentinel used to trigger os.Exit(1) via the SilenceErrors flow.
|
||||||
|
// The real error message has already been printed to ctx.Stderr.
|
||||||
|
var errExit = fmt.Errorf("exit 1")
|
||||||
|
|
||||||
|
func projectCmd(ctx *core.Context) *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "project",
|
||||||
|
Short: "Summarize the .NET solution in compact markdown",
|
||||||
|
Long: `Analyze the .NET solution in the current directory and emit a compact
|
||||||
|
markdown summary suitable for Claude Code consumption.
|
||||||
|
|
||||||
|
Requires the Roslyn helper (ctx-roslyn-helper) to be built and accessible.
|
||||||
|
Set CTX_ROSLYN_HELPER to the exact path, or build it alongside ctx.exe.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runProject(ctx)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runProject(ctx *core.Context) error {
|
||||||
|
slnPath, err := findSolution(ctx.WorkDir, ctx)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(ctx.Stderr, err.Error())
|
||||||
|
return errExit
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := helper.NewClient()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(ctx.Stderr, err.Error())
|
||||||
|
return errExit
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
if _, err := client.LoadSolution(slnPath); err != nil {
|
||||||
|
fmt.Fprintln(ctx.Stderr, err.Error())
|
||||||
|
return errExit
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := client.ProjectSummary()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(ctx.Stderr, err.Error())
|
||||||
|
return errExit
|
||||||
|
}
|
||||||
|
|
||||||
|
return WriteSummary(ctx.Stdout, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findSolution locates a .sln file in dir.
|
||||||
|
// Falls back to a single .csproj if no .sln exists.
|
||||||
|
func findSolution(dir string, ctx *core.Context) (string, error) {
|
||||||
|
slns, err := filepath.Glob(filepath.Join(dir, "*.sln"))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("glob .sln: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to files that actually exist
|
||||||
|
slns = existingFiles(slns)
|
||||||
|
|
||||||
|
if len(slns) > 0 {
|
||||||
|
sort.Strings(slns)
|
||||||
|
if len(slns) > 1 {
|
||||||
|
fmt.Fprintf(ctx.Stderr, "warning: multiple .sln files found, using %s\n", filepath.Base(slns[0]))
|
||||||
|
}
|
||||||
|
return slns[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: single .csproj
|
||||||
|
csprojPaths, err := filepath.Glob(filepath.Join(dir, "*.csproj"))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("glob .csproj: %w", err)
|
||||||
|
}
|
||||||
|
csprojPaths = existingFiles(csprojPaths)
|
||||||
|
|
||||||
|
switch len(csprojPaths) {
|
||||||
|
case 0:
|
||||||
|
return "", fmt.Errorf("no .sln or .csproj found in %s", dir)
|
||||||
|
case 1:
|
||||||
|
return csprojPaths[0], nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"no .sln found and multiple .csproj files exist in %s\n"+
|
||||||
|
"Run ctx inside a specific project folder, or create a .sln:\n"+
|
||||||
|
" dotnet new sln --format sln\n"+
|
||||||
|
" dotnet sln add **/*.csproj",
|
||||||
|
dir,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func existingFiles(paths []string) []string {
|
||||||
|
out := paths[:0]
|
||||||
|
for _, p := range paths {
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user