diff --git a/internal/plugins/csharp/csharp.go b/internal/plugins/csharp/csharp.go index 2f3070d..434d37f 100644 --- a/internal/plugins/csharp/csharp.go +++ b/internal/plugins/csharp/csharp.go @@ -1,10 +1,7 @@ -// Package csharp implements the ctx csharp plugin. -// Full implementation: prompts 4–6 (requires Roslyn helper from prompt 3). +// Package csharp implements the ctx csharp plugin — .NET solution analysis via Roslyn helper. package csharp import ( - "fmt" - "github.com/ricarneiro/ctx/internal/core" "github.com/spf13/cobra" ) @@ -16,19 +13,19 @@ func init() { type csharpPlugin struct{} 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) Command(ctx *core.Context) *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "csharp", Short: c.ShortDescription(), - // placeholder=true tells ctx auto to show a placeholder message instead of - // attempting to invoke this plugin's subcommands. - Annotations: map[string]string{"placeholder": "true"}, - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in prompt 4") - return nil - }, + Long: `Analyze C# / .NET projects and emit compact markdown summaries +optimized for Claude Code consumption. + +Requires the Roslyn helper (ctx-roslyn-helper) to be built. +See 'ctx csharp project --help' for details.`, } + cmd.AddCommand(projectCmd(ctx)) + return cmd } diff --git a/internal/plugins/csharp/format.go b/internal/plugins/csharp/format.go new file mode 100644 index 0000000..2cc7f36 --- /dev/null +++ b/internal/plugins/csharp/format.go @@ -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) +} diff --git a/internal/plugins/csharp/helper/client.go b/internal/plugins/csharp/helper/client.go new file mode 100644 index 0000000..14084e2 --- /dev/null +++ b/internal/plugins/csharp/helper/client.go @@ -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 +} diff --git a/internal/plugins/csharp/helper/locate.go b/internal/plugins/csharp/helper/locate.go new file mode 100644 index 0000000..12d31f5 --- /dev/null +++ b/internal/plugins/csharp/helper/locate.go @@ -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 +} diff --git a/internal/plugins/csharp/helper/process.go b/internal/plugins/csharp/helper/process.go new file mode 100644 index 0000000..48f91a2 --- /dev/null +++ b/internal/plugins/csharp/helper/process.go @@ -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 +} diff --git a/internal/plugins/csharp/helper/protocol.go b/internal/plugins/csharp/helper/protocol.go new file mode 100644 index 0000000..d380c1a --- /dev/null +++ b/internal/plugins/csharp/helper/protocol.go @@ -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 } diff --git a/internal/plugins/csharp/project.go b/internal/plugins/csharp/project.go new file mode 100644 index 0000000..b5ea28c --- /dev/null +++ b/internal/plugins/csharp/project.go @@ -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 +}