PSDirectoryTree.psm1

# Licensed under the MIT License. See LICENSE.txt for details.
function EscapeExceptCharacterClasses([string]$pattern) {
    $result = ""
    $inCharClass = $false

    foreach ($c in $pattern.ToCharArray()) {
        if ($c -eq '[') {
            $inCharClass = $true
            $result += '['
        }
        elseif ($c -eq ']') {
            $inCharClass = $false
            $result += ']'
        }
        elseif ($inCharClass) {
            $result += $c
        }
        else {
            switch ($c) {
                '*'  { $result += '\*' }
                '?'  { $result += '\?' }
                '.'  { $result += '\.' }
                '/'  { $result += '/' }
                '\'  { $result += '\\' }
                default { $result += [Regex]::Escape($c) }
            }
        }
    }

    return $result
}


function Normalize-PathUnixStyle {
    param (
        [string]$Path
    )
    if ($null -eq $script:UnixPathMap) {
        $script:UnixPathMap = @{}
    }
    if ($script:UnixPathMap.ContainsKey($Path)) {
        return $script:UnixPathMap[$Path]
    }
    $UnixPath = $Path -replace "\\", "/"

    if ($IsLinux -or $IsMacOS) {
        if (-not $UnixPath.StartsWith('/')) {
            $UnixPath = '/' + $UnixPath.TrimStart('/')
        }
    }

    if (-not $script:UnixPathMap.ContainsKey($Path)) {
        $script:UnixPathMap[$Path] = $UnixPath
    }

    return $UnixPath
}

function Convert-GitIgnorePatternToRegex {
    param (
        [string]$Pattern
    )
    if ($null -eq $script:RegexMap) {
        $script:RegexMap = @{}
    }
    if ($script:RegexMap.ContainsKey($Pattern)) {
        Write-Verbose "Using cached regex for pattern '$Pattern' = $($script:RegexMap[$Pattern].RegexString)"
        return $script:RegexMap[$Pattern]
    }

    $pattern = $Pattern.Trim()
    $isDirPattern = $pattern.EndsWith("/")

    $pattern = $pattern.TrimEnd('/')
    $isNegation = $pattern.StartsWith("!")
    if ($isNegation) {
        $pattern = $pattern.Substring(1)
    }

    $escaped = EscapeExceptCharacterClasses $pattern
    $escaped = $escaped -replace "\\\*\\\*\/", "(.*\/)?"
    $escaped = $escaped -replace "\\\*", "[^\/]*"
    $escaped = $escaped -replace "\\\?", "."
    $escaped = $escaped -replace "\\\.", "\."

    if ($pattern.StartsWith("/")) {
        $escaped = "\" + $escaped
    } else {
        $escaped = ".*" + $escaped
    }

    $regex = "$escaped$"
    if ($isDirPattern) {
        $regex = "$escaped(\/.*)?$"
    }

    $script:RegexMap[$Pattern] = [PSCustomObject]@{
        IsNegation  = $isNegation
        RegexString = $regex
    }

    return $script:RegexMap[$Pattern]
}

function Should-SkipPath {
    param (
        [string]$Path,
        [hashtable]$IgnoreMap
    )

    $pathUnix = Normalize-PathUnixStyle $Path

    $allRules = @()
    $allKeys = @()
    foreach ($key in $IgnoreMap.Keys) {
        $keyUnix = Normalize-PathUnixStyle $key
        if ($pathUnix.StartsWith($keyUnix)) {
            foreach ($rule in $IgnoreMap[$key]) {
                $allRules += $rule
                $allKeys += $keyUnix.Replace("/", "\/")
            }
        }
    }

    $isIgnored = $false

    for ($i = 0; $i -lt $allRules.Count; $i++) {
        $pattern = $allRules[$i]
        $patternInfo = Convert-GitIgnorePatternToRegex $pattern
        $gitIgnorePath = $allKeys[$i]
        $regex = $gitIgnorePath + $patternInfo.RegexString

        if ($pathUnix -match $regex) {
            if ($patternInfo.IsNegation) {
                Write-Verbose "UNIGNORED '$pathUnix' by rule '$pattern' in '$gitIgnorePath'"
                $isIgnored = $false
            } else {
                Write-Verbose "IGNORED '$pathUnix' by rule '$pattern' in '$gitIgnorePath'"
                $isIgnored = $true
            }
        }
    }

    return $isIgnored
}

function Get-Structure {
    param(
        [string]$Path,
        [string]$Indent = "",
        [bool]$IsLast = $true,
        [int]$CurrentDepth = 0,
        [int]$MaxDepth,
        [string]$Filter,
        [switch]$AsObject,
        [switch]$IgnoreGitIgnore,
        [hashtable]$IgnoreMap = @{}
    )

    $Item = Get-Item -LiteralPath $Path -Force:$IsLinux 
    Write-Verbose "Visiting: $($Item.FullName) (Depth=$CurrentDepth)"

    if ($item.PSIsContainer) {
        $script:progressDirectories++
        Write-Progress -Activity "Scanning directories..." -Status "$($script:progressDirectories.ToString().PadLeft(5, '0')) $($Item.FullName)"
    } else {
        $script:progressFiles++
    }

    $currentIgnoreRules = @()
    $gitignore = Join-Path $Path '.gitignore'
    if (-not $IgnoreGitIgnore -and (Test-Path $gitignore -PathType Leaf)) {
        $currentIgnoreRules = Get-Content $gitignore | Where-Object {
            $_ -and -not $_.StartsWith("#")
        } | ForEach-Object {
            $_.Trim().TrimEnd('/','\')
        }
        $normalizedPath = Normalize-PathUnixStyle $Path
        if (-not $IgnoreMap.ContainsKey($normalizedPath)) {
            $IgnoreMap[$normalizedPath] = @('.git') + $currentIgnoreRules
            Write-Verbose "Loaded .gitignore from '$gitignore' with $($currentIgnoreRules.Count) rule(s)"
            foreach ($rule in $currentIgnoreRules) {
                Write-Verbose " Rule: '$rule' (attached to '$normalizedPath')"
            }
        }
    }

    if ($AsObject) {
        $node = [PSCustomObject]@{
            Name     = $Item.Name
            Path     = $Item.FullName
            Type     = if ($Item.PSIsContainer) { "Folder" } else { "File" }
            Children = @()
        }
    } else {
        $connector = if ($CurrentDepth -eq 0 -and $Item.PSIsContainer) { "" } elseif ($IsLast) { "└──" } else { "├──" }
        $displayIndent = $Indent + $connector

        $lines = @(
            [PSCustomObject]@{
                Line  = "$displayIndent "
                IsDir = $Item.PSIsContainer
                Name  = $Item.Name
                Path  = $Item.FullName
            }
        )

        if ($CurrentDepth -eq 0 -and $Item.PSIsContainer) {
            $Indent = " "
        } elseif ($IsLast) {
            $Indent += " "
        } else {
            $Indent += "│ "
        }
    }

    if ($Item.PSIsContainer -and $CurrentDepth -lt $MaxDepth -and -not ($Item.Attributes -match "ReparsePoint")) {
        $Children = @()
        if ($IgnoreGitIgnore) {
            $Children = @(Get-ChildItem -LiteralPath $Path -Force:$IsLinux -ErrorAction SilentlyContinue | Sort-Object -Property PSIsContainer, Name)
        } else {
            $Children = @(Get-ChildItem -LiteralPath $Path -Force:$IsLinux -ErrorAction SilentlyContinue | ForEach-Object {
                if (-not (Should-SkipPath -Path $_.FullName -IgnoreMap $IgnoreMap)) {
                    Write-Verbose "Included: $($_.FullName)"
                    $_
                } else {
                    Write-Verbose "Skipped by .gitignore: $($_.FullName)"
                }
            } | Sort-Object -Property PSIsContainer, Name)
        }
        $Folders = @($Children | Where-Object { $_.PSIsContainer })
        $Files   = @($Children | Where-Object { -not $_.PSIsContainer })

        switch ($Filter) {
            "All"     { $OrderedChildren = $Folders + $Files }
            "Folders" { $OrderedChildren = $Folders }
            "Files"   { $OrderedChildren = $Files }
        }

        for ($i = 0; $i -lt $OrderedChildren.Count; $i++) {
            $child = $OrderedChildren[$i]

            $isLastChild = ($i -eq $OrderedChildren.Count - 1)
            $nextDepth = if ($child.PSIsContainer) { $CurrentDepth + 1 } else { $CurrentDepth }

            $result = Get-Structure -Path $child.FullName -Indent $Indent -IsLast $isLastChild -CurrentDepth $nextDepth -MaxDepth $MaxDepth -Filter $Filter -IgnoreGitIgnore:$IgnoreGitIgnore -AsObject:$AsObject -IgnoreMap $IgnoreMap

            if ($AsObject) {
                $node.Children += $result
            } else {
                $lines += $result
            }
        }
    }

    if ($AsObject) {
        return $node
    } else {
        return $lines
    }
}

function Get-TreeOutput {
    param(
        [PSCustomObject[]]$tree,
        [switch]$AsList
        )

    $list = @()
    foreach ($entry in $tree) {
        if ($AsList) {
            $list += "$($entry.Line)$($entry.Name)";
        } else {
            $color = if ($entry.IsDir) {
                'Cyan'
            } else {
                switch -Wildcard ("$($entry.Name)") {
                    "*.html" { 'Magenta'; break; }
                    "*.ts"     { 'Blue'; break; }
                    "*.json"   { 'DarkYellow'; break; }
                    "*.js"   { 'Yellow'; break; }
                    "*.*css"   { 'Green'; break; }
                    default    { 'Gray' }
                }
            }

            Write-Host $entry.Line -NoNewline
            Write-Host $entry.Name -ForegroundColor $color
        }
    }

    if ($AsList) {
        return $list
    }
}

function Get-DirectoryTree {
   <#
    .SYNOPSIS
        ┌─────── PSDirectoryTree ───────┐
        │🧾 Directory visualizing tool📁│
        └───────────────────────────────┘
        Visualizes a directory's folder and file structure in a tree-like format.

    .DESCRIPTION
        This tool recursively displays a tree of a given root directory.
        It supports filtering to show only files, only folders, or both.

        It reads and applies .gitignore rules (per folder) if present, using glob-like matching logic
        to skip ignored paths.

        The tool provides:
        - Terminal output with Unicode tree characters and color-coded file types
        - JSON serialization of the directory structure
        - Optional output to a file in either text or JSON format
        - Depth limiting and optional ignore skipping

    .PARAMETER RootPath
        The root folder to scan. Accepts relative or absolute paths.

    .PARAMETER MaxDepth
        Controls how deep to recurse. Default is 100.

    .PARAMETER Filter
        Chooses which items to show:
        - All : both files and folders (default)
        - Folders : folders only
        - Files : files only

    .PARAMETER IgnoreGitIgnore
        If set, skips loading .gitignore patterns. If not set, rules are loaded and applied per directory.

    .PARAMETER AsJson
        If set, outputs the result as a JSON object instead of a tree.

    .PARAMETER OutputFile
        If specified, writes the output (tree or JSON) to this file.

    .EXAMPLE
        Get-DirectoryTree -RootPath "." -MaxDepth 2 -Filter Folders
        # Shows a 2-level folder structure from current directory

    .EXAMPLE
        Get-DirectoryTree -RootPath "./src" -IgnoreGitIgnore -AsJson
        # Outputs structure of ./src as JSON, skipping .gitignore processing

    .EXAMPLE
        Get-DirectoryTree -RootPath "." -AsJson -OutputFile "structure.json"
        # Writes full directory structure to structure.json in JSON format

    .EXAMPLE
        Get-DirectoryTree -RootPath "." -OutputFile "tree.txt"
        # Writes plain text tree structure to tree.txt
    #>

    param (
        [Parameter(Mandatory = $true)]
        [string]$RootPath,
        [int]$MaxDepth = 100,
        [ValidateSet("All", "Folders", "Files")]
        [string]$Filter = "All",
        [switch]$IgnoreGitIgnore,
        [switch]$AsJson,
        [string]$OutputFile
    )

    if (-not [System.IO.Path]::IsPathRooted($RootPath)) {
        $ResolvedPath = Normalize-PathUnixStyle (Resolve-Path -Path $RootPath -ErrorAction Stop).path
    } else {
        $ResolvedPath = Normalize-PathUnixStyle $RootPath
    }
    $ResolvedPath = $ResolvedPath.Trim().TrimEnd("/", "\")

    if (!(Test-Path -LiteralPath $ResolvedPath)) {
        throw "Path does not exist: $ResolvedPath"
    }

    if ($OutputFile) {
        $outputDir = Split-Path -Path $OutputFile -Parent
        if (-not $outputDir) {
            $outputDir = Get-Location
        }

        if (-not (Test-Path -LiteralPath $outputDir)) {
            throw "The directory for the output file does not exist: $outputDir"
        }

        if (Test-Path -LiteralPath $OutputFile -PathType Container) {
            throw "The specified output file path is a directory: $OutputFile"
        }
    }

    $script:progressDirectories = 0
    $script:progressFiles = 0
    $script:UnixPathMap = @{}
    $script:RegexMap = @{}

    if ($IgnoreGitIgnore) {
        Write-Host "Skipping .gitignore, traversing all folders" -ForegroundColor DarkYellow
    }
    $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()

    if ($AsJson) {
        $tree = Get-Structure -Path $ResolvedPath -MaxDepth $MaxDepth -Filter $Filter -IgnoreGitIgnore:$IgnoreGitIgnore -AsObject
        $json = $tree | ConvertTo-Json -Depth 100

        if ($OutputFile) {
            $json | Set-Content -Path $OutputFile -Encoding UTF8
            Write-Host "JSON output written to: $OutputFile" -ForegroundColor Green
        } else {
            $json
        }

    } elseif ($OutputFile) {
        $tree = Get-Structure -Path $ResolvedPath -MaxDepth $MaxDepth -Filter $Filter -IgnoreGitIgnore:$IgnoreGitIgnore
        (Get-TreeOutput $tree -AsList) | Set-Content -Path $OutputFile -Encoding UTF8
        Write-Host "Tree output written to: $OutputFile" -ForegroundColor Green
    } else {
        $tree = Get-Structure -Path $ResolvedPath -MaxDepth $MaxDepth -Filter $Filter -IgnoreGitIgnore:$IgnoreGitIgnore
        Get-TreeOutput $tree
    }

    Write-Verbose ("Scanned $script:progressDirectories directories and $script:progressFiles files in {0:N2} seconds" -f $stopwatch.Elapsed.TotalSeconds)
}