From 6f7119befde2a5890f5bf63e7e95b0579b3c8a43 Mon Sep 17 00:00:00 2001 From: Ade Attwood Date: Wed, 13 Aug 2025 09:59:09 +0100 Subject: [PATCH 1/4] Use a common target framework from all of your projects Summary: When using multiple target frameworks in multiple projects. We are currently resolving the latest target framework that exists over all of your projects. The issue with this is the selected target framework may not work with some of the projects. This resolves the latest target framework that exists in all of your projects. This will give the best framework compatibility. Test Plan: Unit test for the existing select latest framework is still there. I have also tested this locally over a project that uses `net8.0` and `net10.0` in some projects. Ref: #75 --- src/CSharpLanguageServer/RoslynHelpers.fs | 40 ++++++++++--------- .../InternalTests.fs | 4 +- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/CSharpLanguageServer/RoslynHelpers.fs b/src/CSharpLanguageServer/RoslynHelpers.fs index 9f7bc712..3fa0d7ec 100644 --- a/src/CSharpLanguageServer/RoslynHelpers.fs +++ b/src/CSharpLanguageServer/RoslynHelpers.fs @@ -428,7 +428,7 @@ type TfmCategory = | Unknown -let selectMostCapableCompatibleTfm (tfms: string seq) : string option = +let selectLatestTfm (tfms: string seq) : string option = let parseTfm (tfm: string) : TfmCategory = let patterns = [ @"^net(?\d)(?\d)?(?\d)?$", NetFramework @@ -467,7 +467,7 @@ let selectMostCapableCompatibleTfm (tfms: string seq) : string option = let applyWorkspaceTargetFrameworkProp (logger: ILog) (projs: string seq) props = - let tfms = new List() + let targetFrameworkCandidates = new List>() for projectFilename in projs do let projectCollection = new Microsoft.Build.Evaluation.ProjectCollection(); @@ -480,20 +480,17 @@ let applyWorkspaceTargetFrameworkProp (logger: ILog) (projs: string seq) props = s |> Option.ofObj |> Option.bind (fun s -> if String.IsNullOrEmpty(s) then None else Some s) - let targetFramework = buildProject.GetPropertyValue("TargetFramework") |> noneIfEmpty + let targetFramework = + match buildProject.GetPropertyValue("TargetFramework") |> noneIfEmpty with + | Some tfm -> [tfm.Trim()] + | None -> [] - match targetFramework with - | Some tfm -> - tfms.Add(tfm.Trim()) - | _ -> () + let targetFrameworks = + match buildProject.GetPropertyValue("TargetFrameworks") |> noneIfEmpty with + | Some tfms -> tfms.Split(";") |> Array.map (fun s -> s.Trim()) |> List.ofArray + | None -> [] - let targetFrameworks = buildProject.GetPropertyValue("TargetFrameworks") |> noneIfEmpty - - match targetFrameworks with - | Some semicolonSeparatedTfms -> - for tfm in semicolonSeparatedTfms.Split(";") do - tfms.Add(tfm.Trim()) - | _ -> () + targetFrameworkCandidates.Add(List(targetFramework @ targetFrameworks)) projectCollection.UnloadProject(buildProject) with @@ -504,18 +501,23 @@ let applyWorkspaceTargetFrameworkProp (logger: ILog) (projs: string seq) props = >> Log.addContext "ex" (string (ipfe.GetType())) ) - let distinctTfms = tfms |> Set.ofSeq + let distinctCommonTfms = + targetFrameworkCandidates + |> Seq.map Set.ofSeq + |> Seq.reduce Set.intersect + |> Seq.distinct + |> Set.ofSeq logger.debug ( - Log.setMessage "applyWorkspaceTargetFrameworkProp: distinctTfms={distinctTfms}" - >> Log.addContext "distinctTfms" (String.Join(";", distinctTfms)) + Log.setMessage "applyWorkspaceTargetFrameworkProp: distinctCommonTfms={distinctCommonTfms}" + >> Log.addContext "distinctCommonTfms" (String.Join(";", distinctCommonTfms)) ) - match distinctTfms.Count with + match distinctCommonTfms.Count with | 0 -> props | 1 -> props | _ -> - match selectMostCapableCompatibleTfm distinctTfms with + match selectLatestTfm distinctCommonTfms with | Some tfm -> props |> Map.add "TargetFramework" tfm | None -> props diff --git a/tests/CSharpLanguageServer.Tests/InternalTests.fs b/tests/CSharpLanguageServer.Tests/InternalTests.fs index 5fbc4c2a..8e97ab97 100644 --- a/tests/CSharpLanguageServer.Tests/InternalTests.fs +++ b/tests/CSharpLanguageServer.Tests/InternalTests.fs @@ -10,6 +10,6 @@ open CSharpLanguageServer.RoslynHelpers [] [] [] -let testTheMostCapableTfmIsSelected(tfmList: string, expectedTfm: string) = - let selectedTfm = tfmList.Split(";") |> selectMostCapableCompatibleTfm +let testTheLatestTfmIsSelected(tfmList: string, expectedTfm: string) = + let selectedTfm = tfmList.Split(";") |> selectLatestTfm Assert.AreEqual(expectedTfm |> Option.ofObj, selectedTfm) From afcd2e84c18a7bd8349fcf33a732ed4ced17d6a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saulius=20Menkevi=C4=8Dius?= Date: Thu, 14 Aug 2025 12:16:16 +0300 Subject: [PATCH 2/4] Update the testTheLatestTfmIsSelected test to actually test applyWorkspaceTargetFrameworkProp --- src/CSharpLanguageServer/RoslynHelpers.fs | 36 +++++++++--------- .../InternalTests.fs | 38 ++++++++++++++----- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/src/CSharpLanguageServer/RoslynHelpers.fs b/src/CSharpLanguageServer/RoslynHelpers.fs index 3fa0d7ec..b27456f6 100644 --- a/src/CSharpLanguageServer/RoslynHelpers.fs +++ b/src/CSharpLanguageServer/RoslynHelpers.fs @@ -466,8 +466,8 @@ let selectLatestTfm (tfms: string seq) : string option = |> Seq.tryHead -let applyWorkspaceTargetFrameworkProp (logger: ILog) (projs: string seq) props = - let targetFrameworkCandidates = new List>() +let loadProjectTfms (logger: ILog) (projs: string seq) : Map> = + let mutable projectTfms = Map.empty for projectFilename in projs do let projectCollection = new Microsoft.Build.Evaluation.ProjectCollection(); @@ -478,40 +478,40 @@ let applyWorkspaceTargetFrameworkProp (logger: ILog) (projs: string seq) props = let noneIfEmpty s = s |> Option.ofObj - |> Option.bind (fun s -> if String.IsNullOrEmpty(s) then None else Some s) + |> Option.bind (fun s -> if String.IsNullOrEmpty(s) then None else Some s) let targetFramework = - match buildProject.GetPropertyValue("TargetFramework") |> noneIfEmpty with - | Some tfm -> [tfm.Trim()] - | None -> [] + match buildProject.GetPropertyValue("TargetFramework") |> noneIfEmpty with + | Some tfm -> [tfm.Trim()] + | None -> [] let targetFrameworks = - match buildProject.GetPropertyValue("TargetFrameworks") |> noneIfEmpty with - | Some tfms -> tfms.Split(";") |> Array.map (fun s -> s.Trim()) |> List.ofArray - | None -> [] + match buildProject.GetPropertyValue("TargetFrameworks") |> noneIfEmpty with + | Some tfms -> tfms.Split(";") |> Array.map (fun s -> s.Trim()) |> List.ofArray + | None -> [] - targetFrameworkCandidates.Add(List(targetFramework @ targetFrameworks)) + projectTfms <- projectTfms |> Map.add projectFilename (targetFramework @ targetFrameworks) projectCollection.UnloadProject(buildProject) with | :? InvalidProjectFileException as ipfe -> logger.debug ( - Log.setMessage "applyWorkspaceTargetFrameworkProp: failed to load {projectFilename}: {ex}" + Log.setMessage "loadProjectTfms: failed to load {projectFilename}: {ex}" >> Log.addContext "projectFilename" projectFilename >> Log.addContext "ex" (string (ipfe.GetType())) ) + projectTfms + + +let applyWorkspaceTargetFrameworkProp (tfmsPerProject: Map>) props : Map = let distinctCommonTfms = - targetFrameworkCandidates + tfmsPerProject.Values |> Seq.map Set.ofSeq |> Seq.reduce Set.intersect |> Seq.distinct |> Set.ofSeq - logger.debug ( - Log.setMessage "applyWorkspaceTargetFrameworkProp: distinctCommonTfms={distinctCommonTfms}" - >> Log.addContext "distinctCommonTfms" (String.Join(";", distinctCommonTfms)) - ) match distinctCommonTfms.Count with | 0 -> props @@ -523,8 +523,10 @@ let applyWorkspaceTargetFrameworkProp (logger: ILog) (projs: string seq) props = let resolveDefaultWorkspaceProps (logger: ILog) projs : Map = + let tfmsPerProject = loadProjectTfms logger projs + Map.empty - |> applyWorkspaceTargetFrameworkProp logger projs + |> applyWorkspaceTargetFrameworkProp tfmsPerProject let tryLoadSolutionOnPath diff --git a/tests/CSharpLanguageServer.Tests/InternalTests.fs b/tests/CSharpLanguageServer.Tests/InternalTests.fs index 8e97ab97..63778481 100644 --- a/tests/CSharpLanguageServer.Tests/InternalTests.fs +++ b/tests/CSharpLanguageServer.Tests/InternalTests.fs @@ -1,15 +1,35 @@ module CSharpLanguageServer.Tests.InternalTests +open System open NUnit.Framework open CSharpLanguageServer.RoslynHelpers -[] -[] -[] -[] -[] -[] -let testTheLatestTfmIsSelected(tfmList: string, expectedTfm: string) = - let selectedTfm = tfmList.Split(";") |> selectLatestTfm - Assert.AreEqual(expectedTfm |> Option.ofObj, selectedTfm) +[] +[] +[] +[] +[] +[] +[] +[] +[] +let testApplyWorkspaceTargetFrameworkProp(tfmList: string, expectedTfm: string | null) = + + let parseTfmList (projectEntry: string) : string * list = + let parts = projectEntry.Split(':') + if parts.Length <> 2 then + failwithf "Invalid project entry format: '%s'. Expected 'ProjectName:tfm1,tfm2,...'" projectEntry + let projectName = parts.[0] + let tfmStrings = parts.[1].Split(',') |> List.ofSeq + (projectName, tfmStrings) + + let tfmsPerProject: Map> = + tfmList + |> _.Split([|' '|], StringSplitOptions.RemoveEmptyEntries) + |> Array.map parseTfmList + |> Map.ofArray + + let props = Map.empty |> applyWorkspaceTargetFrameworkProp tfmsPerProject + + Assert.AreEqual(expectedTfm |> Option.ofObj, props |> Map.tryFind "TargetFramework") From bf50aa7e9760206263ef579c4005b4bb40732b70 Mon Sep 17 00:00:00 2001 From: Ade Attwood Date: Thu, 14 Aug 2025 16:13:49 +0100 Subject: [PATCH 3/4] Update applyWorkspaceTargetFrameworkProp to handle one and no projects --- src/CSharpLanguageServer/RoslynHelpers.fs | 28 ++++++++----------- .../InternalTests.fs | 11 ++++++-- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/CSharpLanguageServer/RoslynHelpers.fs b/src/CSharpLanguageServer/RoslynHelpers.fs index b27456f6..e03a95ea 100644 --- a/src/CSharpLanguageServer/RoslynHelpers.fs +++ b/src/CSharpLanguageServer/RoslynHelpers.fs @@ -505,22 +505,18 @@ let loadProjectTfms (logger: ILog) (projs: string seq) : Map>) props : Map = - let distinctCommonTfms = - tfmsPerProject.Values - |> Seq.map Set.ofSeq - |> Seq.reduce Set.intersect - |> Seq.distinct - |> Set.ofSeq - - - match distinctCommonTfms.Count with - | 0 -> props - | 1 -> props - | _ -> - match selectLatestTfm distinctCommonTfms with - | Some tfm -> props |> Map.add "TargetFramework" tfm - | None -> props - + let selectedTfm = + match tfmsPerProject.Count with + | 0 -> None + | _ -> + tfmsPerProject.Values + |> Seq.map Set.ofSeq + |> Set.intersectMany + |> selectLatestTfm + + match selectedTfm with + | Some tfm -> props |> Map.add "TargetFramework" tfm + | None -> props let resolveDefaultWorkspaceProps (logger: ILog) projs : Map = let tfmsPerProject = loadProjectTfms logger projs diff --git a/tests/CSharpLanguageServer.Tests/InternalTests.fs b/tests/CSharpLanguageServer.Tests/InternalTests.fs index 63778481..6445dd09 100644 --- a/tests/CSharpLanguageServer.Tests/InternalTests.fs +++ b/tests/CSharpLanguageServer.Tests/InternalTests.fs @@ -5,15 +5,16 @@ open NUnit.Framework open CSharpLanguageServer.RoslynHelpers -[] +[] [] [] [] [] [] -[] +[] [] [] +[] let testApplyWorkspaceTargetFrameworkProp(tfmList: string, expectedTfm: string | null) = let parseTfmList (projectEntry: string) : string * list = @@ -33,3 +34,9 @@ let testApplyWorkspaceTargetFrameworkProp(tfmList: string, expectedTfm: string | let props = Map.empty |> applyWorkspaceTargetFrameworkProp tfmsPerProject Assert.AreEqual(expectedTfm |> Option.ofObj, props |> Map.tryFind "TargetFramework") + +[] +let testApplyWorkspaceTargetFrameworkPropWithEmptyMap() = + let props = Map.empty |> applyWorkspaceTargetFrameworkProp Map.empty + + Assert.AreEqual(None, props |> Map.tryFind "TargetFramework") From 8be369481a509614ac28ee0bec64a4711f6ba680 Mon Sep 17 00:00:00 2001 From: Ade Attwood Date: Thu, 14 Aug 2025 16:20:45 +0100 Subject: [PATCH 4/4] Update the changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b7cc310..7a1e86ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ 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/). ## [Unreleased] +* Select common target framework when multiple projects are found + - By @AdeAttwood in https://github.com/razzmatazz/csharp-language-server/pull/253 * Fix how completion item details are resolved - https://github.com/razzmatazz/csharp-language-server/pull/251 * Apply simple heuristics to select a .sln/.slnx file when multiple are found.