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;
}