inb4 Scott :-)
Use functions more. Composability is not very important in this example, but it's good to get into that habit early on.
In that spirit, turn the non-trivial value bindings into functions instead of closing over a previously defined value. Also try turning the non-trivial lambda expressions into functions; that gives them a name and makes the call site more readable.
Also, pipe more. :-) Especially for the list/sequence functions that take a higher order function, it's more readable to pipe the list into them, because it makes it more obvious what you're starting from.
Also, in my experience, defining "constant" values and functions first before actually doing any work makes the code much easier to follow, because you don't have to think about what values you already "have" at which point.
Oh, and while tail recursion is a very cool thing to have, it's not very useful here and actually makes the code quite a bit less understandable.
Did you put the type annotations in on purpose? In most cases, you don't need them because of F#'s powerful type inference. They can sometimes make code more easy to follow, but they might also constrain it where it could actually be more generic, and in general not having to specify types all the time is one of the really nice things about F#. :-)
Edit: Reading this again made me think of another important point: The individual function declarations don't belong in the main
function; moving them out of there makes the distinction between "parts" and the actual "running" program even clearer.
With these things taken into consideration, your code could look like this:
open System
open System.IO
// It's a tiny bit more maintainable to keep this list separate, and makes it more readable where it is used
let separators = [|' ';'_';';';':';'(';')';'\\';'/';'>';'<';'{';'}';'0';'1';'2';'3';'4';'5';'6';'7';'8';'9';'.'|]
let fileNamePattern = "*Tests.cs"
// Function with the directory and pattern as a parameter
let getFileNames fileNamePattern directory =
Directory.EnumerateFiles(directory, fileNamePattern, SearchOption.AllDirectories)
|> Seq.toList
// Function with the list of filenames as a parameter
let getAllLines fileNames = fileNames |> List.collect (System.IO.File.ReadLines >> Seq.toList)
// Function with the list of lines as a parameter
let getAllWords lines =
lines
|> List.collect (fun (line : string) ->
line.Split(separators, StringSplitOptions.RemoveEmptyEntries)
|> Seq.toList)
// Reordered parameters for pipeability
let countOccurrences allWords word =
// The = operator can be used as a function that takes two arguments.
let count = allWords |> List.filter ((=) word) |> List.length
word, count
// Function that takes a sequence of "anything" and returns a list of the distinct values
let distinctWords = Seq.distinct >> Seq.toList
// F# string formatting is strongly typed and compiler checked; take advantage of that, don't just use %A
let printWordCount (word, count) = printfn "%s: %i" word count
// Now that everything is "set up", we can "run" with the current data:
[<EntryPoint>]
let main args =
// This binding isn't really necessary, but it's nice to give the value a name
let directory = args.[0]
// We now have a clear execution path that is very easy to follow
let allWords =
directory
|> getFileNames fileNamePattern
|> getAllLines
|> getAllWords
let distinct = distinctWords allWords
distinct
|> List.map (countOccurrences allWords)
|> List.iter printWordCount
0