helpers/cyclesDetector.psm1

using module ..\models\fileInfo.psm1

Class CyclesDetector {

    [boolean]Check([System.Collections.Specialized.OrderedDictionary]$importsMap) {
        foreach ($file in $importsMap.Values) {
            $result = $this.FindCycle($file, @{}, @{}, [System.Collections.Generic.List[string]]::new())
            if (-not $result) { continue }
            Write-Host "Circular import of the file '$($file.path)' found:" -ForegroundColor Red
            $this.ShowCycledValsPretty($result)
            return $true
        }

        return $false
    }


    [System.Collections.Generic.List[string]]FindCycle(
        [FileInfo]$file,
        [hashtable]$visited = @{},
        [hashtable]$stack = @{},
        [System.Collections.Generic.List[string]]$pathList = [System.Collections.Generic.List[string]]::new()
    ) {

        $path = $file.path

        # If the node is already in the current recursion stack, a cycle is found
        if ($stack.ContainsKey($path)) {
            # Find the starting index of the repeated path
            $startIndex = $pathList.IndexOf($path)
            if ($startIndex -ge 0) {
                # Return the sublist representing the cycle (including the repeated node)
                return $pathList[$startIndex..($pathList.Count - 1)]
            }
        }

        # Skip if the node has been fully visited already
        if ($visited.ContainsKey($path)) { return $null }

        # Mark the node as active (in the recursion stack)
        $stack[$path] = $true
        $pathList.Add($path)

        # Recursively check all imported files (dependencies)
        foreach ($importInfo in $file.imports.Values) {
            $importFile = $importInfo.file
            $result = $this.FindCycle( $importFile, $visited, $stack, $pathList)
            if ($result) { return $result }
        }

        # Remove the node from the stack after processing all dependencies
        $stack.Remove($path)
        $visited[$path] = $true
        [void]$pathList.RemoveAt($pathList.Count - 1)

        # No cycle found in this path
        return $null
    }

    [void]ShowCycledValsPretty([array]$Cycles) {
        if (-not $Cycles) { return }

        $maxWidth = 0
        for ($i = 0; $i -lt $Cycles.Count; $i++) {
            $val = $Cycles[$i]
            $gap = " " * $i
            $maxWidth = [Math]::Max($maxWidth, $gap.Length + 1 + $val.Length ) 
        }
    

        for ($i = 0; $i -lt $Cycles.Count ; $i++) {
            $val = $Cycles[$i]
            $gap = " " * $i
            $lines1 = "└─>"
            $gap2Len = $maxWidth - $val.Length - $gap.Length
            $lines2 = " " + "$(" " * $gap2Len)│" 
            if ($i -eq 0) {
                $lines1 = " " 
                $lines2 = "<" + "$("─" * $gap2Len)┐"
            }
            elseif ($i -eq $Cycles.Count - 1) {
                $lines2 = " " + "$("─" * $gap2Len)┘"
            }

            $text = "$gap$lines1 $val $lines2"
            Write-Host $text -ForegroundColor Red
        }
    }
}