Compare commits
No commits in common. "4105f02468fb88889011b966a353eb1ccc3808b3" and "69cadb4ea6c6347ee1e93ea279138d0bb46833ef" have entirely different histories.
4105f02468
...
69cadb4ea6
@ -1,36 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# ctx pre-commit hook — shell entry point (works in Git Bash on Windows)
|
|
||||||
# Delegates to pre-commit.ps1 on Windows, runs natively on Linux/macOS.
|
|
||||||
#
|
|
||||||
# To activate:
|
|
||||||
# git config core.hooksPath .githooks
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if command -v powershell.exe >/dev/null 2>&1; then
|
|
||||||
# Windows: delegate to PowerShell script
|
|
||||||
exec powershell.exe -ExecutionPolicy Bypass -File .githooks/pre-commit.ps1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Linux / macOS: run directly
|
|
||||||
echo "Running gofmt..."
|
|
||||||
unformatted=$(gofmt -l .)
|
|
||||||
if [ -n "$unformatted" ]; then
|
|
||||||
echo "Files not formatted with gofmt:"
|
|
||||||
echo "$unformatted"
|
|
||||||
echo
|
|
||||||
echo "Run: gofmt -w ."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Running go vet..."
|
|
||||||
go vet ./...
|
|
||||||
|
|
||||||
if command -v golangci-lint >/dev/null 2>&1; then
|
|
||||||
echo "Running golangci-lint..."
|
|
||||||
golangci-lint run ./...
|
|
||||||
else
|
|
||||||
echo "warning: golangci-lint not installed, skipping"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "pre-commit checks passed"
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
# ctx pre-commit hook (PowerShell)
|
|
||||||
# To activate: git config core.hooksPath .githooks
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
Write-Host "Running gofmt..."
|
|
||||||
$unformatted = gofmt -l .
|
|
||||||
if ($unformatted) {
|
|
||||||
Write-Host "Files not formatted with gofmt:"
|
|
||||||
Write-Host $unformatted
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "Run: gofmt -w ."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Running go vet..."
|
|
||||||
go vet ./...
|
|
||||||
|
|
||||||
if (Get-Command golangci-lint -ErrorAction SilentlyContinue) {
|
|
||||||
Write-Host "Running golangci-lint..."
|
|
||||||
golangci-lint run ./...
|
|
||||||
} else {
|
|
||||||
Write-Host "warning: golangci-lint not installed, skipping"
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "pre-commit checks passed"
|
|
||||||
55
.github/workflows/ci.yml
vendored
55
.github/workflows/ci.yml
vendored
@ -1,55 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
name: Lint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: "1.26"
|
|
||||||
cache: true
|
|
||||||
- name: golangci-lint
|
|
||||||
uses: golangci/golangci-lint-action@v6
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
|
|
||||||
build:
|
|
||||||
name: Build & Test
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: "1.26"
|
|
||||||
cache: true
|
|
||||||
- name: Build
|
|
||||||
run: go build -v ./...
|
|
||||||
- name: Vet
|
|
||||||
run: go vet ./...
|
|
||||||
- name: Test
|
|
||||||
run: go test -v -race -count=1 ./...
|
|
||||||
|
|
||||||
build-helper:
|
|
||||||
name: Build Roslyn Helper
|
|
||||||
runs-on: windows-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Setup .NET
|
|
||||||
uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: "10.0.x"
|
|
||||||
- name: Build helper
|
|
||||||
working-directory: tools/roslyn-helper
|
|
||||||
run: dotnet build src/RoslynHelper -c Release
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -27,11 +27,6 @@ coverage.html
|
|||||||
# Claude Code local settings
|
# Claude 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
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
run:
|
|
||||||
timeout: 3m
|
|
||||||
tests: true
|
|
||||||
go: "1.26"
|
|
||||||
|
|
||||||
output:
|
|
||||||
formats:
|
|
||||||
- format: colored-line-number
|
|
||||||
print-issued-lines: true
|
|
||||||
print-linter-name: true
|
|
||||||
sort-results: true
|
|
||||||
|
|
||||||
linters:
|
|
||||||
disable-all: true
|
|
||||||
enable:
|
|
||||||
- errcheck # unhandled errors
|
|
||||||
- govet # official compiler checks
|
|
||||||
- ineffassign # useless assignments
|
|
||||||
- staticcheck # bugs and simplifications
|
|
||||||
- unused # dead code
|
|
||||||
- gofmt # formatting
|
|
||||||
- goimports # import organization
|
|
||||||
- misspell # typos in strings/comments
|
|
||||||
- revive # golint successor
|
|
||||||
- gocyclo # high cyclomatic complexity
|
|
||||||
- gocritic # additional checks
|
|
||||||
- bodyclose # http.Response.Body closed
|
|
||||||
- nilerr # returning nil when err intended
|
|
||||||
- errorlint # non-wrappable errors
|
|
||||||
|
|
||||||
linters-settings:
|
|
||||||
gocyclo:
|
|
||||||
min-complexity: 15
|
|
||||||
govet:
|
|
||||||
enable-all: true
|
|
||||||
disable:
|
|
||||||
- fieldalignment # unnecessary noise
|
|
||||||
revive:
|
|
||||||
rules:
|
|
||||||
- name: var-naming
|
|
||||||
- name: exported
|
|
||||||
arguments: ["disableStutteringCheck"] # allows ctx.Context
|
|
||||||
- name: error-return
|
|
||||||
- name: error-naming
|
|
||||||
- name: unexported-return
|
|
||||||
- name: indent-error-flow
|
|
||||||
- name: unreachable-code
|
|
||||||
|
|
||||||
issues:
|
|
||||||
exclude-dirs:
|
|
||||||
- tools/roslyn-helper # C# code, ignore
|
|
||||||
- vendor
|
|
||||||
- bin
|
|
||||||
exclude-rules:
|
|
||||||
# Allow errcheck in cobra command handlers — cobra's RunE owns error printing
|
|
||||||
- path: cmd/
|
|
||||||
linters: [errcheck]
|
|
||||||
# Allow higher complexity in plugin format functions (inherently procedural)
|
|
||||||
- path: internal/plugins/.*/format\.go
|
|
||||||
linters: [gocyclo]
|
|
||||||
text: "cyclomatic complexity"
|
|
||||||
# Allow higher complexity in plugin collect/detect logic
|
|
||||||
- path: internal/plugins/.*/detect
|
|
||||||
linters: [gocyclo]
|
|
||||||
text: "cyclomatic complexity"
|
|
||||||
|
|
||||||
max-issues-per-linter: 0
|
|
||||||
max-same-issues: 0
|
|
||||||
new: false
|
|
||||||
252
README.md
252
README.md
@ -1,145 +1,3 @@
|
|||||||
# ctx
|
|
||||||
|
|
||||||
Ferramenta CLI para compactar contexto de código antes de enviar ao Claude — economize tokens, expanda o que cabe na janela.
|
|
||||||
[→ Leia em Português](#pt-br) · [→ Read in English](#en)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<a id="pt-br"></a>
|
|
||||||
|
|
||||||
# ctx — anti-tokens CLI para Claude Code
|
|
||||||
|
|
||||||
> Analise seu projeto localmente. Envie ao Claude resumos densos, não arquivos brutos.
|
|
||||||
|
|
||||||
**Status:** alpha — em desenvolvimento ativo. Interfaces podem mudar.
|
|
||||||
|
|
||||||
## Por que usar
|
|
||||||
|
|
||||||
Cada token que o Claude lê tem custo e consome janela de contexto. Ao trabalhar em projetos grandes, o Claude frequentemente gasta milhares de tokens apenas lendo arquivos para construir um modelo mental antes de fazer qualquer trabalho real.
|
|
||||||
|
|
||||||
`ctx` roda localmente, analisa seu projeto com ferramentas especializadas por linguagem (Roslyn para C#, tree-sitter para TypeScript), e emite resumos compactos em markdown que dão ao Claude tudo que ele precisa em uma fração dos tokens.
|
|
||||||
|
|
||||||
### Benchmark
|
|
||||||
|
|
||||||
Teste real: análise de "o que preciso mudar para adicionar uma feature" em projeto em produção.
|
|
||||||
Ambas as sessões iniciadas com `/clear` — condições equivalentes.
|
|
||||||
|
|
||||||
| Teste | Consumo de janela de contexto |
|
|
||||||
|-------|-------------------------------|
|
|
||||||
| Sem ctx | +7% |
|
|
||||||
| Com ctx | +3% |
|
|
||||||
|
|
||||||
**Economia: 57%.** Em sessões longas de desenvolvimento, onde o Claude faz a mesma exploração várias vezes ao longo da conversa, essa economia acumula.
|
|
||||||
|
|
||||||
## Instalação
|
|
||||||
|
|
||||||
**1. Instale o Go**
|
|
||||||
Baixe em https://go.dev/dl/ e siga o instalador. Requer Go 1.22+.
|
|
||||||
|
|
||||||
**2. Instale o ctx**
|
|
||||||
```sh
|
|
||||||
go install github.com/ricarneiro/ctx/cmd/ctx@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. Verifique**
|
|
||||||
```sh
|
|
||||||
ctx --version
|
|
||||||
```
|
|
||||||
|
|
||||||
## Uso
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Contexto git: commits recentes, status, info da branch
|
|
||||||
ctx git
|
|
||||||
|
|
||||||
# Detectar stack e emitir visão geral do projeto
|
|
||||||
ctx auto project
|
|
||||||
|
|
||||||
# Estrutura de projeto C# (requer .NET SDK)
|
|
||||||
ctx csharp project
|
|
||||||
|
|
||||||
# Outline de arquivo C#: tipos, métodos, assinaturas
|
|
||||||
ctx csharp outline src/MyService.cs
|
|
||||||
|
|
||||||
# Listar erros de compilação
|
|
||||||
ctx csharp errors
|
|
||||||
```
|
|
||||||
|
|
||||||
Toda saída é markdown UTF-8 no stdout. Redirecione onde precisar:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ctx csharp project | clip # Windows
|
|
||||||
ctx csharp project | pbcopy # macOS
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integrando com Claude Code
|
|
||||||
|
|
||||||
Para que o Claude Code use o `ctx` automaticamente, adicione o `ctx` ao PATH do Windows e inclua as instruções abaixo no `CLAUDE.md` do seu projeto.
|
|
||||||
|
|
||||||
### 1. Adicionar ctx ao PATH no Windows
|
|
||||||
|
|
||||||
1. Copie o `ctx.exe` para uma pasta fixa (ex: `C:\tools\ctx\`)
|
|
||||||
2. Abra **Configurações do Sistema → Variáveis de Ambiente**
|
|
||||||
3. Em "Variáveis do sistema", selecione `Path` → **Editar**
|
|
||||||
4. Clique em **Novo** e adicione `C:\tools\ctx\`
|
|
||||||
5. Confirme com OK e reinicie o terminal
|
|
||||||
|
|
||||||
### 2. Adicionar ao CLAUDE.md do projeto
|
|
||||||
|
|
||||||
Crie ou edite o arquivo `CLAUDE.md` na raiz do seu projeto e inclua:
|
|
||||||
|
|
||||||
````markdown
|
|
||||||
# Project instructions
|
|
||||||
|
|
||||||
## `ctx` — compact code analysis tool (use to save tokens)
|
|
||||||
|
|
||||||
This project has `ctx` in PATH. It produces compact markdown summaries of code structure.
|
|
||||||
|
|
||||||
### When to use `ctx` (saves tokens)
|
|
||||||
|
|
||||||
- **Understanding the whole project:** run `ctx csharp project` instead of exploring files one by one.
|
|
||||||
- **Understanding a large file (100+ lines):** run `ctx csharp outline <file>` before reading the full file. Only read the full file if you need method body details.
|
|
||||||
- **Checking build errors:** run `ctx csharp errors` instead of `dotnet build` (filtered output, 90% smaller).
|
|
||||||
- **Understanding git state:** run `ctx git` instead of running multiple git commands.
|
|
||||||
|
|
||||||
### When NOT to use `ctx`
|
|
||||||
|
|
||||||
- If you already know which specific small file to read, just read it directly.
|
|
||||||
- If you need to see method body logic, read the file — `ctx outline` only shows signatures.
|
|
||||||
- For simple `Search()` by pattern across files, search is already efficient.
|
|
||||||
|
|
||||||
### Commands
|
|
||||||
|
|
||||||
```
|
|
||||||
ctx csharp project # solution overview: projects, refs, packages, structure
|
|
||||||
ctx csharp outline <file.cs> # class/method signatures without bodies (~85% smaller)
|
|
||||||
ctx csharp errors # dotnet build filtered to errors + top warnings (~90% smaller)
|
|
||||||
ctx git # branch, status, recent commits, diff summary
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rule of thumb
|
|
||||||
|
|
||||||
Ask yourself: "Am I about to read multiple files to understand structure?" If yes → `ctx` first. If you just need one specific file → read it directly.
|
|
||||||
````
|
|
||||||
|
|
||||||
## Contribuindo
|
|
||||||
|
|
||||||
Contribuições são bem-vindas. Clone o repositório, faça suas alterações e abra um Pull Request. Para mudanças grandes, abra uma issue primeiro para alinhar o escopo.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git clone https://github.com/ricarneiro/CTX.git
|
|
||||||
cd CTX
|
|
||||||
go build -o ctx.exe ./cmd/ctx
|
|
||||||
```
|
|
||||||
|
|
||||||
## Licença
|
|
||||||
|
|
||||||
MIT — veja [LICENSE](LICENSE).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<a id="en"></a>
|
|
||||||
|
|
||||||
# ctx — anti-tokens CLI for Claude Code
|
# 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.
|
||||||
@ -148,36 +6,22 @@ MIT — veja [LICENSE](LICENSE).
|
|||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
Every token Claude reads costs money and burns context window. When working on a large codebase, Claude often spends thousands of tokens just reading files to build a mental model before doing any actual work.
|
Every token Claude reads costs money and burns context window. When working on
|
||||||
|
a large C# or React codebase, Claude often spends thousands of tokens just
|
||||||
|
reading files to build a mental model before doing any actual work.
|
||||||
|
|
||||||
`ctx` runs locally, analyzes your project with language-aware tools (Roslyn for C#, tree-sitter for TypeScript), and emits compact markdown summaries that give Claude everything it needs in a fraction of the tokens.
|
`ctx` runs locally, analyzes your project with language-aware tools (Roslyn for
|
||||||
|
C#, tree-sitter for TypeScript), and emits compact markdown summaries that give
|
||||||
### Benchmark
|
Claude everything it needs in a fraction of the tokens.
|
||||||
|
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. Verify**
|
Requires Go 1.22+. Binaries for common platforms will be published after the
|
||||||
```sh
|
MVP is validated.
|
||||||
ctx --version
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@ -205,65 +49,41 @@ ctx csharp project | pbcopy # macOS
|
|||||||
ctx csharp project | clip # Windows
|
ctx csharp project | clip # Windows
|
||||||
```
|
```
|
||||||
|
|
||||||
## Integrating with Claude Code
|
Or reference it in a `CLAUDE.md`:
|
||||||
|
|
||||||
To have Claude Code use `ctx` automatically, add `ctx` to your Windows PATH and include the instructions below in your project's `CLAUDE.md`.
|
```markdown
|
||||||
|
Run `ctx csharp project` to get the project overview before making changes.
|
||||||
### 1. Add ctx to PATH on Windows
|
|
||||||
|
|
||||||
1. Copy `ctx.exe` to a fixed folder (e.g. `C:\tools\ctx\`)
|
|
||||||
2. Open **System Settings → Environment Variables**
|
|
||||||
3. Under "System variables", select `Path` → **Edit**
|
|
||||||
4. Click **New** and add `C:\tools\ctx\`
|
|
||||||
5. Confirm with OK and restart your terminal
|
|
||||||
|
|
||||||
### 2. Add to your project's CLAUDE.md
|
|
||||||
|
|
||||||
Create or edit `CLAUDE.md` at the root of your project and include:
|
|
||||||
|
|
||||||
````markdown
|
|
||||||
# Project instructions
|
|
||||||
|
|
||||||
## `ctx` — compact code analysis tool (use to save tokens)
|
|
||||||
|
|
||||||
This project has `ctx` in PATH. It produces compact markdown summaries of code structure.
|
|
||||||
|
|
||||||
### When to use `ctx` (saves tokens)
|
|
||||||
|
|
||||||
- **Understanding the whole project:** run `ctx csharp project` instead of exploring files one by one.
|
|
||||||
- **Understanding a large file (100+ lines):** run `ctx csharp outline <file>` before reading the full file. Only read the full file if you need method body details.
|
|
||||||
- **Checking build errors:** run `ctx csharp errors` instead of `dotnet build` (filtered output, 90% smaller).
|
|
||||||
- **Understanding git state:** run `ctx git` instead of running multiple git commands.
|
|
||||||
|
|
||||||
### When NOT to use `ctx`
|
|
||||||
|
|
||||||
- If you already know which specific small file to read, just read it directly.
|
|
||||||
- If you need to see method body logic, read the file — `ctx outline` only shows signatures.
|
|
||||||
- For simple `Search()` by pattern across files, search is already efficient.
|
|
||||||
|
|
||||||
### Commands
|
|
||||||
|
|
||||||
```
|
|
||||||
ctx csharp project # solution overview: projects, refs, packages, structure
|
|
||||||
ctx csharp outline <file.cs> # class/method signatures without bodies (~85% smaller)
|
|
||||||
ctx csharp errors # dotnet build filtered to errors + top warnings (~90% smaller)
|
|
||||||
ctx git # branch, status, recent commits, diff summary
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rule of thumb
|
## Architecture
|
||||||
|
|
||||||
Ask yourself: "Am I about to read multiple files to understand structure?" If yes → `ctx` first. If you just need one specific file → read it directly.
|
`ctx` uses a plugin system. Each stack (`git`, `csharp`, `react`, `auto`) is a
|
||||||
````
|
plugin that implements the `core.Plugin` interface and registers itself via
|
||||||
|
`init()`.
|
||||||
|
|
||||||
|
```
|
||||||
|
core.Plugin interface
|
||||||
|
Name() string
|
||||||
|
Version() string
|
||||||
|
ShortDescription() string
|
||||||
|
Command(ctx *core.Context) *cobra.Command
|
||||||
|
```
|
||||||
|
|
||||||
|
In the MVP, plugins are compiled into the binary. Future plan: migrate to
|
||||||
|
subprocess dispatch (binaries named `ctx-csharp`, `ctx-react` in PATH), same
|
||||||
|
pattern as `kubectl` plugins.
|
||||||
|
|
||||||
|
C# analysis uses a separate helper process (`tools/roslyn-helper/`) written in
|
||||||
|
C# with Roslyn. The helper communicates with `ctx` via JSON-RPC over
|
||||||
|
stdin/stdout. This lets us use the best tool for the job without pulling a .NET
|
||||||
|
runtime into the Go binary.
|
||||||
|
|
||||||
|
See `docs/DECISIONS.md` for the full rationale behind each architectural choice.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome. Clone the repository, make your changes, and open a Pull Request. For large changes, open an issue first to align on scope.
|
Contributions welcome. See `CONTRIBUTING.md` (coming soon) for guidelines.
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@ -22,8 +22,7 @@ Each subcommand targets a specific stack:
|
|||||||
ctx react — React / TypeScript analysis
|
ctx react — React / TypeScript analysis
|
||||||
|
|
||||||
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,178 +1,31 @@
|
|||||||
// Package auto implements the ctx auto plugin — stack detection and routing.
|
// Package auto implements the ctx auto plugin.
|
||||||
|
// Full implementation: prompt 2.
|
||||||
package auto
|
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(&plugin{})
|
core.Register(&autoPlugin{})
|
||||||
}
|
}
|
||||||
|
|
||||||
type plugin struct{}
|
type autoPlugin struct{}
|
||||||
|
|
||||||
func (p *plugin) Name() string { return "auto" }
|
func (a *autoPlugin) Name() string { return "auto" }
|
||||||
func (p *plugin) Version() string { return "0.1.0" }
|
func (a *autoPlugin) Version() string { return "0.0.1" }
|
||||||
func (p *plugin) ShortDescription() string { return "Detect stack and route to appropriate plugin" }
|
func (a *autoPlugin) ShortDescription() string { return "Auto-detect project stack and emit context" }
|
||||||
|
|
||||||
func (p *plugin) Command(ctx *core.Context) *cobra.Command {
|
func (a *autoPlugin) Command(ctx *core.Context) *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "auto",
|
Use: "auto",
|
||||||
Short: p.ShortDescription(),
|
Short: a.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 {
|
||||||
return runDetect(ctx)
|
fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in prompt 2")
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDetect(ctx *core.Context) error {
|
|
||||||
stacks, err := Detect(ctx.WorkDir)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(ctx.Stderr, err.Error())
|
|
||||||
return fmt.Errorf("exit 1")
|
|
||||||
}
|
|
||||||
formatDetect(ctx, stacks)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatDetect(ctx *core.Context, stacks []Stack) {
|
|
||||||
w := ctx.Stdout
|
|
||||||
fmt.Fprintln(w, "# Stack detection")
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
fmt.Fprintf(w, "**directory:** %s\n\n", ctx.WorkDir)
|
|
||||||
|
|
||||||
if len(stacks) == 0 {
|
|
||||||
fmt.Fprintln(w, "No known stack detected.")
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
plugins := core.All()
|
|
||||||
names := make([]string, len(plugins))
|
|
||||||
for i, p := range plugins {
|
|
||||||
names[i] = p.Name()
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "Available plugins: %s\n", strings.Join(names, ", "))
|
|
||||||
fmt.Fprintln(w, "Run `ctx <plugin> --help` to see what each plugin offers.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(w, "## Detected stacks")
|
|
||||||
for _, s := range stacks {
|
|
||||||
fmt.Fprintf(w, "- **%s** (confidence: %s)\n", s.Name, s.Confidence)
|
|
||||||
for _, e := range s.Evidence {
|
|
||||||
fmt.Fprintf(w, " - %s\n", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
|
|
||||||
fmt.Fprintln(w, "## Suggested commands")
|
|
||||||
for _, s := range stacks {
|
|
||||||
fmt.Fprintf(w, "- `ctx %s project`\n", s.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- project subcommand ---
|
|
||||||
|
|
||||||
func newProjectCmd(ctx *core.Context) *cobra.Command {
|
|
||||||
return &cobra.Command{
|
|
||||||
Use: "project",
|
|
||||||
Short: "Detect stack and emit project context summary",
|
|
||||||
Long: "Auto-detect the project stack and run the `project` command of each matching plugin.",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return runProject(ctx)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runProject(ctx *core.Context) error {
|
|
||||||
stacks, err := Detect(ctx.WorkDir)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(ctx.Stderr, err.Error())
|
|
||||||
return fmt.Errorf("exit 1")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(stacks) == 0 {
|
|
||||||
formatDetect(ctx, stacks)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
first := true
|
|
||||||
for _, stack := range stacks {
|
|
||||||
if !first {
|
|
||||||
fmt.Fprintln(ctx.Stdout, "---")
|
|
||||||
fmt.Fprintln(ctx.Stdout)
|
|
||||||
}
|
|
||||||
first = false
|
|
||||||
|
|
||||||
p := core.Get(stack.Name)
|
|
||||||
if p == nil {
|
|
||||||
fmt.Fprintf(ctx.Stdout,
|
|
||||||
"# %s\n\nNo plugin registered for stack `%s`.\n\n",
|
|
||||||
stack.Name, stack.Name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
pluginCmd := p.Command(ctx)
|
|
||||||
|
|
||||||
// If the root command itself is a placeholder, the whole plugin is unimplemented.
|
|
||||||
if pluginCmd.Annotations["placeholder"] == "true" {
|
|
||||||
writePlaceholder(ctx, stack.Name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
sub := findSubcommand(pluginCmd, "project")
|
|
||||||
if sub == nil {
|
|
||||||
fmt.Fprintf(ctx.Stdout,
|
|
||||||
"# %s\n\nThe `%s` plugin does not have a `project` command.\n\n",
|
|
||||||
stack.Name, stack.Name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if sub.Annotations["placeholder"] == "true" {
|
|
||||||
writePlaceholder(ctx, stack.Name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute the project subcommand directly (no cobra routing overhead).
|
|
||||||
if sub.RunE != nil {
|
|
||||||
if err := sub.RunE(sub, []string{}); err != nil {
|
|
||||||
fmt.Fprintf(ctx.Stderr, "error running %s project: %v\n", stack.Name, err)
|
|
||||||
}
|
|
||||||
} else if sub.Run != nil {
|
|
||||||
sub.Run(sub, []string{})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// findSubcommand returns the named subcommand of cmd, or nil if not found.
|
|
||||||
func findSubcommand(cmd *cobra.Command, name string) *cobra.Command {
|
|
||||||
for _, sub := range cmd.Commands() {
|
|
||||||
if sub.Name() == name {
|
|
||||||
return sub
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writePlaceholder(ctx *core.Context, name string) {
|
|
||||||
fmt.Fprintf(ctx.Stdout,
|
|
||||||
"# %s (placeholder)\n\nThe `%s` plugin detected this stack but its `project` command is not yet implemented.\nThis will be available in a future version of ctx.\n\n",
|
|
||||||
name, name)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,258 +0,0 @@
|
|||||||
package auto
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Stack represents a detected technology stack in a project directory.
|
|
||||||
type Stack struct {
|
|
||||||
Name string // "csharp", "react", "go", "python"
|
|
||||||
Confidence string // "high", "medium", "low"
|
|
||||||
Evidence []string // human-readable items explaining the detection
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect scans rootDir up to 3 levels deep and returns detected stacks,
|
|
||||||
// ordered by confidence (high first). Build artifacts and dependency
|
|
||||||
// directories (node_modules, bin, obj, etc.) are skipped.
|
|
||||||
func Detect(rootDir string) ([]Stack, error) {
|
|
||||||
f, err := scanDir(rootDir, 3)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return buildStacks(f), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// skipDirs lists directory names that are never descended into during scanning.
|
|
||||||
var skipDirs = map[string]bool{
|
|
||||||
"node_modules": true,
|
|
||||||
"bin": true,
|
|
||||||
"obj": true,
|
|
||||||
"dist": true,
|
|
||||||
"build": true,
|
|
||||||
"out": true,
|
|
||||||
".git": true,
|
|
||||||
".vs": true,
|
|
||||||
".vscode": true,
|
|
||||||
".idea": true,
|
|
||||||
"target": true,
|
|
||||||
"vendor": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// findings holds raw evidence gathered during the directory walk.
|
|
||||||
type findings struct {
|
|
||||||
slnFiles []string // forward-slash relative paths to .sln files
|
|
||||||
csprojFiles []string // forward-slash relative paths to .csproj/.fsproj/.vbproj files
|
|
||||||
hasGlobalJSON bool
|
|
||||||
packageJSONs []pkgJSON
|
|
||||||
hasGoModRoot bool // go.mod is a direct child of rootDir (depth 1)
|
|
||||||
hasPyProject bool
|
|
||||||
hasReqTxt bool
|
|
||||||
hasSetupPy bool
|
|
||||||
hasTSXorJSX bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type pkgJSON struct {
|
|
||||||
relPath string
|
|
||||||
hasReact bool
|
|
||||||
hasTypeScript bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanDir(rootDir string, maxDepth int) (findings, error) {
|
|
||||||
var f findings
|
|
||||||
|
|
||||||
err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return nil //nolint:nilerr // skip unreadable entries; returning err would abort the walk
|
|
||||||
}
|
|
||||||
if path == rootDir {
|
|
||||||
return nil // skip root itself
|
|
||||||
}
|
|
||||||
|
|
||||||
rel, _ := filepath.Rel(rootDir, path)
|
|
||||||
depth := len(strings.Split(rel, string(filepath.Separator)))
|
|
||||||
relFwd := filepath.ToSlash(rel)
|
|
||||||
|
|
||||||
if d.IsDir() {
|
|
||||||
if skipDirs[d.Name()] {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
if depth >= maxDepth {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// File: classify by extension then by base name.
|
|
||||||
name := d.Name()
|
|
||||||
base := strings.ToLower(name)
|
|
||||||
ext := strings.ToLower(filepath.Ext(name))
|
|
||||||
|
|
||||||
switch ext {
|
|
||||||
case ".sln":
|
|
||||||
f.slnFiles = append(f.slnFiles, relFwd)
|
|
||||||
case ".csproj", ".fsproj", ".vbproj":
|
|
||||||
f.csprojFiles = append(f.csprojFiles, relFwd)
|
|
||||||
case ".tsx", ".jsx":
|
|
||||||
f.hasTSXorJSX = true
|
|
||||||
}
|
|
||||||
|
|
||||||
switch base {
|
|
||||||
case "global.json":
|
|
||||||
f.hasGlobalJSON = true
|
|
||||||
case "package.json":
|
|
||||||
f.packageJSONs = append(f.packageJSONs, parsePackageJSON(path, relFwd))
|
|
||||||
case "go.mod":
|
|
||||||
if depth == 1 {
|
|
||||||
f.hasGoModRoot = true
|
|
||||||
}
|
|
||||||
case "pyproject.toml":
|
|
||||||
f.hasPyProject = true
|
|
||||||
case "requirements.txt":
|
|
||||||
f.hasReqTxt = true
|
|
||||||
case "setup.py":
|
|
||||||
f.hasSetupPy = true
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
return f, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// pkgJSONSchema is the minimal structure needed to check for specific deps.
|
|
||||||
type pkgJSONSchema struct {
|
|
||||||
Dependencies map[string]json.RawMessage `json:"dependencies"`
|
|
||||||
DevDependencies map[string]json.RawMessage `json:"devDependencies"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePackageJSON(path, relPath string) pkgJSON {
|
|
||||||
result := pkgJSON{relPath: relPath}
|
|
||||||
b, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
var raw pkgJSONSchema
|
|
||||||
if err := json.Unmarshal(b, &raw); err != nil {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
allDeps := make(map[string]bool, len(raw.Dependencies)+len(raw.DevDependencies))
|
|
||||||
for k := range raw.Dependencies {
|
|
||||||
allDeps[k] = true
|
|
||||||
}
|
|
||||||
for k := range raw.DevDependencies {
|
|
||||||
allDeps[k] = true
|
|
||||||
}
|
|
||||||
result.hasReact = allDeps["react"]
|
|
||||||
result.hasTypeScript = allDeps["typescript"] || allDeps["@types/react"]
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildStacks applies heuristics to the findings and returns stacks sorted
|
|
||||||
// by confidence descending.
|
|
||||||
func buildStacks(f findings) []Stack {
|
|
||||||
var stacks []Stack
|
|
||||||
|
|
||||||
// C# — high if .sln or .csproj present; medium if only global.json.
|
|
||||||
if len(f.slnFiles) > 0 || len(f.csprojFiles) > 0 {
|
|
||||||
var evidence []string
|
|
||||||
for _, s := range f.slnFiles {
|
|
||||||
evidence = append(evidence, evidenceItem(s))
|
|
||||||
}
|
|
||||||
for i, s := range f.csprojFiles {
|
|
||||||
if i >= 3 {
|
|
||||||
evidence = append(evidence, fmt.Sprintf("...and %d more .csproj files", len(f.csprojFiles)-3))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
evidence = append(evidence, evidenceItem(s))
|
|
||||||
}
|
|
||||||
stacks = append(stacks, Stack{Name: "csharp", Confidence: "high", Evidence: evidence})
|
|
||||||
} else if f.hasGlobalJSON {
|
|
||||||
stacks = append(stacks, Stack{
|
|
||||||
Name: "csharp", Confidence: "medium",
|
|
||||||
Evidence: []string{"found: `global.json`"},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// React — high if package.json lists react; medium if TypeScript + tsx/jsx files.
|
|
||||||
reactFound := false
|
|
||||||
for _, pkg := range f.packageJSONs {
|
|
||||||
if pkg.hasReact {
|
|
||||||
stacks = append(stacks, Stack{
|
|
||||||
Name: "react",
|
|
||||||
Confidence: "high",
|
|
||||||
Evidence: []string{fmt.Sprintf("found: `%s` with `react` in dependencies", pkg.relPath)},
|
|
||||||
})
|
|
||||||
reactFound = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !reactFound {
|
|
||||||
for _, pkg := range f.packageJSONs {
|
|
||||||
if pkg.hasTypeScript && f.hasTSXorJSX {
|
|
||||||
stacks = append(stacks, Stack{
|
|
||||||
Name: "react",
|
|
||||||
Confidence: "medium",
|
|
||||||
Evidence: []string{fmt.Sprintf("found: `%s` with TypeScript + `.tsx`/`.jsx` files", pkg.relPath)},
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Go — high if go.mod is at the root (not nested).
|
|
||||||
if f.hasGoModRoot {
|
|
||||||
stacks = append(stacks, Stack{
|
|
||||||
Name: "go", Confidence: "high",
|
|
||||||
Evidence: []string{"found: `go.mod`"},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Python — medium if any standard config file is present.
|
|
||||||
if f.hasPyProject || f.hasReqTxt || f.hasSetupPy {
|
|
||||||
var evidence []string
|
|
||||||
if f.hasPyProject {
|
|
||||||
evidence = append(evidence, "found: `pyproject.toml`")
|
|
||||||
}
|
|
||||||
if f.hasReqTxt {
|
|
||||||
evidence = append(evidence, "found: `requirements.txt`")
|
|
||||||
}
|
|
||||||
if f.hasSetupPy {
|
|
||||||
evidence = append(evidence, "found: `setup.py`")
|
|
||||||
}
|
|
||||||
stacks = append(stacks, Stack{Name: "python", Confidence: "medium", Evidence: evidence})
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.SliceStable(stacks, func(i, j int) bool {
|
|
||||||
return confidenceRank(stacks[i].Confidence) > confidenceRank(stacks[j].Confidence)
|
|
||||||
})
|
|
||||||
|
|
||||||
return stacks
|
|
||||||
}
|
|
||||||
|
|
||||||
// evidenceItem formats a relative path for evidence display.
|
|
||||||
// Root-level files get an "at root" suffix.
|
|
||||||
func evidenceItem(relFwd string) string {
|
|
||||||
if !strings.Contains(relFwd, "/") {
|
|
||||||
return fmt.Sprintf("found: `%s` at root", relFwd)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("found: `%s`", relFwd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func confidenceRank(c string) int {
|
|
||||||
switch c {
|
|
||||||
case "high":
|
|
||||||
return 3
|
|
||||||
case "medium":
|
|
||||||
return 2
|
|
||||||
case "low":
|
|
||||||
return 1
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +1,10 @@
|
|||||||
// Package csharp implements the ctx csharp plugin — .NET solution analysis via Roslyn helper.
|
// Package csharp implements the ctx csharp plugin.
|
||||||
|
// Full implementation: prompts 4–6 (requires Roslyn helper from prompt 3).
|
||||||
package csharp
|
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"
|
||||||
)
|
)
|
||||||
@ -13,20 +16,16 @@ 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.1.0" }
|
func (c *csharpPlugin) Version() string { return "0.0.1" }
|
||||||
func (c *csharpPlugin) ShortDescription() string { return "C# / .NET project analysis via Roslyn" }
|
func (c *csharpPlugin) 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 {
|
||||||
cmd := &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "csharp",
|
Use: "csharp",
|
||||||
Short: c.ShortDescription(),
|
Short: c.ShortDescription(),
|
||||||
Long: `Analyze C# / .NET projects and emit compact markdown summaries
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
optimized for Claude Code consumption.
|
fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in prompt 4")
|
||||||
|
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,425 +0,0 @@
|
|||||||
package csharp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/ricarneiro/ctx/internal/output"
|
|
||||||
"github.com/ricarneiro/ctx/internal/plugins/csharp/helper"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WriteSummary formats a ProjectSummary as dense markdown.
|
|
||||||
func WriteSummary(w io.Writer, s *helper.ProjectSummary) error {
|
|
||||||
output.H1(w, "Solution: "+s.SolutionName)
|
|
||||||
writeOverview(w, s)
|
|
||||||
output.H2(w, "Projects")
|
|
||||||
writeProjects(w, s.Projects)
|
|
||||||
writeReferenceGraph(w, s.Projects)
|
|
||||||
writeMultiTargeting(w, s.Projects)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeOverview(w io.Writer, s *helper.ProjectSummary) {
|
|
||||||
exeCount, libCount := 0, 0
|
|
||||||
totalDocs := 0
|
|
||||||
for _, p := range s.Projects {
|
|
||||||
if p.Type == "exe" {
|
|
||||||
exeCount++
|
|
||||||
} else {
|
|
||||||
libCount++
|
|
||||||
}
|
|
||||||
totalDocs += p.DocumentCount
|
|
||||||
}
|
|
||||||
|
|
||||||
projectSummary := fmt.Sprintf("%d", len(s.Projects))
|
|
||||||
parts := []string{}
|
|
||||||
if exeCount > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("%d exe", exeCount))
|
|
||||||
}
|
|
||||||
if libCount > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("%d lib", libCount))
|
|
||||||
}
|
|
||||||
if len(parts) > 0 {
|
|
||||||
projectSummary += " (" + strings.Join(parts, ", ") + ")"
|
|
||||||
}
|
|
||||||
|
|
||||||
output.KeyValue(w, "path", "`"+s.SolutionPath+"`")
|
|
||||||
output.KeyValue(w, "projects", projectSummary)
|
|
||||||
output.KeyValue(w, "documents", fmt.Sprintf("%d", totalDocs))
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeProjects(w io.Writer, projects []helper.ProjectInfo) {
|
|
||||||
for _, p := range projects {
|
|
||||||
output.H3(w, fmt.Sprintf("%s (%s)", p.Name, p.Type))
|
|
||||||
|
|
||||||
fmt.Fprintf(w, "- **path:** `%s`\n", p.Path)
|
|
||||||
|
|
||||||
targets := strings.Join(p.TargetFrameworks, ", ")
|
|
||||||
if targets == "" {
|
|
||||||
targets = "_(unknown)_"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "- **target:** %s\n", targets)
|
|
||||||
fmt.Fprintf(w, "- **namespace:** `%s`\n", p.RootNamespace)
|
|
||||||
fmt.Fprintf(w, "- **documents:** %d\n", p.DocumentCount)
|
|
||||||
|
|
||||||
// Project references
|
|
||||||
if len(p.ProjectReferences) == 0 {
|
|
||||||
fmt.Fprintf(w, "- **references:** _(none)_\n")
|
|
||||||
} else {
|
|
||||||
refs := make([]string, len(p.ProjectReferences))
|
|
||||||
for i, r := range p.ProjectReferences {
|
|
||||||
refs[i] = "`" + r + "`"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "- **references:**\n")
|
|
||||||
for _, r := range refs {
|
|
||||||
fmt.Fprintf(w, " - %s\n", r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Package references
|
|
||||||
if len(p.PackageReferences) == 0 {
|
|
||||||
fmt.Fprintf(w, "- **packages:** _(none)_\n")
|
|
||||||
} else {
|
|
||||||
pkgs := make([]string, len(p.PackageReferences))
|
|
||||||
for i, pkg := range p.PackageReferences {
|
|
||||||
pkgs[i] = pkg.Name + " " + pkg.Version
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "- **packages:** %s\n", strings.Join(pkgs, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeReferenceGraph(w io.Writer, projects []helper.ProjectInfo) {
|
|
||||||
// Build reverse dependency map: who depends on each project
|
|
||||||
dependents := map[string][]string{}
|
|
||||||
refMap := map[string][]string{}
|
|
||||||
nameSet := map[string]bool{}
|
|
||||||
|
|
||||||
for _, p := range projects {
|
|
||||||
nameSet[p.Name] = true
|
|
||||||
refMap[p.Name] = p.ProjectReferences
|
|
||||||
for _, ref := range p.ProjectReferences {
|
|
||||||
dependents[ref] = append(dependents[ref], p.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there are any references at all
|
|
||||||
hasRefs := false
|
|
||||||
for _, p := range projects {
|
|
||||||
if len(p.ProjectReferences) > 0 {
|
|
||||||
hasRefs = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
output.H2(w, "Reference graph")
|
|
||||||
|
|
||||||
if !hasRefs {
|
|
||||||
fmt.Fprintf(w, "No inter-project references.\n\n")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// For complex graphs (>5 projects with references), use flat list
|
|
||||||
refsCount := 0
|
|
||||||
for _, p := range projects {
|
|
||||||
if len(p.ProjectReferences) > 0 {
|
|
||||||
refsCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(projects) > 5 && refsCount > 3 {
|
|
||||||
for _, p := range projects {
|
|
||||||
if len(p.ProjectReferences) > 0 {
|
|
||||||
refs := make([]string, len(p.ProjectReferences))
|
|
||||||
for i, r := range p.ProjectReferences {
|
|
||||||
refs[i] = "`" + r + "`"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "- `%s` → %s\n", p.Name, strings.Join(refs, ", "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// DFS from roots (projects with no dependents)
|
|
||||||
roots := []string{}
|
|
||||||
for _, p := range projects {
|
|
||||||
if len(dependents[p.Name]) == 0 {
|
|
||||||
roots = append(roots, p.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(roots) == 0 {
|
|
||||||
// Cycle or all have dependents — fall back to flat list
|
|
||||||
for _, p := range projects {
|
|
||||||
if len(p.ProjectReferences) > 0 {
|
|
||||||
refs := make([]string, len(p.ProjectReferences))
|
|
||||||
for i, r := range p.ProjectReferences {
|
|
||||||
refs[i] = "`" + r + "`"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "- `%s` → %s\n", p.Name, strings.Join(refs, ", "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
visited := map[string]bool{}
|
|
||||||
for _, root := range roots {
|
|
||||||
dfsRender(&sb, root, refMap, visited, 0)
|
|
||||||
}
|
|
||||||
output.CodeBlock(w, "", strings.TrimRight(sb.String(), "\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func dfsRender(sb *strings.Builder, name string, refMap map[string][]string, visited map[string]bool, depth int) {
|
|
||||||
indent := strings.Repeat(" ", depth)
|
|
||||||
refs := refMap[name]
|
|
||||||
if len(refs) == 0 {
|
|
||||||
if depth == 0 {
|
|
||||||
fmt.Fprintf(sb, "%s%s (no deps)\n", indent, name)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(sb, "%s%s\n", indent, name)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if visited[name] {
|
|
||||||
fmt.Fprintf(sb, "%s%s (see above)\n", indent, name)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
visited[name] = true
|
|
||||||
|
|
||||||
for i, ref := range refs {
|
|
||||||
if i == 0 {
|
|
||||||
fmt.Fprintf(sb, "%s%s → %s\n", indent, name, ref)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(sb, "%s%s └──→ %s\n", indent, strings.Repeat(" ", len(name)), ref)
|
|
||||||
}
|
|
||||||
dfsRender(sb, ref, refMap, visited, depth+1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeMultiTargeting(w io.Writer, projects []helper.ProjectInfo) {
|
|
||||||
output.H2(w, "Multi-targeting")
|
|
||||||
|
|
||||||
// Collect all unique frameworks
|
|
||||||
frameworkSets := map[string][]string{} // project name → frameworks
|
|
||||||
allFrameworks := map[string]bool{}
|
|
||||||
|
|
||||||
for _, p := range projects {
|
|
||||||
if len(p.TargetFrameworks) > 1 {
|
|
||||||
frameworkSets[p.Name] = p.TargetFrameworks
|
|
||||||
}
|
|
||||||
for _, tf := range p.TargetFrameworks {
|
|
||||||
allFrameworks[tf] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(frameworkSets) == 0 {
|
|
||||||
// All same framework, or single framework each
|
|
||||||
switch len(allFrameworks) {
|
|
||||||
case 0:
|
|
||||||
fmt.Fprintf(w, "No target frameworks detected.\n\n")
|
|
||||||
case 1:
|
|
||||||
for tf := range allFrameworks {
|
|
||||||
fmt.Fprintf(w, "None — all projects target `%s`.\n\n", tf)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// Multiple different single targets
|
|
||||||
for _, p := range projects {
|
|
||||||
if len(p.TargetFrameworks) > 0 {
|
|
||||||
fmt.Fprintf(w, "- `%s` targets: `%s`\n", p.Name, strings.Join(p.TargetFrameworks, "`, `"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range projects {
|
|
||||||
if tfs, ok := frameworkSets[p.Name]; ok {
|
|
||||||
fmt.Fprintf(w, "- `%s` targets: `%s`\n", p.Name, strings.Join(tfs, "`, `"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Outline formatter ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// WriteOutline formats an OutlineResult as dense markdown.
|
|
||||||
func WriteOutline(w io.Writer, o *helper.OutlineResult) error {
|
|
||||||
fileName := filepath.Base(o.Path)
|
|
||||||
output.H1(w, "Outline: "+fileName)
|
|
||||||
|
|
||||||
if o.HasSyntaxErrors {
|
|
||||||
fmt.Fprintf(w, "> ⚠️ File has syntax errors — outline may be incomplete.\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overview
|
|
||||||
typeSummary := outlineTypesSummary(o.Types)
|
|
||||||
output.KeyValue(w, "path", "`"+o.Path+"`")
|
|
||||||
if o.Namespace != "" {
|
|
||||||
output.KeyValue(w, "namespace", "`"+o.Namespace+"`")
|
|
||||||
}
|
|
||||||
output.KeyValue(w, "lines", fmt.Sprintf("%d", o.LineCount))
|
|
||||||
output.KeyValue(w, "types", typeSummary)
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
|
|
||||||
// Usings
|
|
||||||
if len(o.Usings) > 0 {
|
|
||||||
output.H2(w, "Usings")
|
|
||||||
for _, u := range o.Usings {
|
|
||||||
fmt.Fprintf(w, "- `%s`\n", u)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Types
|
|
||||||
if len(o.Types) > 0 {
|
|
||||||
output.H2(w, "Types")
|
|
||||||
for i := range o.Types {
|
|
||||||
writeOutlineType(w, &o.Types[i], 3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func outlineTypesSummary(types []helper.OutlineType) string {
|
|
||||||
counts := map[string]int{}
|
|
||||||
for _, t := range types {
|
|
||||||
counts[t.Kind]++
|
|
||||||
}
|
|
||||||
if len(counts) == 0 {
|
|
||||||
return "none"
|
|
||||||
}
|
|
||||||
// Fixed display order
|
|
||||||
order := []string{"class", "interface", "struct", "record", "record struct", "enum"}
|
|
||||||
parts := []string{}
|
|
||||||
for _, k := range order {
|
|
||||||
if n, ok := counts[k]; ok {
|
|
||||||
parts = append(parts, fmt.Sprintf("%d %s", n, k))
|
|
||||||
delete(counts, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Any remaining unknown kinds
|
|
||||||
for k, n := range counts {
|
|
||||||
parts = append(parts, fmt.Sprintf("%d %s", n, k))
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeOutlineType(w io.Writer, t *helper.OutlineType, headingLevel int) {
|
|
||||||
// Build header: `kind Name : Base1, Base2` (modifiers)
|
|
||||||
header := t.Kind + " " + t.Name
|
|
||||||
if len(t.BaseTypes) > 0 {
|
|
||||||
header += " : " + strings.Join(t.BaseTypes, ", ")
|
|
||||||
}
|
|
||||||
heading := "`" + header + "`"
|
|
||||||
if len(t.Modifiers) > 0 {
|
|
||||||
heading += " (" + strings.Join(t.Modifiers, ", ") + ")"
|
|
||||||
}
|
|
||||||
writeHeading(w, headingLevel, heading)
|
|
||||||
|
|
||||||
// Group members by kind, in canonical order
|
|
||||||
writeOutlineMembers(w, t.Members, headingLevel+1)
|
|
||||||
|
|
||||||
// Nested types — shown as bullet list for simplicity
|
|
||||||
if len(t.Nested) > 0 {
|
|
||||||
writeHeading(w, headingLevel+1, "Nested types")
|
|
||||||
for _, n := range t.Nested {
|
|
||||||
nestedHeader := n.Kind + " " + n.Name
|
|
||||||
if len(n.BaseTypes) > 0 {
|
|
||||||
nestedHeader += " : " + strings.Join(n.BaseTypes, ", ")
|
|
||||||
}
|
|
||||||
prefix := modPrefix(n.Modifiers)
|
|
||||||
fmt.Fprintf(w, "- `%s%s`\n", prefix, nestedHeader)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeOutlineMembers(w io.Writer, members []helper.OutlineMember, headingLevel int) {
|
|
||||||
// Collect by kind
|
|
||||||
var fields, constructors, properties, methods, events []helper.OutlineMember
|
|
||||||
for _, m := range members {
|
|
||||||
switch m.Kind {
|
|
||||||
case "field":
|
|
||||||
fields = append(fields, m)
|
|
||||||
case "constructor":
|
|
||||||
constructors = append(constructors, m)
|
|
||||||
case "property":
|
|
||||||
properties = append(properties, m)
|
|
||||||
case "method":
|
|
||||||
methods = append(methods, m)
|
|
||||||
case "event":
|
|
||||||
events = append(events, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(fields) > 0 {
|
|
||||||
writeHeading(w, headingLevel, "Fields")
|
|
||||||
for _, m := range fields {
|
|
||||||
writeMemberLine(w, m)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
}
|
|
||||||
if len(constructors) > 0 {
|
|
||||||
writeHeading(w, headingLevel, "Constructor")
|
|
||||||
for _, m := range constructors {
|
|
||||||
writeMemberLine(w, m)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
}
|
|
||||||
if len(properties) > 0 {
|
|
||||||
writeHeading(w, headingLevel, "Properties")
|
|
||||||
for _, m := range properties {
|
|
||||||
writeMemberLine(w, m)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
}
|
|
||||||
if len(methods) > 0 {
|
|
||||||
writeHeading(w, headingLevel, "Methods")
|
|
||||||
for _, m := range methods {
|
|
||||||
writeMemberLine(w, m)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
}
|
|
||||||
if len(events) > 0 {
|
|
||||||
writeHeading(w, headingLevel, "Events")
|
|
||||||
for _, m := range events {
|
|
||||||
writeMemberLine(w, m)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeMemberLine(w io.Writer, m helper.OutlineMember) {
|
|
||||||
prefix := modPrefix(m.Modifiers)
|
|
||||||
obsolete := ""
|
|
||||||
if m.IsObsolete {
|
|
||||||
obsolete = " _(obsolete)_"
|
|
||||||
}
|
|
||||||
lineRef := ""
|
|
||||||
if m.Line > 0 {
|
|
||||||
lineRef = fmt.Sprintf(" (line %d)", m.Line)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "- `%s%s`%s%s\n", prefix, m.Signature, lineRef, obsolete)
|
|
||||||
}
|
|
||||||
|
|
||||||
func modPrefix(mods []string) string {
|
|
||||||
if len(mods) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.Join(mods, " ") + " "
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeHeading(w io.Writer, level int, text string) {
|
|
||||||
fmt.Fprintf(w, "%s %s\n\n", strings.Repeat("#", level), text)
|
|
||||||
}
|
|
||||||
@ -1,185 +0,0 @@
|
|||||||
package helper
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Client is a high-level typed interface to the roslyn-helper subprocess.
|
|
||||||
type Client struct {
|
|
||||||
proc *Process
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClient locates the helper binary, starts the process, and verifies it
|
|
||||||
// responds to ping. Returns an error if any step fails.
|
|
||||||
func NewClient() (*Client, error) {
|
|
||||||
helperPath, err := LocateHelper()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
proc, err := Start(helperPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("start roslyn helper: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c := &Client{proc: proc}
|
|
||||||
if _, err := c.Ping(); err != nil {
|
|
||||||
_ = proc.Close()
|
|
||||||
return nil, fmt.Errorf("roslyn helper ping failed: %w", err)
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close shuts down the helper process.
|
|
||||||
func (c *Client) Close() error {
|
|
||||||
return c.proc.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Result types ---
|
|
||||||
|
|
||||||
// PingResult is returned by the ping method.
|
|
||||||
type PingResult struct {
|
|
||||||
Pong bool `json:"pong"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadSolutionResult is returned by the loadSolution method.
|
|
||||||
type LoadSolutionResult struct {
|
|
||||||
Loaded bool `json:"loaded"`
|
|
||||||
ProjectCount int `json:"projectCount"`
|
|
||||||
DocumentCount int `json:"documentCount"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProjectSummary is the top-level result of the projectSummary method.
|
|
||||||
type ProjectSummary struct {
|
|
||||||
SolutionPath string `json:"solutionPath"`
|
|
||||||
SolutionName string `json:"solutionName"`
|
|
||||||
Projects []ProjectInfo `json:"projects"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProjectInfo describes a single project in the solution.
|
|
||||||
type ProjectInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
TargetFrameworks []string `json:"targetFrameworks"`
|
|
||||||
OutputType string `json:"outputType"`
|
|
||||||
RootNamespace string `json:"rootNamespace"`
|
|
||||||
DocumentCount int `json:"documentCount"`
|
|
||||||
ProjectReferences []string `json:"projectReferences"`
|
|
||||||
PackageReferences []PackageReference `json:"packageReferences"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PackageReference is a NuGet package dependency.
|
|
||||||
type PackageReference struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Methods ---
|
|
||||||
|
|
||||||
// Ping sends a ping to the helper and returns the pong result.
|
|
||||||
func (c *Client) Ping() (*PingResult, error) {
|
|
||||||
raw, err := c.proc.Send("ping", nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, wrapRPC("ping", err)
|
|
||||||
}
|
|
||||||
var r PingResult
|
|
||||||
if err := json.Unmarshal(raw, &r); err != nil {
|
|
||||||
return nil, fmt.Errorf("ping: decode response: %w", err)
|
|
||||||
}
|
|
||||||
return &r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadSolution instructs the helper to load the given .sln or .csproj file.
|
|
||||||
func (c *Client) LoadSolution(path string) (*LoadSolutionResult, error) {
|
|
||||||
params := map[string]string{"path": path}
|
|
||||||
raw, err := c.proc.Send("loadSolution", params)
|
|
||||||
if err != nil {
|
|
||||||
return nil, wrapRPC("loadSolution", err)
|
|
||||||
}
|
|
||||||
var r LoadSolutionResult
|
|
||||||
if err := json.Unmarshal(raw, &r); err != nil {
|
|
||||||
return nil, fmt.Errorf("loadSolution: decode response: %w", err)
|
|
||||||
}
|
|
||||||
return &r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProjectSummary retrieves the project summary for the currently loaded solution.
|
|
||||||
func (c *Client) ProjectSummary() (*ProjectSummary, error) {
|
|
||||||
raw, err := c.proc.Send("projectSummary", nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, wrapRPC("projectSummary", err)
|
|
||||||
}
|
|
||||||
var r ProjectSummary
|
|
||||||
if err := json.Unmarshal(raw, &r); err != nil {
|
|
||||||
return nil, fmt.Errorf("projectSummary: decode response: %w", err)
|
|
||||||
}
|
|
||||||
return &r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Outline types ---
|
|
||||||
|
|
||||||
// OutlineResult is the structural outline of a single .cs file.
|
|
||||||
type OutlineResult struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
Namespace string `json:"namespace"`
|
|
||||||
LineCount int `json:"lineCount"`
|
|
||||||
Usings []string `json:"usings"`
|
|
||||||
Types []OutlineType `json:"types"`
|
|
||||||
HasSyntaxErrors bool `json:"hasSyntaxErrors"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OutlineType describes a type (class, interface, struct, record, enum) in the file.
|
|
||||||
type OutlineType struct {
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Modifiers []string `json:"modifiers"`
|
|
||||||
BaseTypes []string `json:"baseTypes"`
|
|
||||||
Members []OutlineMember `json:"members"`
|
|
||||||
Nested []OutlineType `json:"nested"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OutlineMember describes a member of a type (method, property, field, event, constructor).
|
|
||||||
type OutlineMember struct {
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Signature string `json:"signature"`
|
|
||||||
Modifiers []string `json:"modifiers"`
|
|
||||||
Line int `json:"line"`
|
|
||||||
IsObsolete bool `json:"isObsolete,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Outline requests a structural outline of the given .cs file.
|
|
||||||
// Does not require a solution to be loaded.
|
|
||||||
func (c *Client) Outline(path string) (*OutlineResult, error) {
|
|
||||||
params := map[string]string{"path": path}
|
|
||||||
raw, err := c.proc.Send("outline", params)
|
|
||||||
if err != nil {
|
|
||||||
return nil, wrapRPC("outline", err)
|
|
||||||
}
|
|
||||||
var r OutlineResult
|
|
||||||
if err := json.Unmarshal(raw, &r); err != nil {
|
|
||||||
return nil, fmt.Errorf("outline: decode response: %w", err)
|
|
||||||
}
|
|
||||||
return &r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// wrapRPC wraps RPCError values into user-friendly messages.
|
|
||||||
func wrapRPC(method string, err error) error {
|
|
||||||
var rpcErr *RPCError
|
|
||||||
if errors.As(err, &rpcErr) {
|
|
||||||
switch rpcErr.Code {
|
|
||||||
case "E_NOT_FOUND":
|
|
||||||
return fmt.Errorf("solution not found: %s", rpcErr.Message)
|
|
||||||
case "E_LOAD_FAILED":
|
|
||||||
return fmt.Errorf("failed to load solution: %s", rpcErr.Message)
|
|
||||||
case "E_INVALID_PARAMS":
|
|
||||||
return fmt.Errorf("invalid request: %s", rpcErr.Message)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("%s failed: %s", method, rpcErr.Message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
package helper
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
const helperBinary = "ctx-roslyn-helper"
|
|
||||||
|
|
||||||
// ErrHelperNotFound is returned when the Roslyn helper binary cannot be located.
|
|
||||||
var ErrHelperNotFound = errors.New("roslyn helper not found")
|
|
||||||
|
|
||||||
// LocateHelper searches for the ctx-roslyn-helper binary using four strategies:
|
|
||||||
// 1. CTX_ROSLYN_HELPER environment variable
|
|
||||||
// 2. Same directory as the running ctx binary
|
|
||||||
// 3. PATH
|
|
||||||
// 4. tools/roslyn-helper/publish/ relative to working directory
|
|
||||||
//
|
|
||||||
// Returns the absolute path or ErrHelperNotFound with a diagnostic message.
|
|
||||||
func LocateHelper() (string, error) {
|
|
||||||
name := helperBinary
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
name += ".exe"
|
|
||||||
}
|
|
||||||
|
|
||||||
tried := []string{}
|
|
||||||
|
|
||||||
// 1. Environment variable
|
|
||||||
if env := os.Getenv("CTX_ROSLYN_HELPER"); env != "" {
|
|
||||||
if fileExists(env) {
|
|
||||||
return env, nil
|
|
||||||
}
|
|
||||||
tried = append(tried, fmt.Sprintf("$CTX_ROSLYN_HELPER = %s (not found)", env))
|
|
||||||
} else {
|
|
||||||
tried = append(tried, "$CTX_ROSLYN_HELPER (not set)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Same directory as ctx binary
|
|
||||||
if exePath, err := os.Executable(); err == nil {
|
|
||||||
exeDir := filepath.Dir(exePath)
|
|
||||||
candidate := filepath.Join(exeDir, name)
|
|
||||||
if fileExists(candidate) {
|
|
||||||
return candidate, nil
|
|
||||||
}
|
|
||||||
tried = append(tried, fmt.Sprintf("%s (not found)", exeDir))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. PATH
|
|
||||||
if found, err := exec.LookPath(name); err == nil {
|
|
||||||
return found, nil
|
|
||||||
}
|
|
||||||
tried = append(tried, "PATH (not found)")
|
|
||||||
|
|
||||||
// 4. Dev fallback: tools/roslyn-helper/publish/ relative to working dir
|
|
||||||
if cwd, err := os.Getwd(); err == nil {
|
|
||||||
candidate := filepath.Join(cwd, "tools", "roslyn-helper", "publish", name)
|
|
||||||
if fileExists(candidate) {
|
|
||||||
return candidate, nil
|
|
||||||
}
|
|
||||||
tried = append(tried, fmt.Sprintf("tools/roslyn-helper/publish/ (not found)"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("%w\n\nLooked in:\n - %s\n - %s\n - %s\n - %s\n\nTo build the helper, run:\n cd tools\\roslyn-helper\n dotnet publish src/RoslynHelper -c Release -r win-x64 --self-contained false -o publish/",
|
|
||||||
ErrHelperNotFound,
|
|
||||||
safeIdx(tried, 0, "$CTX_ROSLYN_HELPER (not set)"),
|
|
||||||
safeIdx(tried, 1, "ctx.exe directory"),
|
|
||||||
safeIdx(tried, 2, "PATH"),
|
|
||||||
safeIdx(tried, 3, "tools/roslyn-helper/publish/"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fileExists(path string) bool {
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
return err == nil && !info.IsDir()
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeIdx(s []string, i int, fallback string) string {
|
|
||||||
if i < len(s) {
|
|
||||||
return s[i]
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
package helper
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os/exec"
|
|
||||||
"sync/atomic"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Process manages the lifetime of the roslyn-helper subprocess.
|
|
||||||
// Calls are sequential — no concurrency within a single Process.
|
|
||||||
type Process struct {
|
|
||||||
cmd *exec.Cmd
|
|
||||||
stdin io.WriteCloser
|
|
||||||
stdout *bufio.Reader
|
|
||||||
nextID atomic.Int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start launches the helper subprocess and returns a Process ready to use.
|
|
||||||
func Start(helperPath string) (*Process, error) {
|
|
||||||
cmd := exec.Command(helperPath)
|
|
||||||
|
|
||||||
stdin, err := cmd.StdinPipe()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("helper stdin pipe: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stdoutPipe, err := cmd.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("helper stdout pipe: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stderrPipe, err := cmd.StderrPipe()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("helper stderr pipe: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
return nil, fmt.Errorf("helper start: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drain stderr in background to prevent blocking.
|
|
||||||
go func() { _, _ = io.Copy(io.Discard, stderrPipe) }()
|
|
||||||
|
|
||||||
p := &Process{
|
|
||||||
cmd: cmd,
|
|
||||||
stdin: stdin,
|
|
||||||
stdout: bufio.NewReader(stdoutPipe),
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send sends a JSON-RPC request and returns the raw result JSON.
|
|
||||||
// Returns an error if the helper returns an RpcError or dies.
|
|
||||||
func (p *Process) Send(method string, params interface{}) (json.RawMessage, error) {
|
|
||||||
id := int(p.nextID.Add(1))
|
|
||||||
|
|
||||||
var rawParams json.RawMessage
|
|
||||||
if params != nil {
|
|
||||||
var err error
|
|
||||||
rawParams, err = json.Marshal(params)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("marshal params: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
req := Request{ID: id, Method: method, Params: rawParams}
|
|
||||||
line, err := json.Marshal(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("marshal request: %w", err)
|
|
||||||
}
|
|
||||||
line = append(line, '\n')
|
|
||||||
|
|
||||||
if _, werr := p.stdin.Write(line); werr != nil {
|
|
||||||
return nil, fmt.Errorf("helper write (process may have crashed): %w", werr)
|
|
||||||
}
|
|
||||||
|
|
||||||
respLine, err := p.stdout.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("helper read (process may have crashed): %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp Response
|
|
||||||
if err := json.Unmarshal([]byte(respLine), &resp); err != nil {
|
|
||||||
return nil, fmt.Errorf("unmarshal response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.ID != id {
|
|
||||||
return nil, fmt.Errorf("response id mismatch: got %d, want %d", resp.ID, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.Error != nil {
|
|
||||||
return nil, resp.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp.Result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close sends a shutdown request, closes stdin, and waits for the process to exit.
|
|
||||||
func (p *Process) Close() error {
|
|
||||||
// Best-effort shutdown — ignore errors here.
|
|
||||||
_, _ = p.Send("shutdown", nil)
|
|
||||||
_ = p.stdin.Close()
|
|
||||||
_ = p.cmd.Wait()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
package helper
|
|
||||||
|
|
||||||
import "encoding/json"
|
|
||||||
|
|
||||||
// Request is a newline-delimited JSON-RPC request sent to the helper process.
|
|
||||||
type Request struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Method string `json:"method"`
|
|
||||||
Params json.RawMessage `json:"params"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response is a newline-delimited JSON-RPC response from the helper process.
|
|
||||||
type Response struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Result json.RawMessage `json:"result,omitempty"`
|
|
||||||
Error *RPCError `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RpcError is a structured error from the helper process.
|
|
||||||
type RPCError struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Data json.RawMessage `json:"data,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *RPCError) Error() string { return e.Code + ": " + e.Message }
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
package csharp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/ricarneiro/ctx/internal/core"
|
|
||||||
"github.com/ricarneiro/ctx/internal/plugins/csharp/helper"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func outlineCmd(ctx *core.Context) *cobra.Command {
|
|
||||||
return &cobra.Command{
|
|
||||||
Use: "outline <file.cs>",
|
|
||||||
Short: "Show structural outline of a C# file (no method bodies)",
|
|
||||||
Long: `Parse a C# source file and emit its structural skeleton:
|
|
||||||
namespaces, types, method signatures, properties, fields, events.
|
|
||||||
Method bodies are omitted — reduces large files by 80–90%.
|
|
||||||
|
|
||||||
Does not require a loaded solution. Works on a single file.`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return runOutline(ctx, args[0])
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runOutline(ctx *core.Context, file string) error {
|
|
||||||
abs, err := resolveFilePath(ctx.WorkDir, file)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(ctx.Stderr, err.Error())
|
|
||||||
return errExit
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasSuffix(strings.ToLower(abs), ".cs") {
|
|
||||||
fmt.Fprintf(ctx.Stderr, "not a C# file: %s\n", abs)
|
|
||||||
return errExit
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, statErr := os.Stat(abs); statErr != nil {
|
|
||||||
fmt.Fprintf(ctx.Stderr, "file not found: %s\n", abs)
|
|
||||||
return errExit
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := helper.NewClient()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(ctx.Stderr, err.Error())
|
|
||||||
return errExit
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
outline, err := client.Outline(abs)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(ctx.Stderr, err.Error())
|
|
||||||
return errExit
|
|
||||||
}
|
|
||||||
|
|
||||||
return WriteOutline(ctx.Stdout, outline)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveFilePath(workDir, file string) (string, error) {
|
|
||||||
if filepath.IsAbs(file) {
|
|
||||||
return filepath.Clean(file), nil
|
|
||||||
}
|
|
||||||
return filepath.Clean(filepath.Join(workDir, file)), nil
|
|
||||||
}
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
package csharp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
|
|
||||||
"github.com/ricarneiro/ctx/internal/core"
|
|
||||||
"github.com/ricarneiro/ctx/internal/plugins/csharp/helper"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// errExit is a sentinel used to trigger os.Exit(1) via the SilenceErrors flow.
|
|
||||||
// The real error message has already been printed to ctx.Stderr.
|
|
||||||
var errExit = fmt.Errorf("exit 1")
|
|
||||||
|
|
||||||
func projectCmd(ctx *core.Context) *cobra.Command {
|
|
||||||
return &cobra.Command{
|
|
||||||
Use: "project",
|
|
||||||
Short: "Summarize the .NET solution in compact markdown",
|
|
||||||
Long: `Analyze the .NET solution in the current directory and emit a compact
|
|
||||||
markdown summary suitable for Claude Code consumption.
|
|
||||||
|
|
||||||
Requires the Roslyn helper (ctx-roslyn-helper) to be built and accessible.
|
|
||||||
Set CTX_ROSLYN_HELPER to the exact path, or build it alongside ctx.exe.`,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return runProject(ctx)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runProject(ctx *core.Context) error {
|
|
||||||
slnPath, err := findSolution(ctx.WorkDir, ctx)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(ctx.Stderr, err.Error())
|
|
||||||
return errExit
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := helper.NewClient()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(ctx.Stderr, err.Error())
|
|
||||||
return errExit
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
if _, loadErr := client.LoadSolution(slnPath); loadErr != nil {
|
|
||||||
fmt.Fprintln(ctx.Stderr, loadErr.Error())
|
|
||||||
return errExit
|
|
||||||
}
|
|
||||||
|
|
||||||
summary, err := client.ProjectSummary()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(ctx.Stderr, err.Error())
|
|
||||||
return errExit
|
|
||||||
}
|
|
||||||
|
|
||||||
return WriteSummary(ctx.Stdout, summary)
|
|
||||||
}
|
|
||||||
|
|
||||||
// findSolution locates a .sln file in dir.
|
|
||||||
// Falls back to a single .csproj if no .sln exists.
|
|
||||||
func findSolution(dir string, ctx *core.Context) (string, error) {
|
|
||||||
slns, err := filepath.Glob(filepath.Join(dir, "*.sln"))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("glob .sln: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter to files that actually exist
|
|
||||||
slns = existingFiles(slns)
|
|
||||||
|
|
||||||
if len(slns) > 0 {
|
|
||||||
sort.Strings(slns)
|
|
||||||
if len(slns) > 1 {
|
|
||||||
fmt.Fprintf(ctx.Stderr, "warning: multiple .sln files found, using %s\n", filepath.Base(slns[0]))
|
|
||||||
}
|
|
||||||
return slns[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: single .csproj
|
|
||||||
csprojPaths, err := filepath.Glob(filepath.Join(dir, "*.csproj"))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("glob .csproj: %w", err)
|
|
||||||
}
|
|
||||||
csprojPaths = existingFiles(csprojPaths)
|
|
||||||
|
|
||||||
switch len(csprojPaths) {
|
|
||||||
case 0:
|
|
||||||
return "", fmt.Errorf("no .sln or .csproj found in %s", dir)
|
|
||||||
case 1:
|
|
||||||
return csprojPaths[0], nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"no .sln found and multiple .csproj files exist in %s\n"+
|
|
||||||
"Run ctx inside a specific project folder, or create a .sln:\n"+
|
|
||||||
" dotnet new sln --format sln\n"+
|
|
||||||
" dotnet sln add **/*.csproj",
|
|
||||||
dir,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func existingFiles(paths []string) []string {
|
|
||||||
out := paths[:0]
|
|
||||||
for _, p := range paths {
|
|
||||||
if _, err := os.Stat(p); err == nil {
|
|
||||||
out = append(out, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@ -1,292 +0,0 @@
|
|||||||
package git
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- Data types ---
|
|
||||||
|
|
||||||
type repoInfo struct {
|
|
||||||
branch string
|
|
||||||
upstream string // "origin/main" or "" if no upstream
|
|
||||||
ahead int
|
|
||||||
behind int
|
|
||||||
lastFetch time.Time
|
|
||||||
hasFetch bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type commit struct {
|
|
||||||
hash string
|
|
||||||
unixTs int64
|
|
||||||
author string
|
|
||||||
subject string
|
|
||||||
}
|
|
||||||
|
|
||||||
type fileChange struct {
|
|
||||||
path string
|
|
||||||
added int
|
|
||||||
removed int
|
|
||||||
}
|
|
||||||
|
|
||||||
type workingTree struct {
|
|
||||||
modified []fileChange // unstaged (git diff --numstat)
|
|
||||||
staged []fileChange // staged (git diff --cached --numstat)
|
|
||||||
untracked []string
|
|
||||||
}
|
|
||||||
|
|
||||||
type gitData struct {
|
|
||||||
repo repoInfo
|
|
||||||
commits []commit
|
|
||||||
tree workingTree
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Git runner ---
|
|
||||||
|
|
||||||
// gitCmd runs a git command in dir and returns trimmed stdout.
|
|
||||||
// Returns an error if git exits non-zero.
|
|
||||||
func gitCmd(dir string, args ...string) (string, error) {
|
|
||||||
cmd := exec.Command("git", args...)
|
|
||||||
cmd.Dir = dir
|
|
||||||
var out, errBuf bytes.Buffer
|
|
||||||
cmd.Stdout = &out
|
|
||||||
cmd.Stderr = &errBuf
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
|
|
||||||
}
|
|
||||||
return strings.TrimRight(out.String(), "\r\n"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Top-level collector ---
|
|
||||||
|
|
||||||
// collect gathers all git data in parallel. Returns a friendly error if dir
|
|
||||||
// is not inside a git repository.
|
|
||||||
func collect(dir string) (*gitData, error) {
|
|
||||||
// Verify we're in a repo before spawning goroutines.
|
|
||||||
if _, err := gitCmd(dir, "rev-parse", "--is-inside-work-tree"); err != nil {
|
|
||||||
return nil, fmt.Errorf("not a git repository (run from inside a git repo)")
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
data gitData
|
|
||||||
mu sync.Mutex
|
|
||||||
wg sync.WaitGroup
|
|
||||||
errs []error
|
|
||||||
)
|
|
||||||
|
|
||||||
record := func(err error) {
|
|
||||||
if err != nil {
|
|
||||||
mu.Lock()
|
|
||||||
errs = append(errs, err)
|
|
||||||
mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Add(3)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
info, err := collectRepoInfo(dir)
|
|
||||||
record(err)
|
|
||||||
if err == nil {
|
|
||||||
mu.Lock()
|
|
||||||
data.repo = info
|
|
||||||
mu.Unlock()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
commits, err := collectCommits(dir, 5)
|
|
||||||
record(err)
|
|
||||||
if err == nil {
|
|
||||||
mu.Lock()
|
|
||||||
data.commits = commits
|
|
||||||
mu.Unlock()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
tree, err := collectWorkingTree(dir)
|
|
||||||
record(err)
|
|
||||||
if err == nil {
|
|
||||||
mu.Lock()
|
|
||||||
data.tree = tree
|
|
||||||
mu.Unlock()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
if len(errs) > 0 {
|
|
||||||
return nil, errs[0]
|
|
||||||
}
|
|
||||||
return &data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Individual collectors ---
|
|
||||||
|
|
||||||
func collectRepoInfo(dir string) (repoInfo, error) {
|
|
||||||
var info repoInfo
|
|
||||||
|
|
||||||
branch, err := gitCmd(dir, "rev-parse", "--abbrev-ref", "HEAD")
|
|
||||||
if err != nil {
|
|
||||||
return info, err
|
|
||||||
}
|
|
||||||
info.branch = branch
|
|
||||||
|
|
||||||
// Ahead/behind vs upstream (fails gracefully if no upstream).
|
|
||||||
if ab, err := gitCmd(dir, "rev-list", "--left-right", "--count", "HEAD...@{u}"); err == nil {
|
|
||||||
parts := strings.Fields(ab)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
info.ahead, _ = strconv.Atoi(parts[0])
|
|
||||||
info.behind, _ = strconv.Atoi(parts[1])
|
|
||||||
if u, err := gitCmd(dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"); err == nil {
|
|
||||||
info.upstream = u
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FETCH_HEAD mtime — find git dir first (handles worktrees, submodules).
|
|
||||||
if gitDir, err := gitCmd(dir, "rev-parse", "--git-dir"); err == nil {
|
|
||||||
if !filepath.IsAbs(gitDir) {
|
|
||||||
gitDir = filepath.Join(dir, gitDir)
|
|
||||||
}
|
|
||||||
if fi, err := os.Stat(filepath.Join(gitDir, "FETCH_HEAD")); err == nil {
|
|
||||||
info.lastFetch = fi.ModTime()
|
|
||||||
info.hasFetch = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return info, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectCommits(dir string, n int) ([]commit, error) {
|
|
||||||
out, err := gitCmd(dir, "log", fmt.Sprintf("-n%d", n), "--pretty=format:%h|%at|%an|%s")
|
|
||||||
if err != nil || out == "" {
|
|
||||||
// Empty repo or no commits — not an error for our purposes.
|
|
||||||
return nil, nil //nolint:nilerr // intentional: git failures here mean "nothing to show"
|
|
||||||
}
|
|
||||||
var commits []commit
|
|
||||||
for _, line := range strings.Split(out, "\n") {
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// SplitN 4 so subject can contain "|".
|
|
||||||
parts := strings.SplitN(line, "|", 4)
|
|
||||||
if len(parts) != 4 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ts, _ := strconv.ParseInt(parts[1], 10, 64)
|
|
||||||
commits = append(commits, commit{
|
|
||||||
hash: parts[0],
|
|
||||||
unixTs: ts,
|
|
||||||
author: parts[2],
|
|
||||||
subject: parts[3],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return commits, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectWorkingTree(dir string) (workingTree, error) {
|
|
||||||
var (
|
|
||||||
tree workingTree
|
|
||||||
wg sync.WaitGroup
|
|
||||||
mu sync.Mutex
|
|
||||||
errs []error
|
|
||||||
)
|
|
||||||
|
|
||||||
record := func(err error) {
|
|
||||||
if err != nil {
|
|
||||||
mu.Lock()
|
|
||||||
errs = append(errs, err)
|
|
||||||
mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Add(3)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
changes, err := parseNumstat(dir, false)
|
|
||||||
record(err)
|
|
||||||
mu.Lock()
|
|
||||||
tree.modified = changes
|
|
||||||
mu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
changes, err := parseNumstat(dir, true)
|
|
||||||
record(err)
|
|
||||||
mu.Lock()
|
|
||||||
tree.staged = changes
|
|
||||||
mu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
untracked, err := parseUntracked(dir)
|
|
||||||
record(err)
|
|
||||||
mu.Lock()
|
|
||||||
tree.untracked = untracked
|
|
||||||
mu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
if len(errs) > 0 {
|
|
||||||
return tree, errs[0]
|
|
||||||
}
|
|
||||||
return tree, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseNumstat(dir string, cached bool) ([]fileChange, error) {
|
|
||||||
args := []string{"diff", "--numstat"}
|
|
||||||
if cached {
|
|
||||||
args = append(args, "--cached")
|
|
||||||
}
|
|
||||||
out, err := gitCmd(dir, args...)
|
|
||||||
if err != nil || out == "" {
|
|
||||||
return nil, nil //nolint:nilerr // no changes or not in a git repo — not an error
|
|
||||||
}
|
|
||||||
var changes []fileChange
|
|
||||||
for _, line := range strings.Split(out, "\n") {
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts := strings.SplitN(line, "\t", 3)
|
|
||||||
if len(parts) != 3 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Binary files show "-" — treat as 0.
|
|
||||||
added, _ := strconv.Atoi(parts[0])
|
|
||||||
removed, _ := strconv.Atoi(parts[1])
|
|
||||||
changes = append(changes, fileChange{
|
|
||||||
path: parts[2],
|
|
||||||
added: added,
|
|
||||||
removed: removed,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return changes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseUntracked(dir string) ([]string, error) {
|
|
||||||
out, err := gitCmd(dir, "status", "--porcelain=v1")
|
|
||||||
if err != nil || out == "" {
|
|
||||||
return nil, nil //nolint:nilerr // clean working tree or not in a git repo — not an error
|
|
||||||
}
|
|
||||||
var untracked []string
|
|
||||||
for _, line := range strings.Split(out, "\n") {
|
|
||||||
if len(line) >= 3 && line[0] == '?' && line[1] == '?' {
|
|
||||||
untracked = append(untracked, strings.TrimSpace(line[3:]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return untracked, nil
|
|
||||||
}
|
|
||||||
@ -1,293 +0,0 @@
|
|||||||
package git
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// formatOutput writes the full markdown report to w.
|
|
||||||
func formatOutput(w io.Writer, data *gitData) {
|
|
||||||
fmt.Fprintln(w, "# Git Context")
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
|
|
||||||
writeMeta(w, data)
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
|
|
||||||
writeCommits(w, data.commits)
|
|
||||||
writeWorkingTree(w, data.tree)
|
|
||||||
writeDiffSummary(w, data.tree)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeMeta(w io.Writer, data *gitData) {
|
|
||||||
// Branch + upstream
|
|
||||||
branch := data.repo.branch
|
|
||||||
if data.repo.upstream != "" {
|
|
||||||
branch += fmt.Sprintf(" (ahead %d, behind %d vs %s)",
|
|
||||||
data.repo.ahead, data.repo.behind, data.repo.upstream)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "**branch:** %s\n", branch)
|
|
||||||
|
|
||||||
// Status summary
|
|
||||||
m, s, u := len(data.tree.modified), len(data.tree.staged), len(data.tree.untracked)
|
|
||||||
if m == 0 && s == 0 && u == 0 {
|
|
||||||
fmt.Fprintln(w, "**status:** clean")
|
|
||||||
} else {
|
|
||||||
var parts []string
|
|
||||||
if m > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("%d modified", m))
|
|
||||||
}
|
|
||||||
if s > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("%d staged", s))
|
|
||||||
}
|
|
||||||
if u > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("%d untracked", u))
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "**status:** %s\n", strings.Join(parts, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last fetched
|
|
||||||
if data.repo.hasFetch {
|
|
||||||
fmt.Fprintf(w, "**last fetched:** %s\n", relativeTime(data.repo.lastFetch))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeCommits(w io.Writer, commits []commit) {
|
|
||||||
if len(commits) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "## Recent commits (last %d)\n", len(commits))
|
|
||||||
for _, c := range commits {
|
|
||||||
t := time.Unix(c.unixTs, 0)
|
|
||||||
fmt.Fprintf(w, "- `%s` (%s, %s) %s\n", c.hash, relativeTime(t), c.author, c.subject)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeWorkingTree(w io.Writer, tree workingTree) {
|
|
||||||
if len(tree.modified) == 0 && len(tree.staged) == 0 && len(tree.untracked) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w, "## Working tree")
|
|
||||||
|
|
||||||
if len(tree.modified) > 0 {
|
|
||||||
fmt.Fprintf(w, "### Modified (%d)\n", len(tree.modified))
|
|
||||||
for _, f := range tree.modified {
|
|
||||||
fmt.Fprintf(w, "- `%s` (+%d -%d)\n", f.path, f.added, f.removed)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tree.staged) > 0 {
|
|
||||||
fmt.Fprintf(w, "### Staged (%d)\n", len(tree.staged))
|
|
||||||
for _, f := range tree.staged {
|
|
||||||
fmt.Fprintf(w, "- `%s` (+%d -%d)\n", f.path, f.added, f.removed)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tree.untracked) > 0 {
|
|
||||||
fmt.Fprintf(w, "### Untracked (%d)\n", len(tree.untracked))
|
|
||||||
for _, u := range tree.untracked {
|
|
||||||
fmt.Fprintf(w, "- `%s`\n", u)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeDiffSummary(w io.Writer, tree workingTree) {
|
|
||||||
all := mergeChanges(tree.modified, tree.staged)
|
|
||||||
if len(all) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
totalAdded, totalRemoved := 0, 0
|
|
||||||
for _, f := range all {
|
|
||||||
totalAdded += f.added
|
|
||||||
totalRemoved += f.removed
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(w, "## Diff summary (unstaged + staged)")
|
|
||||||
fmt.Fprintf(w, "**Total:** +%d -%d across %d file%s\n\n",
|
|
||||||
totalAdded, totalRemoved, len(all), plural(len(all)))
|
|
||||||
|
|
||||||
// By top-level directory (only if files span more than one).
|
|
||||||
byDir := groupByTopDir(all)
|
|
||||||
if len(byDir) > 1 {
|
|
||||||
fmt.Fprintln(w, "### By directory")
|
|
||||||
dirs := make([]string, 0, len(byDir))
|
|
||||||
for d := range byDir {
|
|
||||||
dirs = append(dirs, d)
|
|
||||||
}
|
|
||||||
sort.Strings(dirs)
|
|
||||||
for _, d := range dirs {
|
|
||||||
s := byDir[d]
|
|
||||||
label := d + "/"
|
|
||||||
if d == "." {
|
|
||||||
label = "root"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "- `%s`: +%d -%d\n", label, s.added, s.removed)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notable changes — top 5 by total lines changed.
|
|
||||||
notable := topBySize(all, 5)
|
|
||||||
fmt.Fprintln(w, "### Notable changes")
|
|
||||||
for _, f := range notable {
|
|
||||||
kind := classify(f)
|
|
||||||
fmt.Fprintf(w, "- `%s`: %s (+%d lines) — %s\n", f.path, kind, f.added, reason(kind))
|
|
||||||
}
|
|
||||||
if len(all) > 5 {
|
|
||||||
more := len(all) - 5
|
|
||||||
fmt.Fprintf(w, "- ...and %d more file%s\n", more, plural(more))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
type dirStat struct{ added, removed int }
|
|
||||||
|
|
||||||
func groupByTopDir(changes []fileChange) map[string]dirStat {
|
|
||||||
result := make(map[string]dirStat)
|
|
||||||
for _, f := range changes {
|
|
||||||
d := topDir(f.path)
|
|
||||||
s := result[d]
|
|
||||||
s.added += f.added
|
|
||||||
s.removed += f.removed
|
|
||||||
result[d] = s
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// topDir returns the first path component (directory), or "." for root files.
|
|
||||||
func topDir(path string) string {
|
|
||||||
// Normalize to forward slashes.
|
|
||||||
clean := filepath.ToSlash(path)
|
|
||||||
idx := strings.Index(clean, "/")
|
|
||||||
if idx == -1 {
|
|
||||||
return "."
|
|
||||||
}
|
|
||||||
return clean[:idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeChanges deduplicates by path, summing stats for files that appear in both lists.
|
|
||||||
func mergeChanges(a, b []fileChange) []fileChange {
|
|
||||||
merged := make(map[string]fileChange)
|
|
||||||
for _, f := range a {
|
|
||||||
merged[f.path] = f
|
|
||||||
}
|
|
||||||
for _, f := range b {
|
|
||||||
if existing, ok := merged[f.path]; ok {
|
|
||||||
existing.added += f.added
|
|
||||||
existing.removed += f.removed
|
|
||||||
merged[f.path] = existing
|
|
||||||
} else {
|
|
||||||
merged[f.path] = f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result := make([]fileChange, 0, len(merged))
|
|
||||||
for _, f := range merged {
|
|
||||||
result = append(result, f)
|
|
||||||
}
|
|
||||||
sort.Slice(result, func(i, j int) bool { return result[i].path < result[j].path })
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// topBySize returns up to n files sorted by (added+removed) descending.
|
|
||||||
func topBySize(changes []fileChange, n int) []fileChange {
|
|
||||||
sorted := make([]fileChange, len(changes))
|
|
||||||
copy(sorted, changes)
|
|
||||||
sort.Slice(sorted, func(i, j int) bool {
|
|
||||||
ti := sorted[i].added + sorted[i].removed
|
|
||||||
tj := sorted[j].added + sorted[j].removed
|
|
||||||
return ti > tj
|
|
||||||
})
|
|
||||||
if n > len(sorted) {
|
|
||||||
n = len(sorted)
|
|
||||||
}
|
|
||||||
return sorted[:n]
|
|
||||||
}
|
|
||||||
|
|
||||||
func classify(f fileChange) string {
|
|
||||||
a, r := f.added, f.removed
|
|
||||||
switch {
|
|
||||||
case a > 30:
|
|
||||||
return "large change"
|
|
||||||
case a+r <= 10:
|
|
||||||
return "small change"
|
|
||||||
case r == 0:
|
|
||||||
return "pure addition"
|
|
||||||
case a == 0:
|
|
||||||
return "pure deletion"
|
|
||||||
case a > 20 && r > 20:
|
|
||||||
return "rewrite"
|
|
||||||
default:
|
|
||||||
if r > 0 {
|
|
||||||
ratio := float64(a) / float64(r)
|
|
||||||
if ratio >= 0.5 && ratio <= 2.0 {
|
|
||||||
return "refactor"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "mixed change"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reason(kind string) string {
|
|
||||||
switch kind {
|
|
||||||
case "large change":
|
|
||||||
return "likely new feature"
|
|
||||||
case "small change":
|
|
||||||
return "likely refactor or tweak"
|
|
||||||
case "pure addition":
|
|
||||||
return "new file or additions only"
|
|
||||||
case "pure deletion":
|
|
||||||
return "deletions only"
|
|
||||||
case "rewrite":
|
|
||||||
return "significant rewrite"
|
|
||||||
case "refactor":
|
|
||||||
return "likely refactor"
|
|
||||||
default:
|
|
||||||
return "mixed additions and removals"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// relativeTime returns a human-readable relative duration string.
|
|
||||||
func relativeTime(t time.Time) string {
|
|
||||||
d := time.Since(t)
|
|
||||||
if d < 0 {
|
|
||||||
d = -d
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case d < time.Minute:
|
|
||||||
return "just now"
|
|
||||||
case d < time.Hour:
|
|
||||||
n := int(d.Minutes())
|
|
||||||
return fmt.Sprintf("%d minute%s ago", n, plural(n))
|
|
||||||
case d < 24*time.Hour:
|
|
||||||
n := int(d.Hours())
|
|
||||||
return fmt.Sprintf("%d hour%s ago", n, plural(n))
|
|
||||||
case d < 7*24*time.Hour:
|
|
||||||
n := int(d.Hours() / 24)
|
|
||||||
return fmt.Sprintf("%d day%s ago", n, plural(n))
|
|
||||||
case d < 30*24*time.Hour:
|
|
||||||
n := int(d.Hours() / (24 * 7))
|
|
||||||
return fmt.Sprintf("%d week%s ago", n, plural(n))
|
|
||||||
case d < 365*24*time.Hour:
|
|
||||||
n := int(d.Hours() / (24 * 30))
|
|
||||||
return fmt.Sprintf("%d month%s ago", n, plural(n))
|
|
||||||
default:
|
|
||||||
n := int(d.Hours() / (24 * 365))
|
|
||||||
return fmt.Sprintf("%d year%s ago", n, plural(n))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func plural(n int) string {
|
|
||||||
if n == 1 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return "s"
|
|
||||||
}
|
|
||||||
@ -1,4 +1,5 @@
|
|||||||
// Package git implements the ctx git plugin — compact git state summary.
|
// Package git implements the ctx git plugin.
|
||||||
|
// Full implementation: prompt 1.
|
||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -9,36 +10,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
core.Register(&plugin{})
|
core.Register(&gitPlugin{})
|
||||||
}
|
}
|
||||||
|
|
||||||
type plugin struct{}
|
type gitPlugin struct{}
|
||||||
|
|
||||||
func (p *plugin) Name() string { return "git" }
|
func (g *gitPlugin) Name() string { return "git" }
|
||||||
func (p *plugin) Version() string { return "0.1.0" }
|
func (g *gitPlugin) Version() string { return "0.0.1" }
|
||||||
func (p *plugin) ShortDescription() string { return "Summarize git state in compact markdown" }
|
func (g *gitPlugin) ShortDescription() string { return "Git repository summary for Claude" }
|
||||||
|
|
||||||
func (p *plugin) Command(ctx *core.Context) *cobra.Command {
|
func (g *gitPlugin) Command(ctx *core.Context) *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "git",
|
Use: "git",
|
||||||
Short: p.ShortDescription(),
|
Short: g.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 {
|
||||||
return run(ctx)
|
fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in prompt 1")
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(ctx *core.Context) error {
|
|
||||||
data, err := collect(ctx.WorkDir)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(ctx.Stderr, err.Error())
|
|
||||||
return fmt.Errorf("exit 1") // non-nil → os.Exit(1); SilenceErrors suppresses print
|
|
||||||
}
|
|
||||||
formatOutput(ctx.Stdout, data)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@ -23,9 +23,6 @@ func (r *reactPlugin) Command(ctx *core.Context) *cobra.Command {
|
|||||||
return &cobra.Command{
|
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
4
tools/roslyn-helper/.gitignore
vendored
@ -1,4 +0,0 @@
|
|||||||
bin/
|
|
||||||
obj/
|
|
||||||
publish/
|
|
||||||
*.user
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
# ctx-roslyn-helper
|
|
||||||
|
|
||||||
C# subprocess invoked by `ctx csharp` commands. Loads .NET solutions with
|
|
||||||
Roslyn and answers JSON-RPC queries over stdin/stdout.
|
|
||||||
|
|
||||||
## Protocol
|
|
||||||
|
|
||||||
One JSON object per line, UTF-8 without BOM.
|
|
||||||
|
|
||||||
**Request:** `{"id": 1, "method": "ping", "params": {}}`
|
|
||||||
**Success:** `{"id": 1, "result": {...}}`
|
|
||||||
**Error:** `{"id": 1, "error": {"code": "E_NOT_FOUND", "message": "..."}}`
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
|
|
||||||
| Method | Params | Description |
|
|
||||||
|------------------|---------------------------|------------------------------------|
|
|
||||||
| `ping` | `{}` | Health check, returns version |
|
|
||||||
| `loadSolution` | `{"path": "C:\\...\\x.sln"}` | Load solution into workspace |
|
|
||||||
| `projectSummary` | `{}` | Summary of loaded solution |
|
|
||||||
| `shutdown` | `{}` | Exit cleanly (no response written) |
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
Requires: .NET 8+ SDK.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
dotnet build src/RoslynHelper -c Release
|
|
||||||
```
|
|
||||||
|
|
||||||
## Publish
|
|
||||||
|
|
||||||
```sh
|
|
||||||
dotnet publish src/RoslynHelper -c Release -r win-x64 --self-contained false -o publish/
|
|
||||||
```
|
|
||||||
|
|
||||||
Output: `publish/ctx-roslyn-helper.exe`
|
|
||||||
|
|
||||||
## Manual test
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd publish
|
|
||||||
./ctx-roslyn-helper.exe
|
|
||||||
```
|
|
||||||
|
|
||||||
Then type line by line:
|
|
||||||
|
|
||||||
```
|
|
||||||
{"id": 1, "method": "ping", "params": {}}
|
|
||||||
{"id": 2, "method": "loadSolution", "params": {"path": "C:\\path\\to\\My.sln"}}
|
|
||||||
{"id": 3, "method": "projectSummary", "params": {}}
|
|
||||||
{"id": 99, "method": "shutdown", "params": {}}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Does NOT need to be self-contained. Requires .NET 8+ runtime on the target machine.
|
|
||||||
Users of `ctx csharp` are .NET developers — the runtime is already there.
|
|
||||||
- MSBuild warnings logged to stderr are informational (missing SDK targets, etc.).
|
|
||||||
Only `WorkspaceFailed` events with `Failure` kind indicate real problems.
|
|
||||||
- The helper is spawned once per `ctx` invocation and kept alive for all queries
|
|
||||||
in that session. ctx Go manages the subprocess lifecycle.
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
|
||||||
# Visual Studio Version 17
|
|
||||||
VisualStudioVersion = 17.0.31903.59
|
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RoslynHelper", "src\RoslynHelper\RoslynHelper.csproj", "{CE832F0D-696C-4830-977F-D694DDCBA532}"
|
|
||||||
EndProject
|
|
||||||
Global
|
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
|
||||||
Debug|Any CPU = Debug|Any CPU
|
|
||||||
Debug|x64 = Debug|x64
|
|
||||||
Debug|x86 = Debug|x86
|
|
||||||
Release|Any CPU = Release|Any CPU
|
|
||||||
Release|x64 = Release|x64
|
|
||||||
Release|x86 = Release|x86
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
|
||||||
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
|
||||||
HideSolutionNode = FALSE
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(NestedProjects) = preSolution
|
|
||||||
{CE832F0D-696C-4830-977F-D694DDCBA532} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
|
||||||
EndGlobalSection
|
|
||||||
EndGlobal
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
using System.Text.Encodings.Web;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using RoslynHelper.JsonRpc.Handlers;
|
|
||||||
using RoslynHelper.Workspace;
|
|
||||||
|
|
||||||
namespace RoslynHelper.JsonRpc;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads newline-delimited JSON requests from stdin, dispatches to registered
|
|
||||||
/// handlers, and writes responses to stdout. One request per line, one response
|
|
||||||
/// per line, UTF-8 without BOM.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class Dispatcher
|
|
||||||
{
|
|
||||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
||||||
WriteIndented = false,
|
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
||||||
// Allow literal single quotes and em dashes; Go's json.Unmarshal handles them fine.
|
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly Dictionary<string, IHandler> _handlers;
|
|
||||||
|
|
||||||
public Dispatcher(WorkspaceManager workspace)
|
|
||||||
{
|
|
||||||
_handlers = new Dictionary<string, IHandler>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
["ping"] = new PingHandler(),
|
|
||||||
["loadSolution"] = new LoadSolutionHandler(workspace),
|
|
||||||
["projectSummary"] = new ProjectSummaryHandler(workspace),
|
|
||||||
["outline"] = new OutlineHandler(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task RunAsync(TextReader stdin, TextWriter stdout)
|
|
||||||
{
|
|
||||||
string? line;
|
|
||||||
while ((line = await stdin.ReadLineAsync()) is not null)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
|
||||||
|
|
||||||
var response = await DispatchAsync(line);
|
|
||||||
if (response is null) return; // shutdown
|
|
||||||
|
|
||||||
await stdout.WriteLineAsync(response);
|
|
||||||
await stdout.FlushAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<string?> DispatchAsync(string line)
|
|
||||||
{
|
|
||||||
Request? req;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
req = JsonSerializer.Deserialize<Request>(line, JsonOpts);
|
|
||||||
if (req is null || string.IsNullOrEmpty(req.Method))
|
|
||||||
return Err(0, "E_INVALID_REQUEST", "request must have id and method");
|
|
||||||
}
|
|
||||||
catch (JsonException ex)
|
|
||||||
{
|
|
||||||
return Err(0, "E_INVALID_REQUEST", $"invalid JSON: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.Method.Equals("shutdown", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return null; // signal caller to exit
|
|
||||||
|
|
||||||
if (!_handlers.TryGetValue(req.Method, out var handler))
|
|
||||||
return Err(req.Id, "E_UNKNOWN_METHOD", $"method '{req.Method}' not found");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await handler.HandleAsync(req.Params);
|
|
||||||
return Ok(req.Id, result);
|
|
||||||
}
|
|
||||||
catch (KnownException kex)
|
|
||||||
{
|
|
||||||
return Err(req.Id, kex.Code, kex.Message);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return ErrWithData(req.Id, "E_INTERNAL", ex.Message, new { stackTrace = ex.StackTrace });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string Ok(int id, object result) =>
|
|
||||||
JsonSerializer.Serialize(new { id, result }, JsonOpts);
|
|
||||||
|
|
||||||
private string Err(int id, string code, string message) =>
|
|
||||||
JsonSerializer.Serialize(new { id, error = new { code, message } }, JsonOpts);
|
|
||||||
|
|
||||||
private string ErrWithData(int id, string code, string message, object data) =>
|
|
||||||
JsonSerializer.Serialize(new { id, error = new { code, message, data } }, JsonOpts);
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace RoslynHelper.JsonRpc.Handlers;
|
|
||||||
|
|
||||||
/// <summary>Contract for a JSON-RPC method handler.</summary>
|
|
||||||
public interface IHandler
|
|
||||||
{
|
|
||||||
/// <summary>Execute the handler and return the result object (serialized as the 'result' field).</summary>
|
|
||||||
Task<object> HandleAsync(JsonElement? @params);
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using RoslynHelper.Workspace;
|
|
||||||
|
|
||||||
namespace RoslynHelper.JsonRpc.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Loads a .sln file into the Roslyn workspace.
|
|
||||||
/// Idempotent — if the same path is already loaded, reloads it.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class LoadSolutionHandler(WorkspaceManager workspace) : IHandler
|
|
||||||
{
|
|
||||||
public async Task<object> HandleAsync(JsonElement? @params)
|
|
||||||
{
|
|
||||||
if (@params is not { } p)
|
|
||||||
throw new KnownException("E_INVALID_REQUEST", "params required for loadSolution");
|
|
||||||
|
|
||||||
if (!p.TryGetProperty("path", out var pathEl) || pathEl.ValueKind != JsonValueKind.String)
|
|
||||||
throw new KnownException("E_INVALID_REQUEST", "params.path (string) is required");
|
|
||||||
|
|
||||||
var path = pathEl.GetString()!;
|
|
||||||
var (projectCount, documentCount) = await workspace.LoadAsync(path);
|
|
||||||
return new { loaded = true, projectCount, documentCount };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,281 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using Microsoft.CodeAnalysis;
|
|
||||||
using Microsoft.CodeAnalysis.CSharp;
|
|
||||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
|
||||||
using RoslynHelper.Models;
|
|
||||||
|
|
||||||
namespace RoslynHelper.JsonRpc.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses a single .cs file and returns its structural outline:
|
|
||||||
/// namespaces, types, method signatures (no bodies), properties, fields, events.
|
|
||||||
/// Does NOT require a solution to be loaded — works on a single file.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class OutlineHandler : IHandler
|
|
||||||
{
|
|
||||||
public async Task<object> HandleAsync(JsonElement? @params)
|
|
||||||
{
|
|
||||||
if (@params is not { } p)
|
|
||||||
throw new KnownException("E_INVALID_PARAMS", "params required for outline");
|
|
||||||
|
|
||||||
if (!p.TryGetProperty("path", out var pathEl) || pathEl.ValueKind != JsonValueKind.String)
|
|
||||||
throw new KnownException("E_INVALID_PARAMS", "params.path (string) is required");
|
|
||||||
|
|
||||||
var path = pathEl.GetString()!;
|
|
||||||
if (!File.Exists(path))
|
|
||||||
throw new KnownException("E_NOT_FOUND", $"file not found: {path}");
|
|
||||||
|
|
||||||
var source = await File.ReadAllTextAsync(path, System.Text.Encoding.UTF8);
|
|
||||||
var tree = CSharpSyntaxTree.ParseText(source, path: path);
|
|
||||||
var root = (CompilationUnitSyntax)await tree.GetRootAsync();
|
|
||||||
|
|
||||||
bool hasSyntaxErrors = tree.GetDiagnostics()
|
|
||||||
.Any(d => d.Severity == DiagnosticSeverity.Error);
|
|
||||||
|
|
||||||
int lineCount = source.Split('\n').Length;
|
|
||||||
|
|
||||||
// Top-level usings
|
|
||||||
var usings = CollectUsings(root.Usings);
|
|
||||||
string ns = "";
|
|
||||||
var types = new List<OutlineTypeModel>();
|
|
||||||
bool hasTopLevel = false;
|
|
||||||
|
|
||||||
foreach (var member in root.Members)
|
|
||||||
{
|
|
||||||
switch (member)
|
|
||||||
{
|
|
||||||
case NamespaceDeclarationSyntax blockNs:
|
|
||||||
ns = blockNs.Name.ToString();
|
|
||||||
usings.AddRange(CollectUsings(blockNs.Usings));
|
|
||||||
foreach (var m in blockNs.Members)
|
|
||||||
CollectType(m, types);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case FileScopedNamespaceDeclarationSyntax fileScopedNs:
|
|
||||||
ns = fileScopedNs.Name.ToString();
|
|
||||||
usings.AddRange(CollectUsings(fileScopedNs.Usings));
|
|
||||||
foreach (var m in fileScopedNs.Members)
|
|
||||||
CollectType(m, types);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case GlobalStatementSyntax:
|
|
||||||
hasTopLevel = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
CollectType(member, types);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Synthetic Program type for top-level statement files
|
|
||||||
if (hasTopLevel)
|
|
||||||
{
|
|
||||||
types.Insert(0, new OutlineTypeModel(
|
|
||||||
"class", "Program (top-level program)", [], [],
|
|
||||||
[new OutlineMemberModel("method", "static void Main(string[] args)", ["static"], 1, false)],
|
|
||||||
[]));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new OutlineResult(path, ns, lineCount, usings.Distinct().ToList(), types, hasSyntaxErrors);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Usings ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private static List<string> CollectUsings(SyntaxList<UsingDirectiveSyntax> usings) =>
|
|
||||||
usings
|
|
||||||
.Where(u => u.Alias is null)
|
|
||||||
.Select(u => u.Name?.ToString() ?? "")
|
|
||||||
.Where(s => !string.IsNullOrEmpty(s))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// ─── Type dispatch ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private static void CollectType(MemberDeclarationSyntax member, List<OutlineTypeModel> target)
|
|
||||||
{
|
|
||||||
switch (member)
|
|
||||||
{
|
|
||||||
case ClassDeclarationSyntax cls:
|
|
||||||
target.Add(ExtractTypeDecl("class", cls.Identifier.Text,
|
|
||||||
cls.TypeParameterList, null, cls.Modifiers, cls.BaseList, cls.Members));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case InterfaceDeclarationSyntax iface:
|
|
||||||
target.Add(ExtractTypeDecl("interface", iface.Identifier.Text,
|
|
||||||
iface.TypeParameterList, null, iface.Modifiers, iface.BaseList, iface.Members));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case StructDeclarationSyntax str:
|
|
||||||
target.Add(ExtractTypeDecl("struct", str.Identifier.Text,
|
|
||||||
str.TypeParameterList, null, str.Modifiers, str.BaseList, str.Members));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case RecordDeclarationSyntax rec:
|
|
||||||
{
|
|
||||||
var recKind = rec.ClassOrStructKeyword.IsKind(SyntaxKind.StructKeyword)
|
|
||||||
? "record struct" : "record";
|
|
||||||
target.Add(ExtractTypeDecl(recKind, rec.Identifier.Text,
|
|
||||||
rec.TypeParameterList, rec.ParameterList, rec.Modifiers, rec.BaseList, rec.Members));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case EnumDeclarationSyntax en:
|
|
||||||
target.Add(ExtractEnum(en));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Type extraction ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private static OutlineTypeModel ExtractTypeDecl(
|
|
||||||
string kind,
|
|
||||||
string name,
|
|
||||||
TypeParameterListSyntax? typeParams,
|
|
||||||
ParameterListSyntax? recordParams,
|
|
||||||
SyntaxTokenList modifiers,
|
|
||||||
BaseListSyntax? baseList,
|
|
||||||
SyntaxList<MemberDeclarationSyntax> members)
|
|
||||||
{
|
|
||||||
var mods = modifiers.Select(m => m.Text).ToList();
|
|
||||||
var baseTypes = baseList?.Types.Select(t => t.Type.ToString()).ToList() ?? [];
|
|
||||||
var fullName = name
|
|
||||||
+ (typeParams?.ToString() ?? "")
|
|
||||||
+ (recordParams?.ToString() ?? "");
|
|
||||||
|
|
||||||
var memberModels = new List<OutlineMemberModel>();
|
|
||||||
var nested = new List<OutlineTypeModel>();
|
|
||||||
|
|
||||||
foreach (var member in members)
|
|
||||||
{
|
|
||||||
switch (member)
|
|
||||||
{
|
|
||||||
case MethodDeclarationSyntax m:
|
|
||||||
memberModels.Add(ExtractMethod(m));
|
|
||||||
break;
|
|
||||||
case ConstructorDeclarationSyntax c:
|
|
||||||
memberModels.Add(ExtractConstructor(c));
|
|
||||||
break;
|
|
||||||
case PropertyDeclarationSyntax prop:
|
|
||||||
memberModels.Add(ExtractProperty(prop));
|
|
||||||
break;
|
|
||||||
case FieldDeclarationSyntax f:
|
|
||||||
memberModels.AddRange(ExtractFields(f));
|
|
||||||
break;
|
|
||||||
case EventDeclarationSyntax e:
|
|
||||||
memberModels.Add(ExtractEventDecl(e));
|
|
||||||
break;
|
|
||||||
case EventFieldDeclarationSyntax ef:
|
|
||||||
memberModels.AddRange(ExtractEventFields(ef));
|
|
||||||
break;
|
|
||||||
case ClassDeclarationSyntax _:
|
|
||||||
case InterfaceDeclarationSyntax _:
|
|
||||||
case StructDeclarationSyntax _:
|
|
||||||
case RecordDeclarationSyntax _:
|
|
||||||
case EnumDeclarationSyntax _:
|
|
||||||
CollectType(member, nested);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new OutlineTypeModel(kind, fullName, mods, baseTypes, memberModels, nested);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static OutlineTypeModel ExtractEnum(EnumDeclarationSyntax en)
|
|
||||||
{
|
|
||||||
var mods = en.Modifiers.Select(m => m.Text).ToList();
|
|
||||||
var members = en.Members
|
|
||||||
.Select(m => new OutlineMemberModel("enumValue", m.Identifier.Text, [], GetLine(m), false))
|
|
||||||
.ToList();
|
|
||||||
return new OutlineTypeModel("enum", en.Identifier.Text, mods, [], members, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Member extraction ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private static OutlineMemberModel ExtractMethod(MethodDeclarationSyntax m)
|
|
||||||
{
|
|
||||||
var typeParams = m.TypeParameterList?.ToString() ?? "";
|
|
||||||
var sig = $"{m.ReturnType} {m.Identifier.Text}{typeParams}{m.ParameterList}".Trim();
|
|
||||||
var mods = m.Modifiers.Select(x => x.Text).ToList();
|
|
||||||
return new OutlineMemberModel("method", sig, mods, GetLine(m), HasObsolete(m.AttributeLists));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static OutlineMemberModel ExtractConstructor(ConstructorDeclarationSyntax c)
|
|
||||||
{
|
|
||||||
var sig = $"{c.Identifier.Text}{c.ParameterList}".Trim();
|
|
||||||
var mods = c.Modifiers.Select(x => x.Text).ToList();
|
|
||||||
return new OutlineMemberModel("constructor", sig, mods, GetLine(c), HasObsolete(c.AttributeLists));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static OutlineMemberModel ExtractProperty(PropertyDeclarationSyntax p)
|
|
||||||
{
|
|
||||||
string accessors;
|
|
||||||
if (p.AccessorList is { } al)
|
|
||||||
{
|
|
||||||
var parts = al.Accessors.Select(a =>
|
|
||||||
{
|
|
||||||
var accMods = a.Modifiers.Any() ? a.Modifiers.ToString() + " " : "";
|
|
||||||
return accMods + a.Keyword.Text;
|
|
||||||
});
|
|
||||||
accessors = "{ " + string.Join("; ", parts) + "; }";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
accessors = "=> ..."; // expression-bodied
|
|
||||||
}
|
|
||||||
|
|
||||||
var sig = $"{p.Type} {p.Identifier.Text} {accessors}".Trim();
|
|
||||||
var mods = p.Modifiers.Select(x => x.Text).ToList();
|
|
||||||
return new OutlineMemberModel("property", sig, mods, GetLine(p), HasObsolete(p.AttributeLists));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IEnumerable<OutlineMemberModel> ExtractFields(FieldDeclarationSyntax f)
|
|
||||||
{
|
|
||||||
var mods = f.Modifiers.Select(x => x.Text).ToList();
|
|
||||||
var typeName = f.Declaration.Type.ToString();
|
|
||||||
var isConst = mods.Contains("const");
|
|
||||||
var obs = HasObsolete(f.AttributeLists);
|
|
||||||
var line = GetLine(f);
|
|
||||||
|
|
||||||
foreach (var v in f.Declaration.Variables)
|
|
||||||
{
|
|
||||||
var sig = isConst && v.Initializer is { } init
|
|
||||||
? $"{typeName} {v.Identifier.Text} = {init.Value}"
|
|
||||||
: $"{typeName} {v.Identifier.Text}";
|
|
||||||
yield return new OutlineMemberModel("field", sig.Trim(), mods, line, obs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static OutlineMemberModel ExtractEventDecl(EventDeclarationSyntax e)
|
|
||||||
{
|
|
||||||
var sig = $"event {e.Type} {e.Identifier.Text}".Trim();
|
|
||||||
var mods = e.Modifiers.Select(x => x.Text).ToList();
|
|
||||||
return new OutlineMemberModel("event", sig, mods, GetLine(e), HasObsolete(e.AttributeLists));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IEnumerable<OutlineMemberModel> ExtractEventFields(EventFieldDeclarationSyntax ef)
|
|
||||||
{
|
|
||||||
var mods = ef.Modifiers.Select(x => x.Text).ToList();
|
|
||||||
var typeName = ef.Declaration.Type.ToString();
|
|
||||||
var obs = HasObsolete(ef.AttributeLists);
|
|
||||||
var line = GetLine(ef);
|
|
||||||
|
|
||||||
foreach (var v in ef.Declaration.Variables)
|
|
||||||
{
|
|
||||||
yield return new OutlineMemberModel(
|
|
||||||
"event", $"event {typeName} {v.Identifier.Text}".Trim(), mods, line, obs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Returns true if obsolete, null otherwise (null omitted by WhenWritingNull in dispatcher)
|
|
||||||
private static bool? HasObsolete(SyntaxList<AttributeListSyntax> attrs)
|
|
||||||
{
|
|
||||||
var found = attrs.SelectMany(al => al.Attributes)
|
|
||||||
.Any(a => a.Name.ToString() is "Obsolete" or "ObsoleteAttribute");
|
|
||||||
return found ? true : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int GetLine(SyntaxNode node) =>
|
|
||||||
node.GetLocation().GetLineSpan().StartLinePosition.Line + 1;
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace RoslynHelper.JsonRpc.Handlers;
|
|
||||||
|
|
||||||
/// <summary>Health-check handler. Returns version string.</summary>
|
|
||||||
public sealed class PingHandler : IHandler
|
|
||||||
{
|
|
||||||
public Task<object> HandleAsync(JsonElement? @params) =>
|
|
||||||
Task.FromResult<object>(new { pong = true, version = "0.1.0" });
|
|
||||||
}
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using RoslynHelper.Models;
|
|
||||||
using RoslynHelper.Workspace;
|
|
||||||
|
|
||||||
namespace RoslynHelper.JsonRpc.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns a compact summary of the loaded solution: projects, target frameworks,
|
|
||||||
/// package references, and project-to-project dependencies.
|
|
||||||
/// Parses .csproj XML directly — no Roslyn workspace required.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ProjectSummaryHandler(WorkspaceManager workspace) : IHandler
|
|
||||||
{
|
|
||||||
public Task<object> HandleAsync(JsonElement? @params)
|
|
||||||
{
|
|
||||||
var solution = workspace.GetCurrentSolution();
|
|
||||||
var solutionPath = workspace.SolutionPath!;
|
|
||||||
var solutionName = Path.GetFileNameWithoutExtension(solutionPath);
|
|
||||||
var solutionDir = Path.GetDirectoryName(solutionPath) ?? string.Empty;
|
|
||||||
|
|
||||||
// Document counts are tracked per-project in the WorkspaceManager load.
|
|
||||||
// Re-compute here from file system for simplicity.
|
|
||||||
var projects = solution.Projects
|
|
||||||
.OrderBy(p => p.Name)
|
|
||||||
.Select(p =>
|
|
||||||
{
|
|
||||||
var docCount = CountDocuments(p.ProjectPath);
|
|
||||||
return ProjectSummaryBuilder.Build(p, solutionDir, docCount);
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return Task.FromResult<object>(new SolutionSummary(solutionPath, solutionName, projects));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int CountDocuments(string projPath)
|
|
||||||
{
|
|
||||||
var dir = Path.GetDirectoryName(projPath);
|
|
||||||
if (dir is null || !Directory.Exists(dir)) return 0;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories).Count()
|
|
||||||
+ Directory.EnumerateFiles(dir, "*.fs", SearchOption.AllDirectories).Count()
|
|
||||||
+ Directory.EnumerateFiles(dir, "*.vb", SearchOption.AllDirectories).Count();
|
|
||||||
}
|
|
||||||
catch { return 0; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace RoslynHelper.JsonRpc;
|
|
||||||
|
|
||||||
/// <summary>Incoming JSON-RPC request from ctx (Go).</summary>
|
|
||||||
public sealed record Request(
|
|
||||||
[property: JsonPropertyName("id")] int Id,
|
|
||||||
[property: JsonPropertyName("method")] string Method,
|
|
||||||
[property: JsonPropertyName("params")] JsonElement? Params
|
|
||||||
);
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace RoslynHelper.JsonRpc;
|
|
||||||
|
|
||||||
/// <summary>Error detail embedded in a JSON-RPC error response.</summary>
|
|
||||||
public sealed record ErrorDetail(
|
|
||||||
[property: JsonPropertyName("code")] string Code,
|
|
||||||
[property: JsonPropertyName("message")] string Message,
|
|
||||||
[property: JsonPropertyName("data")] object? Data = null
|
|
||||||
);
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
namespace RoslynHelper;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// An expected error with a standardized error code.
|
|
||||||
/// Throw from handlers to produce structured JSON-RPC error responses.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class KnownException(string code, string message) : Exception(message)
|
|
||||||
{
|
|
||||||
public string Code { get; } = code;
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace RoslynHelper.Models;
|
|
||||||
|
|
||||||
public sealed record OutlineResult(
|
|
||||||
[property: JsonPropertyName("path")] string Path,
|
|
||||||
[property: JsonPropertyName("namespace")] string Namespace,
|
|
||||||
[property: JsonPropertyName("lineCount")] int LineCount,
|
|
||||||
[property: JsonPropertyName("usings")] List<string> Usings,
|
|
||||||
[property: JsonPropertyName("types")] List<OutlineTypeModel> Types,
|
|
||||||
[property: JsonPropertyName("hasSyntaxErrors")] bool HasSyntaxErrors
|
|
||||||
);
|
|
||||||
|
|
||||||
public sealed record OutlineTypeModel(
|
|
||||||
[property: JsonPropertyName("kind")] string Kind,
|
|
||||||
[property: JsonPropertyName("name")] string Name,
|
|
||||||
[property: JsonPropertyName("modifiers")] List<string> Modifiers,
|
|
||||||
[property: JsonPropertyName("baseTypes")] List<string> BaseTypes,
|
|
||||||
[property: JsonPropertyName("members")] List<OutlineMemberModel> Members,
|
|
||||||
[property: JsonPropertyName("nested")] List<OutlineTypeModel> Nested
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// <c>IsObsolete</c> is nullable so that <c>null</c> (not obsolete) is omitted
|
|
||||||
/// from JSON by the dispatcher's <c>WhenWritingNull</c> policy.
|
|
||||||
/// </remarks>
|
|
||||||
public sealed record OutlineMemberModel(
|
|
||||||
[property: JsonPropertyName("kind")] string Kind,
|
|
||||||
[property: JsonPropertyName("signature")] string Signature,
|
|
||||||
[property: JsonPropertyName("modifiers")] List<string> Modifiers,
|
|
||||||
[property: JsonPropertyName("line")] int Line,
|
|
||||||
[property: JsonPropertyName("isObsolete")] bool? IsObsolete = null
|
|
||||||
);
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using System.Xml.Linq;
|
|
||||||
using RoslynHelper.Workspace;
|
|
||||||
|
|
||||||
namespace RoslynHelper.Models;
|
|
||||||
|
|
||||||
public sealed record PackageRef(
|
|
||||||
[property: JsonPropertyName("name")] string Name,
|
|
||||||
[property: JsonPropertyName("version")] string Version
|
|
||||||
);
|
|
||||||
|
|
||||||
public sealed record ProjectSummary(
|
|
||||||
[property: JsonPropertyName("name")] string Name,
|
|
||||||
[property: JsonPropertyName("path")] string Path,
|
|
||||||
[property: JsonPropertyName("type")] string Type,
|
|
||||||
[property: JsonPropertyName("targetFrameworks")] string[] TargetFrameworks,
|
|
||||||
[property: JsonPropertyName("outputType")] string OutputType,
|
|
||||||
[property: JsonPropertyName("rootNamespace")] string RootNamespace,
|
|
||||||
[property: JsonPropertyName("documentCount")] int DocumentCount,
|
|
||||||
[property: JsonPropertyName("projectReferences")] List<string> ProjectReferences,
|
|
||||||
[property: JsonPropertyName("packageReferences")] List<PackageRef> PackageReferences
|
|
||||||
);
|
|
||||||
|
|
||||||
public sealed record SolutionSummary(
|
|
||||||
[property: JsonPropertyName("solutionPath")] string SolutionPath,
|
|
||||||
[property: JsonPropertyName("solutionName")] string SolutionName,
|
|
||||||
[property: JsonPropertyName("projects")] List<ProjectSummary> Projects
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds a <see cref="ProjectSummary"/> from a <see cref="ProjectEntry"/>
|
|
||||||
/// by parsing the .csproj XML for MSBuild properties.
|
|
||||||
/// </summary>
|
|
||||||
internal static class ProjectSummaryBuilder
|
|
||||||
{
|
|
||||||
public static ProjectSummary Build(ProjectEntry entry, string solutionDir, int documentCount)
|
|
||||||
{
|
|
||||||
var projPath = entry.ProjectPath;
|
|
||||||
|
|
||||||
string[] targetFrameworks = [];
|
|
||||||
string outputType = "Library";
|
|
||||||
string rootNamespace = entry.Name;
|
|
||||||
List<PackageRef> packageRefs = [];
|
|
||||||
List<string> projRefs = [];
|
|
||||||
|
|
||||||
if (File.Exists(projPath))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var doc = XDocument.Load(projPath);
|
|
||||||
|
|
||||||
var tf = doc.Descendants("TargetFramework").FirstOrDefault()?.Value?.Trim();
|
|
||||||
var tfs = doc.Descendants("TargetFrameworks").FirstOrDefault()?.Value?.Trim();
|
|
||||||
|
|
||||||
targetFrameworks = tfs is not null
|
|
||||||
? tfs.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
||||||
: tf is not null ? [tf] : [];
|
|
||||||
|
|
||||||
outputType = doc.Descendants("OutputType").FirstOrDefault()?.Value?.Trim() ?? "Library";
|
|
||||||
rootNamespace = doc.Descendants("RootNamespace").FirstOrDefault()?.Value?.Trim() ?? entry.Name;
|
|
||||||
|
|
||||||
packageRefs = doc.Descendants("PackageReference")
|
|
||||||
.Select(el => new PackageRef(
|
|
||||||
el.Attribute("Include")?.Value ?? string.Empty,
|
|
||||||
el.Attribute("Version")?.Value ?? el.Element("Version")?.Value ?? string.Empty))
|
|
||||||
.Where(pr => !string.IsNullOrEmpty(pr.Name))
|
|
||||||
.OrderBy(pr => pr.Name)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
projRefs = doc.Descendants("ProjectReference")
|
|
||||||
.Select(el =>
|
|
||||||
{
|
|
||||||
var include = el.Attribute("Include")?.Value ?? string.Empty;
|
|
||||||
return Path.GetFileNameWithoutExtension(include);
|
|
||||||
})
|
|
||||||
.Where(n => !string.IsNullOrEmpty(n))
|
|
||||||
.OrderBy(n => n)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"[summary] warn: could not parse {projPath}: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var type = outputType.Equals("Exe", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
outputType.Equals("WinExe", StringComparison.OrdinalIgnoreCase)
|
|
||||||
? "exe" : "lib";
|
|
||||||
|
|
||||||
return new ProjectSummary(
|
|
||||||
entry.Name,
|
|
||||||
entry.RelativePath,
|
|
||||||
type,
|
|
||||||
targetFrameworks,
|
|
||||||
outputType,
|
|
||||||
rootNamespace,
|
|
||||||
documentCount,
|
|
||||||
projRefs,
|
|
||||||
packageRefs
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
using RoslynHelper.JsonRpc;
|
|
||||||
using RoslynHelper.Workspace;
|
|
||||||
|
|
||||||
namespace RoslynHelper;
|
|
||||||
|
|
||||||
public static class Program
|
|
||||||
{
|
|
||||||
public static async Task<int> Main(string[] args)
|
|
||||||
{
|
|
||||||
// UTF-8 without BOM on both ends of the pipe.
|
|
||||||
Console.InputEncoding = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
|
|
||||||
Console.OutputEncoding = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
|
|
||||||
|
|
||||||
using var workspace = new WorkspaceManager();
|
|
||||||
var dispatcher = new Dispatcher(workspace);
|
|
||||||
|
|
||||||
await dispatcher.RunAsync(Console.In, Console.Out);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<LangVersion>latest</LangVersion>
|
|
||||||
<AssemblyName>ctx-roslyn-helper</AssemblyName>
|
|
||||||
<RootNamespace>RoslynHelper</RootNamespace>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Pure C# syntax parser — no MSBuild, no SDK coupling.
|
|
||||||
Used by OutlineHandler for CSharpSyntaxTree.ParseText + SyntaxWalker.
|
|
||||||
Unlike Workspaces.MSBuild (removed in Prompt 3), this package
|
|
||||||
has no runtime dependency on the installed SDK. -->
|
|
||||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.13.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Xml.Linq;
|
|
||||||
|
|
||||||
namespace RoslynHelper.Workspace;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses .sln and .csproj files directly without a Roslyn/MSBuild workspace.
|
|
||||||
/// This avoids tight version coupling between MSBuild NuGet packages and the
|
|
||||||
/// installed SDK. For MVP commands (projectSummary), structural parsing is sufficient.
|
|
||||||
/// Roslyn semantic APIs will be integrated in a later phase.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class WorkspaceManager : IDisposable
|
|
||||||
{
|
|
||||||
public void Dispose() { } // reserved for future Roslyn workspace disposal
|
|
||||||
private SolutionData? _solution;
|
|
||||||
private string? _solutionPath;
|
|
||||||
|
|
||||||
public string? SolutionPath => _solutionPath;
|
|
||||||
|
|
||||||
public Task<(int projectCount, int documentCount)> LoadAsync(string path)
|
|
||||||
{
|
|
||||||
if (!File.Exists(path))
|
|
||||||
throw new KnownException("E_NOT_FOUND", $"solution file not found: {path}");
|
|
||||||
|
|
||||||
SolutionData sln;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
sln = SolutionParser.Parse(path);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
throw new KnownException("E_LOAD_FAILED", $"failed to load solution: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
_solution = sln;
|
|
||||||
_solutionPath = path;
|
|
||||||
|
|
||||||
var documentCount = sln.Projects.Sum(p => CountDocuments(p.ProjectPath));
|
|
||||||
return Task.FromResult((sln.Projects.Count, documentCount));
|
|
||||||
}
|
|
||||||
|
|
||||||
public SolutionData GetCurrentSolution()
|
|
||||||
{
|
|
||||||
if (_solution is null)
|
|
||||||
throw new KnownException("E_NOT_FOUND", "no solution loaded — call loadSolution first");
|
|
||||||
return _solution;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Counts .cs/.fs/.vb source files in the project directory.</summary>
|
|
||||||
private static int CountDocuments(string projPath)
|
|
||||||
{
|
|
||||||
var dir = Path.GetDirectoryName(projPath);
|
|
||||||
if (dir is null || !Directory.Exists(dir)) return 0;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories).Count()
|
|
||||||
+ Directory.EnumerateFiles(dir, "*.fs", SearchOption.AllDirectories).Count()
|
|
||||||
+ Directory.EnumerateFiles(dir, "*.vb", SearchOption.AllDirectories).Count();
|
|
||||||
}
|
|
||||||
catch { return 0; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Lightweight data model for a loaded solution.</summary>
|
|
||||||
public sealed class SolutionData(string solutionPath, List<ProjectEntry> projects)
|
|
||||||
{
|
|
||||||
public string SolutionPath { get; } = solutionPath;
|
|
||||||
public List<ProjectEntry> Projects { get; } = projects;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>One project entry from the .sln file.</summary>
|
|
||||||
public sealed class ProjectEntry(string name, string relativePath, string absolutePath)
|
|
||||||
{
|
|
||||||
public string Name { get; } = name;
|
|
||||||
public string RelativePath { get; } = relativePath;
|
|
||||||
public string ProjectPath { get; } = absolutePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses the classic Visual Studio .sln format to extract project entries.
|
|
||||||
/// Handles both SDK-style and legacy projects.
|
|
||||||
/// </summary>
|
|
||||||
internal static class SolutionParser
|
|
||||||
{
|
|
||||||
// Matches: Project("{type-guid}") = "Name", "path\to\project.csproj", "{project-guid}"
|
|
||||||
private static readonly Regex ProjectLine = new(
|
|
||||||
@"Project\(""\{[^}]+\}""\)\s*=\s*""([^""]+)""\s*,\s*""([^""]+)""\s*,",
|
|
||||||
RegexOptions.Compiled);
|
|
||||||
|
|
||||||
private static readonly HashSet<string> ProjectExtensions = new(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
".csproj", ".fsproj", ".vbproj", ".pyproj", ".vcxproj"
|
|
||||||
};
|
|
||||||
|
|
||||||
public static SolutionData Parse(string slnPath)
|
|
||||||
{
|
|
||||||
var slnDir = Path.GetDirectoryName(slnPath) ?? string.Empty;
|
|
||||||
var content = File.ReadAllText(slnPath, System.Text.Encoding.UTF8);
|
|
||||||
var projects = new List<ProjectEntry>();
|
|
||||||
|
|
||||||
foreach (Match m in ProjectLine.Matches(content))
|
|
||||||
{
|
|
||||||
var name = m.Groups[1].Value;
|
|
||||||
var relPath = m.Groups[2].Value.Replace('/', Path.DirectorySeparatorChar);
|
|
||||||
var ext = Path.GetExtension(relPath);
|
|
||||||
|
|
||||||
// Skip solution folders (no file extension) and non-code projects.
|
|
||||||
if (string.IsNullOrEmpty(ext) || !ProjectExtensions.Contains(ext))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var absPath = Path.GetFullPath(Path.Combine(slnDir, relPath));
|
|
||||||
if (!File.Exists(absPath)) continue; // skip phantom entries
|
|
||||||
|
|
||||||
projects.Add(new ProjectEntry(name, relPath.Replace('\\', '/'), absPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new SolutionData(slnPath, projects);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
{"id": 1, "method": "loadSolution", "params": {"path": "C:\\gocode\\ctx\\tools\\roslyn-helper\\RoslynHelper.sln"}}
|
|
||||||
{"id": 2, "method": "projectSummary", "params": {}}
|
|
||||||
{"id": 3, "method": "shutdown", "params": {}}
|
|
||||||
Loading…
Reference in New Issue
Block a user