diff --git a/CHANGELOG.md b/CHANGELOG.md
index d64104ec..c276490e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Changed
+
+- [Change solution file parsing to report the structure of solution folders/projects](https://github.com/ionide/proj-info/pull/241) (thanks @Numpsy)
+
## [0.73.0] - 2025-11-11
### Changed
diff --git a/src/Ionide.ProjInfo/InspectSln.fs b/src/Ionide.ProjInfo/InspectSln.fs
index d021afb9..0b5b9d25 100644
--- a/src/Ionide.ProjInfo/InspectSln.fs
+++ b/src/Ionide.ProjInfo/InspectSln.fs
@@ -75,6 +75,14 @@ module InspectSln =
let parseSln (sln: Model.SolutionModel) (projectsToRead: string Set option) =
sln.DistillProjectConfigurations()
+ // Work out the subset of projects that we care about
+ let projectsWeCareAbout =
+ match projectsToRead with
+ | None -> sln.SolutionProjects :> seq<_>
+ | Some filteredProjects ->
+ sln.SolutionProjects
+ |> Seq.filter (fun slnProject -> filteredProjects.Contains(makeAbsoluteFromSlnDir slnProject.FilePath))
+
let parseItem (item: Model.SolutionItemModel) : SolutionItem = {
Guid = item.Id
Name = ""
@@ -87,7 +95,7 @@ module InspectSln =
Kind = SolutionItemKind.MSBuildFormat [] // TODO: could theoretically parse configurations here
}
- let parseFolder (folder: Model.SolutionFolderModel) : SolutionItem = {
+ let rec parseFolder (folder: Model.SolutionFolderModel) : SolutionItem = {
Guid = folder.Id
Name = folder.ActualDisplayName
Kind =
@@ -97,7 +105,25 @@ module InspectSln =
not (isNull item.Parent)
&& item.Parent.Id = folder.Id
)
- |> Seq.map (fun p -> parseItem p)
+ |> Seq.choose (fun p ->
+ // If this item is a subfolder, map it recursively
+ // if it's a project, map it if it's in the 'projectsWeCareAbout' collection
+ // for anything else, just use a generic item
+ match p with
+ | :? Model.SolutionFolderModel as childFolder -> Some(parseFolder childFolder)
+ | :? Model.SolutionProjectModel as childProject ->
+ if
+ projectsWeCareAbout
+ |> Seq.exists (
+ _.Id
+ >> (=) childProject.Id
+ )
+ then
+ Some(parseProject childProject)
+ else
+ None
+ | _ -> Some(parseItem p)
+ )
|> List.ofSeq,
folder.Files
@@ -112,23 +138,49 @@ module InspectSln =
// three kinds of items - projects, folders, items
// yield them all here
- let projectsWeCareAbout =
- match projectsToRead with
- | None -> sln.SolutionProjects :> seq<_>
- | Some filteredProjects ->
- sln.SolutionProjects
- |> Seq.filter (fun slnProject -> filteredProjects.Contains(makeAbsoluteFromSlnDir slnProject.FilePath))
-
let allItems = [
- yield!
- projectsWeCareAbout
- |> Seq.map parseProject
+ // Return solution folders first, and solution level projects second, see https://github.com/ionide/ionide-vscode-fsharp/issues/2109
+
+ // parseFolder will parse any projects or folders within the specified folder itself, so just process the root folders here
yield!
sln.SolutionFolders
- |> Seq.map parseFolder
+ |> Seq.choose (fun folder ->
+ if isNull folder.Parent then
+ Some(parseFolder folder)
+ else
+ None
+ )
+
+ // Projects at solution level get returned directly
+ yield!
+ projectsWeCareAbout
+ |> Seq.choose (fun project ->
+ if isNull project.Parent then
+ Some(parseProject project)
+ else
+ None
+ )
+
+ // 'SolutionItems' contains all of SolutionFolders and SolutionProjects, so only include things that aren't in those to avoid duplication
yield!
sln.SolutionItems
- |> Seq.filter (fun item -> isNull item.Parent)
+ |> Seq.filter (fun item ->
+ isNull item.Parent
+ && not (
+ sln.SolutionFolders
+ |> Seq.exists (
+ _.Id
+ >> (=) item.Id
+ )
+ )
+ && not (
+ sln.SolutionProjects
+ |> Seq.exists (
+ _.Id
+ >> (=) item.Id
+ )
+ )
+ )
|> Seq.map parseItem
]
diff --git a/test/Ionide.ProjInfo.Tests/TestAssets.fs b/test/Ionide.ProjInfo.Tests/TestAssets.fs
index 540d892c..a8959743 100644
--- a/test/Ionide.ProjInfo.Tests/TestAssets.fs
+++ b/test/Ionide.ProjInfo.Tests/TestAssets.fs
@@ -376,3 +376,21 @@ let ``sample 15 nuget analyzers`` = {
TargetFrameworks = Map.empty
ProjectReferences = []
}
+
+/// A test for a solution with projects
+let ``sample 16 solution folders (.sln)`` = {
+ ProjDir = "sample16-solution-with-solution-folders"
+ AssemblyName = ""
+ ProjectFile = "sample16-solution-with-solution-folders.sln"
+ TargetFrameworks = Map.empty
+ ProjectReferences = []
+}
+
+/// and the same with a slnc format solution
+let ``sample 16 solution folders (.slnx)`` = {
+ ProjDir = "sample16-solution-with-solution-folders"
+ AssemblyName = ""
+ ProjectFile = "sample16-solution-with-solution-folders.slnx"
+ TargetFrameworks = Map.empty
+ ProjectReferences = []
+}
diff --git a/test/Ionide.ProjInfo.Tests/Tests.fs b/test/Ionide.ProjInfo.Tests/Tests.fs
index 1496de5c..eeb16b36 100644
--- a/test/Ionide.ProjInfo.Tests/Tests.fs
+++ b/test/Ionide.ProjInfo.Tests/Tests.fs
@@ -2397,6 +2397,73 @@ let sample15NugetAnalyzers toolsPath loaderType workspaceFactory =
)
+/// Common code for sample16SolutionFoldersSlnTest/sample16SolutionFoldersSlnxTest - they are the same test, but for both /sln/slnx files
+let sample16SolutionFoldersTest testAsset =
+
+ let projPath = pathForProject testAsset
+ let slnDir = Path.GetDirectoryName projPath
+
+ let solutionContents =
+ InspectSln.tryParseSln projPath
+ |> getResult
+
+ let solutionData = solutionContents
+
+ // There should be 3 items at the solution level - build.fsproj, a 'src' folder and a 'tests' folder
+ Expect.equal solutionData.Items.Length 3 "There should be 3 items in the solution"
+
+ // The solution folders are first
+ // The first item should be the 'src' folder, which should contain proj1.fsproj
+ let firstItem = solutionData.Items[0]
+ Expect.equal firstItem.Name "src" "Should have the src folder"
+
+ match firstItem.Kind with
+ | InspectSln.Folder(solutionItems, _) ->
+ match solutionItems with
+ | [ {
+ Name = folderName
+ Kind = InspectSln.MSBuildFormat []
+ } ] ->
+ let expectedProjectPath = Path.Combine(slnDir, "src", "proj1", "proj1.fsproj")
+ Expect.equal folderName expectedProjectPath "Should have the expected project path"
+ | _ -> failtestf "Expected one folder item, but got %A" solutionItems
+ | unexpected -> failtestf "Expected a folder, but got %A" unexpected
+
+ // The third item should be the 'src' folder, which should contain proj1.fsproj
+ let secondItem = solutionData.Items[1]
+ Expect.equal secondItem.Name "tests" "Should have the tests folder"
+
+ match secondItem.Kind with
+ | InspectSln.Folder(solutionItems, _) ->
+ match solutionItems with
+ | [ {
+ Name = folderName
+ Kind = InspectSln.MSBuildFormat []
+ } ] ->
+ let expectedProjectPath = Path.Combine(slnDir, "test", "proj1.tests", "proj1.tests.fsproj")
+ Expect.equal folderName expectedProjectPath "Should have the expected test project path"
+ | _ -> failtestf "Expected one folder item, but got %A" solutionItems
+ | unexpected -> failtestf "Expected a folder, but got %A" unexpected
+
+ // Then projects at the root level of the solution.
+ // The first item should be "build.fsproj
+ let thirdItem = solutionData.Items[2]
+ let expectedBuildProjectPath = Path.Combine(slnDir, "build.fsproj")
+ Expect.equal thirdItem.Name expectedBuildProjectPath "Should have the expected build project path"
+
+ match thirdItem.Kind with
+ | InspectSln.MSBuildFormat items -> Expect.isEmpty items "we don't currently store anything here"
+ | unexpected -> failtestf "Expected a project, but got %A" unexpected
+
+/// A test that we can load a solution that contains projects inside solution folders, and get the expected structure
+let sample16SolutionFoldersSlnTest toolsPath loaderType workspaceFactory =
+
+ testCase $"Can load sample16 solution folders test (.sln) - {loaderType}" (fun () -> sample16SolutionFoldersTest ``sample 16 solution folders (.sln)``)
+
+/// As above, but for a .slnx format solution
+let sample16SolutionFoldersSlnxTest toolsPath loaderType workspaceFactory =
+
+ testCase $"Can load sample16 solution folders test (.slnx) - {loaderType}" (fun () -> sample16SolutionFoldersTest ``sample 16 solution folders (.slnx)``)
let tests toolsPath =
let testSample3WorkspaceLoaderExpected = [
@@ -2539,4 +2606,10 @@ let tests toolsPath =
sample15NugetAnalyzers toolsPath (nameof (WorkspaceLoader)) WorkspaceLoader.Create
sample15NugetAnalyzers toolsPath (nameof (WorkspaceLoaderViaProjectGraph)) WorkspaceLoaderViaProjectGraph.Create
+
+ sample16SolutionFoldersSlnTest toolsPath (nameof (WorkspaceLoader)) WorkspaceLoader.Create
+ sample16SolutionFoldersSlnTest toolsPath (nameof (WorkspaceLoaderViaProjectGraph)) WorkspaceLoaderViaProjectGraph.Create
+
+ sample16SolutionFoldersSlnxTest toolsPath (nameof (WorkspaceLoader)) WorkspaceLoader.Create
+ sample16SolutionFoldersSlnxTest toolsPath (nameof (WorkspaceLoaderViaProjectGraph)) WorkspaceLoaderViaProjectGraph.Create
]
diff --git a/test/examples/sample16-solution-with-solution-folders/build.fsproj b/test/examples/sample16-solution-with-solution-folders/build.fsproj
new file mode 100644
index 00000000..bac5eca1
--- /dev/null
+++ b/test/examples/sample16-solution-with-solution-folders/build.fsproj
@@ -0,0 +1,6 @@
+
+
+ Exe
+ net8.0
+
+
diff --git a/test/examples/sample16-solution-with-solution-folders/sample16-solution-with-solution-folders.sln b/test/examples/sample16-solution-with-solution-folders/sample16-solution-with-solution-folders.sln
new file mode 100644
index 00000000..4923032f
--- /dev/null
+++ b/test/examples/sample16-solution-with-solution-folders/sample16-solution-with-solution-folders.sln
@@ -0,0 +1,42 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "build", "build.fsproj", "{6BC5F14A-400D-5678-6478-C5DAFCEBD79E}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
+EndProject
+Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "proj1", "src\proj1\proj1.fsproj", "{A06D38C3-42C2-132A-6394-D695EEDD47C9}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{7ECEE39C-5B10-412F-A075-DCF155771625}"
+EndProject
+Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "proj1.tests", "test\proj1.tests\proj1.tests.fsproj", "{A40559BD-B085-0F90-1CAE-8FF1D7405814}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {6BC5F14A-400D-5678-6478-C5DAFCEBD79E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6BC5F14A-400D-5678-6478-C5DAFCEBD79E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6BC5F14A-400D-5678-6478-C5DAFCEBD79E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6BC5F14A-400D-5678-6478-C5DAFCEBD79E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A06D38C3-42C2-132A-6394-D695EEDD47C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A06D38C3-42C2-132A-6394-D695EEDD47C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A06D38C3-42C2-132A-6394-D695EEDD47C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A06D38C3-42C2-132A-6394-D695EEDD47C9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A40559BD-B085-0F90-1CAE-8FF1D7405814}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A40559BD-B085-0F90-1CAE-8FF1D7405814}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A40559BD-B085-0F90-1CAE-8FF1D7405814}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A40559BD-B085-0F90-1CAE-8FF1D7405814}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {A06D38C3-42C2-132A-6394-D695EEDD47C9} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
+ {A40559BD-B085-0F90-1CAE-8FF1D7405814} = {7ECEE39C-5B10-412F-A075-DCF155771625}
+ EndGlobalSection
+EndGlobal
diff --git a/test/examples/sample16-solution-with-solution-folders/sample16-solution-with-solution-folders.slnx b/test/examples/sample16-solution-with-solution-folders/sample16-solution-with-solution-folders.slnx
new file mode 100644
index 00000000..59b77d7c
--- /dev/null
+++ b/test/examples/sample16-solution-with-solution-folders/sample16-solution-with-solution-folders.slnx
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/test/examples/sample16-solution-with-solution-folders/src/proj1/Library.fs b/test/examples/sample16-solution-with-solution-folders/src/proj1/Library.fs
new file mode 100644
index 00000000..07c33334
--- /dev/null
+++ b/test/examples/sample16-solution-with-solution-folders/src/proj1/Library.fs
@@ -0,0 +1,5 @@
+namespace proj1
+
+module Say =
+ let hello name =
+ printfn "Hello %s" name
diff --git a/test/examples/sample16-solution-with-solution-folders/src/proj1/proj1.fsproj b/test/examples/sample16-solution-with-solution-folders/src/proj1/proj1.fsproj
new file mode 100644
index 00000000..f81f7f5b
--- /dev/null
+++ b/test/examples/sample16-solution-with-solution-folders/src/proj1/proj1.fsproj
@@ -0,0 +1,12 @@
+
+
+
+ net8.0
+ true
+
+
+
+
+
+
+
diff --git a/test/examples/sample16-solution-with-solution-folders/test/proj1.tests/Library.fs b/test/examples/sample16-solution-with-solution-folders/test/proj1.tests/Library.fs
new file mode 100644
index 00000000..f868a294
--- /dev/null
+++ b/test/examples/sample16-solution-with-solution-folders/test/proj1.tests/Library.fs
@@ -0,0 +1,5 @@
+namespace proj1.tests
+
+module Say =
+ let hello name =
+ printfn "Hello %s" name
diff --git a/test/examples/sample16-solution-with-solution-folders/test/proj1.tests/proj1.tests.fsproj b/test/examples/sample16-solution-with-solution-folders/test/proj1.tests/proj1.tests.fsproj
new file mode 100644
index 00000000..f81f7f5b
--- /dev/null
+++ b/test/examples/sample16-solution-with-solution-folders/test/proj1.tests/proj1.tests.fsproj
@@ -0,0 +1,12 @@
+
+
+
+ net8.0
+ true
+
+
+
+
+
+
+