Skip to content

Commit 873ad37

Browse files
authored
Support local functions in outline/breadcrumbs (#78605)
Resolves dotnet/vscode-csharp#6767 I am open to other ideas on how to render the top level entry point. For now I just used the display name, which should match what other places like diagnostics may report. Potentially could not include it at all, or name it something else? In VSCode: ![image](https://github.com/user-attachments/assets/0106820e-c8bf-4921-9864-75ca040ebc47) In VS, we only show max 2 levels, so the only change is that the top level program entry point can be seen, instead of it being blank: ![image](https://github.com/user-attachments/assets/ccf24277-df54-4c5c-b7ef-cd13fb68bab1)
2 parents c3c7ad6 + 26a9f7b commit 873ad37

File tree

3 files changed

+291
-13
lines changed

3 files changed

+291
-13
lines changed

src/EditorFeatures/Test2/NavigationBar/CSharpNavigationBarTests.vb

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,5 +390,187 @@ static class C
390390
Item("C.extension(string)", Glyph.ClassPublic), False,
391391
Item("Goo()", Glyph.ExtensionMethodPublic), False)
392392
End Function
393+
394+
<Theory, CombinatorialData, WorkItem("https://github.com/dotnet/vscode-csharp/issues/6767")>
395+
Public Async Function TestLocalFunction(host As TestHost) As Task
396+
Await AssertItemsAreAsync(
397+
<Workspace>
398+
<Project Language="C#" CommonReferences="true">
399+
<Document>
400+
class C { void M() { void Local() { } } }
401+
</Document>
402+
</Project>
403+
</Workspace>,
404+
host,
405+
Item("C", Glyph.ClassInternal, children:={
406+
Item("M()", Glyph.MethodPrivate, children:={
407+
Item("Local()", Glyph.MethodPrivate)})}))
408+
End Function
409+
410+
<Theory, CombinatorialData, WorkItem("https://github.com/dotnet/vscode-csharp/issues/6767")>
411+
Public Async Function TestNestedLocalFunction(host As TestHost) As Task
412+
Await AssertItemsAreAsync(
413+
<Workspace>
414+
<Project Language="C#" CommonReferences="true">
415+
<Document>
416+
class C { void M() { void Local() { void NestedLocal() { } } } }
417+
</Document>
418+
</Project>
419+
</Workspace>,
420+
host,
421+
Item("C", Glyph.ClassInternal, children:={
422+
Item("M()", Glyph.MethodPrivate, children:={
423+
Item("Local()", Glyph.MethodPrivate, children:={
424+
Item("NestedLocal()", Glyph.MethodPrivate)})})}))
425+
End Function
426+
427+
<Theory, CombinatorialData, WorkItem("https://github.com/dotnet/vscode-csharp/issues/6767")>
428+
Public Async Function TestMultipleLocalFunction(host As TestHost) As Task
429+
Await AssertItemsAreAsync(
430+
<Workspace>
431+
<Project Language="C#" CommonReferences="true">
432+
<Document>
433+
class C { void M() { void Local1() { } void Local2() { } } }
434+
</Document>
435+
</Project>
436+
</Workspace>,
437+
host,
438+
Item("C", Glyph.ClassInternal, children:={
439+
Item("M()", Glyph.MethodPrivate, children:={
440+
Item("Local1()", Glyph.MethodPrivate),
441+
Item("Local2()", Glyph.MethodPrivate)})}))
442+
End Function
443+
444+
<Theory, CombinatorialData, WorkItem("https://github.com/dotnet/vscode-csharp/issues/6767")>
445+
Public Async Function TestMultipleAndNestedLocalFunction(host As TestHost) As Task
446+
Await AssertItemsAreAsync(
447+
<Workspace>
448+
<Project Language="C#" CommonReferences="true">
449+
<Document>
450+
class C
451+
{
452+
void M()
453+
{
454+
void Local()
455+
{
456+
void NestedLocal() { }
457+
}
458+
void Local2()
459+
{
460+
void NestedLocal2() { }
461+
}
462+
}
463+
}
464+
</Document>
465+
</Project>
466+
</Workspace>,
467+
host,
468+
Item("C", Glyph.ClassInternal, children:={
469+
Item("M()", Glyph.MethodPrivate, children:={
470+
Item("Local()", Glyph.MethodPrivate, children:={
471+
Item("NestedLocal()", Glyph.MethodPrivate)}),
472+
Item("Local2()", Glyph.MethodPrivate, children:={
473+
Item("NestedLocal2()", Glyph.MethodPrivate)})})}))
474+
End Function
475+
476+
<Theory, CombinatorialData, WorkItem("https://github.com/dotnet/vscode-csharp/issues/6767")>
477+
Public Async Function TestTopLevelProgram(host As TestHost) As Task
478+
Await AssertItemsAreAsync(
479+
<Workspace>
480+
<Project Language="C#" CommonReferences="true">
481+
<Document>
482+
using System;
483+
Console.WriteLine("Hello World!");
484+
485+
void Method() { }
486+
</Document>
487+
</Project>
488+
</Workspace>,
489+
host,
490+
Item("Program", Glyph.ClassInternal, children:={
491+
Item("<top-level-statements-entry-point>", Glyph.MethodPrivate, children:={
492+
Item("Method()", Glyph.MethodPrivate)})}))
493+
End Function
494+
495+
<Theory, CombinatorialData, WorkItem("https://github.com/dotnet/vscode-csharp/issues/6767")>
496+
Public Async Function TestLocalFunctionInProperty(host As TestHost) As Task
497+
Await AssertItemsAreAsync(
498+
<Workspace>
499+
<Project Language="C#" CommonReferences="true">
500+
<Document>
501+
class C
502+
{
503+
private string _field = string.Empty;
504+
public string Prop
505+
{
506+
get
507+
{
508+
return GetField();
509+
510+
string GetField()
511+
{
512+
return _field;
513+
}
514+
}
515+
set
516+
{
517+
if (IsValid())
518+
{
519+
_field = value;
520+
}
521+
522+
bool IsValid()
523+
{
524+
return true;
525+
}
526+
}
527+
}
528+
}
529+
</Document>
530+
</Project>
531+
</Workspace>,
532+
host,
533+
Item("C", Glyph.ClassInternal, children:={
534+
Item("_field", Glyph.FieldPrivate),
535+
Item("Prop", Glyph.PropertyPublic, children:={
536+
Item("GetField()", Glyph.MethodPrivate),
537+
Item("IsValid()", Glyph.MethodPrivate)})}))
538+
End Function
539+
540+
<Theory, CombinatorialData, WorkItem("https://github.com/dotnet/vscode-csharp/issues/6767")>
541+
Public Async Function TestNestedLocalFunctionInProperty(host As TestHost) As Task
542+
Await AssertItemsAreAsync(
543+
<Workspace>
544+
<Project Language="C#" CommonReferences="true">
545+
<Document>
546+
class C
547+
{
548+
private string _field = string.Empty;
549+
public string Prop
550+
{
551+
get
552+
{
553+
return _field;
554+
}
555+
set
556+
{
557+
_field = value;
558+
void Local()
559+
{
560+
void NestedLocal() { }
561+
}
562+
}
563+
}
564+
}
565+
</Document>
566+
</Project>
567+
</Workspace>,
568+
host,
569+
Item("C", Glyph.ClassInternal, children:={
570+
Item("_field", Glyph.FieldPrivate),
571+
Item("Prop", Glyph.PropertyPublic, children:={
572+
Item("Local()", Glyph.MethodPrivate, children:={
573+
Item("NestedLocal()", Glyph.MethodPrivate)})})}))
574+
End Function
393575
End Class
394576
End Namespace

src/Features/CSharp/Portable/NavigationBar/CSharpNavigationBarItemService.cs

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
using System.Collections.Immutable;
88
using System.Composition;
99
using System.Diagnostics;
10+
using System.Linq;
1011
using System.Threading;
1112
using System.Threading.Tasks;
13+
using Microsoft.CodeAnalysis.CSharp.Extensions;
1214
using Microsoft.CodeAnalysis.CSharp.Syntax;
1315
using Microsoft.CodeAnalysis.Host.Mef;
1416
using Microsoft.CodeAnalysis.Internal.Log;
@@ -54,14 +56,15 @@ protected override async Task<ImmutableArray<RoslynNavigationBarItem>> GetItemsI
5456
if (cancellationToken.IsCancellationRequested)
5557
return [];
5658

57-
return GetMembersInTypes(document.Project.Solution, semanticModel.SyntaxTree, typesInFile, cancellationToken);
59+
return GetMembersInTypes(document.Project.Solution, semanticModel, typesInFile, cancellationToken);
5860
}
5961

6062
private static ImmutableArray<RoslynNavigationBarItem> GetMembersInTypes(
61-
Solution solution, SyntaxTree tree, HashSet<INamedTypeSymbol> types, CancellationToken cancellationToken)
63+
Solution solution, SemanticModel semanticModel, HashSet<INamedTypeSymbol> types, CancellationToken cancellationToken)
6264
{
6365
using (Logger.LogBlock(FunctionId.NavigationBar_ItemService_GetMembersInTypes_CSharp, cancellationToken))
6466
{
67+
var tree = semanticModel.SyntaxTree;
6568
using var _1 = ArrayBuilder<RoslynNavigationBarItem>.GetInstance(out var items);
6669

6770
foreach (var type in types)
@@ -79,29 +82,29 @@ private static ImmutableArray<RoslynNavigationBarItem> GetMembersInTypes(
7982

8083
if (member is IMethodSymbol { PartialImplementationPart: { } } methodSymbol)
8184
{
82-
memberItems.AddIfNotNull(CreateItemForMember(solution, methodSymbol, tree, cancellationToken));
83-
memberItems.AddIfNotNull(CreateItemForMember(solution, methodSymbol.PartialImplementationPart, tree, cancellationToken));
85+
memberItems.AddIfNotNull(CreateItemForMember(solution, methodSymbol, semanticModel, cancellationToken));
86+
memberItems.AddIfNotNull(CreateItemForMember(solution, methodSymbol.PartialImplementationPart, semanticModel, cancellationToken));
8487
}
8588
else if (member is IPropertySymbol { PartialImplementationPart: { } } propertySymbol)
8689
{
87-
memberItems.AddIfNotNull(CreateItemForMember(solution, propertySymbol, tree, cancellationToken));
88-
memberItems.AddIfNotNull(CreateItemForMember(solution, propertySymbol.PartialImplementationPart, tree, cancellationToken));
90+
memberItems.AddIfNotNull(CreateItemForMember(solution, propertySymbol, semanticModel, cancellationToken));
91+
memberItems.AddIfNotNull(CreateItemForMember(solution, propertySymbol.PartialImplementationPart, semanticModel, cancellationToken));
8992
}
9093
else if (member is IEventSymbol { PartialImplementationPart: { } } eventSymbol)
9194
{
92-
memberItems.AddIfNotNull(CreateItemForMember(solution, eventSymbol, tree, cancellationToken));
93-
memberItems.AddIfNotNull(CreateItemForMember(solution, eventSymbol.PartialImplementationPart, tree, cancellationToken));
95+
memberItems.AddIfNotNull(CreateItemForMember(solution, eventSymbol, semanticModel, cancellationToken));
96+
memberItems.AddIfNotNull(CreateItemForMember(solution, eventSymbol.PartialImplementationPart, semanticModel, cancellationToken));
9497
}
9598
else if (member is IMethodSymbol or IPropertySymbol or IEventSymbol)
9699
{
97100
Debug.Assert(member is IMethodSymbol { PartialDefinitionPart: null } or IPropertySymbol { PartialDefinitionPart: null } or IEventSymbol { PartialDefinitionPart: null },
98101
$"NavBar expected GetMembers to return partial method/property/event definition parts but the implementation part was returned.");
99102

100-
memberItems.AddIfNotNull(CreateItemForMember(solution, member, tree, cancellationToken));
103+
memberItems.AddIfNotNull(CreateItemForMember(solution, member, semanticModel, cancellationToken));
101104
}
102105
else
103106
{
104-
memberItems.AddIfNotNull(CreateItemForMember(solution, member, tree, cancellationToken));
107+
memberItems.AddIfNotNull(CreateItemForMember(solution, member, semanticModel, cancellationToken));
105108
}
106109
}
107110

@@ -166,6 +169,7 @@ StatementSyntax or
166169
{
167170
BaseTypeDeclarationSyntax t => semanticModel.GetDeclaredSymbol(t, cancellationToken),
168171
DelegateDeclarationSyntax d => semanticModel.GetDeclaredSymbol(d, cancellationToken),
172+
CompilationUnitSyntax c => c.IsTopLevelProgram() ? semanticModel.GetDeclaredSymbol(c, cancellationToken)?.ContainingType : null,
169173
_ => null,
170174
};
171175

@@ -182,18 +186,67 @@ private static bool IsAccessor(ISymbol member)
182186
}
183187

184188
private static SymbolItem? CreateItemForMember(
185-
Solution solution, ISymbol member, SyntaxTree tree, CancellationToken cancellationToken)
189+
Solution solution, ISymbol member, SemanticModel semanticModel, CancellationToken cancellationToken)
186190
{
187-
var location = GetSymbolLocation(solution, member, tree, cancellationToken);
191+
var location = GetSymbolLocation(solution, member, semanticModel.SyntaxTree, cancellationToken);
188192
if (location == null)
189193
return null;
190194

195+
using var _ = ArrayBuilder<RoslynNavigationBarItem>.GetInstance(out var localFunctionItems);
196+
foreach (var syntaxReference in member.DeclaringSyntaxReferences)
197+
{
198+
if (syntaxReference.SyntaxTree != semanticModel.SyntaxTree)
199+
{
200+
// The reference is not in this file, no need to include in the outline view.
201+
continue;
202+
}
203+
204+
var referenceNode = syntaxReference.GetSyntax(cancellationToken);
205+
localFunctionItems.AddRange(CreateLocalFunctionMembers(solution, referenceNode, semanticModel, cancellationToken));
206+
}
207+
191208
return new SymbolItem(
192209
member.ToDisplayString(s_memberNameFormat),
193210
member.ToDisplayString(s_memberDetailsFormat),
194211
member.GetGlyph(),
195212
member.IsObsolete(),
196-
location.Value);
213+
location.Value,
214+
localFunctionItems.ToImmutable());
215+
216+
static ImmutableArray<RoslynNavigationBarItem> CreateLocalFunctionMembers(
217+
Solution solution, SyntaxNode node, SemanticModel semanticModel, CancellationToken cancellationToken)
218+
{
219+
// Get only the local functions that are direct descendents of this method.
220+
var localFunctions = node.DescendantNodes(descendIntoChildren: (n) =>
221+
// Always descend from the original node even if its a local function, but do not descend further into descendent local functions.
222+
n == node || n is not LocalFunctionStatementSyntax).Where(n => n is LocalFunctionStatementSyntax);
223+
using var _ = ArrayBuilder<RoslynNavigationBarItem>.GetInstance(out var items);
224+
foreach (var localFunction in localFunctions)
225+
{
226+
var localFunctionSymbol = semanticModel.GetDeclaredSymbol(localFunction, cancellationToken);
227+
if (localFunctionSymbol == null)
228+
continue;
229+
230+
var location = GetSymbolLocation(solution, localFunctionSymbol, semanticModel.SyntaxTree, cancellationToken);
231+
if (location == null)
232+
continue;
233+
234+
// Check the child local functions to see if they have nested local functions.
235+
var childItems = CreateLocalFunctionMembers(solution, localFunction, semanticModel, cancellationToken);
236+
237+
var symbolItem = new SymbolItem(
238+
localFunctionSymbol.ToDisplayString(s_memberNameFormat),
239+
localFunctionSymbol.ToDisplayString(s_memberDetailsFormat),
240+
localFunctionSymbol.GetGlyph(),
241+
localFunctionSymbol.IsObsolete(),
242+
location.Value,
243+
childItems);
244+
245+
items.Add(symbolItem);
246+
}
247+
248+
return items.ToImmutable();
249+
}
197250
}
198251

199252
private static SymbolItemLocation? GetSymbolLocation(

src/LanguageServer/ProtocolUnitTests/Symbols/DocumentSymbolsTests.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,49 @@ public async Task TestGetDocumentSymbolsAsync__NoSymbols(bool mutatingLspWorkspa
175175
Assert.Empty(results);
176176
}
177177

178+
[Theory, CombinatorialData]
179+
public async Task TestGetDocumentSymbolsAsync_LocalFunction(bool mutatingLspWorkspace)
180+
{
181+
var markup =
182+
"""
183+
namespace Test;
184+
{|class:class {|classSelection:A|}
185+
{
186+
{|method:void {|methodSelection:M|}()
187+
{
188+
{|localFunction:void {|localFunctionSelection:LocalFunction|}()
189+
{
190+
}|}
191+
}|}
192+
}|}
193+
""";
194+
var clientCapabilities = new LSP.ClientCapabilities()
195+
{
196+
TextDocument = new LSP.TextDocumentClientCapabilities()
197+
{
198+
DocumentSymbol = new LSP.DocumentSymbolSetting()
199+
{
200+
HierarchicalDocumentSymbolSupport = true
201+
}
202+
}
203+
};
204+
205+
await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace, clientCapabilities);
206+
var classSymbol = CreateDocumentSymbol(LSP.SymbolKind.Class, "A", "Test.A", testLspServer.GetLocations("class").Single(), testLspServer.GetLocations("classSelection").Single());
207+
var methodSymbol = CreateDocumentSymbol(LSP.SymbolKind.Method, "M", "M()", testLspServer.GetLocations("method").Single(), testLspServer.GetLocations("methodSelection").Single(), classSymbol);
208+
var localFunctionSymbol = CreateDocumentSymbol(LSP.SymbolKind.Method, "LocalFunction", "LocalFunction()", testLspServer.GetLocations("localFunction").Single(), testLspServer.GetLocations("localFunctionSelection").Single(), methodSymbol);
209+
210+
LSP.DocumentSymbol[] expected = [classSymbol];
211+
212+
var results = await RunGetDocumentSymbolsAsync<LSP.DocumentSymbol[]>(testLspServer);
213+
Assert.NotNull(results);
214+
Assert.Equal(expected.Length, results.Length);
215+
for (var i = 0; i < results.Length; i++)
216+
{
217+
AssertDocumentSymbolEquals(expected[i], results[i]);
218+
}
219+
}
220+
178221
private static async Task<TReturn?> RunGetDocumentSymbolsAsync<TReturn>(TestLspServer testLspServer)
179222
{
180223
var document = testLspServer.GetCurrentSolution().Projects.First().Documents.First();

0 commit comments

Comments
 (0)