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.`,
|
||||
}
|
||||
cmd.AddCommand(projectCmd(ctx))
|
||||
cmd.AddCommand(outlineCmd(ctx))
|
||||
return cmd
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package csharp
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ricarneiro/ctx/internal/output"
|
||||
@ -247,3 +248,177 @@ func writeMultiTargeting(w io.Writer, projects []helper.ProjectInfo) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// --- 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
|
||||
|
||||
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(),
|
||||
["loadSolution"] = new LoadSolutionHandler(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>
|
||||
<RootNamespace>RoslynHelper</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<!-- No external NuGet dependencies: System.Text.Json and System.Xml.Linq
|
||||
are included in the net10.0 framework. MSBuild/Roslyn are not used
|
||||
for the MVP commands (projectSummary) — csproj/sln files are parsed
|
||||
directly. Roslyn semantic workspace will be added in a later prompt
|
||||
once SDK version alignment is established. -->
|
||||
<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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user