Compare commits
No commits in common. "4105f02468fb88889011b966a353eb1ccc3808b3" and "69cadb4ea6c6347ee1e93ea279138d0bb46833ef" have entirely different histories.
4105f02468
...
69cadb4ea6
@ -1,36 +0,0 @@
|
||||
#!/bin/sh
|
||||
# ctx pre-commit hook — shell entry point (works in Git Bash on Windows)
|
||||
# Delegates to pre-commit.ps1 on Windows, runs natively on Linux/macOS.
|
||||
#
|
||||
# To activate:
|
||||
# git config core.hooksPath .githooks
|
||||
|
||||
set -e
|
||||
|
||||
if command -v powershell.exe >/dev/null 2>&1; then
|
||||
# Windows: delegate to PowerShell script
|
||||
exec powershell.exe -ExecutionPolicy Bypass -File .githooks/pre-commit.ps1
|
||||
fi
|
||||
|
||||
# Linux / macOS: run directly
|
||||
echo "Running gofmt..."
|
||||
unformatted=$(gofmt -l .)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "Files not formatted with gofmt:"
|
||||
echo "$unformatted"
|
||||
echo
|
||||
echo "Run: gofmt -w ."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Running go vet..."
|
||||
go vet ./...
|
||||
|
||||
if command -v golangci-lint >/dev/null 2>&1; then
|
||||
echo "Running golangci-lint..."
|
||||
golangci-lint run ./...
|
||||
else
|
||||
echo "warning: golangci-lint not installed, skipping"
|
||||
fi
|
||||
|
||||
echo "pre-commit checks passed"
|
||||
@ -1,25 +0,0 @@
|
||||
# ctx pre-commit hook (PowerShell)
|
||||
# To activate: git config core.hooksPath .githooks
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "Running gofmt..."
|
||||
$unformatted = gofmt -l .
|
||||
if ($unformatted) {
|
||||
Write-Host "Files not formatted with gofmt:"
|
||||
Write-Host $unformatted
|
||||
Write-Host ""
|
||||
Write-Host "Run: gofmt -w ."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Running go vet..."
|
||||
go vet ./...
|
||||
|
||||
if (Get-Command golangci-lint -ErrorAction SilentlyContinue) {
|
||||
Write-Host "Running golangci-lint..."
|
||||
golangci-lint run ./...
|
||||
} else {
|
||||
Write-Host "warning: golangci-lint not installed, skipping"
|
||||
}
|
||||
|
||||
Write-Host "pre-commit checks passed"
|
||||
55
.github/workflows/ci.yml
vendored
55
.github/workflows/ci.yml
vendored
@ -1,55 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: latest
|
||||
|
||||
build:
|
||||
name: Build & Test
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
- name: Test
|
||||
run: go test -v -race -count=1 ./...
|
||||
|
||||
build-helper:
|
||||
name: Build Roslyn Helper
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
- name: Build helper
|
||||
working-directory: tools/roslyn-helper
|
||||
run: dotnet build src/RoslynHelper -c Release
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -27,11 +27,6 @@ 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
|
||||
|
||||
@ -1,69 +0,0 @@
|
||||
run:
|
||||
timeout: 3m
|
||||
tests: true
|
||||
go: "1.26"
|
||||
|
||||
output:
|
||||
formats:
|
||||
- format: colored-line-number
|
||||
print-issued-lines: true
|
||||
print-linter-name: true
|
||||
sort-results: true
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- errcheck # unhandled errors
|
||||
- govet # official compiler checks
|
||||
- ineffassign # useless assignments
|
||||
- staticcheck # bugs and simplifications
|
||||
- unused # dead code
|
||||
- gofmt # formatting
|
||||
- goimports # import organization
|
||||
- misspell # typos in strings/comments
|
||||
- revive # golint successor
|
||||
- gocyclo # high cyclomatic complexity
|
||||
- gocritic # additional checks
|
||||
- bodyclose # http.Response.Body closed
|
||||
- nilerr # returning nil when err intended
|
||||
- errorlint # non-wrappable errors
|
||||
|
||||
linters-settings:
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
govet:
|
||||
enable-all: true
|
||||
disable:
|
||||
- fieldalignment # unnecessary noise
|
||||
revive:
|
||||
rules:
|
||||
- name: var-naming
|
||||
- name: exported
|
||||
arguments: ["disableStutteringCheck"] # allows ctx.Context
|
||||
- name: error-return
|
||||
- name: error-naming
|
||||
- name: unexported-return
|
||||
- name: indent-error-flow
|
||||
- name: unreachable-code
|
||||
|
||||
issues:
|
||||
exclude-dirs:
|
||||
- tools/roslyn-helper # C# code, ignore
|
||||
- vendor
|
||||
- bin
|
||||
exclude-rules:
|
||||
# Allow errcheck in cobra command handlers — cobra's RunE owns error printing
|
||||
- path: cmd/
|
||||
linters: [errcheck]
|
||||
# Allow higher complexity in plugin format functions (inherently procedural)
|
||||
- path: internal/plugins/.*/format\.go
|
||||
linters: [gocyclo]
|
||||
text: "cyclomatic complexity"
|
||||
# Allow higher complexity in plugin collect/detect logic
|
||||
- path: internal/plugins/.*/detect
|
||||
linters: [gocyclo]
|
||||
text: "cyclomatic complexity"
|
||||
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
new: false
|
||||
252
README.md
252
README.md
@ -1,145 +1,3 @@
|
||||
# ctx
|
||||
|
||||
Ferramenta CLI para compactar contexto de código antes de enviar ao Claude — economize tokens, expanda o que cabe na janela.
|
||||
[→ Leia em Português](#pt-br) · [→ Read in English](#en)
|
||||
|
||||
---
|
||||
|
||||
<a id="pt-br"></a>
|
||||
|
||||
# ctx — anti-tokens CLI para Claude Code
|
||||
|
||||
> Analise seu projeto localmente. Envie ao Claude resumos densos, não arquivos brutos.
|
||||
|
||||
**Status:** alpha — em desenvolvimento ativo. Interfaces podem mudar.
|
||||
|
||||
## Por que usar
|
||||
|
||||
Cada token que o Claude lê tem custo e consome janela de contexto. Ao trabalhar em projetos grandes, o Claude frequentemente gasta milhares de tokens apenas lendo arquivos para construir um modelo mental antes de fazer qualquer trabalho real.
|
||||
|
||||
`ctx` roda localmente, analisa seu projeto com ferramentas especializadas por linguagem (Roslyn para C#, tree-sitter para TypeScript), e emite resumos compactos em markdown que dão ao Claude tudo que ele precisa em uma fração dos tokens.
|
||||
|
||||
### Benchmark
|
||||
|
||||
Teste real: análise de "o que preciso mudar para adicionar uma feature" em projeto em produção.
|
||||
Ambas as sessões iniciadas com `/clear` — condições equivalentes.
|
||||
|
||||
| Teste | Consumo de janela de contexto |
|
||||
|-------|-------------------------------|
|
||||
| Sem ctx | +7% |
|
||||
| Com ctx | +3% |
|
||||
|
||||
**Economia: 57%.** Em sessões longas de desenvolvimento, onde o Claude faz a mesma exploração várias vezes ao longo da conversa, essa economia acumula.
|
||||
|
||||
## Instalação
|
||||
|
||||
**1. Instale o Go**
|
||||
Baixe em https://go.dev/dl/ e siga o instalador. Requer Go 1.22+.
|
||||
|
||||
**2. Instale o ctx**
|
||||
```sh
|
||||
go install github.com/ricarneiro/ctx/cmd/ctx@latest
|
||||
```
|
||||
|
||||
**3. Verifique**
|
||||
```sh
|
||||
ctx --version
|
||||
```
|
||||
|
||||
## Uso
|
||||
|
||||
```sh
|
||||
# Contexto git: commits recentes, status, info da branch
|
||||
ctx git
|
||||
|
||||
# Detectar stack e emitir visão geral do projeto
|
||||
ctx auto project
|
||||
|
||||
# Estrutura de projeto C# (requer .NET SDK)
|
||||
ctx csharp project
|
||||
|
||||
# Outline de arquivo C#: tipos, métodos, assinaturas
|
||||
ctx csharp outline src/MyService.cs
|
||||
|
||||
# Listar erros de compilação
|
||||
ctx csharp errors
|
||||
```
|
||||
|
||||
Toda saída é markdown UTF-8 no stdout. Redirecione onde precisar:
|
||||
|
||||
```sh
|
||||
ctx csharp project | clip # Windows
|
||||
ctx csharp project | pbcopy # macOS
|
||||
```
|
||||
|
||||
## Integrando com Claude Code
|
||||
|
||||
Para que o Claude Code use o `ctx` automaticamente, adicione o `ctx` ao PATH do Windows e inclua as instruções abaixo no `CLAUDE.md` do seu projeto.
|
||||
|
||||
### 1. Adicionar ctx ao PATH no Windows
|
||||
|
||||
1. Copie o `ctx.exe` para uma pasta fixa (ex: `C:\tools\ctx\`)
|
||||
2. Abra **Configurações do Sistema → Variáveis de Ambiente**
|
||||
3. Em "Variáveis do sistema", selecione `Path` → **Editar**
|
||||
4. Clique em **Novo** e adicione `C:\tools\ctx\`
|
||||
5. Confirme com OK e reinicie o terminal
|
||||
|
||||
### 2. Adicionar ao CLAUDE.md do projeto
|
||||
|
||||
Crie ou edite o arquivo `CLAUDE.md` na raiz do seu projeto e inclua:
|
||||
|
||||
````markdown
|
||||
# Project instructions
|
||||
|
||||
## `ctx` — compact code analysis tool (use to save tokens)
|
||||
|
||||
This project has `ctx` in PATH. It produces compact markdown summaries of code structure.
|
||||
|
||||
### When to use `ctx` (saves tokens)
|
||||
|
||||
- **Understanding the whole project:** run `ctx csharp project` instead of exploring files one by one.
|
||||
- **Understanding a large file (100+ lines):** run `ctx csharp outline <file>` before reading the full file. Only read the full file if you need method body details.
|
||||
- **Checking build errors:** run `ctx csharp errors` instead of `dotnet build` (filtered output, 90% smaller).
|
||||
- **Understanding git state:** run `ctx git` instead of running multiple git commands.
|
||||
|
||||
### When NOT to use `ctx`
|
||||
|
||||
- If you already know which specific small file to read, just read it directly.
|
||||
- If you need to see method body logic, read the file — `ctx outline` only shows signatures.
|
||||
- For simple `Search()` by pattern across files, search is already efficient.
|
||||
|
||||
### Commands
|
||||
|
||||
```
|
||||
ctx csharp project # solution overview: projects, refs, packages, structure
|
||||
ctx csharp outline <file.cs> # class/method signatures without bodies (~85% smaller)
|
||||
ctx csharp errors # dotnet build filtered to errors + top warnings (~90% smaller)
|
||||
ctx git # branch, status, recent commits, diff summary
|
||||
```
|
||||
|
||||
### Rule of thumb
|
||||
|
||||
Ask yourself: "Am I about to read multiple files to understand structure?" If yes → `ctx` first. If you just need one specific file → read it directly.
|
||||
````
|
||||
|
||||
## Contribuindo
|
||||
|
||||
Contribuições são bem-vindas. Clone o repositório, faça suas alterações e abra um Pull Request. Para mudanças grandes, abra uma issue primeiro para alinhar o escopo.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/ricarneiro/CTX.git
|
||||
cd CTX
|
||||
go build -o ctx.exe ./cmd/ctx
|
||||
```
|
||||
|
||||
## Licença
|
||||
|
||||
MIT — veja [LICENSE](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
<a id="en"></a>
|
||||
|
||||
# ctx — anti-tokens CLI for Claude Code
|
||||
|
||||
> Analyze your codebase locally. Feed Claude dense summaries, not raw files.
|
||||
@ -148,36 +6,22 @@ MIT — veja [LICENSE](LICENSE).
|
||||
|
||||
## Why
|
||||
|
||||
Every token Claude reads costs money and burns context window. When working on a large codebase, Claude often spends thousands of tokens just reading files to build a mental model before doing any actual work.
|
||||
Every token Claude reads costs money and burns context window. When working on
|
||||
a large C# or React codebase, Claude often spends thousands of tokens just
|
||||
reading files to build a mental model before doing any actual work.
|
||||
|
||||
`ctx` runs locally, analyzes your project with language-aware tools (Roslyn for C#, tree-sitter for TypeScript), and emits compact markdown summaries that give Claude everything it needs in a fraction of the tokens.
|
||||
|
||||
### Benchmark
|
||||
|
||||
Real test: analyzing "what needs to change to add a feature" on a production project.
|
||||
Both sessions started with `/clear` — equivalent conditions.
|
||||
|
||||
| Test | Context window usage |
|
||||
|------|----------------------|
|
||||
| Without ctx | +7% |
|
||||
| With ctx | +3% |
|
||||
|
||||
**Savings: 57%.** In long development sessions, where Claude performs the same exploration repeatedly throughout a conversation, the savings compound.
|
||||
`ctx` runs locally, analyzes your project with language-aware tools (Roslyn for
|
||||
C#, tree-sitter for TypeScript), and emits compact markdown summaries that give
|
||||
Claude everything it needs in a fraction of the tokens.
|
||||
|
||||
## Installation
|
||||
|
||||
**1. Install Go**
|
||||
Download from https://go.dev/dl/ and run the installer. Requires Go 1.22+.
|
||||
|
||||
**2. Install ctx**
|
||||
```sh
|
||||
go install github.com/ricarneiro/ctx/cmd/ctx@latest
|
||||
```
|
||||
|
||||
**3. Verify**
|
||||
```sh
|
||||
ctx --version
|
||||
```
|
||||
Requires Go 1.22+. Binaries for common platforms will be published after the
|
||||
MVP is validated.
|
||||
|
||||
## Usage
|
||||
|
||||
@ -205,65 +49,41 @@ ctx csharp project | pbcopy # macOS
|
||||
ctx csharp project | clip # Windows
|
||||
```
|
||||
|
||||
## Integrating with Claude Code
|
||||
Or reference it in a `CLAUDE.md`:
|
||||
|
||||
To have Claude Code use `ctx` automatically, add `ctx` to your Windows PATH and include the instructions below in your project's `CLAUDE.md`.
|
||||
|
||||
### 1. Add ctx to PATH on Windows
|
||||
|
||||
1. Copy `ctx.exe` to a fixed folder (e.g. `C:\tools\ctx\`)
|
||||
2. Open **System Settings → Environment Variables**
|
||||
3. Under "System variables", select `Path` → **Edit**
|
||||
4. Click **New** and add `C:\tools\ctx\`
|
||||
5. Confirm with OK and restart your terminal
|
||||
|
||||
### 2. Add to your project's CLAUDE.md
|
||||
|
||||
Create or edit `CLAUDE.md` at the root of your project and include:
|
||||
|
||||
````markdown
|
||||
# Project instructions
|
||||
|
||||
## `ctx` — compact code analysis tool (use to save tokens)
|
||||
|
||||
This project has `ctx` in PATH. It produces compact markdown summaries of code structure.
|
||||
|
||||
### When to use `ctx` (saves tokens)
|
||||
|
||||
- **Understanding the whole project:** run `ctx csharp project` instead of exploring files one by one.
|
||||
- **Understanding a large file (100+ lines):** run `ctx csharp outline <file>` before reading the full file. Only read the full file if you need method body details.
|
||||
- **Checking build errors:** run `ctx csharp errors` instead of `dotnet build` (filtered output, 90% smaller).
|
||||
- **Understanding git state:** run `ctx git` instead of running multiple git commands.
|
||||
|
||||
### When NOT to use `ctx`
|
||||
|
||||
- If you already know which specific small file to read, just read it directly.
|
||||
- If you need to see method body logic, read the file — `ctx outline` only shows signatures.
|
||||
- For simple `Search()` by pattern across files, search is already efficient.
|
||||
|
||||
### Commands
|
||||
|
||||
```
|
||||
ctx csharp project # solution overview: projects, refs, packages, structure
|
||||
ctx csharp outline <file.cs> # class/method signatures without bodies (~85% smaller)
|
||||
ctx csharp errors # dotnet build filtered to errors + top warnings (~90% smaller)
|
||||
ctx git # branch, status, recent commits, diff summary
|
||||
```markdown
|
||||
Run `ctx csharp project` to get the project overview before making changes.
|
||||
```
|
||||
|
||||
### Rule of thumb
|
||||
## Architecture
|
||||
|
||||
Ask yourself: "Am I about to read multiple files to understand structure?" If yes → `ctx` first. If you just need one specific file → read it directly.
|
||||
````
|
||||
`ctx` uses a plugin system. Each stack (`git`, `csharp`, `react`, `auto`) is a
|
||||
plugin that implements the `core.Plugin` interface and registers itself via
|
||||
`init()`.
|
||||
|
||||
```
|
||||
core.Plugin interface
|
||||
Name() string
|
||||
Version() string
|
||||
ShortDescription() string
|
||||
Command(ctx *core.Context) *cobra.Command
|
||||
```
|
||||
|
||||
In the MVP, plugins are compiled into the binary. Future plan: migrate to
|
||||
subprocess dispatch (binaries named `ctx-csharp`, `ctx-react` in PATH), same
|
||||
pattern as `kubectl` plugins.
|
||||
|
||||
C# analysis uses a separate helper process (`tools/roslyn-helper/`) written in
|
||||
C# with Roslyn. The helper communicates with `ctx` via JSON-RPC over
|
||||
stdin/stdout. This lets us use the best tool for the job without pulling a .NET
|
||||
runtime into the Go binary.
|
||||
|
||||
See `docs/DECISIONS.md` for the full rationale behind each architectural choice.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. Clone the repository, make your changes, and open a Pull Request. For large changes, open an issue first to align on scope.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/ricarneiro/CTX.git
|
||||
cd CTX
|
||||
go build -o ctx.exe ./cmd/ctx
|
||||
```
|
||||
Contributions welcome. See `CONTRIBUTING.md` (coming soon) for guidelines.
|
||||
Open an issue first if you plan a large change.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@ -22,8 +22,7 @@ Each subcommand targets a specific stack:
|
||||
ctx react — React / TypeScript analysis
|
||||
|
||||
Output is always UTF-8 markdown on stdout, suitable for piping into Claude.`,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true, // plugins print their own errors to stderr
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
// Execute runs the root command. Called by cmd/ctx/main.go.
|
||||
|
||||
@ -1,178 +1,31 @@
|
||||
// Package auto implements the ctx auto plugin — stack detection and routing.
|
||||
// Package auto implements the ctx auto plugin.
|
||||
// Full implementation: prompt 2.
|
||||
package auto
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/ricarneiro/ctx/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
core.Register(&plugin{})
|
||||
core.Register(&autoPlugin{})
|
||||
}
|
||||
|
||||
type plugin struct{}
|
||||
type autoPlugin struct{}
|
||||
|
||||
func (p *plugin) Name() string { return "auto" }
|
||||
func (p *plugin) Version() string { return "0.1.0" }
|
||||
func (p *plugin) ShortDescription() string { return "Detect stack and route to appropriate plugin" }
|
||||
func (a *autoPlugin) Name() string { return "auto" }
|
||||
func (a *autoPlugin) Version() string { return "0.0.1" }
|
||||
func (a *autoPlugin) ShortDescription() string { return "Auto-detect project stack and emit context" }
|
||||
|
||||
func (p *plugin) Command(ctx *core.Context) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
func (a *autoPlugin) Command(ctx *core.Context) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "auto",
|
||||
Short: p.ShortDescription(),
|
||||
Long: "Auto-detect the project stack and route to the appropriate ctx plugin.",
|
||||
}
|
||||
cmd.AddCommand(newDetectCmd(ctx))
|
||||
cmd.AddCommand(newProjectCmd(ctx))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// --- detect subcommand ---
|
||||
|
||||
func newDetectCmd(ctx *core.Context) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "detect",
|
||||
Short: "List detected stacks in the current directory",
|
||||
Long: "Scan the current directory and list detected technology stacks without running any plugin.",
|
||||
Short: a.ShortDescription(),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runDetect(ctx)
|
||||
fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in prompt 2")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runDetect(ctx *core.Context) error {
|
||||
stacks, err := Detect(ctx.WorkDir)
|
||||
if err != nil {
|
||||
fmt.Fprintln(ctx.Stderr, err.Error())
|
||||
return fmt.Errorf("exit 1")
|
||||
}
|
||||
formatDetect(ctx, stacks)
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatDetect(ctx *core.Context, stacks []Stack) {
|
||||
w := ctx.Stdout
|
||||
fmt.Fprintln(w, "# Stack detection")
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "**directory:** %s\n\n", ctx.WorkDir)
|
||||
|
||||
if len(stacks) == 0 {
|
||||
fmt.Fprintln(w, "No known stack detected.")
|
||||
fmt.Fprintln(w)
|
||||
plugins := core.All()
|
||||
names := make([]string, len(plugins))
|
||||
for i, p := range plugins {
|
||||
names[i] = p.Name()
|
||||
}
|
||||
fmt.Fprintf(w, "Available plugins: %s\n", strings.Join(names, ", "))
|
||||
fmt.Fprintln(w, "Run `ctx <plugin> --help` to see what each plugin offers.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, "## Detected stacks")
|
||||
for _, s := range stacks {
|
||||
fmt.Fprintf(w, "- **%s** (confidence: %s)\n", s.Name, s.Confidence)
|
||||
for _, e := range s.Evidence {
|
||||
fmt.Fprintf(w, " - %s\n", e)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
|
||||
fmt.Fprintln(w, "## Suggested commands")
|
||||
for _, s := range stacks {
|
||||
fmt.Fprintf(w, "- `ctx %s project`\n", s.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- project subcommand ---
|
||||
|
||||
func newProjectCmd(ctx *core.Context) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "project",
|
||||
Short: "Detect stack and emit project context summary",
|
||||
Long: "Auto-detect the project stack and run the `project` command of each matching plugin.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runProject(ctx)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runProject(ctx *core.Context) error {
|
||||
stacks, err := Detect(ctx.WorkDir)
|
||||
if err != nil {
|
||||
fmt.Fprintln(ctx.Stderr, err.Error())
|
||||
return fmt.Errorf("exit 1")
|
||||
}
|
||||
|
||||
if len(stacks) == 0 {
|
||||
formatDetect(ctx, stacks)
|
||||
return nil
|
||||
}
|
||||
|
||||
first := true
|
||||
for _, stack := range stacks {
|
||||
if !first {
|
||||
fmt.Fprintln(ctx.Stdout, "---")
|
||||
fmt.Fprintln(ctx.Stdout)
|
||||
}
|
||||
first = false
|
||||
|
||||
p := core.Get(stack.Name)
|
||||
if p == nil {
|
||||
fmt.Fprintf(ctx.Stdout,
|
||||
"# %s\n\nNo plugin registered for stack `%s`.\n\n",
|
||||
stack.Name, stack.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
pluginCmd := p.Command(ctx)
|
||||
|
||||
// If the root command itself is a placeholder, the whole plugin is unimplemented.
|
||||
if pluginCmd.Annotations["placeholder"] == "true" {
|
||||
writePlaceholder(ctx, stack.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
sub := findSubcommand(pluginCmd, "project")
|
||||
if sub == nil {
|
||||
fmt.Fprintf(ctx.Stdout,
|
||||
"# %s\n\nThe `%s` plugin does not have a `project` command.\n\n",
|
||||
stack.Name, stack.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if sub.Annotations["placeholder"] == "true" {
|
||||
writePlaceholder(ctx, stack.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
// Execute the project subcommand directly (no cobra routing overhead).
|
||||
if sub.RunE != nil {
|
||||
if err := sub.RunE(sub, []string{}); err != nil {
|
||||
fmt.Fprintf(ctx.Stderr, "error running %s project: %v\n", stack.Name, err)
|
||||
}
|
||||
} else if sub.Run != nil {
|
||||
sub.Run(sub, []string{})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findSubcommand returns the named subcommand of cmd, or nil if not found.
|
||||
func findSubcommand(cmd *cobra.Command, name string) *cobra.Command {
|
||||
for _, sub := range cmd.Commands() {
|
||||
if sub.Name() == name {
|
||||
return sub
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writePlaceholder(ctx *core.Context, name string) {
|
||||
fmt.Fprintf(ctx.Stdout,
|
||||
"# %s (placeholder)\n\nThe `%s` plugin detected this stack but its `project` command is not yet implemented.\nThis will be available in a future version of ctx.\n\n",
|
||||
name, name)
|
||||
}
|
||||
|
||||
@ -1,258 +0,0 @@
|
||||
package auto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Stack represents a detected technology stack in a project directory.
|
||||
type Stack struct {
|
||||
Name string // "csharp", "react", "go", "python"
|
||||
Confidence string // "high", "medium", "low"
|
||||
Evidence []string // human-readable items explaining the detection
|
||||
}
|
||||
|
||||
// Detect scans rootDir up to 3 levels deep and returns detected stacks,
|
||||
// ordered by confidence (high first). Build artifacts and dependency
|
||||
// directories (node_modules, bin, obj, etc.) are skipped.
|
||||
func Detect(rootDir string) ([]Stack, error) {
|
||||
f, err := scanDir(rootDir, 3)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildStacks(f), nil
|
||||
}
|
||||
|
||||
// skipDirs lists directory names that are never descended into during scanning.
|
||||
var skipDirs = map[string]bool{
|
||||
"node_modules": true,
|
||||
"bin": true,
|
||||
"obj": true,
|
||||
"dist": true,
|
||||
"build": true,
|
||||
"out": true,
|
||||
".git": true,
|
||||
".vs": true,
|
||||
".vscode": true,
|
||||
".idea": true,
|
||||
"target": true,
|
||||
"vendor": true,
|
||||
}
|
||||
|
||||
// findings holds raw evidence gathered during the directory walk.
|
||||
type findings struct {
|
||||
slnFiles []string // forward-slash relative paths to .sln files
|
||||
csprojFiles []string // forward-slash relative paths to .csproj/.fsproj/.vbproj files
|
||||
hasGlobalJSON bool
|
||||
packageJSONs []pkgJSON
|
||||
hasGoModRoot bool // go.mod is a direct child of rootDir (depth 1)
|
||||
hasPyProject bool
|
||||
hasReqTxt bool
|
||||
hasSetupPy bool
|
||||
hasTSXorJSX bool
|
||||
}
|
||||
|
||||
type pkgJSON struct {
|
||||
relPath string
|
||||
hasReact bool
|
||||
hasTypeScript bool
|
||||
}
|
||||
|
||||
func scanDir(rootDir string, maxDepth int) (findings, error) {
|
||||
var f findings
|
||||
|
||||
err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil //nolint:nilerr // skip unreadable entries; returning err would abort the walk
|
||||
}
|
||||
if path == rootDir {
|
||||
return nil // skip root itself
|
||||
}
|
||||
|
||||
rel, _ := filepath.Rel(rootDir, path)
|
||||
depth := len(strings.Split(rel, string(filepath.Separator)))
|
||||
relFwd := filepath.ToSlash(rel)
|
||||
|
||||
if d.IsDir() {
|
||||
if skipDirs[d.Name()] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if depth >= maxDepth {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// File: classify by extension then by base name.
|
||||
name := d.Name()
|
||||
base := strings.ToLower(name)
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
|
||||
switch ext {
|
||||
case ".sln":
|
||||
f.slnFiles = append(f.slnFiles, relFwd)
|
||||
case ".csproj", ".fsproj", ".vbproj":
|
||||
f.csprojFiles = append(f.csprojFiles, relFwd)
|
||||
case ".tsx", ".jsx":
|
||||
f.hasTSXorJSX = true
|
||||
}
|
||||
|
||||
switch base {
|
||||
case "global.json":
|
||||
f.hasGlobalJSON = true
|
||||
case "package.json":
|
||||
f.packageJSONs = append(f.packageJSONs, parsePackageJSON(path, relFwd))
|
||||
case "go.mod":
|
||||
if depth == 1 {
|
||||
f.hasGoModRoot = true
|
||||
}
|
||||
case "pyproject.toml":
|
||||
f.hasPyProject = true
|
||||
case "requirements.txt":
|
||||
f.hasReqTxt = true
|
||||
case "setup.py":
|
||||
f.hasSetupPy = true
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return f, err
|
||||
}
|
||||
|
||||
// pkgJSONSchema is the minimal structure needed to check for specific deps.
|
||||
type pkgJSONSchema struct {
|
||||
Dependencies map[string]json.RawMessage `json:"dependencies"`
|
||||
DevDependencies map[string]json.RawMessage `json:"devDependencies"`
|
||||
}
|
||||
|
||||
func parsePackageJSON(path, relPath string) pkgJSON {
|
||||
result := pkgJSON{relPath: relPath}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
var raw pkgJSONSchema
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return result
|
||||
}
|
||||
allDeps := make(map[string]bool, len(raw.Dependencies)+len(raw.DevDependencies))
|
||||
for k := range raw.Dependencies {
|
||||
allDeps[k] = true
|
||||
}
|
||||
for k := range raw.DevDependencies {
|
||||
allDeps[k] = true
|
||||
}
|
||||
result.hasReact = allDeps["react"]
|
||||
result.hasTypeScript = allDeps["typescript"] || allDeps["@types/react"]
|
||||
return result
|
||||
}
|
||||
|
||||
// buildStacks applies heuristics to the findings and returns stacks sorted
|
||||
// by confidence descending.
|
||||
func buildStacks(f findings) []Stack {
|
||||
var stacks []Stack
|
||||
|
||||
// C# — high if .sln or .csproj present; medium if only global.json.
|
||||
if len(f.slnFiles) > 0 || len(f.csprojFiles) > 0 {
|
||||
var evidence []string
|
||||
for _, s := range f.slnFiles {
|
||||
evidence = append(evidence, evidenceItem(s))
|
||||
}
|
||||
for i, s := range f.csprojFiles {
|
||||
if i >= 3 {
|
||||
evidence = append(evidence, fmt.Sprintf("...and %d more .csproj files", len(f.csprojFiles)-3))
|
||||
break
|
||||
}
|
||||
evidence = append(evidence, evidenceItem(s))
|
||||
}
|
||||
stacks = append(stacks, Stack{Name: "csharp", Confidence: "high", Evidence: evidence})
|
||||
} else if f.hasGlobalJSON {
|
||||
stacks = append(stacks, Stack{
|
||||
Name: "csharp", Confidence: "medium",
|
||||
Evidence: []string{"found: `global.json`"},
|
||||
})
|
||||
}
|
||||
|
||||
// React — high if package.json lists react; medium if TypeScript + tsx/jsx files.
|
||||
reactFound := false
|
||||
for _, pkg := range f.packageJSONs {
|
||||
if pkg.hasReact {
|
||||
stacks = append(stacks, Stack{
|
||||
Name: "react",
|
||||
Confidence: "high",
|
||||
Evidence: []string{fmt.Sprintf("found: `%s` with `react` in dependencies", pkg.relPath)},
|
||||
})
|
||||
reactFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !reactFound {
|
||||
for _, pkg := range f.packageJSONs {
|
||||
if pkg.hasTypeScript && f.hasTSXorJSX {
|
||||
stacks = append(stacks, Stack{
|
||||
Name: "react",
|
||||
Confidence: "medium",
|
||||
Evidence: []string{fmt.Sprintf("found: `%s` with TypeScript + `.tsx`/`.jsx` files", pkg.relPath)},
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Go — high if go.mod is at the root (not nested).
|
||||
if f.hasGoModRoot {
|
||||
stacks = append(stacks, Stack{
|
||||
Name: "go", Confidence: "high",
|
||||
Evidence: []string{"found: `go.mod`"},
|
||||
})
|
||||
}
|
||||
|
||||
// Python — medium if any standard config file is present.
|
||||
if f.hasPyProject || f.hasReqTxt || f.hasSetupPy {
|
||||
var evidence []string
|
||||
if f.hasPyProject {
|
||||
evidence = append(evidence, "found: `pyproject.toml`")
|
||||
}
|
||||
if f.hasReqTxt {
|
||||
evidence = append(evidence, "found: `requirements.txt`")
|
||||
}
|
||||
if f.hasSetupPy {
|
||||
evidence = append(evidence, "found: `setup.py`")
|
||||
}
|
||||
stacks = append(stacks, Stack{Name: "python", Confidence: "medium", Evidence: evidence})
|
||||
}
|
||||
|
||||
sort.SliceStable(stacks, func(i, j int) bool {
|
||||
return confidenceRank(stacks[i].Confidence) > confidenceRank(stacks[j].Confidence)
|
||||
})
|
||||
|
||||
return stacks
|
||||
}
|
||||
|
||||
// evidenceItem formats a relative path for evidence display.
|
||||
// Root-level files get an "at root" suffix.
|
||||
func evidenceItem(relFwd string) string {
|
||||
if !strings.Contains(relFwd, "/") {
|
||||
return fmt.Sprintf("found: `%s` at root", relFwd)
|
||||
}
|
||||
return fmt.Sprintf("found: `%s`", relFwd)
|
||||
}
|
||||
|
||||
func confidenceRank(c string) int {
|
||||
switch c {
|
||||
case "high":
|
||||
return 3
|
||||
case "medium":
|
||||
return 2
|
||||
case "low":
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,10 @@
|
||||
// Package csharp implements the ctx csharp plugin — .NET solution analysis via Roslyn helper.
|
||||
// Package csharp implements the ctx csharp plugin.
|
||||
// Full implementation: prompts 4–6 (requires Roslyn helper from prompt 3).
|
||||
package csharp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ricarneiro/ctx/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@ -13,20 +16,16 @@ func init() {
|
||||
type csharpPlugin struct{}
|
||||
|
||||
func (c *csharpPlugin) Name() string { return "csharp" }
|
||||
func (c *csharpPlugin) Version() string { return "0.1.0" }
|
||||
func (c *csharpPlugin) Version() string { return "0.0.1" }
|
||||
func (c *csharpPlugin) ShortDescription() string { return "C# / .NET project analysis via Roslyn" }
|
||||
|
||||
func (c *csharpPlugin) Command(ctx *core.Context) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
return &cobra.Command{
|
||||
Use: "csharp",
|
||||
Short: c.ShortDescription(),
|
||||
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.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in prompt 4")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(projectCmd(ctx))
|
||||
cmd.AddCommand(outlineCmd(ctx))
|
||||
return cmd
|
||||
}
|
||||
|
||||
@ -1,425 +0,0 @@
|
||||
package csharp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"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
|
||||
switch len(allFrameworks) {
|
||||
case 0:
|
||||
fmt.Fprintf(w, "No target frameworks detected.\n\n")
|
||||
case 1:
|
||||
for tf := range allFrameworks {
|
||||
fmt.Fprintf(w, "None — all projects target `%s`.\n\n", tf)
|
||||
}
|
||||
default:
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ─── Outline formatter ───────────────────────────────────────────────────────
|
||||
|
||||
// WriteOutline formats an OutlineResult as dense markdown.
|
||||
func WriteOutline(w io.Writer, o *helper.OutlineResult) error {
|
||||
fileName := filepath.Base(o.Path)
|
||||
output.H1(w, "Outline: "+fileName)
|
||||
|
||||
if o.HasSyntaxErrors {
|
||||
fmt.Fprintf(w, "> ⚠️ File has syntax errors — outline may be incomplete.\n\n")
|
||||
}
|
||||
|
||||
// Overview
|
||||
typeSummary := outlineTypesSummary(o.Types)
|
||||
output.KeyValue(w, "path", "`"+o.Path+"`")
|
||||
if o.Namespace != "" {
|
||||
output.KeyValue(w, "namespace", "`"+o.Namespace+"`")
|
||||
}
|
||||
output.KeyValue(w, "lines", fmt.Sprintf("%d", o.LineCount))
|
||||
output.KeyValue(w, "types", typeSummary)
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// Usings
|
||||
if len(o.Usings) > 0 {
|
||||
output.H2(w, "Usings")
|
||||
for _, u := range o.Usings {
|
||||
fmt.Fprintf(w, "- `%s`\n", u)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// Types
|
||||
if len(o.Types) > 0 {
|
||||
output.H2(w, "Types")
|
||||
for i := range o.Types {
|
||||
writeOutlineType(w, &o.Types[i], 3)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func outlineTypesSummary(types []helper.OutlineType) string {
|
||||
counts := map[string]int{}
|
||||
for _, t := range types {
|
||||
counts[t.Kind]++
|
||||
}
|
||||
if len(counts) == 0 {
|
||||
return "none"
|
||||
}
|
||||
// Fixed display order
|
||||
order := []string{"class", "interface", "struct", "record", "record struct", "enum"}
|
||||
parts := []string{}
|
||||
for _, k := range order {
|
||||
if n, ok := counts[k]; ok {
|
||||
parts = append(parts, fmt.Sprintf("%d %s", n, k))
|
||||
delete(counts, k)
|
||||
}
|
||||
}
|
||||
// Any remaining unknown kinds
|
||||
for k, n := range counts {
|
||||
parts = append(parts, fmt.Sprintf("%d %s", n, k))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func writeOutlineType(w io.Writer, t *helper.OutlineType, headingLevel int) {
|
||||
// Build header: `kind Name : Base1, Base2` (modifiers)
|
||||
header := t.Kind + " " + t.Name
|
||||
if len(t.BaseTypes) > 0 {
|
||||
header += " : " + strings.Join(t.BaseTypes, ", ")
|
||||
}
|
||||
heading := "`" + header + "`"
|
||||
if len(t.Modifiers) > 0 {
|
||||
heading += " (" + strings.Join(t.Modifiers, ", ") + ")"
|
||||
}
|
||||
writeHeading(w, headingLevel, heading)
|
||||
|
||||
// Group members by kind, in canonical order
|
||||
writeOutlineMembers(w, t.Members, headingLevel+1)
|
||||
|
||||
// Nested types — shown as bullet list for simplicity
|
||||
if len(t.Nested) > 0 {
|
||||
writeHeading(w, headingLevel+1, "Nested types")
|
||||
for _, n := range t.Nested {
|
||||
nestedHeader := n.Kind + " " + n.Name
|
||||
if len(n.BaseTypes) > 0 {
|
||||
nestedHeader += " : " + strings.Join(n.BaseTypes, ", ")
|
||||
}
|
||||
prefix := modPrefix(n.Modifiers)
|
||||
fmt.Fprintf(w, "- `%s%s`\n", prefix, nestedHeader)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func writeOutlineMembers(w io.Writer, members []helper.OutlineMember, headingLevel int) {
|
||||
// Collect by kind
|
||||
var fields, constructors, properties, methods, events []helper.OutlineMember
|
||||
for _, m := range members {
|
||||
switch m.Kind {
|
||||
case "field":
|
||||
fields = append(fields, m)
|
||||
case "constructor":
|
||||
constructors = append(constructors, m)
|
||||
case "property":
|
||||
properties = append(properties, m)
|
||||
case "method":
|
||||
methods = append(methods, m)
|
||||
case "event":
|
||||
events = append(events, m)
|
||||
}
|
||||
}
|
||||
|
||||
if len(fields) > 0 {
|
||||
writeHeading(w, headingLevel, "Fields")
|
||||
for _, m := range fields {
|
||||
writeMemberLine(w, m)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
if len(constructors) > 0 {
|
||||
writeHeading(w, headingLevel, "Constructor")
|
||||
for _, m := range constructors {
|
||||
writeMemberLine(w, m)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
if len(properties) > 0 {
|
||||
writeHeading(w, headingLevel, "Properties")
|
||||
for _, m := range properties {
|
||||
writeMemberLine(w, m)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
if len(methods) > 0 {
|
||||
writeHeading(w, headingLevel, "Methods")
|
||||
for _, m := range methods {
|
||||
writeMemberLine(w, m)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
if len(events) > 0 {
|
||||
writeHeading(w, headingLevel, "Events")
|
||||
for _, m := range events {
|
||||
writeMemberLine(w, m)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func writeMemberLine(w io.Writer, m helper.OutlineMember) {
|
||||
prefix := modPrefix(m.Modifiers)
|
||||
obsolete := ""
|
||||
if m.IsObsolete {
|
||||
obsolete = " _(obsolete)_"
|
||||
}
|
||||
lineRef := ""
|
||||
if m.Line > 0 {
|
||||
lineRef = fmt.Sprintf(" (line %d)", m.Line)
|
||||
}
|
||||
fmt.Fprintf(w, "- `%s%s`%s%s\n", prefix, m.Signature, lineRef, obsolete)
|
||||
}
|
||||
|
||||
func modPrefix(mods []string) string {
|
||||
if len(mods) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(mods, " ") + " "
|
||||
}
|
||||
|
||||
func writeHeading(w io.Writer, level int, text string) {
|
||||
fmt.Fprintf(w, "%s %s\n\n", strings.Repeat("#", level), text)
|
||||
}
|
||||
@ -1,185 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
// --- Outline types ---
|
||||
|
||||
// OutlineResult is the structural outline of a single .cs file.
|
||||
type OutlineResult struct {
|
||||
Path string `json:"path"`
|
||||
Namespace string `json:"namespace"`
|
||||
LineCount int `json:"lineCount"`
|
||||
Usings []string `json:"usings"`
|
||||
Types []OutlineType `json:"types"`
|
||||
HasSyntaxErrors bool `json:"hasSyntaxErrors"`
|
||||
}
|
||||
|
||||
// OutlineType describes a type (class, interface, struct, record, enum) in the file.
|
||||
type OutlineType struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Modifiers []string `json:"modifiers"`
|
||||
BaseTypes []string `json:"baseTypes"`
|
||||
Members []OutlineMember `json:"members"`
|
||||
Nested []OutlineType `json:"nested"`
|
||||
}
|
||||
|
||||
// OutlineMember describes a member of a type (method, property, field, event, constructor).
|
||||
type OutlineMember struct {
|
||||
Kind string `json:"kind"`
|
||||
Signature string `json:"signature"`
|
||||
Modifiers []string `json:"modifiers"`
|
||||
Line int `json:"line"`
|
||||
IsObsolete bool `json:"isObsolete,omitempty"`
|
||||
}
|
||||
|
||||
// Outline requests a structural outline of the given .cs file.
|
||||
// Does not require a solution to be loaded.
|
||||
func (c *Client) Outline(path string) (*OutlineResult, error) {
|
||||
params := map[string]string{"path": path}
|
||||
raw, err := c.proc.Send("outline", params)
|
||||
if err != nil {
|
||||
return nil, wrapRPC("outline", err)
|
||||
}
|
||||
var r OutlineResult
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return nil, fmt.Errorf("outline: 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
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
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
|
||||
}
|
||||
@ -1,108 +0,0 @@
|
||||
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 _, werr := p.stdin.Write(line); werr != nil {
|
||||
return nil, fmt.Errorf("helper write (process may have crashed): %w", werr)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
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 }
|
||||
@ -1,68 +0,0 @@
|
||||
package csharp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ricarneiro/ctx/internal/core"
|
||||
"github.com/ricarneiro/ctx/internal/plugins/csharp/helper"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func outlineCmd(ctx *core.Context) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "outline <file.cs>",
|
||||
Short: "Show structural outline of a C# file (no method bodies)",
|
||||
Long: `Parse a C# source file and emit its structural skeleton:
|
||||
namespaces, types, method signatures, properties, fields, events.
|
||||
Method bodies are omitted — reduces large files by 80–90%.
|
||||
|
||||
Does not require a loaded solution. Works on a single file.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runOutline(ctx, args[0])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runOutline(ctx *core.Context, file string) error {
|
||||
abs, err := resolveFilePath(ctx.WorkDir, file)
|
||||
if err != nil {
|
||||
fmt.Fprintln(ctx.Stderr, err.Error())
|
||||
return errExit
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(strings.ToLower(abs), ".cs") {
|
||||
fmt.Fprintf(ctx.Stderr, "not a C# file: %s\n", abs)
|
||||
return errExit
|
||||
}
|
||||
|
||||
if _, statErr := os.Stat(abs); statErr != nil {
|
||||
fmt.Fprintf(ctx.Stderr, "file not found: %s\n", abs)
|
||||
return errExit
|
||||
}
|
||||
|
||||
client, err := helper.NewClient()
|
||||
if err != nil {
|
||||
fmt.Fprintln(ctx.Stderr, err.Error())
|
||||
return errExit
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
outline, err := client.Outline(abs)
|
||||
if err != nil {
|
||||
fmt.Fprintln(ctx.Stderr, err.Error())
|
||||
return errExit
|
||||
}
|
||||
|
||||
return WriteOutline(ctx.Stdout, outline)
|
||||
}
|
||||
|
||||
func resolveFilePath(workDir, file string) (string, error) {
|
||||
if filepath.IsAbs(file) {
|
||||
return filepath.Clean(file), nil
|
||||
}
|
||||
return filepath.Clean(filepath.Join(workDir, file)), nil
|
||||
}
|
||||
@ -1,111 +0,0 @@
|
||||
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 _, loadErr := client.LoadSolution(slnPath); loadErr != nil {
|
||||
fmt.Fprintln(ctx.Stderr, loadErr.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
|
||||
}
|
||||
@ -1,292 +0,0 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- Data types ---
|
||||
|
||||
type repoInfo struct {
|
||||
branch string
|
||||
upstream string // "origin/main" or "" if no upstream
|
||||
ahead int
|
||||
behind int
|
||||
lastFetch time.Time
|
||||
hasFetch bool
|
||||
}
|
||||
|
||||
type commit struct {
|
||||
hash string
|
||||
unixTs int64
|
||||
author string
|
||||
subject string
|
||||
}
|
||||
|
||||
type fileChange struct {
|
||||
path string
|
||||
added int
|
||||
removed int
|
||||
}
|
||||
|
||||
type workingTree struct {
|
||||
modified []fileChange // unstaged (git diff --numstat)
|
||||
staged []fileChange // staged (git diff --cached --numstat)
|
||||
untracked []string
|
||||
}
|
||||
|
||||
type gitData struct {
|
||||
repo repoInfo
|
||||
commits []commit
|
||||
tree workingTree
|
||||
}
|
||||
|
||||
// --- Git runner ---
|
||||
|
||||
// gitCmd runs a git command in dir and returns trimmed stdout.
|
||||
// Returns an error if git exits non-zero.
|
||||
func gitCmd(dir string, args ...string) (string, error) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
var out, errBuf bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errBuf
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
|
||||
}
|
||||
return strings.TrimRight(out.String(), "\r\n"), nil
|
||||
}
|
||||
|
||||
// --- Top-level collector ---
|
||||
|
||||
// collect gathers all git data in parallel. Returns a friendly error if dir
|
||||
// is not inside a git repository.
|
||||
func collect(dir string) (*gitData, error) {
|
||||
// Verify we're in a repo before spawning goroutines.
|
||||
if _, err := gitCmd(dir, "rev-parse", "--is-inside-work-tree"); err != nil {
|
||||
return nil, fmt.Errorf("not a git repository (run from inside a git repo)")
|
||||
}
|
||||
|
||||
var (
|
||||
data gitData
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
errs []error
|
||||
)
|
||||
|
||||
record := func(err error) {
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
errs = append(errs, err)
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
wg.Add(3)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
info, err := collectRepoInfo(dir)
|
||||
record(err)
|
||||
if err == nil {
|
||||
mu.Lock()
|
||||
data.repo = info
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
commits, err := collectCommits(dir, 5)
|
||||
record(err)
|
||||
if err == nil {
|
||||
mu.Lock()
|
||||
data.commits = commits
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
tree, err := collectWorkingTree(dir)
|
||||
record(err)
|
||||
if err == nil {
|
||||
mu.Lock()
|
||||
data.tree = tree
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
if len(errs) > 0 {
|
||||
return nil, errs[0]
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// --- Individual collectors ---
|
||||
|
||||
func collectRepoInfo(dir string) (repoInfo, error) {
|
||||
var info repoInfo
|
||||
|
||||
branch, err := gitCmd(dir, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
info.branch = branch
|
||||
|
||||
// Ahead/behind vs upstream (fails gracefully if no upstream).
|
||||
if ab, err := gitCmd(dir, "rev-list", "--left-right", "--count", "HEAD...@{u}"); err == nil {
|
||||
parts := strings.Fields(ab)
|
||||
if len(parts) == 2 {
|
||||
info.ahead, _ = strconv.Atoi(parts[0])
|
||||
info.behind, _ = strconv.Atoi(parts[1])
|
||||
if u, err := gitCmd(dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"); err == nil {
|
||||
info.upstream = u
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FETCH_HEAD mtime — find git dir first (handles worktrees, submodules).
|
||||
if gitDir, err := gitCmd(dir, "rev-parse", "--git-dir"); err == nil {
|
||||
if !filepath.IsAbs(gitDir) {
|
||||
gitDir = filepath.Join(dir, gitDir)
|
||||
}
|
||||
if fi, err := os.Stat(filepath.Join(gitDir, "FETCH_HEAD")); err == nil {
|
||||
info.lastFetch = fi.ModTime()
|
||||
info.hasFetch = true
|
||||
}
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func collectCommits(dir string, n int) ([]commit, error) {
|
||||
out, err := gitCmd(dir, "log", fmt.Sprintf("-n%d", n), "--pretty=format:%h|%at|%an|%s")
|
||||
if err != nil || out == "" {
|
||||
// Empty repo or no commits — not an error for our purposes.
|
||||
return nil, nil //nolint:nilerr // intentional: git failures here mean "nothing to show"
|
||||
}
|
||||
var commits []commit
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// SplitN 4 so subject can contain "|".
|
||||
parts := strings.SplitN(line, "|", 4)
|
||||
if len(parts) != 4 {
|
||||
continue
|
||||
}
|
||||
ts, _ := strconv.ParseInt(parts[1], 10, 64)
|
||||
commits = append(commits, commit{
|
||||
hash: parts[0],
|
||||
unixTs: ts,
|
||||
author: parts[2],
|
||||
subject: parts[3],
|
||||
})
|
||||
}
|
||||
return commits, nil
|
||||
}
|
||||
|
||||
func collectWorkingTree(dir string) (workingTree, error) {
|
||||
var (
|
||||
tree workingTree
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
errs []error
|
||||
)
|
||||
|
||||
record := func(err error) {
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
errs = append(errs, err)
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
wg.Add(3)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
changes, err := parseNumstat(dir, false)
|
||||
record(err)
|
||||
mu.Lock()
|
||||
tree.modified = changes
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
changes, err := parseNumstat(dir, true)
|
||||
record(err)
|
||||
mu.Lock()
|
||||
tree.staged = changes
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
untracked, err := parseUntracked(dir)
|
||||
record(err)
|
||||
mu.Lock()
|
||||
tree.untracked = untracked
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
if len(errs) > 0 {
|
||||
return tree, errs[0]
|
||||
}
|
||||
return tree, nil
|
||||
}
|
||||
|
||||
func parseNumstat(dir string, cached bool) ([]fileChange, error) {
|
||||
args := []string{"diff", "--numstat"}
|
||||
if cached {
|
||||
args = append(args, "--cached")
|
||||
}
|
||||
out, err := gitCmd(dir, args...)
|
||||
if err != nil || out == "" {
|
||||
return nil, nil //nolint:nilerr // no changes or not in a git repo — not an error
|
||||
}
|
||||
var changes []fileChange
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "\t", 3)
|
||||
if len(parts) != 3 {
|
||||
continue
|
||||
}
|
||||
// Binary files show "-" — treat as 0.
|
||||
added, _ := strconv.Atoi(parts[0])
|
||||
removed, _ := strconv.Atoi(parts[1])
|
||||
changes = append(changes, fileChange{
|
||||
path: parts[2],
|
||||
added: added,
|
||||
removed: removed,
|
||||
})
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func parseUntracked(dir string) ([]string, error) {
|
||||
out, err := gitCmd(dir, "status", "--porcelain=v1")
|
||||
if err != nil || out == "" {
|
||||
return nil, nil //nolint:nilerr // clean working tree or not in a git repo — not an error
|
||||
}
|
||||
var untracked []string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if len(line) >= 3 && line[0] == '?' && line[1] == '?' {
|
||||
untracked = append(untracked, strings.TrimSpace(line[3:]))
|
||||
}
|
||||
}
|
||||
return untracked, nil
|
||||
}
|
||||
@ -1,293 +0,0 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// formatOutput writes the full markdown report to w.
|
||||
func formatOutput(w io.Writer, data *gitData) {
|
||||
fmt.Fprintln(w, "# Git Context")
|
||||
fmt.Fprintln(w)
|
||||
|
||||
writeMeta(w, data)
|
||||
fmt.Fprintln(w)
|
||||
|
||||
writeCommits(w, data.commits)
|
||||
writeWorkingTree(w, data.tree)
|
||||
writeDiffSummary(w, data.tree)
|
||||
}
|
||||
|
||||
func writeMeta(w io.Writer, data *gitData) {
|
||||
// Branch + upstream
|
||||
branch := data.repo.branch
|
||||
if data.repo.upstream != "" {
|
||||
branch += fmt.Sprintf(" (ahead %d, behind %d vs %s)",
|
||||
data.repo.ahead, data.repo.behind, data.repo.upstream)
|
||||
}
|
||||
fmt.Fprintf(w, "**branch:** %s\n", branch)
|
||||
|
||||
// Status summary
|
||||
m, s, u := len(data.tree.modified), len(data.tree.staged), len(data.tree.untracked)
|
||||
if m == 0 && s == 0 && u == 0 {
|
||||
fmt.Fprintln(w, "**status:** clean")
|
||||
} else {
|
||||
var parts []string
|
||||
if m > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d modified", m))
|
||||
}
|
||||
if s > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d staged", s))
|
||||
}
|
||||
if u > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d untracked", u))
|
||||
}
|
||||
fmt.Fprintf(w, "**status:** %s\n", strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
// Last fetched
|
||||
if data.repo.hasFetch {
|
||||
fmt.Fprintf(w, "**last fetched:** %s\n", relativeTime(data.repo.lastFetch))
|
||||
}
|
||||
}
|
||||
|
||||
func writeCommits(w io.Writer, commits []commit) {
|
||||
if len(commits) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "## Recent commits (last %d)\n", len(commits))
|
||||
for _, c := range commits {
|
||||
t := time.Unix(c.unixTs, 0)
|
||||
fmt.Fprintf(w, "- `%s` (%s, %s) %s\n", c.hash, relativeTime(t), c.author, c.subject)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
func writeWorkingTree(w io.Writer, tree workingTree) {
|
||||
if len(tree.modified) == 0 && len(tree.staged) == 0 && len(tree.untracked) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "## Working tree")
|
||||
|
||||
if len(tree.modified) > 0 {
|
||||
fmt.Fprintf(w, "### Modified (%d)\n", len(tree.modified))
|
||||
for _, f := range tree.modified {
|
||||
fmt.Fprintf(w, "- `%s` (+%d -%d)\n", f.path, f.added, f.removed)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
if len(tree.staged) > 0 {
|
||||
fmt.Fprintf(w, "### Staged (%d)\n", len(tree.staged))
|
||||
for _, f := range tree.staged {
|
||||
fmt.Fprintf(w, "- `%s` (+%d -%d)\n", f.path, f.added, f.removed)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
if len(tree.untracked) > 0 {
|
||||
fmt.Fprintf(w, "### Untracked (%d)\n", len(tree.untracked))
|
||||
for _, u := range tree.untracked {
|
||||
fmt.Fprintf(w, "- `%s`\n", u)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func writeDiffSummary(w io.Writer, tree workingTree) {
|
||||
all := mergeChanges(tree.modified, tree.staged)
|
||||
if len(all) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
totalAdded, totalRemoved := 0, 0
|
||||
for _, f := range all {
|
||||
totalAdded += f.added
|
||||
totalRemoved += f.removed
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, "## Diff summary (unstaged + staged)")
|
||||
fmt.Fprintf(w, "**Total:** +%d -%d across %d file%s\n\n",
|
||||
totalAdded, totalRemoved, len(all), plural(len(all)))
|
||||
|
||||
// By top-level directory (only if files span more than one).
|
||||
byDir := groupByTopDir(all)
|
||||
if len(byDir) > 1 {
|
||||
fmt.Fprintln(w, "### By directory")
|
||||
dirs := make([]string, 0, len(byDir))
|
||||
for d := range byDir {
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
sort.Strings(dirs)
|
||||
for _, d := range dirs {
|
||||
s := byDir[d]
|
||||
label := d + "/"
|
||||
if d == "." {
|
||||
label = "root"
|
||||
}
|
||||
fmt.Fprintf(w, "- `%s`: +%d -%d\n", label, s.added, s.removed)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// Notable changes — top 5 by total lines changed.
|
||||
notable := topBySize(all, 5)
|
||||
fmt.Fprintln(w, "### Notable changes")
|
||||
for _, f := range notable {
|
||||
kind := classify(f)
|
||||
fmt.Fprintf(w, "- `%s`: %s (+%d lines) — %s\n", f.path, kind, f.added, reason(kind))
|
||||
}
|
||||
if len(all) > 5 {
|
||||
more := len(all) - 5
|
||||
fmt.Fprintf(w, "- ...and %d more file%s\n", more, plural(more))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
type dirStat struct{ added, removed int }
|
||||
|
||||
func groupByTopDir(changes []fileChange) map[string]dirStat {
|
||||
result := make(map[string]dirStat)
|
||||
for _, f := range changes {
|
||||
d := topDir(f.path)
|
||||
s := result[d]
|
||||
s.added += f.added
|
||||
s.removed += f.removed
|
||||
result[d] = s
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// topDir returns the first path component (directory), or "." for root files.
|
||||
func topDir(path string) string {
|
||||
// Normalize to forward slashes.
|
||||
clean := filepath.ToSlash(path)
|
||||
idx := strings.Index(clean, "/")
|
||||
if idx == -1 {
|
||||
return "."
|
||||
}
|
||||
return clean[:idx]
|
||||
}
|
||||
|
||||
// mergeChanges deduplicates by path, summing stats for files that appear in both lists.
|
||||
func mergeChanges(a, b []fileChange) []fileChange {
|
||||
merged := make(map[string]fileChange)
|
||||
for _, f := range a {
|
||||
merged[f.path] = f
|
||||
}
|
||||
for _, f := range b {
|
||||
if existing, ok := merged[f.path]; ok {
|
||||
existing.added += f.added
|
||||
existing.removed += f.removed
|
||||
merged[f.path] = existing
|
||||
} else {
|
||||
merged[f.path] = f
|
||||
}
|
||||
}
|
||||
result := make([]fileChange, 0, len(merged))
|
||||
for _, f := range merged {
|
||||
result = append(result, f)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].path < result[j].path })
|
||||
return result
|
||||
}
|
||||
|
||||
// topBySize returns up to n files sorted by (added+removed) descending.
|
||||
func topBySize(changes []fileChange, n int) []fileChange {
|
||||
sorted := make([]fileChange, len(changes))
|
||||
copy(sorted, changes)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
ti := sorted[i].added + sorted[i].removed
|
||||
tj := sorted[j].added + sorted[j].removed
|
||||
return ti > tj
|
||||
})
|
||||
if n > len(sorted) {
|
||||
n = len(sorted)
|
||||
}
|
||||
return sorted[:n]
|
||||
}
|
||||
|
||||
func classify(f fileChange) string {
|
||||
a, r := f.added, f.removed
|
||||
switch {
|
||||
case a > 30:
|
||||
return "large change"
|
||||
case a+r <= 10:
|
||||
return "small change"
|
||||
case r == 0:
|
||||
return "pure addition"
|
||||
case a == 0:
|
||||
return "pure deletion"
|
||||
case a > 20 && r > 20:
|
||||
return "rewrite"
|
||||
default:
|
||||
if r > 0 {
|
||||
ratio := float64(a) / float64(r)
|
||||
if ratio >= 0.5 && ratio <= 2.0 {
|
||||
return "refactor"
|
||||
}
|
||||
}
|
||||
return "mixed change"
|
||||
}
|
||||
}
|
||||
|
||||
func reason(kind string) string {
|
||||
switch kind {
|
||||
case "large change":
|
||||
return "likely new feature"
|
||||
case "small change":
|
||||
return "likely refactor or tweak"
|
||||
case "pure addition":
|
||||
return "new file or additions only"
|
||||
case "pure deletion":
|
||||
return "deletions only"
|
||||
case "rewrite":
|
||||
return "significant rewrite"
|
||||
case "refactor":
|
||||
return "likely refactor"
|
||||
default:
|
||||
return "mixed additions and removals"
|
||||
}
|
||||
}
|
||||
|
||||
// relativeTime returns a human-readable relative duration string.
|
||||
func relativeTime(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
n := int(d.Minutes())
|
||||
return fmt.Sprintf("%d minute%s ago", n, plural(n))
|
||||
case d < 24*time.Hour:
|
||||
n := int(d.Hours())
|
||||
return fmt.Sprintf("%d hour%s ago", n, plural(n))
|
||||
case d < 7*24*time.Hour:
|
||||
n := int(d.Hours() / 24)
|
||||
return fmt.Sprintf("%d day%s ago", n, plural(n))
|
||||
case d < 30*24*time.Hour:
|
||||
n := int(d.Hours() / (24 * 7))
|
||||
return fmt.Sprintf("%d week%s ago", n, plural(n))
|
||||
case d < 365*24*time.Hour:
|
||||
n := int(d.Hours() / (24 * 30))
|
||||
return fmt.Sprintf("%d month%s ago", n, plural(n))
|
||||
default:
|
||||
n := int(d.Hours() / (24 * 365))
|
||||
return fmt.Sprintf("%d year%s ago", n, plural(n))
|
||||
}
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
// Package git implements the ctx git plugin — compact git state summary.
|
||||
// Package git implements the ctx git plugin.
|
||||
// Full implementation: prompt 1.
|
||||
package git
|
||||
|
||||
import (
|
||||
@ -9,36 +10,22 @@ import (
|
||||
)
|
||||
|
||||
func init() {
|
||||
core.Register(&plugin{})
|
||||
core.Register(&gitPlugin{})
|
||||
}
|
||||
|
||||
type plugin struct{}
|
||||
type gitPlugin struct{}
|
||||
|
||||
func (p *plugin) Name() string { return "git" }
|
||||
func (p *plugin) Version() string { return "0.1.0" }
|
||||
func (p *plugin) ShortDescription() string { return "Summarize git state in compact markdown" }
|
||||
func (g *gitPlugin) Name() string { return "git" }
|
||||
func (g *gitPlugin) Version() string { return "0.0.1" }
|
||||
func (g *gitPlugin) ShortDescription() string { return "Git repository summary for Claude" }
|
||||
|
||||
func (p *plugin) Command(ctx *core.Context) *cobra.Command {
|
||||
func (g *gitPlugin) Command(ctx *core.Context) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "git",
|
||||
Short: p.ShortDescription(),
|
||||
Long: `Emit a compact markdown summary of the current git repository:
|
||||
branch, ahead/behind upstream, recent commits, working tree changes,
|
||||
and a diff summary grouped by directory.
|
||||
|
||||
Output goes to stdout. Pipe it into Claude or save it to a file.`,
|
||||
Short: g.ShortDescription(),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return run(ctx)
|
||||
fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in prompt 1")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx *core.Context) error {
|
||||
data, err := collect(ctx.WorkDir)
|
||||
if err != nil {
|
||||
fmt.Fprintln(ctx.Stderr, err.Error())
|
||||
return fmt.Errorf("exit 1") // non-nil → os.Exit(1); SilenceErrors suppresses print
|
||||
}
|
||||
formatOutput(ctx.Stdout, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -23,9 +23,6 @@ func (r *reactPlugin) Command(ctx *core.Context) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "react",
|
||||
Short: r.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 a future prompt")
|
||||
return nil
|
||||
|
||||
4
tools/roslyn-helper/.gitignore
vendored
4
tools/roslyn-helper/.gitignore
vendored
@ -1,4 +0,0 @@
|
||||
bin/
|
||||
obj/
|
||||
publish/
|
||||
*.user
|
||||
@ -1,62 +0,0 @@
|
||||
# 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.
|
||||
@ -1,39 +0,0 @@
|
||||
|
||||
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
|
||||
@ -1,96 +0,0 @@
|
||||
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),
|
||||
["outline"] = new OutlineHandler(),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@ -1,281 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using RoslynHelper.Models;
|
||||
|
||||
namespace RoslynHelper.JsonRpc.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Parses a single .cs file and returns its structural outline:
|
||||
/// namespaces, types, method signatures (no bodies), properties, fields, events.
|
||||
/// Does NOT require a solution to be loaded — works on a single file.
|
||||
/// </summary>
|
||||
public sealed class OutlineHandler : IHandler
|
||||
{
|
||||
public async Task<object> HandleAsync(JsonElement? @params)
|
||||
{
|
||||
if (@params is not { } p)
|
||||
throw new KnownException("E_INVALID_PARAMS", "params required for outline");
|
||||
|
||||
if (!p.TryGetProperty("path", out var pathEl) || pathEl.ValueKind != JsonValueKind.String)
|
||||
throw new KnownException("E_INVALID_PARAMS", "params.path (string) is required");
|
||||
|
||||
var path = pathEl.GetString()!;
|
||||
if (!File.Exists(path))
|
||||
throw new KnownException("E_NOT_FOUND", $"file not found: {path}");
|
||||
|
||||
var source = await File.ReadAllTextAsync(path, System.Text.Encoding.UTF8);
|
||||
var tree = CSharpSyntaxTree.ParseText(source, path: path);
|
||||
var root = (CompilationUnitSyntax)await tree.GetRootAsync();
|
||||
|
||||
bool hasSyntaxErrors = tree.GetDiagnostics()
|
||||
.Any(d => d.Severity == DiagnosticSeverity.Error);
|
||||
|
||||
int lineCount = source.Split('\n').Length;
|
||||
|
||||
// Top-level usings
|
||||
var usings = CollectUsings(root.Usings);
|
||||
string ns = "";
|
||||
var types = new List<OutlineTypeModel>();
|
||||
bool hasTopLevel = false;
|
||||
|
||||
foreach (var member in root.Members)
|
||||
{
|
||||
switch (member)
|
||||
{
|
||||
case NamespaceDeclarationSyntax blockNs:
|
||||
ns = blockNs.Name.ToString();
|
||||
usings.AddRange(CollectUsings(blockNs.Usings));
|
||||
foreach (var m in blockNs.Members)
|
||||
CollectType(m, types);
|
||||
break;
|
||||
|
||||
case FileScopedNamespaceDeclarationSyntax fileScopedNs:
|
||||
ns = fileScopedNs.Name.ToString();
|
||||
usings.AddRange(CollectUsings(fileScopedNs.Usings));
|
||||
foreach (var m in fileScopedNs.Members)
|
||||
CollectType(m, types);
|
||||
break;
|
||||
|
||||
case GlobalStatementSyntax:
|
||||
hasTopLevel = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
CollectType(member, types);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Synthetic Program type for top-level statement files
|
||||
if (hasTopLevel)
|
||||
{
|
||||
types.Insert(0, new OutlineTypeModel(
|
||||
"class", "Program (top-level program)", [], [],
|
||||
[new OutlineMemberModel("method", "static void Main(string[] args)", ["static"], 1, false)],
|
||||
[]));
|
||||
}
|
||||
|
||||
return new OutlineResult(path, ns, lineCount, usings.Distinct().ToList(), types, hasSyntaxErrors);
|
||||
}
|
||||
|
||||
// ─── Usings ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static List<string> CollectUsings(SyntaxList<UsingDirectiveSyntax> usings) =>
|
||||
usings
|
||||
.Where(u => u.Alias is null)
|
||||
.Select(u => u.Name?.ToString() ?? "")
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.ToList();
|
||||
|
||||
// ─── Type dispatch ───────────────────────────────────────────────────────
|
||||
|
||||
private static void CollectType(MemberDeclarationSyntax member, List<OutlineTypeModel> target)
|
||||
{
|
||||
switch (member)
|
||||
{
|
||||
case ClassDeclarationSyntax cls:
|
||||
target.Add(ExtractTypeDecl("class", cls.Identifier.Text,
|
||||
cls.TypeParameterList, null, cls.Modifiers, cls.BaseList, cls.Members));
|
||||
break;
|
||||
|
||||
case InterfaceDeclarationSyntax iface:
|
||||
target.Add(ExtractTypeDecl("interface", iface.Identifier.Text,
|
||||
iface.TypeParameterList, null, iface.Modifiers, iface.BaseList, iface.Members));
|
||||
break;
|
||||
|
||||
case StructDeclarationSyntax str:
|
||||
target.Add(ExtractTypeDecl("struct", str.Identifier.Text,
|
||||
str.TypeParameterList, null, str.Modifiers, str.BaseList, str.Members));
|
||||
break;
|
||||
|
||||
case RecordDeclarationSyntax rec:
|
||||
{
|
||||
var recKind = rec.ClassOrStructKeyword.IsKind(SyntaxKind.StructKeyword)
|
||||
? "record struct" : "record";
|
||||
target.Add(ExtractTypeDecl(recKind, rec.Identifier.Text,
|
||||
rec.TypeParameterList, rec.ParameterList, rec.Modifiers, rec.BaseList, rec.Members));
|
||||
break;
|
||||
}
|
||||
|
||||
case EnumDeclarationSyntax en:
|
||||
target.Add(ExtractEnum(en));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Type extraction ─────────────────────────────────────────────────────
|
||||
|
||||
private static OutlineTypeModel ExtractTypeDecl(
|
||||
string kind,
|
||||
string name,
|
||||
TypeParameterListSyntax? typeParams,
|
||||
ParameterListSyntax? recordParams,
|
||||
SyntaxTokenList modifiers,
|
||||
BaseListSyntax? baseList,
|
||||
SyntaxList<MemberDeclarationSyntax> members)
|
||||
{
|
||||
var mods = modifiers.Select(m => m.Text).ToList();
|
||||
var baseTypes = baseList?.Types.Select(t => t.Type.ToString()).ToList() ?? [];
|
||||
var fullName = name
|
||||
+ (typeParams?.ToString() ?? "")
|
||||
+ (recordParams?.ToString() ?? "");
|
||||
|
||||
var memberModels = new List<OutlineMemberModel>();
|
||||
var nested = new List<OutlineTypeModel>();
|
||||
|
||||
foreach (var member in members)
|
||||
{
|
||||
switch (member)
|
||||
{
|
||||
case MethodDeclarationSyntax m:
|
||||
memberModels.Add(ExtractMethod(m));
|
||||
break;
|
||||
case ConstructorDeclarationSyntax c:
|
||||
memberModels.Add(ExtractConstructor(c));
|
||||
break;
|
||||
case PropertyDeclarationSyntax prop:
|
||||
memberModels.Add(ExtractProperty(prop));
|
||||
break;
|
||||
case FieldDeclarationSyntax f:
|
||||
memberModels.AddRange(ExtractFields(f));
|
||||
break;
|
||||
case EventDeclarationSyntax e:
|
||||
memberModels.Add(ExtractEventDecl(e));
|
||||
break;
|
||||
case EventFieldDeclarationSyntax ef:
|
||||
memberModels.AddRange(ExtractEventFields(ef));
|
||||
break;
|
||||
case ClassDeclarationSyntax _:
|
||||
case InterfaceDeclarationSyntax _:
|
||||
case StructDeclarationSyntax _:
|
||||
case RecordDeclarationSyntax _:
|
||||
case EnumDeclarationSyntax _:
|
||||
CollectType(member, nested);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new OutlineTypeModel(kind, fullName, mods, baseTypes, memberModels, nested);
|
||||
}
|
||||
|
||||
private static OutlineTypeModel ExtractEnum(EnumDeclarationSyntax en)
|
||||
{
|
||||
var mods = en.Modifiers.Select(m => m.Text).ToList();
|
||||
var members = en.Members
|
||||
.Select(m => new OutlineMemberModel("enumValue", m.Identifier.Text, [], GetLine(m), false))
|
||||
.ToList();
|
||||
return new OutlineTypeModel("enum", en.Identifier.Text, mods, [], members, []);
|
||||
}
|
||||
|
||||
// ─── Member extraction ───────────────────────────────────────────────────
|
||||
|
||||
private static OutlineMemberModel ExtractMethod(MethodDeclarationSyntax m)
|
||||
{
|
||||
var typeParams = m.TypeParameterList?.ToString() ?? "";
|
||||
var sig = $"{m.ReturnType} {m.Identifier.Text}{typeParams}{m.ParameterList}".Trim();
|
||||
var mods = m.Modifiers.Select(x => x.Text).ToList();
|
||||
return new OutlineMemberModel("method", sig, mods, GetLine(m), HasObsolete(m.AttributeLists));
|
||||
}
|
||||
|
||||
private static OutlineMemberModel ExtractConstructor(ConstructorDeclarationSyntax c)
|
||||
{
|
||||
var sig = $"{c.Identifier.Text}{c.ParameterList}".Trim();
|
||||
var mods = c.Modifiers.Select(x => x.Text).ToList();
|
||||
return new OutlineMemberModel("constructor", sig, mods, GetLine(c), HasObsolete(c.AttributeLists));
|
||||
}
|
||||
|
||||
private static OutlineMemberModel ExtractProperty(PropertyDeclarationSyntax p)
|
||||
{
|
||||
string accessors;
|
||||
if (p.AccessorList is { } al)
|
||||
{
|
||||
var parts = al.Accessors.Select(a =>
|
||||
{
|
||||
var accMods = a.Modifiers.Any() ? a.Modifiers.ToString() + " " : "";
|
||||
return accMods + a.Keyword.Text;
|
||||
});
|
||||
accessors = "{ " + string.Join("; ", parts) + "; }";
|
||||
}
|
||||
else
|
||||
{
|
||||
accessors = "=> ..."; // expression-bodied
|
||||
}
|
||||
|
||||
var sig = $"{p.Type} {p.Identifier.Text} {accessors}".Trim();
|
||||
var mods = p.Modifiers.Select(x => x.Text).ToList();
|
||||
return new OutlineMemberModel("property", sig, mods, GetLine(p), HasObsolete(p.AttributeLists));
|
||||
}
|
||||
|
||||
private static IEnumerable<OutlineMemberModel> ExtractFields(FieldDeclarationSyntax f)
|
||||
{
|
||||
var mods = f.Modifiers.Select(x => x.Text).ToList();
|
||||
var typeName = f.Declaration.Type.ToString();
|
||||
var isConst = mods.Contains("const");
|
||||
var obs = HasObsolete(f.AttributeLists);
|
||||
var line = GetLine(f);
|
||||
|
||||
foreach (var v in f.Declaration.Variables)
|
||||
{
|
||||
var sig = isConst && v.Initializer is { } init
|
||||
? $"{typeName} {v.Identifier.Text} = {init.Value}"
|
||||
: $"{typeName} {v.Identifier.Text}";
|
||||
yield return new OutlineMemberModel("field", sig.Trim(), mods, line, obs);
|
||||
}
|
||||
}
|
||||
|
||||
private static OutlineMemberModel ExtractEventDecl(EventDeclarationSyntax e)
|
||||
{
|
||||
var sig = $"event {e.Type} {e.Identifier.Text}".Trim();
|
||||
var mods = e.Modifiers.Select(x => x.Text).ToList();
|
||||
return new OutlineMemberModel("event", sig, mods, GetLine(e), HasObsolete(e.AttributeLists));
|
||||
}
|
||||
|
||||
private static IEnumerable<OutlineMemberModel> ExtractEventFields(EventFieldDeclarationSyntax ef)
|
||||
{
|
||||
var mods = ef.Modifiers.Select(x => x.Text).ToList();
|
||||
var typeName = ef.Declaration.Type.ToString();
|
||||
var obs = HasObsolete(ef.AttributeLists);
|
||||
var line = GetLine(ef);
|
||||
|
||||
foreach (var v in ef.Declaration.Variables)
|
||||
{
|
||||
yield return new OutlineMemberModel(
|
||||
"event", $"event {typeName} {v.Identifier.Text}".Trim(), mods, line, obs);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Returns true if obsolete, null otherwise (null omitted by WhenWritingNull in dispatcher)
|
||||
private static bool? HasObsolete(SyntaxList<AttributeListSyntax> attrs)
|
||||
{
|
||||
var found = attrs.SelectMany(al => al.Attributes)
|
||||
.Any(a => a.Name.ToString() is "Obsolete" or "ObsoleteAttribute");
|
||||
return found ? true : null;
|
||||
}
|
||||
|
||||
private static int GetLine(SyntaxNode node) =>
|
||||
node.GetLocation().GetLineSpan().StartLinePosition.Line + 1;
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
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" });
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
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
|
||||
);
|
||||
@ -1,10 +0,0 @@
|
||||
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
|
||||
);
|
||||
@ -1,10 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace RoslynHelper.Models;
|
||||
|
||||
public sealed record OutlineResult(
|
||||
[property: JsonPropertyName("path")] string Path,
|
||||
[property: JsonPropertyName("namespace")] string Namespace,
|
||||
[property: JsonPropertyName("lineCount")] int LineCount,
|
||||
[property: JsonPropertyName("usings")] List<string> Usings,
|
||||
[property: JsonPropertyName("types")] List<OutlineTypeModel> Types,
|
||||
[property: JsonPropertyName("hasSyntaxErrors")] bool HasSyntaxErrors
|
||||
);
|
||||
|
||||
public sealed record OutlineTypeModel(
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("modifiers")] List<string> Modifiers,
|
||||
[property: JsonPropertyName("baseTypes")] List<string> BaseTypes,
|
||||
[property: JsonPropertyName("members")] List<OutlineMemberModel> Members,
|
||||
[property: JsonPropertyName("nested")] List<OutlineTypeModel> Nested
|
||||
);
|
||||
|
||||
/// <remarks>
|
||||
/// <c>IsObsolete</c> is nullable so that <c>null</c> (not obsolete) is omitted
|
||||
/// from JSON by the dispatcher's <c>WhenWritingNull</c> policy.
|
||||
/// </remarks>
|
||||
public sealed record OutlineMemberModel(
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonPropertyName("signature")] string Signature,
|
||||
[property: JsonPropertyName("modifiers")] List<string> Modifiers,
|
||||
[property: JsonPropertyName("line")] int Line,
|
||||
[property: JsonPropertyName("isObsolete")] bool? IsObsolete = null
|
||||
);
|
||||
@ -1,102 +0,0 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
<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>
|
||||
<ItemGroup>
|
||||
<!-- Pure C# syntax parser — no MSBuild, no SDK coupling.
|
||||
Used by OutlineHandler for CSharpSyntaxTree.ParseText + SyntaxWalker.
|
||||
Unlike Workspaces.MSBuild (removed in Prompt 3), this package
|
||||
has no runtime dependency on the installed SDK. -->
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.13.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@ -1,119 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
{"id": 1, "method": "loadSolution", "params": {"path": "C:\\gocode\\ctx\\tools\\roslyn-helper\\RoslynHelper.sln"}}
|
||||
{"id": 2, "method": "projectSummary", "params": {}}
|
||||
{"id": 3, "method": "shutdown", "params": {}}
|
||||
Loading…
Reference in New Issue
Block a user