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 + + + + + + +