diff --git a/src/FSharpLint.Core/Application/Lint.fs b/src/FSharpLint.Core/Application/Lint.fs index cc0c75014..daac75e50 100644 --- a/src/FSharpLint.Core/Application/Lint.fs +++ b/src/FSharpLint.Core/Application/Lint.fs @@ -125,7 +125,7 @@ module Lint = GlobalConfig: Rules.GlobalRuleConfig TypeCheckResults: FSharpCheckFileResults option ProjectCheckResults: FSharpCheckProjectResults option - ProjectOptions: Lazy + ProjectOptions: ParseFile.LinterProjectOptions option FilePath: string FileContent: string Lines: string[] @@ -265,10 +265,7 @@ module Lint = GlobalConfig = enabledRules.GlobalConfig TypeCheckResults = fileInfo.TypeCheckResults ProjectCheckResults = fileInfo.ProjectCheckResults - ProjectOptions = lazy( - fileInfo.ProjectCheckResults - |> Option.map _.ProjectContext.ProjectOptions - ) + ProjectOptions = fileInfo.ProjectOptions FilePath = fileInfo.File FileContent = fileInfo.Text Lines = lines @@ -399,6 +396,8 @@ module Lint = TypeCheckResults:FSharpCheckFileResults option /// Optional results of project-wide type info (allows for a more accurate lint). ProjectCheckResults:FSharpCheckProjectResults option + /// Optional project options. Allows rules to operate on project options. + ProjectOptions: ParseFile.LinterProjectOptions option } /// Gets a FSharpLint Configuration based on the provided ConfigurationParam. @@ -581,6 +580,7 @@ module Lint = ParseFile.Ast = parsedFileInfo.Ast ParseFile.TypeCheckResults = parsedFileInfo.TypeCheckResults ParseFile.ProjectCheckResults = parsedFileInfo.ProjectCheckResults + ParseFile.ProjectOptions = parsedFileInfo.ProjectOptions ParseFile.File = "" } lint lintInformation parsedFileInfo @@ -600,7 +600,8 @@ module Lint = { Source = parseFileInformation.Text Ast = parseFileInformation.Ast TypeCheckResults = parseFileInformation.TypeCheckResults - ProjectCheckResults = None } + ProjectCheckResults = None + ProjectOptions = None } return lintParsedSource optionalParams parsedFileInfo | ParseFile.Failed failure -> return LintResult.Failure(FailedToParseFile failure) @@ -635,6 +636,7 @@ module Lint = ParseFile.Ast = parsedFileInfo.Ast ParseFile.TypeCheckResults = parsedFileInfo.TypeCheckResults ParseFile.ProjectCheckResults = parsedFileInfo.ProjectCheckResults + ParseFile.ProjectOptions = parsedFileInfo.ProjectOptions ParseFile.File = filePath } lint lintInformation parsedFileInfo @@ -653,7 +655,8 @@ module Lint = { Source = astFileParseInfo.Text Ast = astFileParseInfo.Ast TypeCheckResults = astFileParseInfo.TypeCheckResults - ProjectCheckResults = astFileParseInfo.ProjectCheckResults } + ProjectCheckResults = astFileParseInfo.ProjectCheckResults + ProjectOptions = astFileParseInfo.ProjectOptions } return lintParsedFile optionalParams parsedFileInfo filePath | ParseFile.Failed failure -> return LintResult.Failure(FailedToParseFile failure) @@ -684,7 +687,8 @@ module Lint = { Source = astFileParseInfo.Text Ast = astFileParseInfo.Ast TypeCheckResults = astFileParseInfo.TypeCheckResults - ProjectCheckResults = astFileParseInfo.ProjectCheckResults } + ProjectCheckResults = astFileParseInfo.ProjectCheckResults + ProjectOptions = astFileParseInfo.ProjectOptions } return lintParsedFile optionalParams parsedFileInfo filePath | ParseFile.Failed failure -> return LintResult.Failure (FailedToParseFile failure) diff --git a/src/FSharpLint.Core/Application/Lint.fsi b/src/FSharpLint.Core/Application/Lint.fsi index 7128eadb7..d876f2725 100644 --- a/src/FSharpLint.Core/Application/Lint.fsi +++ b/src/FSharpLint.Core/Application/Lint.fsi @@ -78,6 +78,9 @@ module Lint = /// Optional results of project-wide type info (allows for a more accurate lint). ProjectCheckResults:FSharpCheckProjectResults option + + /// Optional project options. Allows rules to operate on project options. + ProjectOptions: ParseFile.LinterProjectOptions option } type BuildFailure = | InvalidProjectFileMessage of string @@ -129,7 +132,7 @@ module Lint = GlobalConfig: Rules.GlobalRuleConfig TypeCheckResults: FSharpCheckFileResults option ProjectCheckResults: FSharpCheckProjectResults option - ProjectOptions: Lazy + ProjectOptions: ParseFile.LinterProjectOptions option FilePath: string FileContent: string Lines: string[] diff --git a/src/FSharpLint.Core/FSharpLint.Core.fsproj b/src/FSharpLint.Core/FSharpLint.Core.fsproj index 7fa0f6aeb..060ab56ac 100644 --- a/src/FSharpLint.Core/FSharpLint.Core.fsproj +++ b/src/FSharpLint.Core/FSharpLint.Core.fsproj @@ -25,9 +25,9 @@ + - diff --git a/src/FSharpLint.Core/Framework/ParseFile.fs b/src/FSharpLint.Core/Framework/ParseFile.fs index 1bad7c1ca..a5b2b7e60 100644 --- a/src/FSharpLint.Core/Framework/ParseFile.fs +++ b/src/FSharpLint.Core/Framework/ParseFile.fs @@ -1,5 +1,7 @@ namespace FSharpLint.Framework +#nowarn "FS0057" // 'FSharpProjectSnapshot' is considered experimental. Note: Could suppress this more locally if building with the .NET 10 compiler + /// Provides functionality to parse F# files using `FSharp.Compiler.Service`. module ParseFile = @@ -11,6 +13,58 @@ module ParseFile = open FSharp.Compiler.Text open Utilities + /// Options related to the project being linted. + /// Based on https://github.com/ionide/FSharp.Analyzers.SDK/blob/f323144f0a4db51be564a3187838f2328f0e9182/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fsi#L66 + [] + type LinterProjectOptions = + | ProjectOptions of options: FSharpProjectOptions + | ProjectSnapshot of snapshot: FSharpProjectSnapshot + + member this.ProjectFileName = + match this with + | ProjectOptions(options) -> options.ProjectFileName + | ProjectSnapshot(snapshot) -> snapshot.ProjectFileName + +#if false + member x.ProjectId = + match x with + | BackgroundCompilerOptions(options) -> options.ProjectId + | TransparentCompilerOptions(snapshot) -> snapshot.ProjectId + + member x.SourceFiles = + match x with + | BackgroundCompilerOptions(options) -> + options.SourceFiles + |> Array.toList + | TransparentCompilerOptions(snapshot) -> + snapshot.SourceFiles + |> List.map (fun f -> f.FileName) + |> List.map System.IO.Path.GetFullPath + + member x.ReferencedProjectsPath = + match x with + | BackgroundCompilerOptions(options) -> + options.ReferencedProjects + |> Array.choose (fun p -> p.ProjectFilePath) + |> Array.toList + | TransparentCompilerOptions(snapshot) -> + snapshot.ReferencedProjects + |> List.choose (fun p -> p.ProjectFilePath) + + member x.LoadTime = + match x with + | BackgroundCompilerOptions(options) -> options.LoadTime + | TransparentCompilerOptions(snapshot) -> snapshot.LoadTime + + member x.OtherOptions = + match x with + | BackgroundCompilerOptions(options) -> + options.OtherOptions + |> Array.toList + | TransparentCompilerOptions(snapshot) -> snapshot.OtherOptions + +#endif + /// Information for a file to be linted that is given to the analysers. [] type FileParseInfo = { @@ -26,6 +80,9 @@ module ParseFile = /// Optional results of project-wide type info (allows for a more accurate lint). ProjectCheckResults:FSharpCheckProjectResults option + /// Optional project options. Allows rules to operate on project options. + ProjectOptions: LinterProjectOptions option + /// Path to the file. File:string } @@ -53,6 +110,7 @@ module ParseFile = Ast = parseResults.ParseTree TypeCheckResults = Some(typeCheckResults) ProjectCheckResults = None + ProjectOptions = Some (ProjectOptions options) File = file } | FSharpCheckFileAnswer.Aborted -> return Failed(AbortedTypeCheck) diff --git a/src/FSharpLint.Core/Framework/Rules.fs b/src/FSharpLint.Core/Framework/Rules.fs index 38c8b2355..57d408b45 100644 --- a/src/FSharpLint.Core/Framework/Rules.fs +++ b/src/FSharpLint.Core/Framework/Rules.fs @@ -31,7 +31,7 @@ type AstNodeRuleParams = Lines:string [] CheckInfo:FSharpCheckFileResults option ProjectCheckInfo:FSharpCheckProjectResults option - ProjectOptions: Lazy + ProjectOptions: ParseFile.LinterProjectOptions option GlobalConfig:GlobalRuleConfig } type LineRuleParams = diff --git a/src/FSharpLint.Core/Rules/Conventions/Naming/AsynchronousFunctionNames.fs b/src/FSharpLint.Core/Rules/Conventions/Naming/AsynchronousFunctionNames.fs index e95c008ea..df29f5c14 100644 --- a/src/FSharpLint.Core/Rules/Conventions/Naming/AsynchronousFunctionNames.fs +++ b/src/FSharpLint.Core/Rules/Conventions/Naming/AsynchronousFunctionNames.fs @@ -32,7 +32,7 @@ let runner (config: Config) (args: AstNodeRuleParams) = | _ -> config.Mode = AllAPIs let likelyhoodOfBeingInLibrary = - match args.ProjectOptions.Value with + match args.ProjectOptions with | Some projectOptions -> howLikelyProjectIsLibrary projectOptions.ProjectFileName | None -> Unlikely diff --git a/src/FSharpLint.Core/Rules/Conventions/Naming/SimpleAsyncComplementaryHelpers.fs b/src/FSharpLint.Core/Rules/Conventions/Naming/SimpleAsyncComplementaryHelpers.fs index 9785b52b8..8b2be2b21 100644 --- a/src/FSharpLint.Core/Rules/Conventions/Naming/SimpleAsyncComplementaryHelpers.fs +++ b/src/FSharpLint.Core/Rules/Conventions/Naming/SimpleAsyncComplementaryHelpers.fs @@ -205,7 +205,7 @@ let runner (config: Config) (args: AstNodeRuleParams) = Array.append (checkFuncs asyncFuncs taskFuncs) (checkFuncs taskFuncs asyncFuncs) let likelyhoodOfBeingInLibrary = - match args.ProjectOptions.Value with + match args.ProjectOptions with | Some projectOptions -> howLikelyProjectIsLibrary projectOptions.ProjectFileName | None -> Unlikely diff --git a/src/FSharpLint.Core/Rules/Smells/NoAsyncRunSynchronouslyInLibrary.fs b/src/FSharpLint.Core/Rules/Smells/NoAsyncRunSynchronouslyInLibrary.fs index 6f4721fd1..6caafa2e7 100644 --- a/src/FSharpLint.Core/Rules/Smells/NoAsyncRunSynchronouslyInLibrary.fs +++ b/src/FSharpLint.Core/Rules/Smells/NoAsyncRunSynchronouslyInLibrary.fs @@ -97,7 +97,7 @@ let checkIfInLibrary (args: AstNodeRuleParams) (range: range) : array let projectFile = System.IO.FileInfo projectOptions.ProjectFileName match howLikelyProjectIsLibrary projectFile.Name with diff --git a/tests/FSharpLint.Benchmarks/Benchmark.fs b/tests/FSharpLint.Benchmarks/Benchmark.fs index c20276c01..148d5c9dd 100644 --- a/tests/FSharpLint.Benchmarks/Benchmark.fs +++ b/tests/FSharpLint.Benchmarks/Benchmark.fs @@ -31,7 +31,7 @@ type Benchmark () = let (fileInfo, _lines) = let text = File.ReadAllText sourceFile let tree = generateAst text sourceFile - ({ Ast = tree; Source = text; TypeCheckResults = None; ProjectCheckResults = None }, String.toLines text |> Array.toList) + ({ Ast = tree; Source = text; TypeCheckResults = None; ProjectCheckResults = None; ProjectOptions = None }, String.toLines text |> Array.toList) [] member this.LintParsedFile () = diff --git a/tests/FSharpLint.Core.Tests/Rules/TestAstNodeRule.fs b/tests/FSharpLint.Core.Tests/Rules/TestAstNodeRule.fs index ee77ae33b..54376aa8e 100644 --- a/tests/FSharpLint.Core.Tests/Rules/TestAstNodeRule.fs +++ b/tests/FSharpLint.Core.Tests/Rules/TestAstNodeRule.fs @@ -43,7 +43,7 @@ type TestAstNodeRuleBase (rule:Rule) = GlobalConfig = resolvedGlobalConfig TypeCheckResults = checkResult ProjectCheckResults = None - ProjectOptions = Lazy<_>(None) + ProjectOptions = None FilePath = (Option.defaultValue String.Empty maybeFileName) FileContent = input Lines = (input.Split("\n")) diff --git a/tests/FSharpLint.Core.Tests/Rules/TestHintMatcherBase.fs b/tests/FSharpLint.Core.Tests/Rules/TestHintMatcherBase.fs index fd52c56f4..8b9f471d0 100644 --- a/tests/FSharpLint.Core.Tests/Rules/TestHintMatcherBase.fs +++ b/tests/FSharpLint.Core.Tests/Rules/TestHintMatcherBase.fs @@ -65,7 +65,7 @@ type TestHintMatcherBase () = GlobalConfig = resolvedGlobalConfig TypeCheckResults = checkResult ProjectCheckResults = None - ProjectOptions = Lazy<_>() + ProjectOptions = None FilePath = (Option.defaultValue String.Empty maybeFileName) FileContent = input Lines = (input.Split("\n")) diff --git a/tests/FSharpLint.Core.Tests/Rules/TestIndentationRule.fs b/tests/FSharpLint.Core.Tests/Rules/TestIndentationRule.fs index 20c94fac0..bab86f7b4 100644 --- a/tests/FSharpLint.Core.Tests/Rules/TestIndentationRule.fs +++ b/tests/FSharpLint.Core.Tests/Rules/TestIndentationRule.fs @@ -38,7 +38,7 @@ type TestIndentationRuleBase (rule:Rule) = GlobalConfig = resolvedGlobalConfig TypeCheckResults = None ProjectCheckResults = None - ProjectOptions = Lazy<_>(None) + ProjectOptions = None FilePath = resolvedFileName FileContent = input Lines = lines diff --git a/tests/FSharpLint.Core.Tests/Rules/TestLineRule.fs b/tests/FSharpLint.Core.Tests/Rules/TestLineRule.fs index c53e93377..a79bcc25d 100644 --- a/tests/FSharpLint.Core.Tests/Rules/TestLineRule.fs +++ b/tests/FSharpLint.Core.Tests/Rules/TestLineRule.fs @@ -38,7 +38,7 @@ type TestLineRuleBase (rule:Rule) = GlobalConfig = resolvedGlobalConfig TypeCheckResults = None ProjectCheckResults = None - ProjectOptions = Lazy<_>(None) + ProjectOptions = None FilePath = resolvedFileName FileContent = input Lines = lines diff --git a/tests/FSharpLint.Core.Tests/Rules/TestNoTabCharactersRule.fs b/tests/FSharpLint.Core.Tests/Rules/TestNoTabCharactersRule.fs index 5e534bb22..d5eb09270 100644 --- a/tests/FSharpLint.Core.Tests/Rules/TestNoTabCharactersRule.fs +++ b/tests/FSharpLint.Core.Tests/Rules/TestNoTabCharactersRule.fs @@ -38,7 +38,7 @@ type TestNoTabCharactersRuleBase (rule:Rule) = GlobalConfig = resolvedGlobalConfig TypeCheckResults = None ProjectCheckResults = None - ProjectOptions = Lazy<_>() + ProjectOptions = None FilePath = resolvedFileName FileContent = input Lines = lines diff --git a/tests/FSharpLint.FunctionalTest.TestedProject/LibAsync/LibAsync.fsproj b/tests/FSharpLint.FunctionalTest.TestedProject/LibAsync/LibAsync.fsproj new file mode 100644 index 000000000..9a9f656a0 --- /dev/null +++ b/tests/FSharpLint.FunctionalTest.TestedProject/LibAsync/LibAsync.fsproj @@ -0,0 +1,9 @@ + + + + net8.0 + + + + + diff --git a/tests/FSharpLint.FunctionalTest.TestedProject/LibAsync/LibAsyncNames.fs b/tests/FSharpLint.FunctionalTest.TestedProject/LibAsync/LibAsyncNames.fs new file mode 100644 index 000000000..85a69cd6c --- /dev/null +++ b/tests/FSharpLint.FunctionalTest.TestedProject/LibAsync/LibAsyncNames.fs @@ -0,0 +1,4 @@ +module Foo + +let Bar(): Async = + async { return 1 } diff --git a/tests/FSharpLint.FunctionalTest/FSharpLint.FunctionalTest.fsproj b/tests/FSharpLint.FunctionalTest/FSharpLint.FunctionalTest.fsproj index 363904185..5cde8a8b1 100644 --- a/tests/FSharpLint.FunctionalTest/FSharpLint.FunctionalTest.fsproj +++ b/tests/FSharpLint.FunctionalTest/FSharpLint.FunctionalTest.fsproj @@ -1,4 +1,4 @@ - + net9.0 @@ -8,6 +8,7 @@ + diff --git a/tests/FSharpLint.FunctionalTest/TestApi.fs b/tests/FSharpLint.FunctionalTest/TestApi.fs index 635113f10..31f86fa77 100644 --- a/tests/FSharpLint.FunctionalTest/TestApi.fs +++ b/tests/FSharpLint.FunctionalTest/TestApi.fs @@ -14,6 +14,10 @@ module TestApi = let sourceFile = basePath "tests" "TypeChecker.fs" + // Test project used for transparent/background compiler project options tests + let asyncTestProjectPath = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "LibAsync" + let asyncTestProjectFile = asyncTestProjectPath "LibAsync.fsproj" + [] type TestApi() = let generateAst source = @@ -38,7 +42,7 @@ module TestApi = member _.``Performance of linting an existing file``() = let text = File.ReadAllText sourceFile let tree = generateAst text - let fileInfo = { Ast = tree; Source = text; TypeCheckResults = None; ProjectCheckResults = None } + let fileInfo = { Ast = tree; Source = text; TypeCheckResults = None; ProjectCheckResults = None; ProjectOptions = None } let stopwatch = Stopwatch.StartNew() let times = ResizeArray() @@ -59,6 +63,22 @@ module TestApi = Assert.Less(result, 250) fprintf TestContext.Out "Average runtime of linter on parsed file: %d (milliseconds)." result + // Test linting the async-name test project with the default linting functions, which use the background compiler + // This should tokenize "LibAsync.fsproj" tokenizes to ["Lib"; "Async"; ".fsproj"] -> Likely a library. + [] + member _.``Lint async naming test project with background compiler``() = + task { + let! result = asyncLintProject OptionalLintParameters.Default asyncTestProjectFile toolsPath + + match result with + | LintResult.Success warnings -> + Assert.AreEqual(1, warnings.Length) + Assert.AreEqual(FSharpLint.Rules.Identifiers.AsynchronousFunctionNames, warnings.[0].RuleIdentifier) + StringAssert.Contains("This function returns Async. Consider renaming it to AsyncBar.", warnings.[0].Details.Message) + | LintResult.Failure err -> + Assert.Fail(string err) + } + [] member _.``Lint project via absolute path``() = let projectPath = basePath "tests" "FSharpLint.FunctionalTest.TestedProject" "FSharpLint.FunctionalTest.TestedProject.NetCore" diff --git a/tests/FSharpLint.FunctionalTest/TransparentCompiler.fs b/tests/FSharpLint.FunctionalTest/TransparentCompiler.fs new file mode 100644 index 000000000..6075dbd09 --- /dev/null +++ b/tests/FSharpLint.FunctionalTest/TransparentCompiler.fs @@ -0,0 +1,93 @@ +namespace FSharpLint.FunctionalTest + +#nowarn "57" // 'FSharpProjectSnapshot' is considered experimental. Note: Could suppress this more locally if building with the .NET 10 compiler + +module TestApiWithTransparentCompiler = + + open System.IO + open NUnit.Framework + open FSharpLint.Application.Lint + open FSharp.Compiler.CodeAnalysis + open FSharp.Compiler.Text + open FSharpLint.Framework.Utilities + open FSharpLint.Framework + + let testSourceFilePath = Path.GetFullPath(TestApi.asyncTestProjectPath "LibAsyncNames.fs") + + [] + type TestApiWithTransparentCompiler() = + + /// Parse + check `source` under FCS's TransparentCompiler (as an analyzer host would), + /// returning the parse tree together with the file and project check results. + let checkSourceUnderTransparentCompiler sourceFile source = + async { + let checker = FSharpChecker.Create(keepAssemblyContents = true, useTransparentCompiler = true) + let sourceText = SourceTextNew.ofString source + let! (snapShot, _diagnostics) = + checker.GetProjectSnapshotFromScript(sourceFile, sourceText) + let! parseResults, checkAnswer = + checker.ParseAndCheckFileInProject(sourceFile, snapShot) + let checkResults = + match checkAnswer with + | FSharpCheckFileAnswer.Succeeded results -> results + | FSharpCheckFileAnswer.Aborted -> failwith "type check aborted" + let! projectResults = checker.ParseAndCheckProject snapShot + return (parseResults.ParseTree, checkResults, projectResults, snapShot) + } + + // Test linting the async-name test project project machinery using the transparent compiler + // This should tokenize "LibAsync.fsproj" tokenizes to ["Lib"; "Async"; ".fsproj"] -> Likely a library. + [] + member _.``Lint async naming test project with transparent compiler``() = + // A public function returning Async<'T>: AsynchronousFunctionNames (default mode + // OnlyPublicAPIsInLibraries) flags it only when the project looks like a library. + let source = File.ReadAllText(testSourceFilePath) + + task { + + let! parseTree, checkResults, projectResults, snapshot = + checkSourceUnderTransparentCompiler testSourceFilePath source + + let fileInfo = + { Ast = parseTree + Source = File.ReadAllText testSourceFilePath + TypeCheckResults = Some checkResults + ProjectCheckResults = Some projectResults + ProjectOptions = Some (ParseFile.LinterProjectOptions.ProjectSnapshot snapshot) } + + match lintParsedFile OptionalLintParameters.Default fileInfo testSourceFilePath with + | LintResult.Success warnings -> + Assert.AreEqual(1, warnings.Length) + Assert.AreEqual(FSharpLint.Rules.Identifiers.AsynchronousFunctionNames, warnings.[0].RuleIdentifier) + StringAssert.Contains("This function returns Async. Consider renaming it to AsyncBar.", warnings.[0].Details.Message) + | LintResult.Failure failure -> + Assert.Fail (string failure) + + } + + // Doing a lint with no project options should mean that the project name based rules won't fire, *but* we shouldn't get any errors + // as a result of the missing optional properties + [] + member _.``Lint async naming test project with transparent compiler with no project options``() = + // A public function returning Async<'T>: AsynchronousFunctionNames (default mode + // OnlyPublicAPIsInLibraries) flags it only when the project looks like a library. + let source = File.ReadAllText(testSourceFilePath) + + task { + + let! parseTree, checkResults, projectResults, _ = + checkSourceUnderTransparentCompiler testSourceFilePath source + + let fileInfo = + { Ast = parseTree + Source = File.ReadAllText testSourceFilePath + TypeCheckResults = Some checkResults + ProjectCheckResults = Some projectResults + ProjectOptions = None } + + match lintParsedFile OptionalLintParameters.Default fileInfo testSourceFilePath with + | LintResult.Success warnings -> + Assert.IsEmpty warnings + | LintResult.Failure failure -> + Assert.Fail (string failure) + }