Skip to content
Draft
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
9 changes: 7 additions & 2 deletions src/Fable.Cli/Main.fs
Original file line number Diff line number Diff line change
Expand Up @@ -965,7 +965,7 @@ let private areCompiledFilesUpToDate (state: State) (filesToCompile: string[]) =
)
|> Array.forall (fun source ->
let outPath = getOutPath state.CliArgs pathResolver source
// Empty files are not written to disk so we only check date for existing files

if IO.File.Exists(outPath) then
foundCompiledFile <- true

Expand All @@ -979,7 +979,12 @@ let private areCompiledFilesUpToDate (state: State) (filesToCompile: string[]) =

upToDate
else
true
// A missing output means the source is new or its output was deleted, so it
// must be compiled. A file generating empty code is never written and also
// lands here, costing that project the skip-compilation shortcut.
Log.verbose (lazy $"Output file {File.relPathToCurDir outPath} does not exist")

false
)
// If we don't find compiled files, assume we need recompilation
upToDate && foundCompiledFile
Expand Down
71 changes: 71 additions & 0 deletions src/Fable.Compiler/ProjectCracker.fs
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,73 @@ let loadPrecompiledInfo (opts: CrackerOptions) otherOptions sourceFiles =
Some info, otherOptions, sourceFiles
| None -> None, otherOptions, sourceFiles

/// Transitive `<Import Project="..."/>` targets plus the implicit Directory.Build.props/targets
/// and Directory.Packages.props found upwards. These can change the `<Compile Include>` list
/// without touching any .fsproj timestamp, so the cache must invalidate on them too.
let private getProjectImports (projFile: string) : string list =
let imports = ResizeArray()
let visited = HashSet<string>(StringComparer.OrdinalIgnoreCase)

// Paths built from other properties are skipped below: missing an import costs
// a stale cache, guessing wrong costs a bogus path.
let expandMacros (fileDir: string) (path: string) =
let withSep = fileDir + string<char> IO.Path.DirectorySeparatorChar

path.Replace("$(MSBuildThisFileDirectory)", withSep).Replace("$(MSBuildProjectDirectory)", withSep)

let rec collectFrom (file: string) =
// Guard against import cycles and repeated visits
if visited.Add(file) && IO.File.Exists(file) then
let fileDir = IO.Path.GetDirectoryName(file: string)

let importPaths =
try
XDocument.Load(file).Descendants()
|> Seq.filter (fun el -> el.Name.LocalName = "Import")
|> Seq.choose (fun el ->
match el.Attribute(XName.Get "Project") with
| null -> None
| attr -> Some(expandMacros fileDir attr.Value)
)
// Unresolved properties or wildcards need a real MSBuild evaluation
|> Seq.filter (fun path -> not (path.Contains("$(") || path.Contains("*")))
|> Seq.toList
with _ ->
// A malformed or unreadable project is the cracker's problem, not ours
[]

for importPath in importPaths do
let fullPath =
if IO.Path.IsPathRooted(importPath) then
importPath
else
IO.Path.Combine(fileDir, importPath)

let fullPath = IO.Path.GetFullPath(fullPath)

if IO.File.Exists(fullPath) then
imports.Add(fullPath)
collectFrom fullPath

collectFrom (IO.Path.GetFullPath(projFile))

// Implicit imports the SDK adds for every project
let projDir = IO.Path.GetDirectoryName(IO.Path.GetFullPath(projFile))

for implicitFile in
[
"Directory.Build.props"
"Directory.Build.targets"
"Directory.Packages.props"
] do
match File.tryFindUpwards implicitFile projDir with
| Some path ->
imports.Add(path)
collectFrom path
| None -> ()

List.ofSeq imports

let getFullProjectOpts (resolver: ProjectCrackerResolver) (opts: CrackerOptions) : CrackerResponse =
if not (IO.File.Exists(opts.ProjFile)) then
Fable.FableError("Project file does not exist: " + opts.ProjFile) |> raise
Expand All @@ -862,6 +929,10 @@ let getFullProjectOpts (resolver: ProjectCrackerResolver) (opts: CrackerOptions)
cacheInfo.Version = Literals.VERSION
&& cacheInfo.Exclude = opts.Exclude
&& cacheInfo.FableOptions.Language = opts.FableOptions.Language
// An imported .props/.targets can add or remove source files with no .fsproj touched
&& ([ cacheInfo.ProjectPath; yield! cacheInfo.References ]
|> List.collect getProjectImports
|> List.forall isOlderThanCache)
&& ([ cacheInfo.ProjectPath; yield! cacheInfo.References ]
|> List.forall (fun fsproj ->
if IO.File.Exists(fsproj) && isOlderThanCache fsproj then
Expand Down
121 changes: 121 additions & 0 deletions tests/Integration/Integration/CacheInvalidationTests.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
module Fable.Tests.CacheInvalidation

open System
open System.IO
open Expecto

/// A project whose compile order lives in an imported .props, so adding a source file
/// leaves the .fsproj timestamp untouched. In a temp dir: these tests mutate the project.
let private createProject (dir: string) =
Directory.CreateDirectory(dir) |> ignore

File.WriteAllText(
Path.Combine(dir, "Test.fsproj"),
"""<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RollForward>Major</RollForward>
</PropertyGroup>
<Import Project="Tests.props" />
</Project>
"""
)

File.WriteAllText(
Path.Combine(dir, "Tests.props"),
"""<Project>
<ItemGroup>
<Compile Include="$(MSBuildThisFileDirectory)Lib.fs" />
<Compile Include="$(MSBuildThisFileDirectory)Main.fs" />
</ItemGroup>
</Project>
"""
)

File.WriteAllText(Path.Combine(dir, "Lib.fs"), "module Lib\n\nlet greeting = \"hello\"\n")

// A second file, so deleting one output leaves another behind: with none left, the
// "no compiled files found" guard forces a recompilation and masks the case under test.
File.WriteAllText(Path.Combine(dir, "Main.fs"), "module Main\n\nlet run () = printfn \"%s\" Lib.greeting\n")

/// Cache invalidation compares timestamps, so a file rewritten within the same tick as the
/// previous compilation could look unchanged. Nudge it forward to keep the test from flaking.
let private writeNewer (path: string) (content: string) =
File.WriteAllText(path, content)
File.SetLastWriteTime(path, DateTime.Now.AddSeconds(2.0))

let private compile (dir: string) (outDir: string) =
Fable.Cli.Entry.main
[|
Path.Combine(dir, "Test.fsproj")
"--cwd"
dir
"--lang"
"javascript"
"--outDir"
outDir
|]

let private withTempProject name (f: string -> string -> unit) =
let unique = Guid.NewGuid().ToString("N")
let dir = Path.Combine(Path.GetTempPath(), $"fable-cache-tests-%s{name}-%s{unique}")

try
createProject dir
f dir (Path.Combine(dir, "out"))
finally
try
Directory.Delete(dir, true)
with _ ->
()

let tests =
testList
"CacheInvalidation"
[
// A `<Compile Include>` arriving through an imported .props touches no .fsproj
// timestamp, so the options cache used to be reused with a stale source list.
testCase "Source file added via an imported .props is compiled"
<| fun () ->
withTempProject
"added"
(fun dir outDir ->
Expect.equal (compile dir outDir) 0 "First compilation should succeed"

writeNewer (Path.Combine(dir, "Scalars.fs")) "module Scalars\n\nlet tests = \"scalars\"\n"

writeNewer
(Path.Combine(dir, "Tests.props"))
"""<Project>
<ItemGroup>
<Compile Include="$(MSBuildThisFileDirectory)Lib.fs" />
<Compile Include="$(MSBuildThisFileDirectory)Scalars.fs" />
<Compile Include="$(MSBuildThisFileDirectory)Main.fs" />
</ItemGroup>
</Project>
"""

Expect.equal (compile dir outDir) 0 "Second compilation should succeed"

Expect.isTrue
(File.Exists(Path.Combine(outDir, "Scalars.js")))
"The file added through the imported .props should have been generated"
)

// `getFilesToCompile` selects a source whose output is missing, so the up-to-date
// check must not then skip the compilation it just asked for.
testCase "Deleted output file is regenerated"
<| fun () ->
withTempProject
"deleted"
(fun dir outDir ->
Expect.equal (compile dir outDir) 0 "First compilation should succeed"

let outFile = Path.Combine(outDir, "Lib.js")
Expect.isTrue (File.Exists outFile) "Expected the first compilation to generate Lib.js"
File.Delete outFile

Expect.equal (compile dir outDir) 0 "Second compilation should succeed"
Expect.isTrue (File.Exists outFile) "The deleted output file should have been regenerated"
)
]
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<Compile Include="FileWatcherTests.fs" />
<Compile Include="CliTests.fs" />
<Compile Include="CompilationTests.fs" />
<Compile Include="CacheInvalidationTests.fs" />
<Compile Include="Main.fs" />
</ItemGroup>
</Project>
1 change: 1 addition & 0 deletions tests/Integration/Integration/Main.fs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ let allTests =
Cli.tests
FileWatcher.tests
CompilationTests.tests
CacheInvalidation.tests
]

open Expecto
Expand Down
Loading