Skip to content

Instantly share code, notes, and snippets.

@kant2002
Created June 6, 2025 13:41
Show Gist options
  • Save kant2002/fb37d810ef529035d3f0068884142a45 to your computer and use it in GitHub Desktop.
Save kant2002/fb37d810ef529035d3f0068884142a45 to your computer and use it in GitHub Desktop.
Property based testing sample of path normalization in F#
module Tests
open System
open Xunit
open FsCheck
open FsCheck.FSharp
open FsCheck.Xunit
open System.IO
open TruePath
[<AutoOpen>]
module PathGenerators =
let LinuxPathItemsGenerator =
let baseDir = Gen.constant ".."
let currentDir = Gen.constant "."
let threeDots = Gen.constant "..."
let dots = Gen.oneof [baseDir; currentDir; threeDots]
let textPath =
Gen.nonEmptyListOf (Gen.oneof [Gen.choose(int 'a', int 'z'); Gen.choose(int 'A', int 'Z'); Gen.choose(int '0', int '9')])
|> Gen.map (fun chars -> chars |> Seq.map char |> String.Concat)
let directorySeparatorChar = Gen.constant (Path.DirectorySeparatorChar.ToString())
let altDirectorySeparatorChar = Gen.constant (Path.AltDirectorySeparatorChar.ToString())
Gen.nonEmptyListOf (Gen.oneof [dots; textPath; directorySeparatorChar; altDirectorySeparatorChar])
|> Gen.filter (fun items ->
items |> Seq.mapi (
fun i item ->
// Avoid consecutive dots like "..../.."
if i < items.Length - 1 && item.StartsWith(".") && items[i + 1].StartsWith(".") then
false
else
true) |> Seq.forall id)
let WindowsPathItemsGenerator =
let driveLetter =
Gen.oneof [Gen.choose(int 'a', int 'z'); Gen.choose(int 'A', int 'Z')]
|> Gen.map (fun c -> $"{char c}:")
let directorySeparatorChar = Gen.constant (Path.DirectorySeparatorChar.ToString())
let altDirectorySeparatorChar = Gen.constant (Path.AltDirectorySeparatorChar.ToString())
let separator = Gen.oneof [directorySeparatorChar; altDirectorySeparatorChar]
let drivePrefix = Gen.zip driveLetter separator |> Gen.map (fun (drive, sep) -> $"{drive}{sep}")
Gen.zip drivePrefix LinuxPathItemsGenerator |> Gen.map (fun (prefix, items) -> prefix :: items)
type AnyOsPath =
static member Paths =
Arb.fromGen(Gen.oneof [LinuxPathItemsGenerator ; WindowsPathItemsGenerator])
let collapseSameBlocks (pathParts: string seq) =
let result = System.Collections.Generic.List<string>()
let mutable lastSeparator = false
let hasHomeDrive = pathParts |> Seq.exists (fun part -> part.Length > 1 && part[1] = ':')
let homeDrive = pathParts |> Seq.tryHead
for item in pathParts do
if item.Length > 1 && item[1] = ':' then
// Skip current directory references
()
else
let currentSeparator = item = Path.DirectorySeparatorChar.ToString() || item = Path.AltDirectorySeparatorChar.ToString()
if lastSeparator && currentSeparator then
()
elif lastSeparator || currentSeparator || result.Count = 0 then
result.Add(item)
lastSeparator <- currentSeparator
else
result[result.Count - 1] <- result[result.Count - 1] + item
lastSeparator <- currentSeparator
if (hasHomeDrive && homeDrive.IsSome) then
result.Insert(0, homeDrive.Value)
result
let countDepth pathParts =
let mutable depth = 0
for part in pathParts do
if part = Path.DirectorySeparatorChar.ToString() || part = Path.AltDirectorySeparatorChar.ToString() || part.Contains(':') then
()
elif part = ".." then
if depth > 0 then
depth <- depth - 1
()
elif part <> "." then
depth <- depth + 1
depth
[<Property(Arbitrary = [| typeof<AnyOsPath> |])>]
let ``Normalized path does not contain AltDirSeparator`` (pathParts: string list) =
let sourcePath = String.Concat(pathParts)
// Act
let normalizedPath = PathStrings.Normalize(sourcePath)
// Assert
Assert.False(normalizedPath.Contains(Path.AltDirectorySeparatorChar))
[<Property(Arbitrary = [| typeof<AnyOsPath> |])>]
let ``Normalized path does not contain two DirSeparator`` (pathParts: string list) =
let sourcePath = String.Concat(pathParts)
// Act
let normalizedPath = PathStrings.Normalize(sourcePath)
// Assert
Assert.False(normalizedPath.Contains(Path.DirectorySeparatorChar.ToString() + Path.DirectorySeparatorChar.ToString()))
[<Property(Arbitrary = [| typeof<AnyOsPath> |])>]
let ``Normalized path does not end with DirSeparator`` (pathParts: string list) =
let sourcePath = String.Concat(pathParts)
// Act
let normalizedPath = PathStrings.Normalize(sourcePath)
// Assert
Assert.True (normalizedPath = ""
|| normalizedPath = Path.DirectorySeparatorChar.ToString()
|| (normalizedPath.Length = 3 && normalizedPath[1] = ':')
|| normalizedPath[normalizedPath.Length-1] <> Path.DirectorySeparatorChar)
[<Property(Arbitrary = [| typeof<AnyOsPath> |])>]
let ``Depth preserved`` (pathParts: string list) =
let sourcePath = String.Concat(pathParts)
// Act
let normalizedPath = PathStrings.Normalize(sourcePath)
// Assert
let collapsedBlock = collapseSameBlocks pathParts
let expectedDepth = countDepth collapsedBlock
let actualDepth = countDepth (normalizedPath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries))
Assert.Equal(expectedDepth, actualDepth)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment