diff --git a/src/FSharpLint.Console/Program.fs b/src/FSharpLint.Console/Program.fs index 0049ff76a..c2ae8d233 100644 --- a/src/FSharpLint.Console/Program.fs +++ b/src/FSharpLint.Console/Program.fs @@ -1,4 +1,4 @@ -module FSharpLint.Console.Program +module FSharpLint.Console.Program open Argu open System @@ -18,6 +18,7 @@ type internal FileType = | Solution = 2 | File = 3 | Source = 4 + | Wildcard = 5 // Allowing underscores in union case names for proper Argu command line option formatting. // fsharplint:disable UnionCasesNames @@ -49,6 +50,42 @@ with | Lint_Config _ -> "Path to the config for the lint." // fsharplint:enable UnionCasesNames +/// Expands a wildcard pattern to a list of matching files. +/// Supports recursive search using ** (e.g., "**/*.fs" or "src/**/*.fs") +let internal expandWildcard (pattern:string) = + let isFSharpFile (filePath:string) = + filePath.EndsWith ".fs" || filePath.EndsWith ".fsx" + + let normalizedPattern = pattern.Replace('\\', '/') + + let directory, searchPattern, searchOption = + match normalizedPattern.IndexOf "**/" with + | -1 -> + // Non-recursive pattern + match normalizedPattern.LastIndexOf '/' with + | -1 -> (".", normalizedPattern, SearchOption.TopDirectoryOnly) + | lastSeparator -> + let dir = normalizedPattern.Substring(0, lastSeparator) + let pat = normalizedPattern.Substring(lastSeparator + 1) + ((if String.IsNullOrEmpty dir then "." else dir), pat, SearchOption.TopDirectoryOnly) + | 0 -> + // Pattern starts with **/ + let pat = normalizedPattern.Substring 3 + (".", pat, SearchOption.AllDirectories) + | doubleStarIndex -> + // Pattern has **/ in the middle + let dir = normalizedPattern.Substring(0, doubleStarIndex).TrimEnd '/' + let pat = normalizedPattern.Substring(doubleStarIndex + 3) + (dir, pat, SearchOption.AllDirectories) + + let fullDirectory = Path.GetFullPath directory + if Directory.Exists fullDirectory then + Directory.GetFiles(fullDirectory, searchPattern, searchOption) + |> Array.filter isFSharpFile + |> Array.toList + else + List.empty + let private parserProgress (output:Output.IOutput) = function | Starting file -> String.Format(Resources.GetString("ConsoleStartingFile"), file) |> output.WriteInfo @@ -59,9 +96,15 @@ let private parserProgress (output:Output.IOutput) = function output.WriteError $"Exception Message:{Environment.NewLine}{parseException.Message}{Environment.NewLine}Exception Stack Trace:{Environment.NewLine}{parseException.StackTrace}{Environment.NewLine}" +/// Checks if a string contains wildcard characters. +let internal containsWildcard (target:string) = + target.Contains("*") || target.Contains("?") + /// Infers the file type of the target based on its file extension. let internal inferFileType (target:string) = - if target.EndsWith ".fs" || target.EndsWith ".fsx" then + if containsWildcard target then + FileType.Wildcard + else if target.EndsWith ".fs" || target.EndsWith ".fsx" then FileType.File else if target.EndsWith ".fsproj" then FileType.Project @@ -125,6 +168,15 @@ let private start (arguments:ParseResults) (toolsPath:Ionide.ProjInfo. | FileType.File -> Lint.lintFile lintParams target | FileType.Source -> Lint.lintSource lintParams target | FileType.Solution -> Lint.lintSolution lintParams target toolsPath + | FileType.Wildcard -> + output.WriteInfo $"Wildcard detected, but not recommended. Using a project (slnx/sln/fsproj) can detect more issues." + let files = expandWildcard target + if List.isEmpty files then + output.WriteInfo $"No files matching pattern '%s{target}' were found." + LintResult.Success List.empty + else + output.WriteInfo $"Found %d{List.length files} file(s) matching pattern '%s{target}'." + Lint.lintFiles lintParams files | FileType.Project | _ -> Lint.lintProject lintParams target toolsPath handleLintResult lintResult diff --git a/src/FSharpLint.Core/Application/Lint.fs b/src/FSharpLint.Core/Application/Lint.fs index bf9a2a986..c63694a91 100644 --- a/src/FSharpLint.Core/Application/Lint.fs +++ b/src/FSharpLint.Core/Application/Lint.fs @@ -1,4 +1,4 @@ -namespace FSharpLint.Application +namespace FSharpLint.Application open System open System.Collections.Concurrent @@ -643,3 +643,36 @@ module Lint = else FailedToLoadFile filePath |> LintResult.Failure + + /// Lints multiple F# files from given file paths. + let lintFiles optionalParams filePaths = + let checker = FSharpChecker.Create(keepAssemblyContents=true) + + match getConfig optionalParams.Configuration with + | Ok config -> + let optionalParams = { optionalParams with Configuration = ConfigurationParam.Configuration config } + + let lintSingleFile filePath = + if IO.File.Exists filePath then + match ParseFile.parseFile filePath checker None with + | ParseFile.Success astFileParseInfo -> + let parsedFileInfo = + { Source = astFileParseInfo.Text + Ast = astFileParseInfo.Ast + TypeCheckResults = astFileParseInfo.TypeCheckResults } + lintParsedFile optionalParams parsedFileInfo filePath + | ParseFile.Failed failure -> + LintResult.Failure (FailedToParseFile failure) + else + LintResult.Failure (FailedToLoadFile filePath) + + let results = filePaths |> Seq.map lintSingleFile |> Seq.toList + + let failures = results |> List.choose (function | LintResult.Failure failure -> Some failure | _ -> None) + let warnings = results |> List.collect (function | LintResult.Success warning -> warning | _ -> List.empty) + + match failures with + | firstFailure :: _ -> LintResult.Failure firstFailure + | [] -> LintResult.Success warnings + | Error err -> + LintResult.Failure (RunTimeConfigError err) diff --git a/src/FSharpLint.Core/Application/Lint.fsi b/src/FSharpLint.Core/Application/Lint.fsi index 7e697df5a..6d7eb05ad 100644 --- a/src/FSharpLint.Core/Application/Lint.fsi +++ b/src/FSharpLint.Core/Application/Lint.fsi @@ -1,4 +1,4 @@ -namespace FSharpLint.Application +namespace FSharpLint.Application /// Provides an API to manage/load FSharpLint configuration files. /// for more information on @@ -163,6 +163,9 @@ module Lint = /// Lints an F# file from a given path to the `.fs` file. val lintFile : optionalParams:OptionalLintParameters -> filePath:string -> LintResult + /// Lints multiple F# files from given file paths. + val lintFiles : optionalParams:OptionalLintParameters -> filePaths:string seq -> LintResult + /// Lints an F# file that has already been parsed using /// `FSharp.Compiler.Services` in the calling application. val lintParsedFile : optionalParams:OptionalLintParameters -> parsedFileInfo:ParsedFileInformation -> filePath:string -> LintResult \ No newline at end of file diff --git a/tests/FSharpLint.Console.Tests/TestApp.fs b/tests/FSharpLint.Console.Tests/TestApp.fs index e200f9c2b..ec55109c1 100644 --- a/tests/FSharpLint.Console.Tests/TestApp.fs +++ b/tests/FSharpLint.Console.Tests/TestApp.fs @@ -135,7 +135,45 @@ type TestFileTypeInference() = [] [] [] + [] + [] + [] + [] member _.``File type inference test cases``(filename: string, expectedType: int) = let result = FSharpLint.Console.Program.inferFileType filename let expectedType = enum(expectedType) Assert.AreEqual(expectedType, result) + +[] +type TestWildcardExpansion() = + + [] + member _.``expandWildcard finds .fs files in current directory``() = + use file1 = new TemporaryFile("module Test1", "fs") + use file2 = new TemporaryFile("module Test2", "fs") + let dir = Path.GetDirectoryName(file1.FileName) + let pattern = Path.Combine(dir, "*.fs") + + let results = expandWildcard pattern + + Assert.That(results, Is.Not.Empty) + Assert.That(results, Does.Contain(file1.FileName)) + Assert.That(results, Does.Contain(file2.FileName)) + + [] + member _.``expandWildcard finds .fsx files``() = + use file1 = new TemporaryFile("printfn \"test\"", "fsx") + let dir = Path.GetDirectoryName(file1.FileName) + let pattern = Path.Combine(dir, "*.fsx") + + let results = expandWildcard pattern + + Assert.That(results, Does.Contain(file1.FileName)) + + [] + member _.``expandWildcard returns empty list for non-existent directory``() = + let pattern = "nonexistent_directory/*.fs" + + let results = expandWildcard pattern + + Assert.That(results, Is.Empty)