Requirements
Note: the requirements are invented by me for practicing functional programming.
Functional Requirements
Given a directory, all files below the directory (and its sub directories) should be filtered by one of the available filter conditions and then printed to the console.
The filter condition can be
a) a filter function based on a FileInfo
object
- If the file passes the filter, the full path should be printed to console
b) a Regex
that tries to match the content of the file.
- Binary files should be always filtered out
- If the file passes the filter, the full path should be printed to the console
- For each matching line, the line number and the full line should be printed to the console
Technical Requirements
- the solution should be as functional as possible
- the solution should work without mutable state
- For performance reasons, the whole program should work lazy (we don't want to create a large list of all files and it's state and finally working on that data structure)
Solution
Usage
[<EntryPoint>]
let main argv =
let path = @"C:\Temp\CommandLineFSharp"
// regex file content fiter
let regex = Regex("Regex", RegexOptions.Compiled ||| RegexOptions.IgnoreCase)
let title = "All files that match the regex 'Regex'"
let allFilesContainingError = FileContentRegexFilter(title, regex)
findIn path allFilesContainingError
// FileInfo filter
let title = "All files whose name start with 'A'"
let allFilesStartingWithA = FileInfoFilter(title, fun fi -> fi.Name.StartsWith("a", StringComparison.InvariantCultureIgnoreCase))
findIn path allFilesStartingWithA
Console.ReadLine() |> ignore
0
Output
/*--------------------------------------------------------------------------------
All files that match the regex 'Regex'
--------------------------------------------------------------------------------
C:\Temp\CommandLineFSharp\FileSystem.fs
[line: 18] | FileContentRegexFilter of String * Regex
[line: 46] let private getMatchingLines (r:Regex) (file:FileInfo) =
[line: 59] | FileContentRegexFilter (title, regex) ->
[line: 60] let matchingLines = file |> (getMatchingLines regex)
[line: 76] | FileInfoFilter(title, _) | FileContentRegexFilter(title, _) ->
C:\Temp\CommandLineFSharp\Program.fs
[line: 12] // regex file content fiter
[line: 13] let regex = Regex("Regex", RegexOptions.Compiled ||| RegexOptions.IgnoreCase)
[line: 14] let title = "All files that match the regex 'Regex'"
[line: 15] let allFilesContainingError = FileContentRegexFilter(title, regex)
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
All files whose name start with 'A'
--------------------------------------------------------------------------------
C:\Temp\CommandLineFSharp\App.config
C:\Temp\CommandLineFSharp\AssemblyInfo.fs
-------------------------------------------------------------------------------- */
Implementation
// Configuration
let private lineLength = 80
let private lineChar = '-'
// Types
type private FileSystemItem =
| File of FileInfo
| Directory of DirectoryInfo * FileSystemItem seq
type FileItemFilter =
| FileInfoFilter of string * (FileInfo -> bool)
| FileContentRegexFilter of String * Regex
// Private Functions
let private isTextFile (file:FileInfo) =
use sr = new StreamReader (file.OpenRead())
let isControlChar ch = Char.IsControl(ch) && ch <> '\r' && ch <> '\n'
let rec processReader (reader:StreamReader) =
match reader with
| r when r.EndOfStream -> true
| r when r.Read() |> char |> isControlChar -> false
| _ -> processReader reader
processReader sr
let private enumerateLines (file:FileInfo) = seq {
use sr = new StreamReader (file.OpenRead())
while not sr.EndOfStream do
yield sr.ReadLine ()
}
let private printMatchingLine m =
printfn " [line: %d] %s" (snd m) (fst m)
let private printFile (file:FileInfo) =
printfn "%s" file.FullName
let private printLine () =
printfn "%s" (String(lineChar, lineLength))
let private getMatchingLines (r:Regex) (file:FileInfo) =
if (file |> isTextFile) then
file
|> enumerateLines
|> Seq.mapi (fun idx line -> (line, idx+1))
|> Seq.filter (fst >> r.IsMatch)
else
Seq.empty
let private processFilter filter file =
match filter with
| FileInfoFilter (title, passFilter) ->
if file |> passFilter then printFile file
| FileContentRegexFilter (title, regex) ->
let matchingLines = file |> (getMatchingLines regex)
if matchingLines |> (not << Seq.isEmpty) then
file |> printFile
matchingLines |> Seq.iter printMatchingLine
let rec private createDirectory (directoryInfo:DirectoryInfo) =
let dir = directoryInfo
let subs = seq {
for d in dir.EnumerateDirectories() |> Seq.map createDirectory do yield d
for f in dir.EnumerateFiles() |> Seq.map File do yield f
}
Directory(dir, subs)
// Public Functions
let findIn directoryPath filter =
match filter with
| FileInfoFilter(title, _) | FileContentRegexFilter(title, _) ->
printLine ()
printfn @" %s" title
printLine ()
let rec findInternal fileSystemItem =
match fileSystemItem with
| File(fi) -> processFilter filter fi
| Directory(di, subs) -> subs |> Seq.iter findInternal
findInternal (directoryPath |> (DirectoryInfo >> createDirectory))
printLine ()
printfn ""
Questions
Of course, every feedback is welcome!
Readability:
I've tried to be as descriptive as possible. However, F# provides lots of ways of doing the same (e.g. (func val
) or (val |> func
)).
Any suggestion for further improvements / are there any fragments that are hard to understand?
Simplicity
Is it possible to simplify some of the code fragments?
Design/Extensibility
That is the point I am most interested in. Actually, the code works fine for the functional requirements mentioned above. But as far as new (even small) requirements come up, it feels that large parts of the program must be rewritten.
For example:
- New Filter Condition
A new filter FileContentRegexStrFilter of String * String
should be added. The filter provides not the Regex
object but a string representing the regex pattern. Internally, a single (compiled) Regex
object should be created and used during the whole execution.
The Problem here is, that it is not possible to create a shared object during pattern matching that can be reused.
- Add Search Summary
At the end of the search, the number of all searched directories, the total number of files as well as the number of files that passed the filter should be displayed.
My brain provides just a mutable counter as pattern for that kind of problem - actually, I have absolutely no idea how to realize that without mutable state....
Is there a way (or something like best practices similar to SOLID for OOP) for designing / organizing functional code to become more extensible?