Skip to content

Commit 4d56a44

Browse files
committed
Further work to solution parsing to better handle nested solution folders.
refs ionide/ionide-vscode-fsharp#2107
1 parent ff2685f commit 4d56a44

File tree

11 files changed

+245
-10
lines changed

11 files changed

+245
-10
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
9+
## [Unreleased]
10+
11+
### Changed
12+
13+
- [Change solution file parsing to report the structure of solution folders/projects](https://github.com/ionide/proj-info/pull/241) (thanks @Numpsy)
14+
815
## [0.73.0] - 2025-11-11
916

1017
### Changed

src/Ionide.ProjInfo/InspectSln.fs

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ module InspectSln =
7575
let parseSln (sln: Model.SolutionModel) (projectsToRead: string Set option) =
7676
sln.DistillProjectConfigurations()
7777

78+
// Work out the subset of projects that we care about
79+
let projectsWeCareAbout =
80+
match projectsToRead with
81+
| None -> sln.SolutionProjects :> seq<_>
82+
| Some filteredProjects ->
83+
sln.SolutionProjects
84+
|> Seq.filter (fun slnProject -> filteredProjects.Contains(makeAbsoluteFromSlnDir slnProject.FilePath))
85+
7886
let parseItem (item: Model.SolutionItemModel) : SolutionItem = {
7987
Guid = item.Id
8088
Name = ""
@@ -87,7 +95,7 @@ module InspectSln =
8795
Kind = SolutionItemKind.MSBuildFormat [] // TODO: could theoretically parse configurations here
8896
}
8997

90-
let parseFolder (folder: Model.SolutionFolderModel) : SolutionItem = {
98+
let rec parseFolder (folder: Model.SolutionFolderModel) : SolutionItem = {
9199
Guid = folder.Id
92100
Name = folder.ActualDisplayName
93101
Kind =
@@ -97,7 +105,25 @@ module InspectSln =
97105
not (isNull item.Parent)
98106
&& item.Parent.Id = folder.Id
99107
)
100-
|> Seq.map (fun p -> parseItem p)
108+
|> Seq.choose (fun p ->
109+
// If this item is a subfolder, map it recursively
110+
// if it's a project, map it if it's in the 'projectsWeCareAbout' collection
111+
// for anything else, just use a generic item
112+
match p with
113+
| :? Model.SolutionFolderModel as childFolder -> Some(parseFolder childFolder)
114+
| :? Model.SolutionProjectModel as childProject ->
115+
if
116+
projectsWeCareAbout
117+
|> Seq.exists (
118+
_.Id
119+
>> (=) childProject.Id
120+
)
121+
then
122+
Some(parseProject childProject)
123+
else
124+
None
125+
| _ -> Some(parseItem p)
126+
)
101127
|> List.ofSeq,
102128

103129
folder.Files
@@ -112,23 +138,45 @@ module InspectSln =
112138

113139
// three kinds of items - projects, folders, items
114140
// yield them all here
115-
let projectsWeCareAbout =
116-
match projectsToRead with
117-
| None -> sln.SolutionProjects :> seq<_>
118-
| Some filteredProjects ->
119-
sln.SolutionProjects
120-
|> Seq.filter (fun slnProject -> filteredProjects.Contains(makeAbsoluteFromSlnDir slnProject.FilePath))
121-
122141
let allItems = [
142+
// Projects at solution level get returned directly
123143
yield!
124144
projectsWeCareAbout
145+
|> Seq.filter (
146+
_.Parent
147+
>> isNull
148+
)
125149
|> Seq.map parseProject
150+
151+
// parseFolder will parse any projects or folders within the specified folder itself, so just process the root folders here
126152
yield!
127153
sln.SolutionFolders
154+
|> Seq.filter (
155+
_.Parent
156+
>> isNull
157+
)
128158
|> Seq.map parseFolder
159+
160+
// 'SolutionItems' contains all of SolutionFolders and SolutionProjects, so only include things that aren't in those to avoid duplication
129161
yield!
130162
sln.SolutionItems
131-
|> Seq.filter (fun item -> isNull item.Parent)
163+
|> Seq.filter (fun item ->
164+
isNull item.Parent
165+
&& not (
166+
sln.SolutionFolders
167+
|> Seq.exists (
168+
_.Id
169+
>> (=) item.Id
170+
)
171+
)
172+
&& not (
173+
sln.SolutionProjects
174+
|> Seq.exists (
175+
_.Id
176+
>> (=) item.Id
177+
)
178+
)
179+
)
132180
|> Seq.map parseItem
133181
]
134182

test/Ionide.ProjInfo.Tests/TestAssets.fs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,3 +376,21 @@ let ``sample 15 nuget analyzers`` = {
376376
TargetFrameworks = Map.empty
377377
ProjectReferences = []
378378
}
379+
380+
/// A test for a solution with projects
381+
let ``sample 16 solution folders (.sln)`` = {
382+
ProjDir = "sample16-solution-with-solution-folders"
383+
AssemblyName = ""
384+
ProjectFile = "sample16-solution-with-solution-folders.sln"
385+
TargetFrameworks = Map.empty
386+
ProjectReferences = []
387+
}
388+
389+
/// and the same with a slnc format solution
390+
let ``sample 16 solution folders (.slnx)`` = {
391+
ProjDir = "sample16-solution-with-solution-folders"
392+
AssemblyName = ""
393+
ProjectFile = "sample16-solution-with-solution-folders.slnx"
394+
TargetFrameworks = Map.empty
395+
ProjectReferences = []
396+
}

test/Ionide.ProjInfo.Tests/Tests.fs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2397,6 +2397,71 @@ let sample15NugetAnalyzers toolsPath loaderType workspaceFactory =
23972397

23982398
)
23992399

2400+
/// Common code for sample16SolutionFoldersSlnTest/sample16SolutionFoldersSlnxTest - they are the same test, but for both /sln/slnx files
2401+
let sample16SolutionFoldersTest testAsset =
2402+
2403+
let projPath = pathForProject testAsset
2404+
let slnDir = Path.GetDirectoryName projPath
2405+
2406+
let solutionContents =
2407+
InspectSln.tryParseSln projPath
2408+
|> getResult
2409+
2410+
let solutionData = solutionContents
2411+
2412+
// There should be 3 items at the solution level - build.fsproj, a 'src' folder and a 'tests' folder
2413+
Expect.equal solutionData.Items.Length 3 "There should be 3 items in the solution"
2414+
2415+
// The first item should be "build.fsproj
2416+
let firstItem = solutionData.Items[0]
2417+
let expectedBuildProjectPath = Path.Combine(slnDir, "build.fsproj")
2418+
Expect.equal firstItem.Name expectedBuildProjectPath "Should have the expected build project path"
2419+
2420+
match firstItem.Kind with
2421+
| InspectSln.MSBuildFormat items -> Expect.isEmpty items "we don't currently store anything here"
2422+
| unexpected -> failtestf "Expected a project, but got %A" unexpected
2423+
2424+
// The second item should be the 'src' folder, which should contain proj1.fsproj
2425+
let secondItem = solutionData.Items[1]
2426+
Expect.equal secondItem.Name "src" "Should have the src folder"
2427+
2428+
match secondItem.Kind with
2429+
| InspectSln.Folder(solutionItems, _) ->
2430+
match solutionItems with
2431+
| [ {
2432+
Name = folderName
2433+
Kind = InspectSln.MSBuildFormat []
2434+
} ] ->
2435+
let expectedProjectPath = Path.Combine(slnDir, "src", "proj1", "proj1.fsproj")
2436+
Expect.equal folderName expectedProjectPath "Should have the expected project path"
2437+
| _ -> failtestf "Expected one folder item, but got %A" solutionItems
2438+
| unexpected -> failtestf "Expected a folder, but got %A" unexpected
2439+
2440+
// The third item should be the 'src' folder, which should contain proj1.fsproj
2441+
let thirdItem = solutionData.Items[2]
2442+
Expect.equal thirdItem.Name "tests" "Should have the tests folder"
2443+
2444+
match thirdItem.Kind with
2445+
| InspectSln.Folder(solutionItems, _) ->
2446+
match solutionItems with
2447+
| [ {
2448+
Name = folderName
2449+
Kind = InspectSln.MSBuildFormat []
2450+
} ] ->
2451+
let expectedProjectPath = Path.Combine(slnDir, "test", "proj1.tests", "proj1.tests.fsproj")
2452+
Expect.equal folderName expectedProjectPath "Should have the expected test project path"
2453+
| _ -> failtestf "Expected one folder item, but got %A" solutionItems
2454+
| unexpected -> failtestf "Expected a folder, but got %A" unexpected
2455+
2456+
/// A test that we can load a solution that contains projects inside solution folders, and get the expected structure
2457+
let sample16SolutionFoldersSlnTest toolsPath loaderType workspaceFactory =
2458+
2459+
testCase $"Can load sample16 solution folders test (.sln) - {loaderType}" (fun () -> sample16SolutionFoldersTest ``sample 16 solution folders (.sln)``)
2460+
2461+
/// As above, but for a .slnx format solution
2462+
let sample16SolutionFoldersSlnxTest toolsPath loaderType workspaceFactory =
2463+
2464+
testCase $"Can load sample16 solution folders test (.slnx) - {loaderType}" (fun () -> sample16SolutionFoldersTest ``sample 16 solution folders (.slnx)``)
24002465

24012466
let tests toolsPath =
24022467
let testSample3WorkspaceLoaderExpected = [
@@ -2539,4 +2604,10 @@ let tests toolsPath =
25392604

25402605
sample15NugetAnalyzers toolsPath (nameof (WorkspaceLoader)) WorkspaceLoader.Create
25412606
sample15NugetAnalyzers toolsPath (nameof (WorkspaceLoaderViaProjectGraph)) WorkspaceLoaderViaProjectGraph.Create
2607+
2608+
sample16SolutionFoldersSlnTest toolsPath (nameof (WorkspaceLoader)) WorkspaceLoader.Create
2609+
sample16SolutionFoldersSlnTest toolsPath (nameof (WorkspaceLoaderViaProjectGraph)) WorkspaceLoaderViaProjectGraph.Create
2610+
2611+
sample16SolutionFoldersSlnxTest toolsPath (nameof (WorkspaceLoader)) WorkspaceLoader.Create
2612+
sample16SolutionFoldersSlnxTest toolsPath (nameof (WorkspaceLoaderViaProjectGraph)) WorkspaceLoaderViaProjectGraph.Create
25422613
]
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<OutputType>Exe</OutputType>
4+
<TargetFramework>net8.0</TargetFramework>
5+
</PropertyGroup>
6+
</Project>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
2+
Microsoft Visual Studio Solution File, Format Version 12.00
3+
# Visual Studio Version 17
4+
VisualStudioVersion = 17.0.31903.59
5+
MinimumVisualStudioVersion = 10.0.40219.1
6+
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "build", "build.fsproj", "{6BC5F14A-400D-5678-6478-C5DAFCEBD79E}"
7+
EndProject
8+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
9+
EndProject
10+
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "proj1", "src\proj1\proj1.fsproj", "{A06D38C3-42C2-132A-6394-D695EEDD47C9}"
11+
EndProject
12+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{7ECEE39C-5B10-412F-A075-DCF155771625}"
13+
EndProject
14+
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "proj1.tests", "test\proj1.tests\proj1.tests.fsproj", "{A40559BD-B085-0F90-1CAE-8FF1D7405814}"
15+
EndProject
16+
Global
17+
GlobalSection(SolutionConfigurationPlatforms) = preSolution
18+
Debug|Any CPU = Debug|Any CPU
19+
Release|Any CPU = Release|Any CPU
20+
EndGlobalSection
21+
GlobalSection(ProjectConfigurationPlatforms) = postSolution
22+
{6BC5F14A-400D-5678-6478-C5DAFCEBD79E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
23+
{6BC5F14A-400D-5678-6478-C5DAFCEBD79E}.Debug|Any CPU.Build.0 = Debug|Any CPU
24+
{6BC5F14A-400D-5678-6478-C5DAFCEBD79E}.Release|Any CPU.ActiveCfg = Release|Any CPU
25+
{6BC5F14A-400D-5678-6478-C5DAFCEBD79E}.Release|Any CPU.Build.0 = Release|Any CPU
26+
{A06D38C3-42C2-132A-6394-D695EEDD47C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
27+
{A06D38C3-42C2-132A-6394-D695EEDD47C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
28+
{A06D38C3-42C2-132A-6394-D695EEDD47C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
29+
{A06D38C3-42C2-132A-6394-D695EEDD47C9}.Release|Any CPU.Build.0 = Release|Any CPU
30+
{A40559BD-B085-0F90-1CAE-8FF1D7405814}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
31+
{A40559BD-B085-0F90-1CAE-8FF1D7405814}.Debug|Any CPU.Build.0 = Debug|Any CPU
32+
{A40559BD-B085-0F90-1CAE-8FF1D7405814}.Release|Any CPU.ActiveCfg = Release|Any CPU
33+
{A40559BD-B085-0F90-1CAE-8FF1D7405814}.Release|Any CPU.Build.0 = Release|Any CPU
34+
EndGlobalSection
35+
GlobalSection(SolutionProperties) = preSolution
36+
HideSolutionNode = FALSE
37+
EndGlobalSection
38+
GlobalSection(NestedProjects) = preSolution
39+
{A06D38C3-42C2-132A-6394-D695EEDD47C9} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
40+
{A40559BD-B085-0F90-1CAE-8FF1D7405814} = {7ECEE39C-5B10-412F-A075-DCF155771625}
41+
EndGlobalSection
42+
EndGlobal
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<Solution>
2+
<Folder Name="/src/">
3+
<Project Path="src/proj1/proj1.fsproj" />
4+
</Folder>
5+
<Folder Name="/tests/">
6+
<Project Path="test/proj1.tests/proj1.tests.fsproj" />
7+
</Folder>
8+
<Project Path="build.fsproj" />
9+
</Solution>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
namespace proj1
2+
3+
module Say =
4+
let hello name =
5+
printfn "Hello %s" name
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<GenerateDocumentationFile>true</GenerateDocumentationFile>
6+
</PropertyGroup>
7+
8+
<ItemGroup>
9+
<Compile Include="Library.fs" />
10+
</ItemGroup>
11+
12+
</Project>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
namespace proj1.tests
2+
3+
module Say =
4+
let hello name =
5+
printfn "Hello %s" name

0 commit comments

Comments
 (0)