Skip to content

Use a common target framework from all of your projects #253

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Aug 15, 2025
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
60 changes: 30 additions & 30 deletions src/CSharpLanguageServer/RoslynHelpers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
override __.Visit(node) =
if sym.Kind = SymbolKind.Method then
if node :? MethodDeclarationSyntax then
let nodeMethodDecl = node :?> MethodDeclarationSyntax

Check warning on line 59 in src/CSharpLanguageServer/RoslynHelpers.fs

View workflow job for this annotation

GitHub Actions / build (ubuntu-24.04, 9.0.x)

Nullness warning: Downcasting from 'SyntaxNode | null' into 'MethodDeclarationSyntax' can introduce unexpected null values. Cast to 'MethodDeclarationSyntax|null' instead or handle the null before downcasting.

if nodeMethodDecl.Identifier.ValueText = sym.Name then
let methodArityMatches =
Expand Down Expand Up @@ -428,7 +428,7 @@
| Unknown


let selectMostCapableCompatibleTfm (tfms: string seq) : string option =
let selectLatestTfm (tfms: string seq) : string option =
let parseTfm (tfm: string) : TfmCategory =
let patterns = [
@"^net(?<major>\d)(?<minor>\d)?(?<build>\d)?$", NetFramework
Expand Down Expand Up @@ -466,8 +466,8 @@
|> Seq.tryHead


let applyWorkspaceTargetFrameworkProp (logger: ILog) (projs: string seq) props =
let tfms = new List<string>()
let loadProjectTfms (logger: ILog) (projs: string seq) : Map<string, list<string>> =
let mutable projectTfms = Map.empty

for projectFilename in projs do
let projectCollection = new Microsoft.Build.Evaluation.ProjectCollection();
Expand All @@ -478,51 +478,51 @@

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 = 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())
| _ -> ()
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()))
)

let distinctTfms = tfms |> Set.ofSeq
projectTfms

logger.debug (
Log.setMessage "applyWorkspaceTargetFrameworkProp: distinctTfms={distinctTfms}"
>> Log.addContext "distinctTfms" (String.Join(";", distinctTfms))
)

match distinctTfms.Count with
| 0 -> props
| 1 -> props
| _ ->
match selectMostCapableCompatibleTfm distinctTfms with
| Some tfm -> props |> Map.add "TargetFramework" tfm
| None -> props
let applyWorkspaceTargetFrameworkProp (tfmsPerProject: Map<string, list<string>>) props : Map<string, string> =
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<string, string> =
let tfmsPerProject = loadProjectTfms logger projs

Map.empty
|> applyWorkspaceTargetFrameworkProp logger projs
|> applyWorkspaceTargetFrameworkProp tfmsPerProject


let tryLoadSolutionOnPath
Expand Down
45 changes: 36 additions & 9 deletions tests/CSharpLanguageServer.Tests/InternalTests.fs
Original file line number Diff line number Diff line change
@@ -1,15 +1,42 @@
module CSharpLanguageServer.Tests.InternalTests

open System
open NUnit.Framework

open CSharpLanguageServer.RoslynHelpers

[<TestCase("net8.0", "net8.0")>]
[<TestCase("net8.0;net10.0", "net10.0")>]
[<TestCase("net8.0;netstandard2.0", "net8.0")>]
[<TestCase("netstandard1.0;netstandard2.0", "netstandard2.0")>]
[<TestCase("net40;net462;net6.0;net8.0;netcoreapp3.1;netstandard2.0", "net8.0")>]
[<TestCase("net40;net462", "net462")>]
let testTheMostCapableTfmIsSelected(tfmList: string, expectedTfm: string) =
let selectedTfm = tfmList.Split(";") |> selectMostCapableCompatibleTfm
Assert.AreEqual(expectedTfm |> Option.ofObj, selectedTfm)
[<TestCase("1.csproj:net8.0", "net8.0")>]
[<TestCase("1.csproj:net8.0,net10.0", "net10.0")>]
[<TestCase("1.csproj:net8.0,netstandard2.0", "net8.0")>]
[<TestCase("1.csproj:netstandard1.0,netstandard2.0", "netstandard2.0")>]
[<TestCase("1.csproj:net40,net462,net6.0,net8.0,netcoreapp3.1,netstandard2.0", "net8.0")>]
[<TestCase("1.csproj:net40,net462", "net462")>]
[<TestCase("1.csproj:net8.0 2.csproj:net8.0", "net8.0")>]
[<TestCase("1.csproj:net8.0,net10.0 2.csproj:netstandard2.0,net462", null)>]
[<TestCase("1.csproj:net8.0,net10.0 2.csproj:net8.0,net10.0", "net10.0")>]
[<TestCase("1.csproj:net8.0 2.csproj:net8.0,net10.0", "net8.0")>]
let testApplyWorkspaceTargetFrameworkProp(tfmList: string, expectedTfm: string | null) =

let parseTfmList (projectEntry: string) : string * list<string> =
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<string, list<string>> =
tfmList
|> _.Split([|' '|], StringSplitOptions.RemoveEmptyEntries)
|> Array.map parseTfmList
|> Map.ofArray

let props = Map.empty |> applyWorkspaceTargetFrameworkProp tfmsPerProject

Assert.AreEqual(expectedTfm |> Option.ofObj, props |> Map.tryFind "TargetFramework")

[<TestCase>]
let testApplyWorkspaceTargetFrameworkPropWithEmptyMap() =
let props = Map.empty |> applyWorkspaceTargetFrameworkProp Map.empty

Assert.AreEqual(None, props |> Map.tryFind "TargetFramework")
Loading