Compare commits
10 Commits
69cadb4ea6
...
4105f02468
| Author | SHA1 | Date | |
|---|---|---|---|
| 4105f02468 | |||
| 6ace5b348e | |||
| 10f8ab530c | |||
| 542315d1c8 | |||
| 22f6e8fde9 | |||
| 15dc1b6b2f | |||
| 59cb2b5ddb | |||
| e67234345b | |||
| 8e022bf5a5 | |||
| 6852be77ff |
36
.githooks/pre-commit
Normal file
36
.githooks/pre-commit
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
#!/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"
|
||||||
25
.githooks/pre-commit.ps1
Normal file
25
.githooks/pre-commit.ps1
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# 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
Normal file
55
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
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,6 +27,11 @@ coverage.html
|
|||||||
# Claude Code local settings
|
# Claude Code local settings
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Roslyn helper artifacts
|
||||||
|
tools/roslyn-helper/**/bin/
|
||||||
|
tools/roslyn-helper/**/obj/
|
||||||
|
tools/roslyn-helper/publish/
|
||||||
|
|
||||||
# OS artifacts
|
# OS artifacts
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
69
.golangci.yml
Normal file
69
.golangci.yml
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
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
|
||||||
244
README.md
244
README.md
@ -1,3 +1,145 @@
|
|||||||
|
# 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
|
# ctx — anti-tokens CLI for Claude Code
|
||||||
|
|
||||||
> Analyze your codebase locally. Feed Claude dense summaries, not raw files.
|
> Analyze your codebase locally. Feed Claude dense summaries, not raw files.
|
||||||
@ -6,22 +148,36 @@
|
|||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
Every token Claude reads costs money and burns context window. When working on
|
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.
|
||||||
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
|
`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.
|
||||||
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.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
**1. Install Go**
|
||||||
|
Download from https://go.dev/dl/ and run the installer. Requires Go 1.22+.
|
||||||
|
|
||||||
|
**2. Install ctx**
|
||||||
```sh
|
```sh
|
||||||
go install github.com/ricarneiro/ctx/cmd/ctx@latest
|
go install github.com/ricarneiro/ctx/cmd/ctx@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Go 1.22+. Binaries for common platforms will be published after the
|
**3. Verify**
|
||||||
MVP is validated.
|
```sh
|
||||||
|
ctx --version
|
||||||
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@ -49,41 +205,65 @@ ctx csharp project | pbcopy # macOS
|
|||||||
ctx csharp project | clip # Windows
|
ctx csharp project | clip # Windows
|
||||||
```
|
```
|
||||||
|
|
||||||
Or reference it in a `CLAUDE.md`:
|
## Integrating with Claude Code
|
||||||
|
|
||||||
```markdown
|
To have Claude Code use `ctx` automatically, add `ctx` to your Windows PATH and include the instructions below in your project's `CLAUDE.md`.
|
||||||
Run `ctx csharp project` to get the project overview before making changes.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
### 1. Add ctx to PATH on Windows
|
||||||
|
|
||||||
`ctx` uses a plugin system. Each stack (`git`, `csharp`, `react`, `auto`) is a
|
1. Copy `ctx.exe` to a fixed folder (e.g. `C:\tools\ctx\`)
|
||||||
plugin that implements the `core.Plugin` interface and registers itself via
|
2. Open **System Settings → Environment Variables**
|
||||||
`init()`.
|
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
|
||||||
|
|
||||||
```
|
```
|
||||||
core.Plugin interface
|
ctx csharp project # solution overview: projects, refs, packages, structure
|
||||||
Name() string
|
ctx csharp outline <file.cs> # class/method signatures without bodies (~85% smaller)
|
||||||
Version() string
|
ctx csharp errors # dotnet build filtered to errors + top warnings (~90% smaller)
|
||||||
ShortDescription() string
|
ctx git # branch, status, recent commits, diff summary
|
||||||
Command(ctx *core.Context) *cobra.Command
|
|
||||||
```
|
```
|
||||||
|
|
||||||
In the MVP, plugins are compiled into the binary. Future plan: migrate to
|
### Rule of thumb
|
||||||
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
|
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.
|
||||||
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
|
## Contributing
|
||||||
|
|
||||||
Contributions welcome. See `CONTRIBUTING.md` (coming soon) for guidelines.
|
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.
|
||||||
Open an issue first if you plan a large change.
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/ricarneiro/CTX.git
|
||||||
|
cd CTX
|
||||||
|
go build -o ctx.exe ./cmd/ctx
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,7 @@ Each subcommand targets a specific stack:
|
|||||||
|
|
||||||
Output is always UTF-8 markdown on stdout, suitable for piping into Claude.`,
|
Output is always UTF-8 markdown on stdout, suitable for piping into Claude.`,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true, // plugins print their own errors to stderr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute runs the root command. Called by cmd/ctx/main.go.
|
// Execute runs the root command. Called by cmd/ctx/main.go.
|
||||||
|
|||||||
@ -1,31 +1,178 @@
|
|||||||
// Package auto implements the ctx auto plugin.
|
// Package auto implements the ctx auto plugin — stack detection and routing.
|
||||||
// Full implementation: prompt 2.
|
|
||||||
package auto
|
package auto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/ricarneiro/ctx/internal/core"
|
"github.com/ricarneiro/ctx/internal/core"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
core.Register(&autoPlugin{})
|
core.Register(&plugin{})
|
||||||
}
|
}
|
||||||
|
|
||||||
type autoPlugin struct{}
|
type plugin struct{}
|
||||||
|
|
||||||
func (a *autoPlugin) Name() string { return "auto" }
|
func (p *plugin) Name() string { return "auto" }
|
||||||
func (a *autoPlugin) Version() string { return "0.0.1" }
|
func (p *plugin) Version() string { return "0.1.0" }
|
||||||
func (a *autoPlugin) ShortDescription() string { return "Auto-detect project stack and emit context" }
|
func (p *plugin) ShortDescription() string { return "Detect stack and route to appropriate plugin" }
|
||||||
|
|
||||||
func (a *autoPlugin) Command(ctx *core.Context) *cobra.Command {
|
func (p *plugin) Command(ctx *core.Context) *cobra.Command {
|
||||||
return &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "auto",
|
Use: "auto",
|
||||||
Short: a.ShortDescription(),
|
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.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in prompt 2")
|
return runDetect(ctx)
|
||||||
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)
|
||||||
|
}
|
||||||
|
|||||||
258
internal/plugins/auto/detector.go
Normal file
258
internal/plugins/auto/detector.go
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
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,10 +1,7 @@
|
|||||||
// Package csharp implements the ctx csharp plugin.
|
// Package csharp implements the ctx csharp plugin — .NET solution analysis via Roslyn helper.
|
||||||
// Full implementation: prompts 4–6 (requires Roslyn helper from prompt 3).
|
|
||||||
package csharp
|
package csharp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/ricarneiro/ctx/internal/core"
|
"github.com/ricarneiro/ctx/internal/core"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@ -16,16 +13,20 @@ func init() {
|
|||||||
type csharpPlugin struct{}
|
type csharpPlugin struct{}
|
||||||
|
|
||||||
func (c *csharpPlugin) Name() string { return "csharp" }
|
func (c *csharpPlugin) Name() string { return "csharp" }
|
||||||
func (c *csharpPlugin) Version() string { return "0.0.1" }
|
func (c *csharpPlugin) Version() string { return "0.1.0" }
|
||||||
func (c *csharpPlugin) ShortDescription() string { return "C# / .NET project analysis via Roslyn" }
|
func (c *csharpPlugin) ShortDescription() string { return "C# / .NET project analysis via Roslyn" }
|
||||||
|
|
||||||
func (c *csharpPlugin) Command(ctx *core.Context) *cobra.Command {
|
func (c *csharpPlugin) Command(ctx *core.Context) *cobra.Command {
|
||||||
return &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "csharp",
|
Use: "csharp",
|
||||||
Short: c.ShortDescription(),
|
Short: c.ShortDescription(),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
Long: `Analyze C# / .NET projects and emit compact markdown summaries
|
||||||
fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in prompt 4")
|
optimized for Claude Code consumption.
|
||||||
return nil
|
|
||||||
},
|
Requires the Roslyn helper (ctx-roslyn-helper) to be built.
|
||||||
|
See 'ctx csharp project --help' for details.`,
|
||||||
}
|
}
|
||||||
|
cmd.AddCommand(projectCmd(ctx))
|
||||||
|
cmd.AddCommand(outlineCmd(ctx))
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
425
internal/plugins/csharp/format.go
Normal file
425
internal/plugins/csharp/format.go
Normal file
@ -0,0 +1,425 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
185
internal/plugins/csharp/helper/client.go
Normal file
185
internal/plugins/csharp/helper/client.go
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
86
internal/plugins/csharp/helper/locate.go
Normal file
86
internal/plugins/csharp/helper/locate.go
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
package helper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
const helperBinary = "ctx-roslyn-helper"
|
||||||
|
|
||||||
|
// ErrHelperNotFound is returned when the Roslyn helper binary cannot be located.
|
||||||
|
var ErrHelperNotFound = errors.New("roslyn helper not found")
|
||||||
|
|
||||||
|
// LocateHelper searches for the ctx-roslyn-helper binary using four strategies:
|
||||||
|
// 1. CTX_ROSLYN_HELPER environment variable
|
||||||
|
// 2. Same directory as the running ctx binary
|
||||||
|
// 3. PATH
|
||||||
|
// 4. tools/roslyn-helper/publish/ relative to working directory
|
||||||
|
//
|
||||||
|
// Returns the absolute path or ErrHelperNotFound with a diagnostic message.
|
||||||
|
func LocateHelper() (string, error) {
|
||||||
|
name := helperBinary
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
name += ".exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
tried := []string{}
|
||||||
|
|
||||||
|
// 1. Environment variable
|
||||||
|
if env := os.Getenv("CTX_ROSLYN_HELPER"); env != "" {
|
||||||
|
if fileExists(env) {
|
||||||
|
return env, nil
|
||||||
|
}
|
||||||
|
tried = append(tried, fmt.Sprintf("$CTX_ROSLYN_HELPER = %s (not found)", env))
|
||||||
|
} else {
|
||||||
|
tried = append(tried, "$CTX_ROSLYN_HELPER (not set)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Same directory as ctx binary
|
||||||
|
if exePath, err := os.Executable(); err == nil {
|
||||||
|
exeDir := filepath.Dir(exePath)
|
||||||
|
candidate := filepath.Join(exeDir, name)
|
||||||
|
if fileExists(candidate) {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
tried = append(tried, fmt.Sprintf("%s (not found)", exeDir))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. PATH
|
||||||
|
if found, err := exec.LookPath(name); err == nil {
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
tried = append(tried, "PATH (not found)")
|
||||||
|
|
||||||
|
// 4. Dev fallback: tools/roslyn-helper/publish/ relative to working dir
|
||||||
|
if cwd, err := os.Getwd(); err == nil {
|
||||||
|
candidate := filepath.Join(cwd, "tools", "roslyn-helper", "publish", name)
|
||||||
|
if fileExists(candidate) {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
tried = append(tried, fmt.Sprintf("tools/roslyn-helper/publish/ (not found)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("%w\n\nLooked in:\n - %s\n - %s\n - %s\n - %s\n\nTo build the helper, run:\n cd tools\\roslyn-helper\n dotnet publish src/RoslynHelper -c Release -r win-x64 --self-contained false -o publish/",
|
||||||
|
ErrHelperNotFound,
|
||||||
|
safeIdx(tried, 0, "$CTX_ROSLYN_HELPER (not set)"),
|
||||||
|
safeIdx(tried, 1, "ctx.exe directory"),
|
||||||
|
safeIdx(tried, 2, "PATH"),
|
||||||
|
safeIdx(tried, 3, "tools/roslyn-helper/publish/"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeIdx(s []string, i int, fallback string) string {
|
||||||
|
if i < len(s) {
|
||||||
|
return s[i]
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
108
internal/plugins/csharp/helper/process.go
Normal file
108
internal/plugins/csharp/helper/process.go
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
package helper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Process manages the lifetime of the roslyn-helper subprocess.
|
||||||
|
// Calls are sequential — no concurrency within a single Process.
|
||||||
|
type Process struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
stdin io.WriteCloser
|
||||||
|
stdout *bufio.Reader
|
||||||
|
nextID atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start launches the helper subprocess and returns a Process ready to use.
|
||||||
|
func Start(helperPath string) (*Process, error) {
|
||||||
|
cmd := exec.Command(helperPath)
|
||||||
|
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("helper stdin pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdoutPipe, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("helper stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stderrPipe, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("helper stderr pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, fmt.Errorf("helper start: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain stderr in background to prevent blocking.
|
||||||
|
go func() { _, _ = io.Copy(io.Discard, stderrPipe) }()
|
||||||
|
|
||||||
|
p := &Process{
|
||||||
|
cmd: cmd,
|
||||||
|
stdin: stdin,
|
||||||
|
stdout: bufio.NewReader(stdoutPipe),
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends a JSON-RPC request and returns the raw result JSON.
|
||||||
|
// Returns an error if the helper returns an RpcError or dies.
|
||||||
|
func (p *Process) Send(method string, params interface{}) (json.RawMessage, error) {
|
||||||
|
id := int(p.nextID.Add(1))
|
||||||
|
|
||||||
|
var rawParams json.RawMessage
|
||||||
|
if params != nil {
|
||||||
|
var err error
|
||||||
|
rawParams, err = json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal params: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := Request{ID: id, Method: method, Params: rawParams}
|
||||||
|
line, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
line = append(line, '\n')
|
||||||
|
|
||||||
|
if _, 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
|
||||||
|
}
|
||||||
26
internal/plugins/csharp/helper/protocol.go
Normal file
26
internal/plugins/csharp/helper/protocol.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package helper
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// Request is a newline-delimited JSON-RPC request sent to the helper process.
|
||||||
|
type Request struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params json.RawMessage `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response is a newline-delimited JSON-RPC response from the helper process.
|
||||||
|
type Response struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
|
Error *RPCError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RpcError is a structured error from the helper process.
|
||||||
|
type RPCError struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data json.RawMessage `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RPCError) Error() string { return e.Code + ": " + e.Message }
|
||||||
68
internal/plugins/csharp/outline.go
Normal file
68
internal/plugins/csharp/outline.go
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
111
internal/plugins/csharp/project.go
Normal file
111
internal/plugins/csharp/project.go
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
package csharp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/ricarneiro/ctx/internal/core"
|
||||||
|
"github.com/ricarneiro/ctx/internal/plugins/csharp/helper"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errExit is a sentinel used to trigger os.Exit(1) via the SilenceErrors flow.
|
||||||
|
// The real error message has already been printed to ctx.Stderr.
|
||||||
|
var errExit = fmt.Errorf("exit 1")
|
||||||
|
|
||||||
|
func projectCmd(ctx *core.Context) *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "project",
|
||||||
|
Short: "Summarize the .NET solution in compact markdown",
|
||||||
|
Long: `Analyze the .NET solution in the current directory and emit a compact
|
||||||
|
markdown summary suitable for Claude Code consumption.
|
||||||
|
|
||||||
|
Requires the Roslyn helper (ctx-roslyn-helper) to be built and accessible.
|
||||||
|
Set CTX_ROSLYN_HELPER to the exact path, or build it alongside ctx.exe.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runProject(ctx)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runProject(ctx *core.Context) error {
|
||||||
|
slnPath, err := findSolution(ctx.WorkDir, ctx)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(ctx.Stderr, err.Error())
|
||||||
|
return errExit
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := helper.NewClient()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(ctx.Stderr, err.Error())
|
||||||
|
return errExit
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
if _, 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
|
||||||
|
}
|
||||||
292
internal/plugins/git/collect.go
Normal file
292
internal/plugins/git/collect.go
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
293
internal/plugins/git/format.go
Normal file
293
internal/plugins/git/format.go
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
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,5 +1,4 @@
|
|||||||
// Package git implements the ctx git plugin.
|
// Package git implements the ctx git plugin — compact git state summary.
|
||||||
// Full implementation: prompt 1.
|
|
||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -10,22 +9,36 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
core.Register(&gitPlugin{})
|
core.Register(&plugin{})
|
||||||
}
|
}
|
||||||
|
|
||||||
type gitPlugin struct{}
|
type plugin struct{}
|
||||||
|
|
||||||
func (g *gitPlugin) Name() string { return "git" }
|
func (p *plugin) Name() string { return "git" }
|
||||||
func (g *gitPlugin) Version() string { return "0.0.1" }
|
func (p *plugin) Version() string { return "0.1.0" }
|
||||||
func (g *gitPlugin) ShortDescription() string { return "Git repository summary for Claude" }
|
func (p *plugin) ShortDescription() string { return "Summarize git state in compact markdown" }
|
||||||
|
|
||||||
func (g *gitPlugin) Command(ctx *core.Context) *cobra.Command {
|
func (p *plugin) Command(ctx *core.Context) *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "git",
|
Use: "git",
|
||||||
Short: g.ShortDescription(),
|
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.`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in prompt 1")
|
return run(ctx)
|
||||||
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,6 +23,9 @@ func (r *reactPlugin) Command(ctx *core.Context) *cobra.Command {
|
|||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "react",
|
Use: "react",
|
||||||
Short: r.ShortDescription(),
|
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 {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in a future prompt")
|
fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in a future prompt")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
4
tools/roslyn-helper/.gitignore
vendored
Normal file
4
tools/roslyn-helper/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
publish/
|
||||||
|
*.user
|
||||||
62
tools/roslyn-helper/README.md
Normal file
62
tools/roslyn-helper/README.md
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# ctx-roslyn-helper
|
||||||
|
|
||||||
|
C# subprocess invoked by `ctx csharp` commands. Loads .NET solutions with
|
||||||
|
Roslyn and answers JSON-RPC queries over stdin/stdout.
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
|
||||||
|
One JSON object per line, UTF-8 without BOM.
|
||||||
|
|
||||||
|
**Request:** `{"id": 1, "method": "ping", "params": {}}`
|
||||||
|
**Success:** `{"id": 1, "result": {...}}`
|
||||||
|
**Error:** `{"id": 1, "error": {"code": "E_NOT_FOUND", "message": "..."}}`
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
| Method | Params | Description |
|
||||||
|
|------------------|---------------------------|------------------------------------|
|
||||||
|
| `ping` | `{}` | Health check, returns version |
|
||||||
|
| `loadSolution` | `{"path": "C:\\...\\x.sln"}` | Load solution into workspace |
|
||||||
|
| `projectSummary` | `{}` | Summary of loaded solution |
|
||||||
|
| `shutdown` | `{}` | Exit cleanly (no response written) |
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Requires: .NET 8+ SDK.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dotnet build src/RoslynHelper -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publish
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dotnet publish src/RoslynHelper -c Release -r win-x64 --self-contained false -o publish/
|
||||||
|
```
|
||||||
|
|
||||||
|
Output: `publish/ctx-roslyn-helper.exe`
|
||||||
|
|
||||||
|
## Manual test
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd publish
|
||||||
|
./ctx-roslyn-helper.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
Then type line by line:
|
||||||
|
|
||||||
|
```
|
||||||
|
{"id": 1, "method": "ping", "params": {}}
|
||||||
|
{"id": 2, "method": "loadSolution", "params": {"path": "C:\\path\\to\\My.sln"}}
|
||||||
|
{"id": 3, "method": "projectSummary", "params": {}}
|
||||||
|
{"id": 99, "method": "shutdown", "params": {}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Does NOT need to be self-contained. Requires .NET 8+ runtime on the target machine.
|
||||||
|
Users of `ctx csharp` are .NET developers — the runtime is already there.
|
||||||
|
- MSBuild warnings logged to stderr are informational (missing SDK targets, etc.).
|
||||||
|
Only `WorkspaceFailed` events with `Failure` kind indicate real problems.
|
||||||
|
- The helper is spawned once per `ctx` invocation and kept alive for all queries
|
||||||
|
in that session. ctx Go manages the subprocess lifecycle.
|
||||||
39
tools/roslyn-helper/RoslynHelper.sln
Normal file
39
tools/roslyn-helper/RoslynHelper.sln
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RoslynHelper", "src\RoslynHelper\RoslynHelper.csproj", "{CE832F0D-696C-4830-977F-D694DDCBA532}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{CE832F0D-696C-4830-977F-D694DDCBA532} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
96
tools/roslyn-helper/src/RoslynHelper/JsonRpc/Dispatcher.cs
Normal file
96
tools/roslyn-helper/src/RoslynHelper/JsonRpc/Dispatcher.cs
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace RoslynHelper.JsonRpc.Handlers;
|
||||||
|
|
||||||
|
/// <summary>Contract for a JSON-RPC method handler.</summary>
|
||||||
|
public interface IHandler
|
||||||
|
{
|
||||||
|
/// <summary>Execute the handler and return the result object (serialized as the 'result' field).</summary>
|
||||||
|
Task<object> HandleAsync(JsonElement? @params);
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using RoslynHelper.Workspace;
|
||||||
|
|
||||||
|
namespace RoslynHelper.JsonRpc.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads a .sln file into the Roslyn workspace.
|
||||||
|
/// Idempotent — if the same path is already loaded, reloads it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LoadSolutionHandler(WorkspaceManager workspace) : IHandler
|
||||||
|
{
|
||||||
|
public async Task<object> HandleAsync(JsonElement? @params)
|
||||||
|
{
|
||||||
|
if (@params is not { } p)
|
||||||
|
throw new KnownException("E_INVALID_REQUEST", "params required for loadSolution");
|
||||||
|
|
||||||
|
if (!p.TryGetProperty("path", out var pathEl) || pathEl.ValueKind != JsonValueKind.String)
|
||||||
|
throw new KnownException("E_INVALID_REQUEST", "params.path (string) is required");
|
||||||
|
|
||||||
|
var path = pathEl.GetString()!;
|
||||||
|
var (projectCount, documentCount) = await workspace.LoadAsync(path);
|
||||||
|
return new { loaded = true, projectCount, documentCount };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,281 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace RoslynHelper.JsonRpc.Handlers;
|
||||||
|
|
||||||
|
/// <summary>Health-check handler. Returns version string.</summary>
|
||||||
|
public sealed class PingHandler : IHandler
|
||||||
|
{
|
||||||
|
public Task<object> HandleAsync(JsonElement? @params) =>
|
||||||
|
Task.FromResult<object>(new { pong = true, version = "0.1.0" });
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using RoslynHelper.Models;
|
||||||
|
using RoslynHelper.Workspace;
|
||||||
|
|
||||||
|
namespace RoslynHelper.JsonRpc.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a compact summary of the loaded solution: projects, target frameworks,
|
||||||
|
/// package references, and project-to-project dependencies.
|
||||||
|
/// Parses .csproj XML directly — no Roslyn workspace required.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProjectSummaryHandler(WorkspaceManager workspace) : IHandler
|
||||||
|
{
|
||||||
|
public Task<object> HandleAsync(JsonElement? @params)
|
||||||
|
{
|
||||||
|
var solution = workspace.GetCurrentSolution();
|
||||||
|
var solutionPath = workspace.SolutionPath!;
|
||||||
|
var solutionName = Path.GetFileNameWithoutExtension(solutionPath);
|
||||||
|
var solutionDir = Path.GetDirectoryName(solutionPath) ?? string.Empty;
|
||||||
|
|
||||||
|
// Document counts are tracked per-project in the WorkspaceManager load.
|
||||||
|
// Re-compute here from file system for simplicity.
|
||||||
|
var projects = solution.Projects
|
||||||
|
.OrderBy(p => p.Name)
|
||||||
|
.Select(p =>
|
||||||
|
{
|
||||||
|
var docCount = CountDocuments(p.ProjectPath);
|
||||||
|
return ProjectSummaryBuilder.Build(p, solutionDir, docCount);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Task.FromResult<object>(new SolutionSummary(solutionPath, solutionName, projects));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountDocuments(string projPath)
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(projPath);
|
||||||
|
if (dir is null || !Directory.Exists(dir)) return 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories).Count()
|
||||||
|
+ Directory.EnumerateFiles(dir, "*.fs", SearchOption.AllDirectories).Count()
|
||||||
|
+ Directory.EnumerateFiles(dir, "*.vb", SearchOption.AllDirectories).Count();
|
||||||
|
}
|
||||||
|
catch { return 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
11
tools/roslyn-helper/src/RoslynHelper/JsonRpc/Request.cs
Normal file
11
tools/roslyn-helper/src/RoslynHelper/JsonRpc/Request.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace RoslynHelper.JsonRpc;
|
||||||
|
|
||||||
|
/// <summary>Incoming JSON-RPC request from ctx (Go).</summary>
|
||||||
|
public sealed record Request(
|
||||||
|
[property: JsonPropertyName("id")] int Id,
|
||||||
|
[property: JsonPropertyName("method")] string Method,
|
||||||
|
[property: JsonPropertyName("params")] JsonElement? Params
|
||||||
|
);
|
||||||
10
tools/roslyn-helper/src/RoslynHelper/JsonRpc/Response.cs
Normal file
10
tools/roslyn-helper/src/RoslynHelper/JsonRpc/Response.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace RoslynHelper.JsonRpc;
|
||||||
|
|
||||||
|
/// <summary>Error detail embedded in a JSON-RPC error response.</summary>
|
||||||
|
public sealed record ErrorDetail(
|
||||||
|
[property: JsonPropertyName("code")] string Code,
|
||||||
|
[property: JsonPropertyName("message")] string Message,
|
||||||
|
[property: JsonPropertyName("data")] object? Data = null
|
||||||
|
);
|
||||||
10
tools/roslyn-helper/src/RoslynHelper/KnownException.cs
Normal file
10
tools/roslyn-helper/src/RoslynHelper/KnownException.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
namespace RoslynHelper;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An expected error with a standardized error code.
|
||||||
|
/// Throw from handlers to produce structured JSON-RPC error responses.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class KnownException(string code, string message) : Exception(message)
|
||||||
|
{
|
||||||
|
public string Code { get; } = code;
|
||||||
|
}
|
||||||
33
tools/roslyn-helper/src/RoslynHelper/Models/OutlineModels.cs
Normal file
33
tools/roslyn-helper/src/RoslynHelper/Models/OutlineModels.cs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
102
tools/roslyn-helper/src/RoslynHelper/Models/ProjectSummary.cs
Normal file
102
tools/roslyn-helper/src/RoslynHelper/Models/ProjectSummary.cs
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using RoslynHelper.Workspace;
|
||||||
|
|
||||||
|
namespace RoslynHelper.Models;
|
||||||
|
|
||||||
|
public sealed record PackageRef(
|
||||||
|
[property: JsonPropertyName("name")] string Name,
|
||||||
|
[property: JsonPropertyName("version")] string Version
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record ProjectSummary(
|
||||||
|
[property: JsonPropertyName("name")] string Name,
|
||||||
|
[property: JsonPropertyName("path")] string Path,
|
||||||
|
[property: JsonPropertyName("type")] string Type,
|
||||||
|
[property: JsonPropertyName("targetFrameworks")] string[] TargetFrameworks,
|
||||||
|
[property: JsonPropertyName("outputType")] string OutputType,
|
||||||
|
[property: JsonPropertyName("rootNamespace")] string RootNamespace,
|
||||||
|
[property: JsonPropertyName("documentCount")] int DocumentCount,
|
||||||
|
[property: JsonPropertyName("projectReferences")] List<string> ProjectReferences,
|
||||||
|
[property: JsonPropertyName("packageReferences")] List<PackageRef> PackageReferences
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record SolutionSummary(
|
||||||
|
[property: JsonPropertyName("solutionPath")] string SolutionPath,
|
||||||
|
[property: JsonPropertyName("solutionName")] string SolutionName,
|
||||||
|
[property: JsonPropertyName("projects")] List<ProjectSummary> Projects
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a <see cref="ProjectSummary"/> from a <see cref="ProjectEntry"/>
|
||||||
|
/// by parsing the .csproj XML for MSBuild properties.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ProjectSummaryBuilder
|
||||||
|
{
|
||||||
|
public static ProjectSummary Build(ProjectEntry entry, string solutionDir, int documentCount)
|
||||||
|
{
|
||||||
|
var projPath = entry.ProjectPath;
|
||||||
|
|
||||||
|
string[] targetFrameworks = [];
|
||||||
|
string outputType = "Library";
|
||||||
|
string rootNamespace = entry.Name;
|
||||||
|
List<PackageRef> packageRefs = [];
|
||||||
|
List<string> projRefs = [];
|
||||||
|
|
||||||
|
if (File.Exists(projPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var doc = XDocument.Load(projPath);
|
||||||
|
|
||||||
|
var tf = doc.Descendants("TargetFramework").FirstOrDefault()?.Value?.Trim();
|
||||||
|
var tfs = doc.Descendants("TargetFrameworks").FirstOrDefault()?.Value?.Trim();
|
||||||
|
|
||||||
|
targetFrameworks = tfs is not null
|
||||||
|
? tfs.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
: tf is not null ? [tf] : [];
|
||||||
|
|
||||||
|
outputType = doc.Descendants("OutputType").FirstOrDefault()?.Value?.Trim() ?? "Library";
|
||||||
|
rootNamespace = doc.Descendants("RootNamespace").FirstOrDefault()?.Value?.Trim() ?? entry.Name;
|
||||||
|
|
||||||
|
packageRefs = doc.Descendants("PackageReference")
|
||||||
|
.Select(el => new PackageRef(
|
||||||
|
el.Attribute("Include")?.Value ?? string.Empty,
|
||||||
|
el.Attribute("Version")?.Value ?? el.Element("Version")?.Value ?? string.Empty))
|
||||||
|
.Where(pr => !string.IsNullOrEmpty(pr.Name))
|
||||||
|
.OrderBy(pr => pr.Name)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
projRefs = doc.Descendants("ProjectReference")
|
||||||
|
.Select(el =>
|
||||||
|
{
|
||||||
|
var include = el.Attribute("Include")?.Value ?? string.Empty;
|
||||||
|
return Path.GetFileNameWithoutExtension(include);
|
||||||
|
})
|
||||||
|
.Where(n => !string.IsNullOrEmpty(n))
|
||||||
|
.OrderBy(n => n)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[summary] warn: could not parse {projPath}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var type = outputType.Equals("Exe", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
outputType.Equals("WinExe", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "exe" : "lib";
|
||||||
|
|
||||||
|
return new ProjectSummary(
|
||||||
|
entry.Name,
|
||||||
|
entry.RelativePath,
|
||||||
|
type,
|
||||||
|
targetFrameworks,
|
||||||
|
outputType,
|
||||||
|
rootNamespace,
|
||||||
|
documentCount,
|
||||||
|
projRefs,
|
||||||
|
packageRefs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
tools/roslyn-helper/src/RoslynHelper/Program.cs
Normal file
20
tools/roslyn-helper/src/RoslynHelper/Program.cs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
using RoslynHelper.JsonRpc;
|
||||||
|
using RoslynHelper.Workspace;
|
||||||
|
|
||||||
|
namespace RoslynHelper;
|
||||||
|
|
||||||
|
public static class Program
|
||||||
|
{
|
||||||
|
public static async Task<int> Main(string[] args)
|
||||||
|
{
|
||||||
|
// UTF-8 without BOM on both ends of the pipe.
|
||||||
|
Console.InputEncoding = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
|
||||||
|
Console.OutputEncoding = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
|
||||||
|
|
||||||
|
using var workspace = new WorkspaceManager();
|
||||||
|
var dispatcher = new Dispatcher(workspace);
|
||||||
|
|
||||||
|
await dispatcher.RunAsync(Console.In, Console.Out);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
tools/roslyn-helper/src/RoslynHelper/RoslynHelper.csproj
Normal file
18
tools/roslyn-helper/src/RoslynHelper/RoslynHelper.csproj
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
|
namespace RoslynHelper.Workspace;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses .sln and .csproj files directly without a Roslyn/MSBuild workspace.
|
||||||
|
/// This avoids tight version coupling between MSBuild NuGet packages and the
|
||||||
|
/// installed SDK. For MVP commands (projectSummary), structural parsing is sufficient.
|
||||||
|
/// Roslyn semantic APIs will be integrated in a later phase.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WorkspaceManager : IDisposable
|
||||||
|
{
|
||||||
|
public void Dispose() { } // reserved for future Roslyn workspace disposal
|
||||||
|
private SolutionData? _solution;
|
||||||
|
private string? _solutionPath;
|
||||||
|
|
||||||
|
public string? SolutionPath => _solutionPath;
|
||||||
|
|
||||||
|
public Task<(int projectCount, int documentCount)> LoadAsync(string path)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
throw new KnownException("E_NOT_FOUND", $"solution file not found: {path}");
|
||||||
|
|
||||||
|
SolutionData sln;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
sln = SolutionParser.Parse(path);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new KnownException("E_LOAD_FAILED", $"failed to load solution: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
_solution = sln;
|
||||||
|
_solutionPath = path;
|
||||||
|
|
||||||
|
var documentCount = sln.Projects.Sum(p => CountDocuments(p.ProjectPath));
|
||||||
|
return Task.FromResult((sln.Projects.Count, documentCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
public SolutionData GetCurrentSolution()
|
||||||
|
{
|
||||||
|
if (_solution is null)
|
||||||
|
throw new KnownException("E_NOT_FOUND", "no solution loaded — call loadSolution first");
|
||||||
|
return _solution;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Counts .cs/.fs/.vb source files in the project directory.</summary>
|
||||||
|
private static int CountDocuments(string projPath)
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(projPath);
|
||||||
|
if (dir is null || !Directory.Exists(dir)) return 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories).Count()
|
||||||
|
+ Directory.EnumerateFiles(dir, "*.fs", SearchOption.AllDirectories).Count()
|
||||||
|
+ Directory.EnumerateFiles(dir, "*.vb", SearchOption.AllDirectories).Count();
|
||||||
|
}
|
||||||
|
catch { return 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lightweight data model for a loaded solution.</summary>
|
||||||
|
public sealed class SolutionData(string solutionPath, List<ProjectEntry> projects)
|
||||||
|
{
|
||||||
|
public string SolutionPath { get; } = solutionPath;
|
||||||
|
public List<ProjectEntry> Projects { get; } = projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One project entry from the .sln file.</summary>
|
||||||
|
public sealed class ProjectEntry(string name, string relativePath, string absolutePath)
|
||||||
|
{
|
||||||
|
public string Name { get; } = name;
|
||||||
|
public string RelativePath { get; } = relativePath;
|
||||||
|
public string ProjectPath { get; } = absolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the classic Visual Studio .sln format to extract project entries.
|
||||||
|
/// Handles both SDK-style and legacy projects.
|
||||||
|
/// </summary>
|
||||||
|
internal static class SolutionParser
|
||||||
|
{
|
||||||
|
// Matches: Project("{type-guid}") = "Name", "path\to\project.csproj", "{project-guid}"
|
||||||
|
private static readonly Regex ProjectLine = new(
|
||||||
|
@"Project\(""\{[^}]+\}""\)\s*=\s*""([^""]+)""\s*,\s*""([^""]+)""\s*,",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static readonly HashSet<string> ProjectExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
".csproj", ".fsproj", ".vbproj", ".pyproj", ".vcxproj"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static SolutionData Parse(string slnPath)
|
||||||
|
{
|
||||||
|
var slnDir = Path.GetDirectoryName(slnPath) ?? string.Empty;
|
||||||
|
var content = File.ReadAllText(slnPath, System.Text.Encoding.UTF8);
|
||||||
|
var projects = new List<ProjectEntry>();
|
||||||
|
|
||||||
|
foreach (Match m in ProjectLine.Matches(content))
|
||||||
|
{
|
||||||
|
var name = m.Groups[1].Value;
|
||||||
|
var relPath = m.Groups[2].Value.Replace('/', Path.DirectorySeparatorChar);
|
||||||
|
var ext = Path.GetExtension(relPath);
|
||||||
|
|
||||||
|
// Skip solution folders (no file extension) and non-code projects.
|
||||||
|
if (string.IsNullOrEmpty(ext) || !ProjectExtensions.Contains(ext))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var absPath = Path.GetFullPath(Path.Combine(slnDir, relPath));
|
||||||
|
if (!File.Exists(absPath)) continue; // skip phantom entries
|
||||||
|
|
||||||
|
projects.Add(new ProjectEntry(name, relPath.Replace('\\', '/'), absPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SolutionData(slnPath, projects);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
tools/roslyn-helper/test-input.txt
Normal file
3
tools/roslyn-helper/test-input.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{"id": 1, "method": "loadSolution", "params": {"path": "C:\\gocode\\ctx\\tools\\roslyn-helper\\RoslynHelper.sln"}}
|
||||||
|
{"id": 2, "method": "projectSummary", "params": {}}
|
||||||
|
{"id": 3, "method": "shutdown", "params": {}}
|
||||||
Loading…
Reference in New Issue
Block a user