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:
parent
59cb2b5ddb
commit
15dc1b6b2f
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
68
internal/plugins/csharp/outline.go
Normal file
68
internal/plugins/csharp/outline.go
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
package csharp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ricarneiro/ctx/internal/core"
|
||||||
|
"github.com/ricarneiro/ctx/internal/plugins/csharp/helper"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func outlineCmd(ctx *core.Context) *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "outline <file.cs>",
|
||||||
|
Short: "Show structural outline of a C# file (no method bodies)",
|
||||||
|
Long: `Parse a C# source file and emit its structural skeleton:
|
||||||
|
namespaces, types, method signatures, properties, fields, events.
|
||||||
|
Method bodies are omitted — reduces large files by 80–90%.
|
||||||
|
|
||||||
|
Does not require a loaded solution. Works on a single file.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runOutline(ctx, args[0])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runOutline(ctx *core.Context, file string) error {
|
||||||
|
abs, err := resolveFilePath(ctx.WorkDir, file)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(ctx.Stderr, err.Error())
|
||||||
|
return errExit
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(strings.ToLower(abs), ".cs") {
|
||||||
|
fmt.Fprintf(ctx.Stderr, "not a C# file: %s\n", abs)
|
||||||
|
return errExit
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, 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
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
33
tools/roslyn-helper/src/RoslynHelper/Models/OutlineModels.cs
Normal file
33
tools/roslyn-helper/src/RoslynHelper/Models/OutlineModels.cs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace RoslynHelper.Models;
|
||||||
|
|
||||||
|
public sealed record OutlineResult(
|
||||||
|
[property: JsonPropertyName("path")] string Path,
|
||||||
|
[property: JsonPropertyName("namespace")] string Namespace,
|
||||||
|
[property: JsonPropertyName("lineCount")] int LineCount,
|
||||||
|
[property: JsonPropertyName("usings")] List<string> Usings,
|
||||||
|
[property: JsonPropertyName("types")] List<OutlineTypeModel> Types,
|
||||||
|
[property: JsonPropertyName("hasSyntaxErrors")] bool HasSyntaxErrors
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record OutlineTypeModel(
|
||||||
|
[property: JsonPropertyName("kind")] string Kind,
|
||||||
|
[property: JsonPropertyName("name")] string Name,
|
||||||
|
[property: JsonPropertyName("modifiers")] List<string> Modifiers,
|
||||||
|
[property: JsonPropertyName("baseTypes")] List<string> BaseTypes,
|
||||||
|
[property: JsonPropertyName("members")] List<OutlineMemberModel> Members,
|
||||||
|
[property: JsonPropertyName("nested")] List<OutlineTypeModel> Nested
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>IsObsolete</c> is nullable so that <c>null</c> (not obsolete) is omitted
|
||||||
|
/// from JSON by the dispatcher's <c>WhenWritingNull</c> policy.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record OutlineMemberModel(
|
||||||
|
[property: JsonPropertyName("kind")] string Kind,
|
||||||
|
[property: JsonPropertyName("signature")] string Signature,
|
||||||
|
[property: JsonPropertyName("modifiers")] List<string> Modifiers,
|
||||||
|
[property: JsonPropertyName("line")] int Line,
|
||||||
|
[property: JsonPropertyName("isObsolete")] bool? IsObsolete = null
|
||||||
|
);
|
||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user