Utils/ls.ps1

Register-GnuUtil @{
    Name          = 'ls'
    Category      = 'File and directory operations'
    Synopsis      = 'List directory contents.'
    Usage         = @('ls [OPTION]... [FILE]...')
    Description   = @(
        'List information about the FILEs (the current directory by default).',
        'Sort entries alphabetically if none of -tSU is specified.'
    )
    FileOperands  = $true
    UsageExitCode = 2
    Options       = @(
        @{ Short = 'a'; Long = 'all'; Key = 'show'; Value = 'all'; Help = 'do not ignore entries starting with .' }
        @{ Short = 'A'; Long = 'almost-all'; Key = 'show'; Value = 'almost'; Help = 'do not list implied . and ..' }
        @{ Long = 'color'; Arg = 'WHEN'; OptionalArg = $true; Help = 'color the output WHEN; more info below' }
        @{ Short = 'C'; Key = 'format'; Value = 'columns'; Help = 'list entries by columns' }
        @{ Short = 'd'; Long = 'directory'; Help = 'list directories themselves, not their contents' }
        @{ Short = 'F'; Long = 'classify'; Key = 'indicator'; Value = 'classify'; Help = 'append indicator (one of */=>@|) to entries' }
        @{ Short = 'h'; Long = 'human-readable'; Help = 'with -l, print sizes like 1K 234M 2G etc.' }
        @{ Short = 'l'; Key = 'format'; Value = 'long'; Help = 'use a long listing format' }
        @{ Short = 'p'; Key = 'indicator'; Value = 'slash'; Help = 'append / indicator to directories' }
        @{ Short = 'r'; Long = 'reverse'; Help = 'reverse order while sorting' }
        @{ Short = 'R'; Long = 'recursive'; Help = 'list subdirectories recursively' }
        @{ Short = 'S'; Key = 'sort'; Value = 'size'; Help = 'sort by file size, largest first' }
        @{ Short = 't'; Key = 'sort'; Value = 'time'; Help = 'sort by time, newest first' }
        @{ Short = 'U'; Key = 'sort'; Value = 'none'; Help = 'do not sort; list entries in directory order' }
        @{ Short = '1'; Key = 'format'; Value = 'single'; Help = 'list one file per line' }
        @{ Long = 'width'; Short = 'w'; Arg = 'COLS'; Help = 'set output width to COLS. 0 means no limit' }
    )
    Notes         = @(
        'The WHEN argument defaults to ''always'' and can also be ''auto'' or ''never''.',
        'Using color to distinguish file types is disabled both by default and with',
        '--color=never. With --color=auto, ls emits color codes only when standard',
        'output is connected to a terminal. The LS_COLORS environment variable can',
        'change the settings (di, ln, ex, fi and *.ext entries are used).',
        '',
        'Exit status:',
        ' 0 if OK,',
        ' 1 if minor problems (e.g., cannot access subdirectory),',
        ' 2 if serious trouble (e.g., cannot access command-line argument).',
        '',
        'PowerShell notes: columns are used when ls is the last command of the pipeline and the output',
        'is not redirected; otherwise one name per line. Names are not quoted. Entries with the Windows',
        'Hidden attribute are hidden like dot files. Names sort case-insensitively, then ordinally.',
        'In -l output, link counts are 1, directory sizes 0, and "total" counts 1K blocks of the file sizes.'
    )
}



function script:Get-GnuLsColorTable {
    <#
    .SYNOPSIS
    Color codes by type (di, ln, ex, fi) and by extension, from LS_COLORS on top of GNU's defaults.
    #>

    $table = @{ di = '01;34'; ln = '01;36'; ex = '01;32'; fi = '' }
    foreach ($entry in ([string]$env:LS_COLORS).Split(':')) {
        if ($entry -match '^(?<name>[^=]+)=(?<code>[0-9;]*)$') { $table[$Matches['name']] = $Matches['code'] }
    }
    $table
}

function script:Test-GnuLsExecutable {
    <#
    .SYNOPSIS
    $true for files ls marks as executable: Unix execute bits, or a PATHEXT extension on Windows.
    #>

    param([Parameter(Mandatory)][System.IO.FileSystemInfo]$Item)
    if ($Item -isnot [System.IO.FileInfo]) { return $false }
    if (Test-GnuWindows) {
        $extensions = ([string]$env:PATHEXT).Split(';')
        if ([string]::IsNullOrEmpty([string]$env:PATHEXT)) { $extensions = @('.COM', '.EXE', '.BAT', '.CMD') }
        return $extensions -contains $Item.Extension
    }
    $mode = Get-GnuUnixFileMode -Path $Item.FullName
    $null -ne $mode -and ($mode -band 73) -ne 0
}

function script:Format-GnuLsName {
    <#
    .SYNOPSIS
    The displayed name of an entry: colored and with type indicator as requested.
    #>

    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][System.IO.FileSystemInfo]$Item,
        [Parameter(Mandatory)][hashtable]$Settings
    )

    $isLink = $null -ne (Get-GnuLinkTarget -Path $Item.FullName)
    $isDirectory = $Item -is [System.IO.DirectoryInfo]
    $suffix = ''
    if ($Settings.Indicator -eq 'classify') {
        if ($isLink) { $suffix = '@' }
        elseif ($isDirectory) { $suffix = '/' }
        elseif (Test-GnuLsExecutable -Item $Item) { $suffix = '*' }
    }
    elseif ($Settings.Indicator -eq 'slash' -and $isDirectory -and -not $isLink) { $suffix = '/' }

    $text = $Name
    if ($Settings.Color) {
        $code = $Settings.Colors['fi']
        if ($isLink) { $code = $Settings.Colors['ln'] }
        elseif ($isDirectory) { $code = $Settings.Colors['di'] }
        elseif (Test-GnuLsExecutable -Item $Item) { $code = $Settings.Colors['ex'] }
        elseif ($Settings.Colors.ContainsKey('*' + $Item.Extension)) { $code = $Settings.Colors['*' + $Item.Extension] }
        if ($code) { $text = [char]27 + "[$($code)m" + $Name + [char]27 + '[0m' }
    }
    $text + $suffix
}

function script:Format-GnuLsSize {
    <#
    .SYNOPSIS
    A size for -l, in bytes or (with -h) like GNU: 1.5K, 10K, 3.2M (always rounded up).
    #>

    param([long]$Size, [bool]$Human)

    if (-not $Human -or $Size -lt 1024) { return [string]$Size }
    $units = 'KMGTPEZY'
    $value = [double]$Size
    $unit = -1
    while ($value -ge 1024 -and $unit -lt $units.Length - 1) {
        $value = $value / 1024
        $unit++
    }
    $invariant = [System.Globalization.CultureInfo]::InvariantCulture
    if ($value -lt 10) {
        $rounded = [Math]::Ceiling($value * 10) / 10
        if ($rounded -lt 10) { return $rounded.ToString('0.0', $invariant) + $units[$unit] }
        $value = $rounded
    }
    $rounded = [Math]::Ceiling($value)
    if ($rounded -ge 1024 -and $unit -lt $units.Length - 1) { return '1.0' + $units[$unit + 1] }
    $rounded.ToString('0', $invariant) + $units[$unit]
}

function script:Get-GnuLsMode {
    <#
    .SYNOPSIS
    The permission string of -l: Unix modes where available, otherwise derived from Windows attributes.
    #>

    param([Parameter(Mandatory)][System.IO.FileSystemInfo]$Item)

    $type = '-'
    if ($null -ne (Get-GnuLinkTarget -Path $Item.FullName)) { $type = 'l' }
    elseif ($Item -is [System.IO.DirectoryInfo]) { $type = 'd' }

    $mode = Get-GnuUnixFileMode -Path $Item.FullName
    if ($null -ne $mode) {
        $bits = ''
        foreach ($shift in @(6, 3, 0)) {
            $part = ($mode -shr $shift) -band 7
            $bits += $(if ($part -band 4) { 'r' } else { '-' }) + $(if ($part -band 2) { 'w' } else { '-' }) + $(if ($part -band 1) { 'x' } else { '-' })
        }
        return $type + $bits
    }
    $write = 'w'
    if ($Item.Attributes -band [System.IO.FileAttributes]::ReadOnly) { $write = '-' }
    $execute = '-'
    if ($type -ne '-' -or (Test-GnuLsExecutable -Item $Item)) { $execute = 'x' }
    $type + "r$write$execute" + "r-$execute" + "r-$execute"
}

function script:Get-GnuLsOwner {
    <#
    .SYNOPSIS
    Owner and group names for -l (Unix: from PowerShell's User/Group properties; Windows: from the ACL).
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCompatibleCmdlets', '', Justification = 'Get-Acl is only called on Windows.')]
    param([Parameter(Mandatory)][System.IO.FileSystemInfo]$Item)

    $owner = $null
    $group = $null
    if ($Item.PSObject.Properties['User'] -and $Item.PSObject.Properties['Group']) {
        $owner = [string]$Item.User
        $group = [string]$Item.Group
    }
    elseif (Test-GnuWindows) {
        $acl = Get-Acl -LiteralPath $Item.FullName -ErrorAction SilentlyContinue
        if ($null -ne $acl) {
            $owner = [string]$acl.Owner
            $group = [string]$acl.Group
        }
    }
    if ([string]::IsNullOrEmpty($owner)) { $owner = '-' }
    if ([string]::IsNullOrEmpty($group)) { $group = '-' }
    @{ Owner = ($owner -replace '^.*\\', ''); Group = ($group -replace '^.*\\', '') }
}

function script:Format-GnuLsTime {
    <#
    .SYNOPSIS
    GNU's -l time: 'Nov 7 20:02' within six months, 'Nov 7 2025' otherwise.
    #>

    param([Parameter(Mandatory)][datetime]$Time, [Parameter(Mandatory)][datetime]$Now)

    $invariant = [System.Globalization.CultureInfo]::InvariantCulture
    $text = $Time.ToString('MMM', $invariant) + ' ' + ([string]$Time.Day).PadLeft(2) + ' '
    if ($Time -gt $Now.AddDays(-182) -and $Time -le $Now.AddMinutes(1)) { return $text + $Time.ToString('HH:mm', $invariant) }
    $text + ([string]$Time.Year).PadLeft(5)
}

function script:Get-GnuLsEntry {
    <#
    .SYNOPSIS
    The sorted, filtered entries of a directory as @{ Name; Item }.
    #>

    param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][hashtable]$Settings)

    $entries = New-Object 'System.Collections.Generic.List[object]'
    if ($Settings.Show -eq 'all') {
        $entries.Add(@{ Name = '.'; Item = (Get-Item -LiteralPath $Path -Force) })
        $parent = [System.IO.Directory]::GetParent($Path)
        $parentItem = Get-Item -LiteralPath $Path -Force
        if ($null -ne $parent) { $parentItem = Get-Item -LiteralPath $parent.FullName -Force }
        $entries.Add(@{ Name = '..'; Item = $parentItem })
    }
    foreach ($entry in [System.IO.Directory]::GetFileSystemEntries($Path)) {
        $item = Get-Item -LiteralPath $entry -Force -ErrorAction SilentlyContinue
        if ($null -eq $item) { continue }
        $hidden = $item.Name.StartsWith('.', [System.StringComparison]::Ordinal) -or ($item.Attributes -band [System.IO.FileAttributes]::Hidden)
        if ($hidden -and $Settings.Show -eq 'visible') { continue }
        $entries.Add(@{ Name = $item.Name; Item = $item })
    }
    , (Get-GnuLsSortedEntry -Entries $entries.ToArray() -Settings $Settings)
}

function script:Get-GnuLsSortedEntry {
    <#
    .SYNOPSIS
    Orders entries by name (default), size (-S), time (-t) or not at all (-U); -r reverses.
    #>

    param([AllowEmptyCollection()][object[]]$Entries, [Parameter(Mandatory)][hashtable]$Settings)

    $list = New-Object 'System.Collections.Generic.List[object]'
    foreach ($entry in $Entries) { $list.Add($entry) }
    $byName = {
        param($a, $b)
        $c = [string]::Compare($a.Name, $b.Name, [System.StringComparison]::OrdinalIgnoreCase)
        if ($c -eq 0) { $c = [string]::CompareOrdinal($a.Name, $b.Name) }
        $c
    }
    switch ($Settings.Sort) {
        'none' { }
        'size' {
            $list.Sort([System.Comparison[object]] {
                    param($a, $b)
                    $sizeA = [long]0; $sizeB = [long]0   # Int32.CompareTo(Int64) throws
                    if ($a.Item -is [System.IO.FileInfo]) { $sizeA = $a.Item.Length }
                    if ($b.Item -is [System.IO.FileInfo]) { $sizeB = $b.Item.Length }
                    $c = $sizeB.CompareTo($sizeA)
                    if ($c -eq 0) { $c = & $byName $a $b }
                    $c
                })
        }
        'time' {
            $list.Sort([System.Comparison[object]] {
                    param($a, $b)
                    $c = $b.Item.LastWriteTime.CompareTo($a.Item.LastWriteTime)
                    if ($c -eq 0) { $c = & $byName $a $b }
                    $c
                })
        }
        default { $list.Sort([System.Comparison[object]]$byName) }
    }
    if ($Settings.Reverse -and $Settings.Sort -ne 'none') { $list.Reverse() }
    , $list.ToArray()
}

function script:Format-GnuLsColumns {
    <#
    .SYNOPSIS
    Lays out names in columns filled top to bottom (GNU -C) within Width characters.
    #>

    param([AllowEmptyCollection()][string[]]$Names, [AllowEmptyCollection()][int[]]$Lengths, [int]$Width)

    $count = $Names.Length
    if ($count -eq 0) { return }
    for ($columns = $count; $columns -ge 1; $columns--) {
        $rows = [int][Math]::Ceiling($count / $columns)
        $columns = [int][Math]::Ceiling($count / $rows)
        $widths = @(for ($c = 0; $c -lt $columns; $c++) {
                $max = 0
                for ($r = 0; $r -lt $rows; $r++) {
                    $index = $c * $rows + $r
                    if ($index -lt $count -and $Lengths[$index] -gt $max) { $max = $Lengths[$index] }
                }
                $max
            })
        $total = ($widths | Measure-Object -Sum).Sum + 2 * ($columns - 1)
        if ($total -le $Width -or $Width -le 0 -or $columns -eq 1) { break }
    }
    for ($r = 0; $r -lt $rows; $r++) {
        $line = New-Object System.Text.StringBuilder
        for ($c = 0; $c -lt $columns; $c++) {
            $index = $c * $rows + $r
            if ($index -ge $count) { continue }
            [void]$line.Append($Names[$index])
            if ($c -lt $columns - 1 -and ($c + 1) * $rows + $r -lt $count) { [void]$line.Append(' ' * ($widths[$c] - $Lengths[$index] + 2)) }
        }
        $line.ToString()
    }
}

function script:Format-GnuLsBlock {
    <#
    .SYNOPSIS
    Formats a group of entries (one directory listing, or the file operands) in the requested format.
    #>

    param([AllowEmptyCollection()][object[]]$Entries, [Parameter(Mandatory)][hashtable]$Settings, [switch]$Total)

    if ($Settings.Format -eq 'long') {
        $rows = @(foreach ($entry in $Entries) {
                $item = $entry.Item
                $size = 0
                if ($item -is [System.IO.FileInfo]) { $size = $item.Length }
                $owner = Get-GnuLsOwner -Item $item
                $name = Format-GnuLsName -Name $entry.Name -Item $item -Settings $Settings
                $link = Get-GnuLinkTarget -Path $item.FullName
                if ($null -ne $link) { $name += " -> $link" }
                @{ Mode = (Get-GnuLsMode -Item $item); Owner = $owner.Owner; Group = $owner.Group; Size = (Format-GnuLsSize -Size $size -Human $Settings.Human); Bytes = $size; Time = (Format-GnuLsTime -Time $item.LastWriteTime -Now $Settings.Now); Name = $name }
            })
        if ($Total) {
            $blocks = 0
            foreach ($row in $rows) { $blocks += [long][Math]::Ceiling($row.Bytes / 1024) }
            if ($Settings.Human) { "total $(Format-GnuLsSize -Size ($blocks * 1024) -Human $true)" } else { "total $blocks" }
        }
        $ownerWidth = ($rows | ForEach-Object { $_.Owner.Length } | Measure-Object -Maximum).Maximum
        $groupWidth = ($rows | ForEach-Object { $_.Group.Length } | Measure-Object -Maximum).Maximum
        $sizeWidth = ($rows | ForEach-Object { $_.Size.Length } | Measure-Object -Maximum).Maximum
        foreach ($row in $rows) {
            "$($row.Mode) 1 $($row.Owner.PadRight($ownerWidth)) $($row.Group.PadRight($groupWidth)) $($row.Size.PadLeft($sizeWidth)) $($row.Time) $($row.Name)"
        }
        return
    }

    $names = [string[]]@(foreach ($entry in $Entries) { Format-GnuLsName -Name $entry.Name -Item $entry.Item -Settings $Settings })
    if ($Settings.Format -eq 'columns') {
        # Color codes take no space on screen.
        $lengths = [int[]]@(foreach ($name in $names) { ($name -replace "$([char]27)\[[0-9;]*m", '').Length })
        Format-GnuLsColumns -Names $names -Lengths $lengths -Width $Settings.Width
        return
    }
    $names
}

function script:Invoke-GnuList {
    <#
    .SYNOPSIS
    Implementation of ls.
    #>

    param([Parameter(Mandatory)][hashtable]$Context, [bool]$ToConsole)

    $color = $false
    if (Test-GnuOption -Context $Context -Key 'color') {
        $when = [string](Get-GnuOptionValue -Context $Context -Key 'color')
        switch -Regex ($when) {
            '^$|^(always|yes|force)$' { $color = $true }
            '^(never|no|none)$' { $color = $false }
            '^(auto|tty|if-tty)$' { $color = $ToConsole }
            default {
                Write-GnuUsageError -Context $Context -Message "invalid argument '$when' for '--color'`nValid arguments are: 'always', 'yes', 'force', 'never', 'no', 'none', 'auto', 'tty', 'if-tty'"
                return
            }
        }
    }

    $width = 80
    if ($env:COLUMNS -match '^\d+$') { $width = [int]$env:COLUMNS }
    elseif ($ToConsole) { try { $width = [System.Console]::WindowWidth } catch { $width = 80 } }
    if (Test-GnuOption -Context $Context -Key 'width') {
        $text = [string](Get-GnuOptionValue -Context $Context -Key 'width')
        if ($text -notmatch '^\d+$') {
            Write-GnuError -Context $Context -Message "invalid line width: '$text'" -ExitCode 2
            return
        }
        $width = [int]$text
    }

    $format = 'single'
    if ($ToConsole) { $format = 'columns' }
    $settings = @{
        Format    = [string](Get-GnuOptionValue -Context $Context -Key 'format' -Default $format)
        Show      = [string](Get-GnuOptionValue -Context $Context -Key 'show' -Default 'visible')
        Sort      = [string](Get-GnuOptionValue -Context $Context -Key 'sort' -Default 'name')
        Reverse   = Test-GnuOption -Context $Context -Key 'reverse'
        Human     = Test-GnuOption -Context $Context -Key 'human-readable'
        Indicator = [string](Get-GnuOptionValue -Context $Context -Key 'indicator' -Default 'none')
        Color     = $color
        Colors    = Get-GnuLsColorTable
        Width     = $width
        Now       = [datetime]::Now
    }
    $recursive = Test-GnuOption -Context $Context -Key 'recursive'
    $directoryItself = Test-GnuOption -Context $Context -Key 'directory'

    $operands = @($Context.Operands)
    $implicit = $operands.Count -eq 0
    if ($implicit) { $operands = @('.') }

    $files = New-Object 'System.Collections.Generic.List[object]'
    $directories = New-Object 'System.Collections.Generic.List[object]'
    foreach ($operand in $operands) {
        $item = Get-Item -LiteralPath (Resolve-GnuPath -Path $operand) -Force -ErrorAction SilentlyContinue
        if ($null -eq $item) {
            Write-GnuError -Context $Context -Message "cannot access '$operand': No such file or directory" -ExitCode 2
            continue
        }
        $entry = @{ Name = $operand; Item = $item }
        if ($item -is [System.IO.DirectoryInfo] -and -not $directoryItself) { $directories.Add($entry) } else { $files.Add($entry) }
    }

    $printed = $false
    if ($files.Count -gt 0) {
        Format-GnuLsBlock -Entries (Get-GnuLsSortedEntry -Entries $files.ToArray() -Settings $settings) -Settings $settings
        $printed = $true
    }

    $showHeaders = $recursive -or ($operands.Count -gt 1)
    $queue = New-Object 'System.Collections.Generic.List[object]'
    foreach ($entry in (Get-GnuLsSortedEntry -Entries $directories.ToArray() -Settings $settings)) { $queue.Add($entry) }
    while ($queue.Count -gt 0) {
        $entry = $queue[0]
        $queue.RemoveAt(0)
        try { $children = Get-GnuLsEntry -Path $entry.Item.FullName -Settings $settings }
        catch {
            Write-GnuError -Context $Context -Message "cannot open directory '$($entry.Name)': $(Get-GnuErrorReason -Exception $_.Exception)"
            continue
        }
        if ($printed) { '' }
        if ($showHeaders) { "$($entry.Name):" }
        Format-GnuLsBlock -Entries $children -Settings $settings -Total
        $printed = $true

        if ($recursive) {
            # GNU lists subdirectories depth first, in display order, right after their parent.
            $subdirectories = @(foreach ($child in $children) {
                    if ($child.Name -eq '.' -or $child.Name -eq '..') { continue }
                    if ($child.Item -is [System.IO.DirectoryInfo] -and $null -eq (Get-GnuLinkTarget -Path $child.Item.FullName)) {
                        @{ Name = (Join-GnuDisplayPath -Directory $entry.Name -Leaf $child.Name); Item = $child.Item }
                    }
                })
            for ($i = $subdirectories.Count - 1; $i -ge 0; $i--) { $queue.Insert(0, $subdirectories[$i]) }
        }
    }
}