Skip to content

Commit

Permalink
Merge pull request #3721 from fable-compiler/msbuild_design_time
Browse files Browse the repository at this point in the history
Experiment with MSBuild DesignTime
  • Loading branch information
MangelMaxime authored Feb 12, 2024
2 parents 478b9de + 100bfc4 commit 53238bc
Show file tree
Hide file tree
Showing 5 changed files with 205 additions and 7 deletions.
1 change: 1 addition & 0 deletions src/Fable.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* `Result.ToArray`
* `Result.ToList`
* `Result.ToOption`
* [GH-3721](https://github.com/fable-compiler/Fable/pull/3721) Add `--test:MSBuildCracker` flag allowing to use the experimental MSBuildCracker (by @nojaf)

#### JavaScript

Expand Down
4 changes: 3 additions & 1 deletion src/Fable.Cli/Entry.fs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ let knownCliArgs () =
[ "--trimRootModule" ], []
[ "--fableLib" ], []
[ "--replace" ], []
[ "--test:MSBuildCracker" ], []
]

let printKnownCliArgs () =
Expand Down Expand Up @@ -268,6 +269,7 @@ type Runner =
args.Value("--precompiledLib") |> Option.map normalizeAbsolutePath

let fableLib = args.Value "--fableLib" |> Option.map Path.normalizePath
let useMSBuildForCracking = args.FlagOr("--test:MSBuildCracker", false)

do!
match watch, outDir, fableLib with
Expand Down Expand Up @@ -383,7 +385,7 @@ type Runner =
None

let startCompilation () =
State.Create(cliArgs, ?watchDelay = watchDelay)
State.Create(cliArgs, ?watchDelay = watchDelay, useMSBuildForCracking = useMSBuildForCracking)
|> startCompilation
|> Async.RunSynchronously

Expand Down
1 change: 1 addition & 0 deletions src/Fable.Cli/Fable.Cli.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
<Compile Include="FileWatchers.fsi" />
<Compile Include="FileWatchers.fs" />
<Compile Include="Pipeline.fs" />
<Compile Include="MSBuildCrackerResolver.fs" />
<Compile Include="BuildalyzerCrackerResolver.fs" />
<Compile Include="Main.fs" />
<Compile Include="CustomLogging.fs" />
Expand Down
182 changes: 182 additions & 0 deletions src/Fable.Cli/MSBuildCrackerResolver.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
module Fable.Cli.MSBuildCrackerResolver

open System
open System.Xml.Linq
open System.Text.RegularExpressions
open Fable
open Fable.AST
open Fable.Compiler.Util
open Fable.Compiler.ProjectCracker
open Buildalyzer
open System.Reflection
open System.IO
open System.Diagnostics
open System.Text.Json


let private fsharpFiles = set [| ".fs"; ".fsi"; ".fsx" |]

let private isFSharpFile (file: string) =
Set.exists (fun (ext: string) -> file.EndsWith(ext, StringComparison.Ordinal)) fsharpFiles


/// Transform F# files into full paths
let private mkOptionsFullPath (projectFile: FileInfo) (compilerArgs: string array) : string array =
compilerArgs
|> Array.map (fun (line: string) ->
if not (isFSharpFile line) then
line
else
Path.Combine(projectFile.DirectoryName, line)
)

type FullPath = string

let private dotnet_msbuild_with_defines (fsproj: FullPath) (args: string) (defines: string list) : Async<string> =
backgroundTask {
let psi = ProcessStartInfo "dotnet"
let pwd = Assembly.GetEntryAssembly().Location |> Path.GetDirectoryName

psi.WorkingDirectory <- Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)

psi.Arguments <- $"msbuild \"%s{fsproj}\" %s{args}"
psi.RedirectStandardOutput <- true
psi.RedirectStandardError <- true
psi.UseShellExecute <- false

if not (List.isEmpty defines) then
let definesValue = defines |> String.concat ";"
psi.Environment.Add("DefineConstants", definesValue)

use ps = new Process()
ps.StartInfo <- psi
ps.Start() |> ignore
let output = ps.StandardOutput.ReadToEnd()
let error = ps.StandardError.ReadToEnd()
do! ps.WaitForExitAsync()

let fullCommand = $"dotnet msbuild %s{fsproj} %s{args}"
// printfn "%s" fullCommand

if not (String.IsNullOrWhiteSpace error) then
failwithf $"In %s{pwd}:\n%s{fullCommand}\nfailed with\n%s{error}"

return output.Trim()
}
|> Async.AwaitTask

let private dotnet_msbuild (fsproj: FullPath) (args: string) : Async<string> =
dotnet_msbuild_with_defines fsproj args List.empty

let mkOptionsFromDesignTimeBuildAux (fsproj: FileInfo) (options: CrackerOptions) : Async<ProjectOptionsResponse> =
async {
let! targetFrameworkJson =
let configuration =
if String.IsNullOrWhiteSpace options.Configuration then
""
else
$"/p:Configuration=%s{options.Configuration} "

dotnet_msbuild
fsproj.FullName
$"%s{configuration} --getProperty:TargetFrameworks --getProperty:TargetFramework"

let targetFramework =
let properties =
JsonDocument.Parse targetFrameworkJson
|> fun json -> json.RootElement.GetProperty "Properties"

let tf, tfs =
properties.GetProperty("TargetFramework").GetString(),
properties.GetProperty("TargetFrameworks").GetString()

if not (String.IsNullOrWhiteSpace tf) then
tf
else
tfs.Split ';' |> Array.head


// When CoreCompile does not need a rebuild, MSBuild will skip that target and thus will not populate the FscCommandLineArgs items.
// To overcome this we want to force a design time build, using the NonExistentFile property helps prevent a cache hit.
let nonExistentFile = Path.Combine("__NonExistentSubDir__", "__NonExistentFile__")

let properties =
[
"/p:Fable=True"
if not (String.IsNullOrWhiteSpace options.Configuration) then
$"/p:Configuration=%s{options.Configuration}"
$"/p:TargetFramework=%s{targetFramework}"
"/p:DesignTimeBuild=True"
"/p:SkipCompilerExecution=True"
"/p:ProvideCommandLineArgs=True"
// See https://github.com/NuGet/Home/issues/13046
"/p:RestoreUseStaticGraphEvaluation=False"
// Avoid restoring with an existing lock file
"/p:RestoreLockedMode=false"
"/p:RestorePackagesWithLockFile=false"
// We trick NuGet into believing there is no lock file create, if it does exist it will try and create it.
" /p:NuGetLockFilePath=Fable.lock"
// Avoid skipping the CoreCompile target via this property.
$"/p:NonExistentFile=\"%s{nonExistentFile}\""
]
|> List.filter (String.IsNullOrWhiteSpace >> not)
|> String.concat " "

let targets =
"ResolveAssemblyReferencesDesignTime,ResolveProjectReferencesDesignTime,ResolvePackageDependenciesDesignTime,FindReferenceAssembliesForReferences,_GenerateCompileDependencyCache,_ComputeNonExistentFileProperty,BeforeBuild,BeforeCompile,CoreCompile"

let arguments =
$"/restore /t:%s{targets} %s{properties} --getItem:FscCommandLineArgs --getItem:ProjectReference --getProperty:OutputType -warnAsMessage:NU1608"

let! json = dotnet_msbuild_with_defines fsproj.FullName arguments options.FableOptions.Define
let jsonDocument = JsonDocument.Parse json
let items = jsonDocument.RootElement.GetProperty "Items"
let properties = jsonDocument.RootElement.GetProperty "Properties"

let options =
items.GetProperty("FscCommandLineArgs").EnumerateArray()
|> Seq.map (fun arg -> arg.GetProperty("Identity").GetString())
|> Seq.toArray

if Array.isEmpty options then
return
failwithf
$"Design time build for %s{fsproj.FullName} failed. CoreCompile was most likely skipped. `dotnet clean` might help here."
else

let options = mkOptionsFullPath fsproj options

let projectReferences =
items.GetProperty("ProjectReference").EnumerateArray()
|> Seq.map (fun arg ->
let relativePath = arg.GetProperty("Identity").GetString()

Path.Combine(fsproj.DirectoryName, relativePath) |> Path.GetFullPath
)
|> Seq.toArray

let outputType = properties.GetProperty("OutputType").GetString()

return
{
ProjectOptions = options
ProjectReferences = projectReferences
OutputType = Some outputType
TargetFramework = Some targetFramework
}
}

type MSBuildCrackerResolver() =

interface ProjectCrackerResolver with
member _.GetProjectOptionsFromProjectFile(isMain, options: CrackerOptions, projectFile) =
let fsproj = FileInfo projectFile

if not fsproj.Exists then
invalidArg (nameof fsproj) $"\"%s{fsproj.FullName}\" does not exist."

// Bad I know...
let result =
mkOptionsFromDesignTimeBuildAux fsproj options |> Async.RunSynchronously

result
24 changes: 18 additions & 6 deletions src/Fable.Cli/Main.fs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ open Fable.Transforms
open Fable.Transforms.State
open Fable.Compiler.ProjectCracker
open Fable.Compiler.Util
open Fable.Cli.MSBuildCrackerResolver

module private Util =
type PathResolver with
Expand Down Expand Up @@ -366,15 +367,20 @@ type ProjectCracked(cliArgs: CliArgs, crackerResponse: CrackerResponse, sourceFi
member _.MapSourceFiles(f) =
ProjectCracked(cliArgs, crackerResponse, Array.map f sourceFiles)

static member Init(cliArgs: CliArgs, ?evaluateOnly: bool) =
static member Init(cliArgs: CliArgs, useMSBuildForCracking, ?evaluateOnly: bool) =
let evaluateOnly = defaultArg evaluateOnly false
Log.always $"Parsing {cliArgs.ProjectFileAsRelativePath}..."

let result, ms =
Performance.measure
<| fun () ->
CrackerOptions(cliArgs, evaluateOnly)
|> getFullProjectOpts (BuildalyzerCrackerResolver())
let resolver: ProjectCrackerResolver =
if useMSBuildForCracking then
MSBuildCrackerResolver()
else
BuildalyzerCrackerResolver()

CrackerOptions(cliArgs, evaluateOnly) |> getFullProjectOpts resolver

// We display "parsed" because "cracked" may not be understood by users
Log.always
Expand Down Expand Up @@ -801,6 +807,7 @@ type State =
Watcher: Watcher option
SilentCompilation: bool
RecompileAllFiles: bool
UseMSBuildForCracking: bool
}

member this.TriggeredByDependency(path: string, changes: ISet<string>) =
Expand All @@ -826,7 +833,7 @@ type State =
this.DeduplicateDic.GetOrAdd(importDir, (fun _ -> set this.DeduplicateDic.Values |> addTargetDir))
}

static member Create(cliArgs, ?watchDelay, ?recompileAllFiles) =
static member Create(cliArgs, ?watchDelay, ?recompileAllFiles, ?useMSBuildForCracking) =
{
CliArgs = cliArgs
ProjectCrackedAndFableCompiler = None
Expand All @@ -836,6 +843,7 @@ type State =
PendingFiles = [||]
SilentCompilation = false
RecompileAllFiles = defaultArg recompileAllFiles false
UseMSBuildForCracking = defaultArg useMSBuildForCracking false
}

let private getFilesToCompile
Expand Down Expand Up @@ -993,7 +1001,7 @@ let private compilationCycle (state: State) (changes: ISet<string>) =
let projCracked, fableCompiler, filesToCompile =
match state.ProjectCrackedAndFableCompiler with
| None ->
let projCracked = ProjectCracked.Init(cliArgs)
let projCracked = ProjectCracked.Init(cliArgs, state.UseMSBuildForCracking)
projCracked, None, projCracked.SourceFilePaths

| Some(projCracked, fableCompiler) ->
Expand All @@ -1005,7 +1013,11 @@ let private compilationCycle (state: State) (changes: ISet<string>) =
let oldProjCracked = projCracked

let newProjCracked =
ProjectCracked.Init({ cliArgs with NoCache = true }, evaluateOnly = true)
ProjectCracked.Init(
{ cliArgs with NoCache = true },
state.UseMSBuildForCracking,
evaluateOnly = true
)

// If only source files have changed, keep the project checker to speed up recompilation
let fableCompiler =
Expand Down

0 comments on commit 53238bc

Please sign in to comment.