diff --git a/docs/Documentation.md b/docs/Documentation.md index 68290ea6e8..cd7261f4ab 100644 --- a/docs/Documentation.md +++ b/docs/Documentation.md @@ -1264,6 +1264,9 @@ Exclusion applies both to formatting and the format checking. *.fsx ``` +Note that Fantomas only searches for a `.fantomasignore` file in or above its current working directory, if one exists; unlike Git, it does not traverse the filesystem for each input file to find an appropriate ignore file. +(This is not true of the Fantomas daemon. The daemon can't rely on being invoked from the right place, and indeed there may not even be a well-defined notion of "right place" for the formatting tasks the daemon is required to perform, so it does search the filesystem for every file individually.) + ## Using the API See [CodeFormatter.fsi](../src/Fantomas/CodeFormatter.fsi) to view the public API of Fantomas. diff --git a/paket.lock b/paket.lock index 2941eb4869..951a0a694b 100644 --- a/paket.lock +++ b/paket.lock @@ -327,6 +327,10 @@ NUGET System.Runtime (>= 4.3) System.Text.Encoding (>= 4.3) System.Threading.Tasks (>= 4.3) + System.IO.Abstractions (16.1.10) + System.IO.FileSystem.AccessControl (>= 4.7) - restriction: || (&& (== net6.0) (< net5.0)) (&& (== net6.0) (< netstandard2.1)) (== netcoreapp3.1) (== netstandard2.0) + System.IO.Abstractions.TestingHelpers (16.1.10) + System.IO.Abstractions (>= 16.1.10) System.IO.FileSystem (4.3) Microsoft.NETCore.Platforms (>= 1.1) Microsoft.NETCore.Targets (>= 1.1) @@ -336,6 +340,11 @@ NUGET System.Runtime.Handles (>= 4.3) System.Text.Encoding (>= 4.3) System.Threading.Tasks (>= 4.3) + System.IO.FileSystem.AccessControl (5.0) - restriction: || (&& (== net6.0) (< net5.0)) (&& (== net6.0) (< netstandard2.1)) (== netcoreapp3.1) (== netstandard2.0) + System.Buffers (>= 4.5.1) - restriction: || (&& (== net6.0) (>= monoandroid) (< netstandard1.3)) (&& (== net6.0) (>= monotouch)) (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (>= xamarinios)) (&& (== net6.0) (>= xamarinmac)) (&& (== net6.0) (>= xamarintvos)) (&& (== net6.0) (>= xamarinwatchos)) (&& (== netcoreapp3.1) (>= monoandroid) (< netstandard1.3)) (&& (== netcoreapp3.1) (>= monotouch)) (&& (== netcoreapp3.1) (< netcoreapp2.0)) (&& (== netcoreapp3.1) (>= xamarinios)) (&& (== netcoreapp3.1) (>= xamarinmac)) (&& (== netcoreapp3.1) (>= xamarintvos)) (&& (== netcoreapp3.1) (>= xamarinwatchos)) (== netstandard2.0) + System.Memory (>= 4.5.4) - restriction: || (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (< netcoreapp2.1)) (&& (== net6.0) (>= uap10.1)) (&& (== netcoreapp3.1) (< netcoreapp2.0)) (&& (== netcoreapp3.1) (< netcoreapp2.1)) (&& (== netcoreapp3.1) (>= uap10.1)) (== netstandard2.0) + System.Security.AccessControl (>= 5.0) + System.Security.Principal.Windows (>= 5.0) System.IO.FileSystem.Primitives (4.3) System.Runtime (>= 4.3) System.Linq (4.3) diff --git a/src/Fantomas.CoreGlobalTool/Daemon.fs b/src/Fantomas.CoreGlobalTool/Daemon.fs index 075d78bf51..12fb098bf9 100644 --- a/src/Fantomas.CoreGlobalTool/Daemon.fs +++ b/src/Fantomas.CoreGlobalTool/Daemon.fs @@ -3,6 +3,7 @@ open System open System.Diagnostics open System.IO +open System.IO.Abstractions open System.Threading open System.Threading.Tasks open FSharp.Compiler.Text.Range @@ -15,6 +16,7 @@ open Fantomas open Fantomas.SourceOrigin open Fantomas.FormatConfig open Fantomas.Extras.EditorConfig +open Fantomas.Extras type FantomasDaemon(sender: Stream, reader: Stream) as this = let rpc: JsonRpc = JsonRpc.Attach(sender, reader, this) @@ -30,6 +32,8 @@ type FantomasDaemon(sender: Stream, reader: Stream) as this = let exit () = disconnectEvent.Set() |> ignore + let fs = FileSystem() + do rpc.Disconnected.Add(fun _ -> exit ()) interface IDisposable with @@ -44,7 +48,7 @@ type FantomasDaemon(sender: Stream, reader: Stream) as this = [] member _.FormatDocumentAsync(request: FormatDocumentRequest) : Task = async { - if Fantomas.Extras.IgnoreFile.isIgnoredFile request.FilePath then + if IgnoreFile.isIgnoredFile (IgnoreFile.find fs IgnoreFile.loadIgnoreList request.FilePath) request.FilePath then return FormatDocumentResponse.IgnoredFile request.FilePath else let config = diff --git a/src/Fantomas.CoreGlobalTool/Program.fs b/src/Fantomas.CoreGlobalTool/Program.fs index 3f6cc3bac7..8cfe02e20a 100644 --- a/src/Fantomas.CoreGlobalTool/Program.fs +++ b/src/Fantomas.CoreGlobalTool/Program.fs @@ -90,7 +90,7 @@ let rec allFiles isRec path = |> Seq.filter (fun f -> isFSharpFile f && not (isInExcludedDir f) - && not (IgnoreFile.isIgnoredFile f)) + && not (IgnoreFile.isIgnoredFile (IgnoreFile.current.Force()) f)) /// Fantomas assumes the input files are UTF-8 /// As is stated in F# language spec: https://fsharp.org/specs/language-spec/4.1/FSharpSpec-4.1-latest.pdf#page=25 @@ -209,7 +209,7 @@ let runCheckCommand (recurse: bool) (inputPath: InputPath) : int = | InputPath.StdIn _ -> eprintfn "No input path provided. Nothing to do." 0 - | InputPath.File f when (IgnoreFile.isIgnoredFile f) -> + | InputPath.File f when (IgnoreFile.isIgnoredFile (IgnoreFile.current.Force()) f) -> printfn "'%s' was ignored" f 0 | InputPath.File path -> @@ -418,7 +418,7 @@ let main argv = let filesAndFolders (files: string list) (folders: string list) : unit = files |> List.iter (fun file -> - if (IgnoreFile.isIgnoredFile file) then + if (IgnoreFile.isIgnoredFile (IgnoreFile.current.Force()) file) then printfn "'%s' was ignored" file else processFile file file) @@ -448,7 +448,8 @@ let main argv = | InputPath.Unspecified, _ -> eprintfn "Input path is missing..." exit 1 - | InputPath.File f, _ when (IgnoreFile.isIgnoredFile f) -> printfn "'%s' was ignored" f + | InputPath.File f, _ when (IgnoreFile.isIgnoredFile (IgnoreFile.current.Force()) f) -> + printfn "'%s' was ignored" f | InputPath.Folder p1, OutputPath.NotKnown -> processFolder p1 p1 | InputPath.File p1, OutputPath.NotKnown -> processFile p1 p1 | InputPath.File p1, OutputPath.IO p2 -> processFile p1 p2 diff --git a/src/Fantomas.Extras/AssemblyInfo.fs b/src/Fantomas.Extras/AssemblyInfo.fs new file mode 100644 index 0000000000..a9d9814e8f --- /dev/null +++ b/src/Fantomas.Extras/AssemblyInfo.fs @@ -0,0 +1,7 @@ +namespace Fantomas.Extras.AssemblyInfo + +open System.Runtime.CompilerServices + +[] + +do () diff --git a/src/Fantomas.Extras/FakeHelpers.fs b/src/Fantomas.Extras/FakeHelpers.fs index cab8d9d041..618c9473dc 100644 --- a/src/Fantomas.Extras/FakeHelpers.fs +++ b/src/Fantomas.Extras/FakeHelpers.fs @@ -44,7 +44,7 @@ let private formatContentInternalAsync (file: string) (originalContent: string) : Async = - if IgnoreFile.isIgnoredFile file then + if IgnoreFile.isIgnoredFile (IgnoreFile.current.Force()) file then async { return IgnoredFile file } else async { @@ -99,7 +99,7 @@ let formatContentAsync = formatContentInternalAsync false let private formatFileInternalAsync (compareWithoutLineEndings: bool) (file: string) = let config = EditorConfig.readConfiguration file - if IgnoreFile.isIgnoredFile file then + if IgnoreFile.isIgnoredFile (IgnoreFile.current.Force()) file then async { return IgnoredFile file } else let originalContent = File.ReadAllText file @@ -167,7 +167,10 @@ let checkCode (filenames: seq) = async { let! formatted = filenames - |> Seq.filter (IgnoreFile.isIgnoredFile >> not) + |> Seq.filter ( + IgnoreFile.isIgnoredFile (IgnoreFile.current.Force()) + >> not + ) |> Seq.map (formatFileInternalAsync true) |> Async.Parallel diff --git a/src/Fantomas.Extras/Fantomas.Extras.fsproj b/src/Fantomas.Extras/Fantomas.Extras.fsproj index aa1732ec28..2013bdb6b8 100644 --- a/src/Fantomas.Extras/Fantomas.Extras.fsproj +++ b/src/Fantomas.Extras/Fantomas.Extras.fsproj @@ -8,6 +8,7 @@ + diff --git a/src/Fantomas.Extras/IgnoreFile.fs b/src/Fantomas.Extras/IgnoreFile.fs index a3bbeb23a5..468167d0ab 100644 --- a/src/Fantomas.Extras/IgnoreFile.fs +++ b/src/Fantomas.Extras/IgnoreFile.fs @@ -1,47 +1,75 @@ namespace Fantomas.Extras +open System.IO.Abstractions +open MAB.DotIgnore + +/// The string argument is taken relative to the location +/// of the ignore-file. +type IsPathIgnored = string -> bool + +type IgnoreFile = + { Location: IFileInfo + IsIgnored: IsPathIgnored } + [] module IgnoreFile = - open System.IO - open MAB.DotIgnore - [] let IgnoreFileName = ".fantomasignore" - let rec private findIgnoreFile (filePath: string) : string option = - let allParents = - let rec addParent (di: DirectoryInfo) (finalContinuation: string list -> string list) = - if isNull di.Parent then - finalContinuation [ di.FullName ] - else - addParent di.Parent (fun parents -> di.FullName :: parents |> finalContinuation) + /// Find the `.fantomasignore` file above the given filepath, if one exists. + /// Note that this is intended for use only in the daemon; the command-line tool + /// does not support `.fantomasignore` files anywhere other than the current + /// working directory. + let find (fs: IFileSystem) (loadIgnoreList: string -> IsPathIgnored) (filePath: string) : IgnoreFile option = + let rec walkUp (currentDirectory: IDirectoryInfo) : IgnoreFile option = + if isNull currentDirectory then + None + else + let potentialFile = + fs.Path.Combine(currentDirectory.FullName, IgnoreFileName) + |> fs.FileInfo.FromFileName - addParent (Directory.GetParent filePath) id + if potentialFile.Exists then + { Location = potentialFile + IsIgnored = loadIgnoreList potentialFile.FullName } + |> Some + else + walkUp currentDirectory.Parent - allParents - |> List.tryFind (fun p -> Path.Combine(p, IgnoreFileName) |> File.Exists) - |> Option.map (fun p -> Path.Combine(p, IgnoreFileName)) + walkUp (fs.FileInfo.FromFileName(filePath).Directory) - let private relativePathPrefix = sprintf ".%c" Path.DirectorySeparatorChar + let loadIgnoreList (path: string) : IsPathIgnored = + let list = IgnoreList(path) + fun path -> list.IsIgnored(path, false) - let private removeRelativePathPrefix (path: string) = - if path.StartsWith(relativePathPrefix) then - path.Substring(2) - else - path + let internal current' (fs: IFileSystem) (currentDirectory: string) (loadIgnoreList: string -> IsPathIgnored) = + lazy find fs loadIgnoreList (fs.Path.Combine(currentDirectory, "_")) - let isIgnoredFile (file: string) = - let fullPath = Path.GetFullPath(file) + /// When executed from the command line, Fantomas will not dynamically locate + /// the most appropriate `.fantomasignore` for each input file; it only finds + /// a single `.fantomasignore` file. This is that file. + let current: Lazy = + current' (FileSystem()) System.Environment.CurrentDirectory loadIgnoreList - match findIgnoreFile fullPath with + let isIgnoredFile (ignoreFile: IgnoreFile option) (file: string) : bool = + match ignoreFile with | None -> false | Some ignoreFile -> - let ignores = IgnoreList(ignoreFile) + let fs = ignoreFile.Location.FileSystem + let fullPath = fs.Path.GetFullPath(file) try - let path = removeRelativePathPrefix file - ignores.IsIgnored(path, false) + let path = + if fullPath.StartsWith ignoreFile.Location.Directory.FullName then + fullPath.[ignoreFile.Location.Directory.FullName.Length + 1 ..] + else + // This scenario is a bit unexpected - it suggests that we are + // trying to ask an ignorefile whether a file that is outside + // its dependency tree is ignored. + fullPath + + ignoreFile.IsIgnored path with | ex -> printfn "%A" ex diff --git a/src/Fantomas.Extras/paket.references b/src/Fantomas.Extras/paket.references index deb2132611..743f5d9601 100644 --- a/src/Fantomas.Extras/paket.references +++ b/src/Fantomas.Extras/paket.references @@ -2,4 +2,5 @@ FSharp.Core Ionide.KeepAChangelog.Tasks copy_local: true Dotnet.ReproducibleBuilds copy_local: true editorconfig -MAB.DotIgnore \ No newline at end of file +MAB.DotIgnore +System.IO.Abstractions diff --git a/src/Fantomas.Tests/Fantomas.Tests.fsproj b/src/Fantomas.Tests/Fantomas.Tests.fsproj index a93bd910ea..ad4a7e5c6f 100644 --- a/src/Fantomas.Tests/Fantomas.Tests.fsproj +++ b/src/Fantomas.Tests/Fantomas.Tests.fsproj @@ -92,6 +92,7 @@ + diff --git a/src/Fantomas.Tests/IgnoreFileTests.fs b/src/Fantomas.Tests/IgnoreFileTests.fs new file mode 100644 index 0000000000..7a96c16c95 --- /dev/null +++ b/src/Fantomas.Tests/IgnoreFileTests.fs @@ -0,0 +1,154 @@ +module Fantomas.Tests.IgnoreFileTests + +open System.Collections.Generic +open NUnit.Framework +open FsUnitTyped +open Fantomas.Extras +open System.IO.Abstractions +open System.IO.Abstractions.TestingHelpers + +let private makeFileHierarchy (fs: IFileSystem) (filePaths: string list) : unit = + for path in filePaths do + let fileInfo = fs.FileInfo.FromFileName path + fileInfo.Directory.Create() + fs.File.WriteAllText(fileInfo.FullName, "some text") + +/// A helper method to create a `loadIgnoreList` function for injection into IgnoreFile; +/// this `loadIgnoreList` function will throw if it tries to load the same file twice. +let private oneShotLoader (isIgnored: IsPathIgnored) : (string -> IsPathIgnored) * (unit -> string Set) = + let loadedFiles = HashSet() + + let load ignoreFilePath = + let added = lock loadedFiles (fun () -> loadedFiles.Add ignoreFilePath) + + if added then + isIgnored + else + failwithf "Attempted duplicate file load: %s" ignoreFilePath + + let freeze () = + lock loadedFiles (fun () -> loadedFiles |> Set.ofSeq) + + load, freeze + +[] +let ``IgnoreFile.find returns None if it can't find an ignorefile`` () = + let fs = MockFileSystem() + let root = fs.Path.GetTempPath() |> fs.Path.GetPathRoot + + let source = fs.Path.Combine(root, "folder1", "folder2", "SomeSource.fs") + + [ source ] |> makeFileHierarchy fs + + match IgnoreFile.find fs (fun _ -> failwith "not called") source with + | None -> () + | Some ignoreFile -> failwithf "Unexpectedly found an ignorefile: %s" ignoreFile.Location.FullName + +[] +let ``IgnoreFile.find does not crash at the root, ignore file present`` () = + let fs = MockFileSystem() + let root = fs.Path.GetTempPath() |> fs.Path.GetPathRoot + + let fileAtRoot = fs.Path.Combine(root, "SomeFile.fs") + + let loadIgnoreList, getLoads = oneShotLoader (fun _ -> failwith "never called") + + let target = fs.Path.Combine(root, ".fantomasignore") + fs.File.WriteAllText(target, "some text") + + let ignoreFile = IgnoreFile.find fs loadIgnoreList fileAtRoot + + match ignoreFile with + | None -> failwith "Failed to find the fantomasignore file at the root" + | Some ignoreFile -> ignoreFile.Location.FullName |> shouldEqual target + + getLoads () |> shouldEqual (Set.ofList [ target ]) + +[] +let ``IgnoreFile.find does not crash at the root, no ignore file present`` () = + let fs = MockFileSystem() + let root = fs.Path.GetTempPath() |> fs.Path.GetPathRoot + + let fileAtRoot = fs.Path.Combine(root, "SomeFile.fs") + + let loadIgnoreList, getLoads = oneShotLoader (fun _ -> failwith "never called") + + let ignoreFile = IgnoreFile.find fs loadIgnoreList fileAtRoot + + match ignoreFile with + | Some ignoreFile -> + failwithf "Somehow found a fantomasignore file even though none was present: %s" ignoreFile.Location.FullName + | None -> () + + getLoads () |> shouldBeEmpty + +[] +let ``IgnoreFile.find preferentially finds the fantomasignore next to the source file`` () = + let fs = MockFileSystem() + let root = fs.Path.GetTempPath() |> fs.Path.GetPathRoot + + let source = fs.Path.Combine(root, "folder1", "folder2", "SomeSource.fs") + let target = fs.Path.Combine(root, "folder1", "folder2", ".fantomasignore") + + [ source + target + // Another couple, at higher levels of the hierarchy + fs.Path.Combine(root, "folder1", ".fantomasignore") + fs.Path.Combine(root, ".fantomasignore") ] + |> makeFileHierarchy fs + + let loadIgnoreList, getLoads = oneShotLoader (fun _ -> failwith "never called") + + let ignoreFile = + IgnoreFile.find fs loadIgnoreList source + |> Option.get + + ignoreFile.Location.FullName |> shouldEqual target + getLoads () |> shouldEqual (Set.ofList [ target ]) + +[] +let ``IgnoreFile.find can find the fantomasignore one layer up from the source file`` () = + let fs = MockFileSystem() + let root = fs.Path.GetTempPath() |> fs.Path.GetPathRoot + + let source = fs.Path.Combine(root, "folder1", "folder2", "SomeSource.fs") + let target = fs.Path.Combine(root, "folder1", ".fantomasignore") + + [ source + target + // Another one, at a higher level of the hierarchy + fs.Path.Combine(root, ".fantomasignore") ] + |> makeFileHierarchy fs + + let loadIgnoreList, getLoads = oneShotLoader (fun _ -> failwith "never called") + + let ignoreFile = + IgnoreFile.find fs loadIgnoreList source + |> Option.get + + ignoreFile.Location.FullName |> shouldEqual target + getLoads () |> shouldEqual (Set.ofList [ target ]) + +[] +let ``IgnoreFile.current' does not load more than once`` () = + let fs = MockFileSystem() + let root = fs.Path.GetTempPath() |> fs.Path.GetPathRoot + + let source = fs.Path.Combine(root, "folder1", "folder2", "SomeSource.fs") + let target = fs.Path.Combine(root, "folder1", ".fantomasignore") + + [ source; target ] |> makeFileHierarchy fs + + let loadIgnoreList, getLoads = oneShotLoader (fun _ -> failwith "never called") + + let ignoreFile = + IgnoreFile.current' fs (fs.Path.GetDirectoryName target) loadIgnoreList + + getLoads () |> shouldBeEmpty + + for _ in 1..2 do + let forced = ignoreFile.Force() |> Option.get + forced.Location.FullName |> shouldEqual target + // The second invocation would throw if we were somehow getting the + // singleton wrong and re-invoking the find-and-load. + getLoads () |> shouldEqual (Set.ofList [ target ]) diff --git a/src/Fantomas.Tests/paket.references b/src/Fantomas.Tests/paket.references index 803cf52330..f05c2812fa 100644 --- a/src/Fantomas.Tests/paket.references +++ b/src/Fantomas.Tests/paket.references @@ -4,4 +4,5 @@ FsCheck Microsoft.NET.Test.Sdk NUnit3TestAdapter NunitXml.TestLogger -FSharp.Core \ No newline at end of file +FSharp.Core +System.IO.Abstractions.TestingHelpers diff --git a/src/Fantomas/paket.references b/src/Fantomas/paket.references index d2919492e9..4d92d1df9e 100644 --- a/src/Fantomas/paket.references +++ b/src/Fantomas/paket.references @@ -1,4 +1,6 @@ FSharp.Compiler.Service FSharp.Core Ionide.KeepAChangelog.Tasks copy_local: true -Dotnet.ReproducibleBuilds copy_local: true \ No newline at end of file +Dotnet.ReproducibleBuilds copy_local: true +System.IO.Abstractions +System.IO.Abstractions.TestingHelpers \ No newline at end of file