diff --git a/internal/plugins/csharp/csharp.go b/internal/plugins/csharp/csharp.go index 434d37f..07d07dd 100644 --- a/internal/plugins/csharp/csharp.go +++ b/internal/plugins/csharp/csharp.go @@ -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 } diff --git a/internal/plugins/csharp/format.go b/internal/plugins/csharp/format.go index 2cc7f36..78ab54d 100644 --- a/internal/plugins/csharp/format.go +++ b/internal/plugins/csharp/format.go @@ -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) +} diff --git a/internal/plugins/csharp/helper/client.go b/internal/plugins/csharp/helper/client.go index 14084e2..45d39b2 100644 --- a/internal/plugins/csharp/helper/client.go +++ b/internal/plugins/csharp/helper/client.go @@ -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 diff --git a/internal/plugins/csharp/outline.go b/internal/plugins/csharp/outline.go new file mode 100644 index 0000000..b8f44c2 --- /dev/null +++ b/internal/plugins/csharp/outline.go @@ -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 ", + 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 +} diff --git a/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Dispatcher.cs b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Dispatcher.cs index aa410cf..0270431 100644 --- a/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Dispatcher.cs +++ b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Dispatcher.cs @@ -31,6 +31,7 @@ public sealed class Dispatcher ["ping"] = new PingHandler(), ["loadSolution"] = new LoadSolutionHandler(workspace), ["projectSummary"] = new ProjectSummaryHandler(workspace), + ["outline"] = new OutlineHandler(), }; } diff --git a/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Handlers/OutlineHandler.cs b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Handlers/OutlineHandler.cs new file mode 100644 index 0000000..1338792 --- /dev/null +++ b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Handlers/OutlineHandler.cs @@ -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; + +/// +/// 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. +/// +public sealed class OutlineHandler : IHandler +{ + public async Task 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(); + 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 CollectUsings(SyntaxList 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 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 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(); + var nested = new List(); + + 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 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 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 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; +} diff --git a/tools/roslyn-helper/src/RoslynHelper/Models/OutlineModels.cs b/tools/roslyn-helper/src/RoslynHelper/Models/OutlineModels.cs new file mode 100644 index 0000000..32ee87a --- /dev/null +++ b/tools/roslyn-helper/src/RoslynHelper/Models/OutlineModels.cs @@ -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 Usings, + [property: JsonPropertyName("types")] List Types, + [property: JsonPropertyName("hasSyntaxErrors")] bool HasSyntaxErrors +); + +public sealed record OutlineTypeModel( + [property: JsonPropertyName("kind")] string Kind, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("modifiers")] List Modifiers, + [property: JsonPropertyName("baseTypes")] List BaseTypes, + [property: JsonPropertyName("members")] List Members, + [property: JsonPropertyName("nested")] List Nested +); + +/// +/// IsObsolete is nullable so that null (not obsolete) is omitted +/// from JSON by the dispatcher's WhenWritingNull policy. +/// +public sealed record OutlineMemberModel( + [property: JsonPropertyName("kind")] string Kind, + [property: JsonPropertyName("signature")] string Signature, + [property: JsonPropertyName("modifiers")] List Modifiers, + [property: JsonPropertyName("line")] int Line, + [property: JsonPropertyName("isObsolete")] bool? IsObsolete = null +); diff --git a/tools/roslyn-helper/src/RoslynHelper/RoslynHelper.csproj b/tools/roslyn-helper/src/RoslynHelper/RoslynHelper.csproj index ed0c4e5..21833f9 100644 --- a/tools/roslyn-helper/src/RoslynHelper/RoslynHelper.csproj +++ b/tools/roslyn-helper/src/RoslynHelper/RoslynHelper.csproj @@ -8,9 +8,11 @@ ctx-roslyn-helper RoslynHelper - + + + +