feat(csharp): implement 'outline' command for single-file structural summary

Adds OutlineHandler.cs to the Roslyn helper using CSharpSyntaxTree.ParseText
and manual syntax tree traversal (no solution load required). Extracts
namespaces, types, method/property/field/event signatures and line numbers
without method bodies. Handles file-scoped namespaces, generics, records,
nested types, partial classes, top-level statements, and [Obsolete] markers.

Adds Microsoft.CodeAnalysis.CSharp 4.13.0 as the sole new NuGet dependency
(pure parser, no MSBuild coupling). Go side adds Outline() client method,
OutlineResult/Type/Member types, outline.go command, and WriteOutline formatter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ricardo Carneiro 2026-04-27 19:41:47 -03:00
parent 59cb2b5ddb
commit 15dc1b6b2f
8 changed files with 612 additions and 5 deletions

View File

@ -27,5 +27,6 @@ Requires the Roslyn helper (ctx-roslyn-helper) to be built.
See 'ctx csharp project --help' for details.`, See 'ctx csharp project --help' for details.`,
} }
cmd.AddCommand(projectCmd(ctx)) cmd.AddCommand(projectCmd(ctx))
cmd.AddCommand(outlineCmd(ctx))
return cmd return cmd
} }

View File

@ -3,6 +3,7 @@ package csharp
import ( import (
"fmt" "fmt"
"io" "io"
"path/filepath"
"strings" "strings"
"github.com/ricarneiro/ctx/internal/output" "github.com/ricarneiro/ctx/internal/output"
@ -247,3 +248,177 @@ func writeMultiTargeting(w io.Writer, projects []helper.ProjectInfo) {
} }
fmt.Fprintln(w) 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)
}

View File

@ -120,6 +120,52 @@ func (c *Client) ProjectSummary() (*ProjectSummary, error) {
return &r, nil 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. // wrapRpc wraps RpcError values into user-friendly messages.
func wrapRpc(method string, err error) error { func wrapRpc(method string, err error) error {
var rpcErr *RpcError var rpcErr *RpcError

View File

@ -0,0 +1,68 @@
package csharp
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/ricarneiro/ctx/internal/core"
"github.com/ricarneiro/ctx/internal/plugins/csharp/helper"
"github.com/spf13/cobra"
)
func outlineCmd(ctx *core.Context) *cobra.Command {
return &cobra.Command{
Use: "outline <file.cs>",
Short: "Show structural outline of a C# file (no method bodies)",
Long: `Parse a C# source file and emit its structural skeleton:
namespaces, types, method signatures, properties, fields, events.
Method bodies are omitted reduces large files by 8090%.
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 _, err := os.Stat(abs); err != 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
}

View File

@ -31,6 +31,7 @@ public sealed class Dispatcher
["ping"] = new PingHandler(), ["ping"] = new PingHandler(),
["loadSolution"] = new LoadSolutionHandler(workspace), ["loadSolution"] = new LoadSolutionHandler(workspace),
["projectSummary"] = new ProjectSummaryHandler(workspace), ["projectSummary"] = new ProjectSummaryHandler(workspace),
["outline"] = new OutlineHandler(),
}; };
} }

View File

@ -0,0 +1,281 @@
using System.Text.Json;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using RoslynHelper.Models;
namespace RoslynHelper.JsonRpc.Handlers;
/// <summary>
/// Parses a single .cs file and returns its structural outline:
/// namespaces, types, method signatures (no bodies), properties, fields, events.
/// Does NOT require a solution to be loaded — works on a single file.
/// </summary>
public sealed class OutlineHandler : IHandler
{
public async Task<object> HandleAsync(JsonElement? @params)
{
if (@params is not { } p)
throw new KnownException("E_INVALID_PARAMS", "params required for outline");
if (!p.TryGetProperty("path", out var pathEl) || pathEl.ValueKind != JsonValueKind.String)
throw new KnownException("E_INVALID_PARAMS", "params.path (string) is required");
var path = pathEl.GetString()!;
if (!File.Exists(path))
throw new KnownException("E_NOT_FOUND", $"file not found: {path}");
var source = await File.ReadAllTextAsync(path, System.Text.Encoding.UTF8);
var tree = CSharpSyntaxTree.ParseText(source, path: path);
var root = (CompilationUnitSyntax)await tree.GetRootAsync();
bool hasSyntaxErrors = tree.GetDiagnostics()
.Any(d => d.Severity == DiagnosticSeverity.Error);
int lineCount = source.Split('\n').Length;
// Top-level usings
var usings = CollectUsings(root.Usings);
string ns = "";
var types = new List<OutlineTypeModel>();
bool hasTopLevel = false;
foreach (var member in root.Members)
{
switch (member)
{
case NamespaceDeclarationSyntax blockNs:
ns = blockNs.Name.ToString();
usings.AddRange(CollectUsings(blockNs.Usings));
foreach (var m in blockNs.Members)
CollectType(m, types);
break;
case FileScopedNamespaceDeclarationSyntax fileScopedNs:
ns = fileScopedNs.Name.ToString();
usings.AddRange(CollectUsings(fileScopedNs.Usings));
foreach (var m in fileScopedNs.Members)
CollectType(m, types);
break;
case GlobalStatementSyntax:
hasTopLevel = true;
break;
default:
CollectType(member, types);
break;
}
}
// Synthetic Program type for top-level statement files
if (hasTopLevel)
{
types.Insert(0, new OutlineTypeModel(
"class", "Program (top-level program)", [], [],
[new OutlineMemberModel("method", "static void Main(string[] args)", ["static"], 1, false)],
[]));
}
return new OutlineResult(path, ns, lineCount, usings.Distinct().ToList(), types, hasSyntaxErrors);
}
// ─── Usings ─────────────────────────────────────────────────────────────
private static List<string> CollectUsings(SyntaxList<UsingDirectiveSyntax> usings) =>
usings
.Where(u => u.Alias is null)
.Select(u => u.Name?.ToString() ?? "")
.Where(s => !string.IsNullOrEmpty(s))
.ToList();
// ─── Type dispatch ───────────────────────────────────────────────────────
private static void CollectType(MemberDeclarationSyntax member, List<OutlineTypeModel> target)
{
switch (member)
{
case ClassDeclarationSyntax cls:
target.Add(ExtractTypeDecl("class", cls.Identifier.Text,
cls.TypeParameterList, null, cls.Modifiers, cls.BaseList, cls.Members));
break;
case InterfaceDeclarationSyntax iface:
target.Add(ExtractTypeDecl("interface", iface.Identifier.Text,
iface.TypeParameterList, null, iface.Modifiers, iface.BaseList, iface.Members));
break;
case StructDeclarationSyntax str:
target.Add(ExtractTypeDecl("struct", str.Identifier.Text,
str.TypeParameterList, null, str.Modifiers, str.BaseList, str.Members));
break;
case RecordDeclarationSyntax rec:
{
var recKind = rec.ClassOrStructKeyword.IsKind(SyntaxKind.StructKeyword)
? "record struct" : "record";
target.Add(ExtractTypeDecl(recKind, rec.Identifier.Text,
rec.TypeParameterList, rec.ParameterList, rec.Modifiers, rec.BaseList, rec.Members));
break;
}
case EnumDeclarationSyntax en:
target.Add(ExtractEnum(en));
break;
}
}
// ─── Type extraction ─────────────────────────────────────────────────────
private static OutlineTypeModel ExtractTypeDecl(
string kind,
string name,
TypeParameterListSyntax? typeParams,
ParameterListSyntax? recordParams,
SyntaxTokenList modifiers,
BaseListSyntax? baseList,
SyntaxList<MemberDeclarationSyntax> members)
{
var mods = modifiers.Select(m => m.Text).ToList();
var baseTypes = baseList?.Types.Select(t => t.Type.ToString()).ToList() ?? [];
var fullName = name
+ (typeParams?.ToString() ?? "")
+ (recordParams?.ToString() ?? "");
var memberModels = new List<OutlineMemberModel>();
var nested = new List<OutlineTypeModel>();
foreach (var member in members)
{
switch (member)
{
case MethodDeclarationSyntax m:
memberModels.Add(ExtractMethod(m));
break;
case ConstructorDeclarationSyntax c:
memberModels.Add(ExtractConstructor(c));
break;
case PropertyDeclarationSyntax prop:
memberModels.Add(ExtractProperty(prop));
break;
case FieldDeclarationSyntax f:
memberModels.AddRange(ExtractFields(f));
break;
case EventDeclarationSyntax e:
memberModels.Add(ExtractEventDecl(e));
break;
case EventFieldDeclarationSyntax ef:
memberModels.AddRange(ExtractEventFields(ef));
break;
case ClassDeclarationSyntax _:
case InterfaceDeclarationSyntax _:
case StructDeclarationSyntax _:
case RecordDeclarationSyntax _:
case EnumDeclarationSyntax _:
CollectType(member, nested);
break;
}
}
return new OutlineTypeModel(kind, fullName, mods, baseTypes, memberModels, nested);
}
private static OutlineTypeModel ExtractEnum(EnumDeclarationSyntax en)
{
var mods = en.Modifiers.Select(m => m.Text).ToList();
var members = en.Members
.Select(m => new OutlineMemberModel("enumValue", m.Identifier.Text, [], GetLine(m), false))
.ToList();
return new OutlineTypeModel("enum", en.Identifier.Text, mods, [], members, []);
}
// ─── Member extraction ───────────────────────────────────────────────────
private static OutlineMemberModel ExtractMethod(MethodDeclarationSyntax m)
{
var typeParams = m.TypeParameterList?.ToString() ?? "";
var sig = $"{m.ReturnType} {m.Identifier.Text}{typeParams}{m.ParameterList}".Trim();
var mods = m.Modifiers.Select(x => x.Text).ToList();
return new OutlineMemberModel("method", sig, mods, GetLine(m), HasObsolete(m.AttributeLists));
}
private static OutlineMemberModel ExtractConstructor(ConstructorDeclarationSyntax c)
{
var sig = $"{c.Identifier.Text}{c.ParameterList}".Trim();
var mods = c.Modifiers.Select(x => x.Text).ToList();
return new OutlineMemberModel("constructor", sig, mods, GetLine(c), HasObsolete(c.AttributeLists));
}
private static OutlineMemberModel ExtractProperty(PropertyDeclarationSyntax p)
{
string accessors;
if (p.AccessorList is { } al)
{
var parts = al.Accessors.Select(a =>
{
var accMods = a.Modifiers.Any() ? a.Modifiers.ToString() + " " : "";
return accMods + a.Keyword.Text;
});
accessors = "{ " + string.Join("; ", parts) + "; }";
}
else
{
accessors = "=> ..."; // expression-bodied
}
var sig = $"{p.Type} {p.Identifier.Text} {accessors}".Trim();
var mods = p.Modifiers.Select(x => x.Text).ToList();
return new OutlineMemberModel("property", sig, mods, GetLine(p), HasObsolete(p.AttributeLists));
}
private static IEnumerable<OutlineMemberModel> ExtractFields(FieldDeclarationSyntax f)
{
var mods = f.Modifiers.Select(x => x.Text).ToList();
var typeName = f.Declaration.Type.ToString();
var isConst = mods.Contains("const");
var obs = HasObsolete(f.AttributeLists);
var line = GetLine(f);
foreach (var v in f.Declaration.Variables)
{
var sig = isConst && v.Initializer is { } init
? $"{typeName} {v.Identifier.Text} = {init.Value}"
: $"{typeName} {v.Identifier.Text}";
yield return new OutlineMemberModel("field", sig.Trim(), mods, line, obs);
}
}
private static OutlineMemberModel ExtractEventDecl(EventDeclarationSyntax e)
{
var sig = $"event {e.Type} {e.Identifier.Text}".Trim();
var mods = e.Modifiers.Select(x => x.Text).ToList();
return new OutlineMemberModel("event", sig, mods, GetLine(e), HasObsolete(e.AttributeLists));
}
private static IEnumerable<OutlineMemberModel> ExtractEventFields(EventFieldDeclarationSyntax ef)
{
var mods = ef.Modifiers.Select(x => x.Text).ToList();
var typeName = ef.Declaration.Type.ToString();
var obs = HasObsolete(ef.AttributeLists);
var line = GetLine(ef);
foreach (var v in ef.Declaration.Variables)
{
yield return new OutlineMemberModel(
"event", $"event {typeName} {v.Identifier.Text}".Trim(), mods, line, obs);
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────
// Returns true if obsolete, null otherwise (null omitted by WhenWritingNull in dispatcher)
private static bool? HasObsolete(SyntaxList<AttributeListSyntax> attrs)
{
var found = attrs.SelectMany(al => al.Attributes)
.Any(a => a.Name.ToString() is "Obsolete" or "ObsoleteAttribute");
return found ? true : null;
}
private static int GetLine(SyntaxNode node) =>
node.GetLocation().GetLineSpan().StartLinePosition.Line + 1;
}

View File

@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace RoslynHelper.Models;
public sealed record OutlineResult(
[property: JsonPropertyName("path")] string Path,
[property: JsonPropertyName("namespace")] string Namespace,
[property: JsonPropertyName("lineCount")] int LineCount,
[property: JsonPropertyName("usings")] List<string> Usings,
[property: JsonPropertyName("types")] List<OutlineTypeModel> Types,
[property: JsonPropertyName("hasSyntaxErrors")] bool HasSyntaxErrors
);
public sealed record OutlineTypeModel(
[property: JsonPropertyName("kind")] string Kind,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("modifiers")] List<string> Modifiers,
[property: JsonPropertyName("baseTypes")] List<string> BaseTypes,
[property: JsonPropertyName("members")] List<OutlineMemberModel> Members,
[property: JsonPropertyName("nested")] List<OutlineTypeModel> Nested
);
/// <remarks>
/// <c>IsObsolete</c> is nullable so that <c>null</c> (not obsolete) is omitted
/// from JSON by the dispatcher's <c>WhenWritingNull</c> policy.
/// </remarks>
public sealed record OutlineMemberModel(
[property: JsonPropertyName("kind")] string Kind,
[property: JsonPropertyName("signature")] string Signature,
[property: JsonPropertyName("modifiers")] List<string> Modifiers,
[property: JsonPropertyName("line")] int Line,
[property: JsonPropertyName("isObsolete")] bool? IsObsolete = null
);

View File

@ -8,9 +8,11 @@
<AssemblyName>ctx-roslyn-helper</AssemblyName> <AssemblyName>ctx-roslyn-helper</AssemblyName>
<RootNamespace>RoslynHelper</RootNamespace> <RootNamespace>RoslynHelper</RootNamespace>
</PropertyGroup> </PropertyGroup>
<!-- No external NuGet dependencies: System.Text.Json and System.Xml.Linq <ItemGroup>
are included in the net10.0 framework. MSBuild/Roslyn are not used <!-- Pure C# syntax parser — no MSBuild, no SDK coupling.
for the MVP commands (projectSummary) — csproj/sln files are parsed Used by OutlineHandler for CSharpSyntaxTree.ParseText + SyntaxWalker.
directly. Roslyn semantic workspace will be added in a later prompt Unlike Workspaces.MSBuild (removed in Prompt 3), this package
once SDK version alignment is established. --> has no runtime dependency on the installed SDK. -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.13.0" />
</ItemGroup>
</Project> </Project>