Skip to content

Commit 304c9c0

Browse files
authored
Change solution file parsing to report the structure of folders/projects, rather than returning projects as a flat list (#241)
* Further work to solution parsing to better handle nested solution folders. refs ionide/ionide-vscode-fsharp#2107 * Return solution folders before solution level projects
1 parent ff2685f commit 304c9c0

File tree

11 files changed

+255
-14
lines changed

11 files changed

+255
-14
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: 66 additions & 14 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,49 @@ 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 = [
123-
yield!
124-
projectsWeCareAbout
125-
|> Seq.map parseProject
142+
// Return solution folders first, and solution level projects second, see https://github.com/ionide/ionide-vscode-fsharp/issues/2109
143+
144+
// parseFolder will parse any projects or folders within the specified folder itself, so just process the root folders here
126145
yield!
127146
sln.SolutionFolders
128-
|> Seq.map parseFolder
147+
|> Seq.choose (fun folder ->
148+
if isNull folder.Parent then
149+
Some(parseFolder folder)
150+
else
151+
None
152+
)
153+
154+
// Projects at solution level get returned directly
155+
yield!
156+
projectsWeCareAbout
157+
|> Seq.choose (fun project ->
158+
if isNull project.Parent then
159+
Some(parseProject project)
160+
else
161+
None
162+
)
163+
164+
// 'SolutionItems' contains all of SolutionFolders and SolutionProjects, so only include things that aren't in those to avoid duplication
129165
yield!
130166
sln.SolutionItems
131-
|> Seq.filter (fun item -> isNull item.Parent)
167+
|> Seq.filter (fun item ->
168+
isNull item.Parent
169+
&& not (
170+
sln.SolutionFolders
171+
|> Seq.exists (
172+
_.Id
173+
>> (=) item.Id
174+
)
175+
)
176+
&& not (
177+
sln.SolutionProjects
178+
|> Seq.exists (
179+
_.Id
180+
>> (=) item.Id
181+
)
182+
)
183+
)
132184
|> Seq.map parseItem
133185
]
134186

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: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2397,6 +2397,73 @@ 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 solution folders are first
2416+
// The first item should be the 'src' folder, which should contain proj1.fsproj
2417+
let firstItem = solutionData.Items[0]
2418+
Expect.equal firstItem.Name "src" "Should have the src folder"
2419+
2420+
match firstItem.Kind with
2421+
| InspectSln.Folder(solutionItems, _) ->
2422+
match solutionItems with
2423+
| [ {
2424+
Name = folderName
2425+
Kind = InspectSln.MSBuildFormat []
2426+
} ] ->
2427+
let expectedProjectPath = Path.Combine(slnDir, "src", "proj1", "proj1.fsproj")
2428+
Expect.equal folderName expectedProjectPath "Should have the expected project path"
2429+
| _ -> failtestf "Expected one folder item, but got %A" solutionItems
2430+
| unexpected -> failtestf "Expected a folder, but got %A" unexpected
2431+
2432+
// The third item should be the 'src' folder, which should contain proj1.fsproj
2433+
let secondItem = solutionData.Items[1]
2434+
Expect.equal secondItem.Name "tests" "Should have the tests folder"
2435+
2436+
match secondItem.Kind with
2437+
| InspectSln.Folder(solutionItems, _) ->
2438+
match solutionItems with
2439+
| [ {
2440+
Name = folderName
2441+
Kind = InspectSln.MSBuildFormat []
2442+
} ] ->
2443+
let expectedProjectPath = Path.Combine(slnDir, "test", "proj1.tests", "proj1.tests.fsproj")
2444+
Expect.equal folderName expectedProjectPath "Should have the expected test project path"
2445+
| _ -> failtestf "Expected one folder item, but got %A" solutionItems
2446+
| unexpected -> failtestf "Expected a folder, but got %A" unexpected
2447+
2448+
// Then projects at the root level of the solution.
2449+
// The first item should be "build.fsproj
2450+
let thirdItem = solutionData.Items[2]
2451+
let expectedBuildProjectPath = Path.Combine(slnDir, "build.fsproj")
2452+
Expect.equal thirdItem.Name expectedBuildProjectPath "Should have the expected build project path"
2453+
2454+
match thirdItem.Kind with
2455+
| InspectSln.MSBuildFormat items -> Expect.isEmpty items "we don't currently store anything here"
2456+
| unexpected -> failtestf "Expected a project, but got %A" unexpected
2457+
2458+
/// A test that we can load a solution that contains projects inside solution folders, and get the expected structure
2459+
let sample16SolutionFoldersSlnTest toolsPath loaderType workspaceFactory =
2460+
2461+
testCase $"Can load sample16 solution folders test (.sln) - {loaderType}" (fun () -> sample16SolutionFoldersTest ``sample 16 solution folders (.sln)``)
2462+
2463+
/// As above, but for a .slnx format solution
2464+
let sample16SolutionFoldersSlnxTest toolsPath loaderType workspaceFactory =
2465+
2466+
testCase $"Can load sample16 solution folders test (.slnx) - {loaderType}" (fun () -> sample16SolutionFoldersTest ``sample 16 solution folders (.slnx)``)
24002467

24012468
let tests toolsPath =
24022469
let testSample3WorkspaceLoaderExpected = [
@@ -2539,4 +2606,10 @@ let tests toolsPath =
25392606

25402607
sample15NugetAnalyzers toolsPath (nameof (WorkspaceLoader)) WorkspaceLoader.Create
25412608
sample15NugetAnalyzers toolsPath (nameof (WorkspaceLoaderViaProjectGraph)) WorkspaceLoaderViaProjectGraph.Create
2609+
2610+
sample16SolutionFoldersSlnTest toolsPath (nameof (WorkspaceLoader)) WorkspaceLoader.Create
2611+
sample16SolutionFoldersSlnTest toolsPath (nameof (WorkspaceLoaderViaProjectGraph)) WorkspaceLoaderViaProjectGraph.Create
2612+
2613+
sample16SolutionFoldersSlnxTest toolsPath (nameof (WorkspaceLoader)) WorkspaceLoader.Create
2614+
sample16SolutionFoldersSlnxTest toolsPath (nameof (WorkspaceLoaderViaProjectGraph)) WorkspaceLoaderViaProjectGraph.Create
25422615
]
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)