Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 66 additions & 14 deletions src/Ionide.ProjInfo/InspectSln.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand All @@ -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 =
Expand All @@ -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
Expand All @@ -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
Copy link
Contributor Author

@Numpsy Numpsy Oct 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When testing I noticed that some solution items are being returned twice here, which didn't seem right (note the duplicated guids in the results, with different Kinds)

image

Looks to be because 'SolutionItems' contains all the folders and so the existing logic will include any root level folders as 'Unknown' as well as including them as folders

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
]

Expand Down
18 changes: 18 additions & 0 deletions test/Ionide.ProjInfo.Tests/TestAssets.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
}
73 changes: 73 additions & 0 deletions test/Ionide.ProjInfo.Tests/Tests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/proj1/proj1.fsproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="test/proj1.tests/proj1.tests.fsproj" />
</Folder>
<Project Path="build.fsproj" />
</Solution>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace proj1

module Say =
let hello name =
printfn "Hello %s" name
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>

<ItemGroup>
<Compile Include="Library.fs" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace proj1.tests

module Say =
let hello name =
printfn "Hello %s" name
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>

<ItemGroup>
<Compile Include="Library.fs" />
</ItemGroup>

</Project>